Skip to main content

max / goingson

11.6 KB · 295 lines History Blame Raw
1 # Contributing to GoingsOn
2
3 Patterns, conventions, and rules for working on the GoingsOn codebase.
4
5 ## Project Structure
6
7 ```
8 goingson/ (workspace root)
9 Cargo.toml # Workspace definition
10 crates/
11 core/ # Domain models, business logic, repository traits
12 db-sqlite/ # SQLite repository implementations
13 plugin-runtime/ # Rhai plugin system
14 src-tauri/
15 src/
16 main.rs # Tauri setup, command registration, background services
17 commands/ # Tauri commands (thin wrappers)
18 state.rs # AppState, sync client management
19 sync_service.rs # SyncKit change tracking and push/pull
20 frontend/
21 js/ # JavaScript modules (IIFE pattern)
22 css/ # Styles (edit styles.css, never styles.min.css)
23 build-css.sh # CSS minification script
24 tauri.conf.json # Tauri configuration
25 ```
26
27 ### Crate Boundaries
28
29 | Crate | Role | May depend on |
30 |-------|------|---------------|
31 | `core` | Models, validation, urgency, recurrence, repository traits | Nothing internal |
32 | `db-sqlite` | SQLite implementations of repository traits | `core` |
33 | `plugin-runtime` | Rhai plugin system | `core` |
34 | `src-tauri` | Tauri app, commands, state | All crates |
35
36 **Strict rule:** Business logic lives in `core`. Repository implementations in `db-sqlite`. Commands in `src-tauri` are thin wrappers. JavaScript never duplicates logic that exists in Rust.
37
38 ## Rust Does the Heavy Lifting
39
40 This is the most important architectural rule. All data filtering, sorting, computation, and validation happens in Rust. JavaScript only renders pre-computed data and handles UI interactions.
41
42 **Bad:**
43 ```javascript
44 // DON'T: Filter in JS
45 const tasks = await GoingsOn.api.tasks.list();
46 const filtered = tasks.filter(t => t.status === 'pending' && !isSnoozed(t));
47 ```
48
49 **Good:**
50 ```javascript
51 // DO: Send filter criteria to Rust, render the result
52 const tasks = await GoingsOn.api.tasks.listFiltered({ status: 'pending', showSnoozed: false });
53 ```
54
55 ## Pre-Computed Response Fields
56
57 Response structs include computed fields so JavaScript doesn't recalculate:
58
59 ```rust
60 #[derive(Serialize)]
61 #[serde(rename_all = "camelCase")]
62 pub struct TaskResponse {
63 // Basic fields from DB
64 pub id: String,
65 pub description: String,
66 pub due: Option<String>,
67
68 // PRE-COMPUTED:
69 pub is_snoozed: bool, // snoozed_until > now
70 pub is_overdue: bool, // due < now
71 pub urgency_class: String, // "overdue", "high", "medium", "low"
72 pub subtask_progress: Option<u8>, // 0-100 percentage
73 pub due_formatted: Option<String>, // "today", "tomorrow", "+3d", "2d ago"
74 pub timer_active: bool,
75 pub time_progress: Option<u8>, // actual vs estimate percentage
76 }
77 ```
78
79 JavaScript renders these directly:
80 ```javascript
81 element.classList.add(`urgency-${task.urgencyClass}`);
82 progressBar.style.width = `${task.subtaskProgress}%`;
83 ```
84
85 When adding new features, always ask: can this be computed once in Rust instead of repeatedly in JavaScript?
86
87 ## Plugin Style
88
89 Import plugins in `plugins/` follow the cross-project Rhai style guide. Run `_meta/scripts/lint-rhai.sh` to check formatting. Key points: 4-space indent, `snake_case` functions, `UPPER_CASE` constants, header comment block, host functions via `goingson::` namespace.
90
91 ## Tauri Commands
92
93 Commands are thin wrappers in `src-tauri/src/commands/`. They extract parameters, call repository methods, and map to response types:
94
95 ```rust
96 #[tauri::command]
97 #[instrument(skip_all)]
98 pub async fn list_projects(state: State<'_, Arc<AppState>>) -> Result<Vec<ProjectResponse>, ApiError> {
99 Ok(state.projects.list_all(DESKTOP_USER_ID).await?
100 .into_iter().map(ProjectResponse::from).collect())
101 }
102 ```
103
104 **Rules:**
105 - Every command gets `#[instrument(skip_all)]` for tracing.
106 - Return type is always `Result<T, ApiError>`.
107 - Commands call repository methods via `state.{repo}.{method}()`.
108 - Domain types convert to response types via `From<T>` impls.
109 - No SQL queries or business logic in commands.
110
111 ## Repository Traits
112
113 `crates/core/src/repository.rs` defines async traits for all data operations:
114
115 ```rust
116 #[async_trait]
117 pub trait TaskRepository: Send + Sync {
118 async fn list_all(&self, user_id: UserId) -> Result<Vec<Task>>;
119 async fn list_filtered(&self, user_id: UserId, query: TaskFilterQuery) -> Result<(Vec<Task>, i64)>;
120 async fn get_by_id(&self, id: TaskId, user_id: UserId) -> Result<Option<Task>>;
121 async fn create(&self, user_id: UserId, task: NewTask) -> Result<Task>;
122 async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>>;
123 // ...
124 }
125 ```
126
127 Implementations live in `crates/db-sqlite/src/repository/`. This indirection means commands work with any backend implementation.
128
129 ## Error Handling
130
131 ```rust
132 #[derive(Debug, thiserror::Error)]
133 pub enum CoreError {
134 #[error("Database error: {message}")]
135 Database { message: String, #[source] source: Option<Box<dyn std::error::Error + Send + Sync>> },
136 #[error("Not found: {resource} with id {id}")]
137 NotFound { resource: &'static str, id: String },
138 #[error("Validation error: {field} - {message}")]
139 Validation { field: &'static str, message: String },
140 // ...
141 }
142 ```
143
144 **Rules:**
145 - Use typed error variants with context (resource name, field name).
146 - Use helper constructors: `CoreError::database(err)`, `CoreError::not_found("task", id)`, `CoreError::validation("name", "too long")`.
147 - Commands return `ApiError` which converts from `CoreError` via `From`.
148 - Never `.unwrap()` in production code. Use `?` for propagation.
149
150 ## JavaScript Architecture
151
152 ### Namespace
153
154 All JavaScript lives under the `GoingsOn` global namespace:
155
156 ```javascript
157 window.GoingsOn = {
158 api: {}, // Tauri IPC wrappers
159 state: null, // Centralized reactive state
160 ui: {}, // Modal, toast, form utilities
161 utils: {}, // escapeHtml, escapeAttr, formatDue, getErrorMessage
162 projects: {}, // Project module
163 tasks: {}, // Task module
164 events: {}, // Events module
165 emails: {}, // Email module
166 dayPlan: {}, // Day planning
167 snooze: {}, // Snooze management
168 navigation: {}, // View switching
169 };
170 ```
171
172 New code attaches to the appropriate namespace. Never use `window.X = ...` for new exports.
173
174 ### Module Pattern (IIFE)
175
176 Every JS file is a strict-mode IIFE:
177
178 ```javascript
179 (function() {
180 'use strict';
181 const esc = GoingsOn.utils.escapeHtml;
182
183 async function load() { /* ... */ }
184 function render(data) { /* ... */ }
185
186 GoingsOn.myModule = { load, render };
187 })();
188 ```
189
190 ### State Management
191
192 `GoingsOn.state` is a centralized store with pub/sub:
193
194 ```javascript
195 GoingsOn.state.set('tasks', newTasks); // Set + notify subscribers
196 GoingsOn.state.tasks; // Read
197 GoingsOn.state.subscribe('tasks', (newVal, oldVal) => { /* ... */ });
198 ```
199
200 All shared data goes through `GoingsOn.state`. No module-local caches for data that other modules need.
201
202 ### Form Builder
203
204 Use `openFormModal()` for all CRUD forms. Define fields as data, not HTML:
205
206 ```javascript
207 const fields = [
208 { name: 'description', type: 'text', label: 'Task', required: true, value: task.description },
209 { name: 'priority', type: 'select', label: 'Priority', options: PRIORITY_OPTIONS, value: task.priority },
210 { name: 'due', type: 'datetime-local', label: 'Due Date', value: formatForDatetimeInput(task.due) },
211 { name: 'tags', type: 'text', label: 'Tags', value: task.tags.join(', '), hint: 'Comma-separated' },
212 ];
213
214 openFormModal({
215 title: 'Edit Task',
216 fields,
217 onSubmit: async (data) => {
218 await GoingsOn.api.tasks.update(task.id, data);
219 GoingsOn.ui.toast('Task updated!');
220 await load();
221 }
222 });
223 ```
224
225 Supported field types: `text`, `textarea`, `select`, `datetime-local`, `checkbox`, `number`, `email`, `password`, `hidden`.
226
227 ### XSS Prevention
228
229 Always escape user content:
230 ```javascript
231 GoingsOn.utils.escapeHtml(str) // For text content
232 GoingsOn.utils.escapeAttr(str) // For HTML attributes
233 ```
234
235 Never insert user-provided strings into innerHTML without escaping.
236
237 ## CSS Workflow
238
239 - **Edit:** `src-tauri/frontend/css/styles.css`
240 - **Build:** Run `src-tauri/frontend/build-css.sh` to generate `styles.min.css`
241 - **Never** edit `styles.min.css` directly — it's auto-generated via clean-css-cli
242 - The HTML loads `styles.min.css`
243
244 ## UI Modes
245
246 GoingsOn has two UI modes: `desktop` and `mobile`. The mode is decided once at boot by an inline script in `index.html` (runs before the stylesheet loads, so no flash) and exposed two ways:
247
248 - **CSS:** `<html class="ui-mode-desktop">` or `<html class="ui-mode-mobile">`. Mobile-specific rules: `.ui-mode-mobile .foo { ... }`. Desktop-specific layout: `.ui-mode-desktop .foo { ... }`.
249 - **JS:** `GoingsOn.viewport.isMobile()` / `isDesktop()` from `js/viewport.js`.
250
251 Mode does **not** change at runtime. Desktop binaries stay desktop even when the window is narrowed; mobile binaries stay mobile. The mode is a property of the build, not the viewport size.
252
253 **Detection precedence** (`index.html` inline script):
254 1. `?ui=mobile|desktop` URL param — dev / testing / bug repros.
255 2. `localStorage.goingson.uiMode` — dev Settings toggle.
256 3. `navigator.userAgentData.mobile` (UA Client Hints) when available.
257 4. UA regex (`iPhone OS|iPad|Android`) with iPad-as-Mac fallback (`navigator.maxTouchPoints > 1` on a Mac-reporting platform).
258
259 **Dev preview:** `?ui=mobile` URL param, or `GoingsOn.viewport.setOverride('mobile')` from the console.
260
261 **Adding mobile rules:** prefix the selector with `.ui-mode-mobile`. Do **not** add new `@media (max-width: ...)` queries to switch UI modes — that path is gone. Intra-mode responsive queries (e.g. wide-vs-narrow desktop) are allowed, but their selectors must be `.ui-mode-desktop`-prefixed inside the query.
262
263 **Input capability is a separate axis.** Use `@media (hover: none)` only for hover suppression. Use `GoingsOn.touch.isTouchDevice` only for input-model decisions (drag vs long-press, tap targets). Never use either for visibility or layout — that's what UI mode is for.
264
265 ## SyncKit Integration
266
267 Cloud sync is optional. The `SyncKitClient` lives in `AppState.sync_client` behind `Arc<RwLock<Option<Arc<SyncKitClient>>>>`.
268
269 **Setup flow:** User enters API key → validate against server → save to config → create client → setup encryption (new master key or import existing).
270
271 **Sync flow:** The sync service tracks changes via a changelog table. Push sends encrypted change batches to the server. Pull downloads and applies remote changes. All operations go through the core repository traits.
272
273 ## Testing
274
275 - **Rust unit tests:** In-file `#[cfg(test)]` modules in each crate
276 - **Rust integration tests:** `tests/` directories in each crate
277 - **JS tests:** Manual testing via the app (no automated JS test runner yet)
278 - Test databases use in-memory SQLite (`:memory:`) with migrations applied
279 - Always verify the full pipeline: Rust command → repository → DB → response → JS render
280
281 ## When Adding Features
282
283 1. **Start with the Rust types** — define the model in `crates/core/src/models/`
284 2. **Add repository trait method** in `crates/core/src/repository.rs`
285 3. **Implement in SQLite** in `crates/db-sqlite/src/repository/`
286 4. **Add Tauri command** in `src-tauri/src/commands/` (thin wrapper)
287 5. **Create response type** with pre-computed fields
288 6. **Build JS integration** — call command, render result using namespace pattern
289
290 ## When Fixing Bugs
291
292 1. **Identify the layer** — is it core logic, repository, command, or UI?
293 2. **Fix at the right layer** — don't patch JS for a Rust bug
294 3. **Add pre-computation** if JS is doing repeated calculations that belong in Rust
295