| 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 |
|
| 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 |
|