Files
wotra/PLAN.md

10 KiB

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

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

# 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