docs: add implementation plan
This commit is contained in:
232
PLAN.md
Normal file
232
PLAN.md
Normal file
@@ -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 <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
|
||||
|
||||
### 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 |
|
||||
Reference in New Issue
Block a user