Skip to main content

max / goingson

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