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