All reads now come directly from Dexie; all mutations write to Dexie + outbox immediately without waiting for the server. The background sync loop (every 30s) pushes the outbox and pulls server changes. Day/week close and reopen remain server-only (require server-side computation). triggerSync() is called after them to update Dexie promptly. The optimistic closedDaysMap update in the week page is kept separate from the Dexie reload to avoid a race that was causing the reopen button and day actions to disappear until a page reload. - client.ts: remove online-first fetch paths; all reads from Dexie - sync.ts: add triggerSync() and waitForSync() exports - DayDetail: pass ClosedDay | null to oninvalidate after close/reopen - week/+page.svelte: update closedDaysMap optimistically on close/reopen; only reload from Dexie on entry mutations - settings/+page.svelte: read history() directly (never throws 503); derive current locally - layout: remove offline banner and online.ts (behaviour is now identical online and offline)
324 lines
17 KiB
Markdown
324 lines
17 KiB
Markdown
# 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)
|
||
mise.toml # task/tool runner (replaces 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
|
||
-- Migration 003: switched to TEXT UUID primary key; added updated_at
|
||
CREATE TABLE settings_history (
|
||
id TEXT PRIMARY KEY, -- UUID (client-generated, sync-friendly)
|
||
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,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
|
||
-- Sync event log (append-only; TTL-pruned; prune marker for stale-client detection)
|
||
CREATE TABLE sync_log (
|
||
entity TEXT NOT NULL, -- 'entries' | 'closed_days' | ... | '_pruned'
|
||
entity_id TEXT NOT NULL,
|
||
op TEXT NOT NULL, -- 'upsert' | 'delete' | 'marker'
|
||
version INTEGER NOT NULL, -- monotonic server-assigned
|
||
payload TEXT NOT NULL, -- JSON snapshot
|
||
logged_at INTEGER NOT NULL, -- unix ms; used for TTL pruning
|
||
PRIMARY KEY (entity, entity_id, version)
|
||
);
|
||
|
||
-- Balance adjustments: manual corrective entries that modify the overall overtime balance
|
||
-- without touching week math. Signed delta_ms (positive = credit, negative = debit).
|
||
CREATE TABLE balance_adjustments (
|
||
id TEXT PRIMARY KEY, -- client-generated UUIDv7 (sync-friendly)
|
||
delta_ms INTEGER NOT NULL, -- signed; non-zero enforced at service layer
|
||
note TEXT NOT NULL DEFAULT '', -- optional free-text reason
|
||
effective_at INTEGER NOT NULL, -- unix ms; backdatable
|
||
created_at INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
CREATE INDEX idx_balance_adjustments_effective_at ON balance_adjustments(effective_at);
|
||
```
|
||
|
||
### 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
|
||
POST /api/entries { start_ms, end_ms, note? } -> Entry (completed interval)
|
||
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)
|
||
GET /api/weeks/balance -> BalanceSummary
|
||
|
||
# Balance adjustments
|
||
GET /api/balance/adjustments -> []BalanceAdjustment
|
||
POST /api/balance/adjustments { delta_ms, note?, effective_at } -> BalanceAdjustment
|
||
PUT /api/balance/adjustments/{id} { delta_ms, note?, effective_at } -> BalanceAdjustment
|
||
DELETE /api/balance/adjustments/{id}
|
||
|
||
# Settings
|
||
GET /api/settings # current effective settings
|
||
PUT /api/settings { effective_from, hours_per_week, workdays_mask, timezone }
|
||
GET /api/settings/history
|
||
|
||
# Sync
|
||
GET /api/sync/pull?since=N -> { changes[], server_version } | 410 Gone
|
||
POST /api/sync/push { changes[] } -> { applied[], skipped[] }
|
||
|
||
# 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 close time, 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.
|
||
- **Future intervals**: server rejects `CreateInterval` and `Update` if the resulting day key is in the future (400 Bad Request).
|
||
|
||
### Auth
|
||
|
||
- `AUTH_TOKEN` environment variable on the server.
|
||
- All `/api/*` routes require `Authorization: Bearer <token>`.
|
||
- 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
|
||
- `vitest` for unit tests
|
||
|
||
### Screens
|
||
|
||
| Screen | Purpose |
|
||
|--------|---------|
|
||
| **Week** | Week picker, 7-day chip strip, day detail panel for selected day. Default: today selected (if in week) else Monday. Today tab in bottom nav is a shortcut that selects today. |
|
||
| **History** | Month/week list of closed days and weeks with overtime indicators |
|
||
| **Settings** | hours/week, workdays mask, timezone, effective-from, token setup |
|
||
|
||
The old dedicated **Today** route is merged into the Week view (see § Merged Week+Today View below).
|
||
|
||
### Merged Week+Today View (`/week`)
|
||
|
||
Three regions:
|
||
|
||
1. **Week header** — week picker, week totals, Close Week button, closed-week banner.
|
||
2. **Day strip** — 7 chips (Mon–Sun), each showing: weekday label + date, mini progress bar (worked/expected), kind icon, closed badge, today highlight. Horizontal scroll-snap on mobile. Selected chip scrolls into view. `role="tablist"` with keyboard arrow navigation.
|
||
3. **Day detail panel** — for the selected day, capabilities gated per table:
|
||
|
||
| Condition | canStartStop | canAddInterval | canEditEntries | canMarkKind | canCloseDay |
|
||
|---|---|---|---|---|---|
|
||
| Future | false | false | false | true | false |
|
||
| Today, open | true | true | true | true | true* |
|
||
| Today, closed | false | false | false | true | reopen |
|
||
| Past, open | false | true | true | true | true |
|
||
| Past, closed | false | false | false | true | reopen |
|
||
|
||
*Close Day button disabled with tooltip while a running entry exists.
|
||
|
||
**URL state**: `?week=2026-W18&day=2026-04-30`. Bare `/week` canonicalizes via `replaceState`. Chip clicks use `replaceState` (no history push). Week navigation (prev/next) uses `goto` (history push). Default day selection on week change: today if in week, else Monday.
|
||
|
||
**Bottom nav**: "Today" tab is a shortcut link to `/week?week=<currentWeek>&day=<todayKey>`. Active when route is `/week` and `?day === todayKey()`.
|
||
|
||
### Offline Strategy
|
||
|
||
Fully offline-first: the UI always reads from and writes to Dexie (IndexedDB) without touching the network. A background sync loop (every 30 s) reconciles with the server.
|
||
|
||
1. App shell precached by Workbox.
|
||
2. **All reads** come directly from Dexie — no network latency in the hot path.
|
||
3. **All mutations** write to Dexie + outbox immediately and return. The background sync loop pushes the outbox via `POST /api/sync/push` and pulls server changes via `GET /api/sync/pull`.
|
||
4. **Exceptions**: day/week close and reopen require server-side computation and remain server-only. `triggerSync()` is called after them to update Dexie promptly without waiting 30 s.
|
||
5. On `410 Gone` (server pruned data the client hasn't seen), `coldStart()` wipes Dexie and re-pulls everything from version 0.
|
||
|
||
## 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
|
||
|
||
```sh
|
||
# Single-binary production build (via mise)
|
||
mise run build
|
||
# 1. npm run build (inside web/)
|
||
# 2. go build -tags production -o wotra ./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–M6 — Done
|
||
Full backend + frontend implemented, PWA offline sync, single-binary build, `mise.toml` task runner.
|
||
|
||
### M7 — Merge Today+Week views ✅ in progress
|
||
|
||
Staged implementation:
|
||
|
||
| Stage | Goal | Status |
|
||
|---|---|---|
|
||
| 0 | Add Vitest setup | pending |
|
||
| 1 | Day strip on week view (read-only) | pending |
|
||
| 2 | Extract DayDetail component; render today in week view | pending |
|
||
| 3 | Day selection + URL-driven state | pending |
|
||
| 4 | Backend guard: reject future-day intervals | pending |
|
||
| 5 | Bottom nav Today shortcut; delete /today route | pending |
|
||
|
||
### M8 — Balance adjustments ✅
|
||
|
||
Manual corrective entries on the History page that adjust the overall overtime balance without touching week math. Separate `balance_adjustments` table with signed `delta_ms`, optional `note`, and `effective_at` (backdatable). Balance summary combines `Σ closed_weeks.delta_ms + Σ balance_adjustments.delta_ms`.
|
||
|
||
### M9 — Sync redesign ✅
|
||
|
||
Full offline support with online-first, offline-fallback mutation strategy.
|
||
|
||
**Backend:**
|
||
- Migration 003: `logged_at` on `sync_log`; `settings_history` migrated to UUID TEXT PK with `updated_at`.
|
||
- `SyncStore.Prune(ctx, ttl)`: deletes rows older than TTL and writes a `_pruned` marker at the boundary version. Clients pulling with `since < marker_version` receive `ErrSyncStale` → `410 Gone`.
|
||
- `GET /api/sync/pull?since=N`: calls Prune lazily (30-day TTL), returns changes or 410.
|
||
- `POST /api/sync/push`: accepts batched outbox items; applies last-`updated_at`-wins for `entries`, `balance_adjustments`, `settings_history`. `closed_days`/`closed_weeks` are server-only and skipped. Returns `{applied, skipped}`.
|
||
|
||
**Frontend:**
|
||
- `client.ts`: all mutation methods (`entries`, `balance`, `settings`) catch `TypeError` (network error) and fall back to writing directly to Dexie + enqueuing in the outbox. IDs for offline creates are generated client-side via `crypto.randomUUID()`.
|
||
- `sync.ts`: `pushOutbox` sends outbox to `POST /api/sync/push`; on success removes applied items. `pullChanges` uses `GET /api/sync/pull?since=N`; on 410 calls `coldStart()` and retries. `coldStart()` clears all Dexie tables and resets `last_version=0`.
|
||
- `db.ts`: Dexie v3 — `settings_history` key path changed to `id` (string); upgrade handler clears the table for repopulation via pull.
|
||
- Settings page: `editingId` and ID params updated from `number` to `string`.
|
||
|
||
### M10 — Offline-first client ✅
|
||
|
||
Switched from online-first/offline-fallback to fully offline-first.
|
||
|
||
- `client.ts`: all reads now come directly from Dexie (no server fetch). All mutations write to Dexie + outbox immediately and return without waiting for the server.
|
||
- `sync.ts`: added `triggerSync()` for imperative sync after server-only mutations. Updated comment header.
|
||
- `DayDetail.svelte`, `week/+page.svelte`: call `triggerSync()` after day/week close and reopen so Dexie reflects server-computed state promptly.
|
||
- `+layout.svelte`: removed offline banner (no longer meaningful; behaviour is identical online or offline).
|
||
- `online.ts`: deleted (unused).
|
||
|
||
### M11 — Future
|
||
CSV/JSON export, monthly summary view.
|
||
|
||
## 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 |
|
||
| Week close settings | Use close-time date, not week Monday | Settings changed mid-week apply to that week's close |
|
||
| weekDayKeys formula | `(weekday+6)%7` for days-since-Monday | Avoids sign bug when Jan 4 is Sunday (affects year 2026) |
|
||
| Merged Today+Week | Single `/week` route with day selection | Reduces duplication; week context always visible |
|
||
| Balance adjustments | Separate `balance_adjustments` table | Avoids conflating measured deltas with manual corrections; preserves week math invariant (`delta_ms = worked - expected`) |
|
||
| Balance adjustment scope | Closed weeks only for auto balance | In-progress week delta shown separately in week view; mixing would make balance jitter |
|
||
| Balance adjustment IDs | TEXT (UUIDv7, client-generated) | Consistent with `entries`; allows offline creation and sync |
|
||
| Settings history PK | TEXT UUID (migration 003) | Consistent with other entities; enables offline create; `updated_at` enables last-write-wins sync |
|
||
| Sync prune strategy | Prune marker row at boundary version | No extra table; client detects stale state from the log itself; 410 triggers full re-sync |
|
||
| Sync conflict resolution | Last `updated_at` wins | Server is authoritative; simple to implement and reason about for single-user |
|
||
| Offline mutation flow | Offline-first | All reads/writes go through Dexie; background sync loop reconciles with server. Day/week close remains server-only (requires server computation); `triggerSync()` called after. |
|