From 4905c6f57031cd36930bbf4de61dcbb424e51c47 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Thu, 30 Apr 2026 16:24:39 +0200 Subject: [PATCH] docs: add implementation plan --- PLAN.md | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..631fc7a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,232 @@ +# Wotra — Working Time Tracker: Implementation Plan + +## 1. High-Level Architecture + +``` +┌─────────────────────────┐ ┌──────────────────────────┐ +│ Svelte PWA (client) │ ◄─────► │ Go service (API) │ +│ - IndexedDB cache │ HTTPS │ - REST/JSON endpoints │ +│ - Service Worker │ JSON │ - Business logic │ +│ - Sync queue │ │ - SQLite (modernc) │ +└─────────────────────────┘ └──────────────────────────┘ +``` + +Single-user, self-hosted. One Go binary serves the Svelte SPA and the API. + +## 2. Backend (Go) + +### Stack + +- `net/http` + `chi` for routing +- `modernc.org/sqlite` (pure Go, no CGO) with `database/sql` +- SQLite WAL mode, foreign keys on, busy timeout 5s +- `golang-migrate` or hand-rolled migrations +- Structured logging via `slog` + +### Project Layout + +``` +cmd/wotra/main.go +internal/ + config/ # timezone, token, port + domain/ # Entry, ClosedDay, ClosedWeek, Settings types + store/ # sqlite repos + migrations + handler/ # HTTP handlers, middleware + service/ # business rules (close day/week, merge, overtime) +migrations/ # SQL migration files +web/ # SvelteKit app (embedded in binary at build time) +Makefile +PLAN.md +``` + +### Database Schema + +```sql +-- Raw tracking events (source of truth for open days) +CREATE TABLE entries ( + id TEXT PRIMARY KEY, -- UUIDv7 (client-generated, sync-friendly) + start_time INTEGER NOT NULL, -- unix epoch ms, UTC + end_time INTEGER, -- NULL while running + auto_stopped INTEGER NOT NULL DEFAULT 0, -- 1 = stopped at midnight automatically + note TEXT, + day_key TEXT NOT NULL, -- 'YYYY-MM-DD' in configured local tz + updated_at INTEGER NOT NULL, + deleted_at INTEGER -- soft delete for sync +); +CREATE INDEX idx_entries_day ON entries(day_key); + +-- Closed days: merged result, effectively immutable +CREATE TABLE closed_days ( + day_key TEXT PRIMARY KEY, -- 'YYYY-MM-DD' + start_time INTEGER, -- min(entries.start_time), NULL for non-work kinds + end_time INTEGER, -- max(entries.end_time), NULL for non-work kinds + worked_ms INTEGER NOT NULL, -- sum of entry durations (breaks excluded) + kind TEXT NOT NULL, -- 'work' | 'holiday' | 'vacation' | 'sick' + closed_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Closed weeks: snapshot of expectations at close time +CREATE TABLE closed_weeks ( + week_key TEXT PRIMARY KEY, -- 'YYYY-Www' ISO week + expected_ms INTEGER NOT NULL, -- frozen at close + worked_ms INTEGER NOT NULL, -- sum of closed_days.worked_ms for the week + delta_ms INTEGER NOT NULL, -- worked_ms - expected_ms (signed) + closed_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Settings with effective-from semantics so past weeks aren't retroactively changed +CREATE TABLE settings_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + effective_from TEXT NOT NULL, -- 'YYYY-MM-DD' + hours_per_week REAL NOT NULL, + workdays_mask INTEGER NOT NULL DEFAULT 31, -- bits Mon=1..Sun=64; Mon-Fri = 31 + timezone TEXT NOT NULL DEFAULT 'UTC', + created_at INTEGER NOT NULL +); + +-- Sync event log (last-write-wins per entity row) +CREATE TABLE sync_log ( + entity TEXT NOT NULL, -- 'entries' | 'closed_days' | 'closed_weeks' + entity_id TEXT NOT NULL, + op TEXT NOT NULL, -- 'upsert' | 'delete' + version INTEGER NOT NULL, -- monotonic server-assigned + payload TEXT NOT NULL, -- JSON snapshot + PRIMARY KEY (entity, entity_id, version) +); +``` + +### API Surface (REST/JSON) + +``` +# Entries +POST /api/entries/start { note? } -> Entry +POST /api/entries/{id}/stop -> Entry +GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD +PUT /api/entries/{id} { start_time?, end_time?, note? } +DELETE /api/entries/{id} (soft delete) + +# Days +GET /api/days?from=YYYY-MM-DD&to=YYYY-MM-DD +POST /api/days/{day_key}/close -> ClosedDay +POST /api/days/{day_key}/mark { kind } # holiday|vacation|sick +DELETE /api/days/{day_key}/close (reopen day) + +# Weeks +GET /api/weeks?from=YYYY-Www&to=YYYY-Www +POST /api/weeks/{week_key}/close -> ClosedWeek +DELETE /api/weeks/{week_key}/close (reopen week) + +# Settings +GET /api/settings # current effective settings +PUT /api/settings { effective_from, hours_per_week, workdays_mask, timezone } +GET /api/settings/history + +# Sync +POST /api/sync/pull { since_version } -> { changes[], server_version } +POST /api/sync/push { changes[] } -> { applied[], conflicts[] } + +# Health +GET /healthz (unauthenticated) +``` + +### Key Business Rules + +- **day_key assignment**: based on `start_time` in the configured timezone. +- **Midnight enforcement**: `end_time` must be on the same calendar day as `start_time`. Server auto-stops any running entry at 23:59:59 local time via a background goroutine. Client warns the user. +- **Close day**: requires all entries for that day to have `end_time != NULL` (409 otherwise). Computes `worked_ms = sum(end_time - start_time)` across non-deleted entries. Entries are preserved but the day is now locked. +- **Mark day**: writes a `closed_days` row with `kind != 'work'`; `worked_ms` = expected daily ms (hours_per_week * 3600000 / popcount(workdays_mask)) for workdays, 0 for non-workdays. +- **Close week**: all workdays in the week must have a `closed_days` row. Sums `worked_ms`, snapshots `expected_ms` from settings effective at the week start, stores signed `delta_ms`. +- **Settings change**: new row in `settings_history` with `effective_from`. Past closed weeks are unaffected because they store a frozen snapshot. + +### Auth + +- `AUTH_TOKEN` environment variable on the server. +- All `/api/*` routes require `Authorization: Bearer `. +- Client stores token in `localStorage` after a one-time setup screen. +- `/healthz` is unauthenticated. + +## 3. Frontend (Svelte PWA) + +### Stack + +- SvelteKit (static adapter for embedding in Go binary) +- TypeScript +- `vite-plugin-pwa` + Workbox for service worker and manifest +- `dexie` for typed IndexedDB access + +### Screens + +| Screen | Purpose | +|--------|---------| +| **Today** | Start/Stop button with live timer, list of today's entries, quick-mark actions | +| **Week** | 7-day grid: worked vs expected per day, running totals, "Close week" CTA | +| **History** | Month/week list of closed days and weeks with overtime indicators | +| **Settings** | hours/week, workdays mask, timezone, effective-from, token setup | + +### Offline Strategy + +1. App shell precached by Workbox. +2. All reads come from Dexie (IndexedDB); a sync worker reconciles with the server in the background. +3. All mutations create a local Dexie record with a client-generated UUIDv7 and an entry in a local `outbox` table. +4. When online, the outbox is flushed via `/api/sync/push`; responses update the local cache. +5. `/api/sync/pull` fetches any server-side changes since `last_pulled_version`. + +## 4. Sync Protocol + +- Every mutable row carries `updated_at` (ms) on the client and `version` (server monotonic int) once accepted. +- **Pull**: `POST /api/sync/pull { since_version: N }` → rows with `version > N` + `server_version`. +- **Push**: client sends batched upsert/delete events with `updated_at`. Server applies last-write-wins per row. Conflicts returned to client, which re-pulls those rows. + +## 5. Build & Deployment + +```makefile +# Single-binary production build +make build +# 1. pnpm build (inside web/) +# 2. go build -tags production ./cmd/wotra +# -> embeds web/build via go:embed +``` + +Dev mode: Go on `:8080`, Vite dev server on `:5173` with `/api` proxied to Go. + +Environment variables: +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_TOKEN` | required | Bearer token for API access | +| `PORT` | `8080` | Server port | +| `DB_PATH` | `wotra.db` | SQLite file path | +| `TZ` | `UTC` | Server timezone for day-key calculation | + +## 6. Milestones + +### M1 — Backend MVP +Repo scaffold, Go module, SQLite with migrations, entries CRUD, start/stop with same-day validation (midnight guard), auth middleware, service-layer tests. + +### M2 — Day Management +Settings with effective-from history, close day (running-entry guard, entry merging), holiday/vacation/sick marking. + +### M3 — Week Close & Overtime +Close week, expected/worked/delta computation with frozen settings snapshot, reopen day/week. + +### M4 — Svelte UI (online-only) +Today view, Week view, History view, Settings screen, all consuming the API directly. + +### M5 — PWA + Offline +Service worker, manifest, Dexie store, outbox pattern, `/api/sync/pull` and `/api/sync/push` endpoints, last-write-wins reconciliation. + +### M6 — Polish +CSV/JSON export, monthly summary view, single-binary build with embedded assets, Makefile, README. + +## 7. Decisions & Rationale + +| Decision | Choice | Reason | +|----------|--------|--------| +| Midnight crossing | Disallow; auto-stop at 23:59:59 | Keeps day-boundary logic simple and lossless | +| Running entry at close | Refuse with 409 | Explicit is better than implicit | +| Expected hours split | Even across workdays | Simplest correct model; per-day config out of scope | +| Auth v1 | Single shared bearer token | Single-user self-hosted; minimal friction | +| Deployment | Single Go binary embeds SPA | Simplest self-hosting story | +| SQLite driver | modernc.org/sqlite (pure Go) | No CGO; easy cross-compilation | +| Breaks in closed day | Not included in worked_ms | Sum of entry durations only; gaps between entries are breaks |