Three stages: node:24-alpine builds the Svelte SPA, golang:1.26-alpine
compiles the Go binary with embedded assets (-tags production), and
alpine:3.21 is the minimal runtime.
Runs as unprivileged user (uid 1000). DB_PATH defaults to /data/wotra.db
so a volume mount at /data provides persistent storage.
podman run -e AUTH_TOKEN=secret -p 8080:8080 -v wotra-data:/data wotra
Shows a sync button in the top-right corner of the nav bar with live
status: spinning while syncing, warning icon on error, grey when idle.
Tooltip shows last synced time or error message. An orange badge
appears when there are pending outbox items.
- sync.ts: add syncState store (status, lastSynced, pendingCount,
error); guard against concurrent sync cycles with syncInFlight flag
- layout: sync button wired to triggerSync(); ticks every 15s so the
'Xs ago' label stays current
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)
Wrap each DayChip in a container-query context so the chip layout
responds to the actual space available rather than the viewport width.
Breakpoints (per-chip width):
- < 72px (mobile, 7 chips in ~320px): compact — label + date + progress bar + badges only
- ≥ 72px (tablet+): show worked duration text below the date number; slightly larger padding/font
- ≥ 100px (desktop, 7 chips in ~750px wide main): larger date number, bigger worked label
The .chip-slot div in week/+page.svelte is the flex child (flex: 1,
min-width: 2.8rem) that feeds width into the container; .chip-wrap
inside DayChip carries container-type: inline-size and fills the slot.
All GET calls in client.ts now fall back to Dexie when a network error
(TypeError) is caught, so pages render from cached data when the server
is unreachable:
- entries.list() → db.entries filtered by day_key range
- days.list() → db.closed_days filtered by day_key range
- weeks.list() → db.closed_weeks filtered by week_key range
- weeks.balance() → computed locally from closed_weeks + balance_adjustments
- balance.list() → db.balance_adjustments ordered by effective_at DESC
- settings.current() → db.settings_history, latest row with effective_from <= today
- settings.history() → db.settings_history ordered by effective_from DESC
Day/week close and reopen remain online-only (they require server-side
computation).
Add isOnline store (navigator.onLine + window online/offline events) and
an amber 'Offline — showing cached data' banner in +layout.svelte shown
whenever the store is false.
- Move embed_prod.go/embed_dev.go out of cmd/wotra/ into root-level
assets_prod.go / assets_dev.go (package assets). go:embed paths
must be relative to the source file; cmd/wotra/ cannot reach
../../web/build.
- Update cmd/wotra/main.go to import the root assets package.
- Change mise.toml build task from parallel depends to sequential
shell commands (build:web then build:go) so the frontend is always
compiled before the Go embed runs.
- Add LogClosedDayDelete and LogClosedWeekDelete to SyncStore
- Inject syncStore into EntryService; log Start, Stop, StopByID,
Update, CreateInterval, Delete, AutoStopStalledEntries
- Inject syncStore into DayService; log CloseDay, MarkDay, ReopenDay,
and the recomputeWeek closed-week upsert
- Inject syncStore into SettingsService; log Upsert, UpdateSettings,
DeleteSettings
- Add LogClosedWeek/LogClosedWeekDelete calls in WeekService.CloseWeek
and ReopenWeek
- Update main.go and all service test helpers for new constructor signatures
- All Go tests and 19 Vitest tests pass
- Migration 003: adds logged_at to sync_log for TTL pruning; migrates
settings_history to UUID TEXT PK with updated_at column
- SyncStore: Prune() deletes rows older than 30d and writes a '_pruned'
marker at the boundary version; Pull() calls Prune lazily and returns
ErrSyncStale (410) when the client's since_version is behind the marker
- sync_handler.go: GET /api/sync/pull?since=N; POST /api/sync/push with
last-updated_at-wins conflict resolution for entries, balance_adjustments,
settings_history; closed_days/closed_weeks skipped (server-only mutations)
- router.go: passes entryStore, adjustmentStore, settingsStore to SyncHandler
- settings_store.go: UUID PK, updated_at column, Upsert() for push path
- settings_service.go: generates UUID on create, sets updated_at on update
- settings_handler.go: ID params changed from int64 to string
- domain.go: Settings.ID string, Settings.UpdatedAt added
- client.ts: all mutation methods catch TypeError (offline) and fall back
to Dexie write + outbox enqueue; crypto.randomUUID() for offline creates;
Settings.id type changed to string
- db.ts: Dexie v3 — settings_history key path changed to string UUID;
upgrade handler clears table for repopulation via pull
- sync.ts: real pushOutbox to POST /api/sync/push; pullChanges uses GET
with ?since=N; 410 triggers coldStart() + retry; coldStart() wipes all
tables and resets last_version
- 4 new Go store tests covering normal pull, stale client, empty prune,
client-ahead-of-marker; all tests pass (store + service, 19 Vitest)
Backend:
- ClosedWeekStore.SumDelta: single SQL aggregate returning total delta_ms and
row count across all closed_weeks
- WeekService.Balance: thin passthrough returning BalanceResult{TotalDeltaMs, ClosedWeekCount}
- GET /api/weeks/balance handler; route registered alongside /weeks list/close/reopen
- Tests: store-level SumDelta (empty + populated), service-level Balance (empty + 2 weeks)
Frontend:
- weeks.balance() added to API client
- History page: balance card at top, fetched in parallel with existing data
- Loading state shows '—'; once loaded shows formatDelta value in green/red/gray
- Shows 'across N closed weeks' count alongside the value
Backend:
- SettingsStore: Add GetByID, Update, Delete, Count methods
- SettingsService: Add UpdateSettings (validates same rules as Upsert),
DeleteSettings (guards against deleting the last row → 409)
- New sentinels: ErrSettingsNotFound, ErrLastSettingsRow
- Handler: PUT /api/settings/history/{id} → 200 updated row
DELETE /api/settings/history/{id} → 204 / 404 / 409
Frontend:
- API client: settings.update(id, body) and settings.delete(id)
- Settings page: history table gains edit (pencil) and delete (×) buttons
- Inline edit form expands in place within the table row
- Delete button disabled and hint shown when only one row remains
- maskLabel() helper shows workday names instead of raw bitmask
- After save/delete: full reload to reflect changes in 'current' section
- Today nav link targets /week?week=<currentWeek>&day=<todayKey>
- Today tab active: route=/week AND ?day===todayKey() (not just pathname)
- Week tab active: route=/week AND today tab not active
- Root / redirect updated from /today to /week?week=…&day=…
- /today route hard-deleted (src/routes/today/ removed)
- +layout.svelte imports todayKey/currentWeekKey for link generation
- Add ErrFutureDay sentinel error
- CreateInterval: rejects if startDayKey > todayKey (400)
- Update: rejects if new start_time moves entry to a future day (400)
- Handler maps ErrFutureDay → 400 Bad Request for both endpoints
- Add TestCreateIntervalRejectsFutureDay
- Add TestUpdateRejectsMoveToFutureDay
- UI already gates this via dayCapabilities (canAddInterval=false,
canEditEntries=false for future days), but server now enforces it too
- weekKey and selectedDay driven by ?week=&?day= query params
- Bare /week canonicalizes via replaceState (adds week+day params)
- Chip clicks: replaceState (no history push, no scroll jump)
- Week prev/next: goto with history push (back/forward works)
- Default day when week changes: today if in week, else Monday
- Keyboard navigation: ArrowLeft/Right cycles through chips
- Selected chip scrolls into view on selection change
- DayDetail always rendered for selectedDay (not just today)
- detailCaps reactive on closedDaysMap — updates immediately after
close/reopen/mark without extra load()
- Chips now wired: selected prop, onclick handler
- DayChip tabindex: 0 for selected chip, -1 for others (roving tabindex)
- New DayDetail.svelte: self-contained day panel with full capability
gating (canStartStop, canAddInterval, canEditEntries, canMarkKind,
canCloseDay, canReopenDay)
- Closed-day state: banner + read-only entry list with 'Reopen day to
edit' hint; mark-kind buttons still available
- Close Day button disabled (with tooltip) when running entry exists
- Timer only ticks when dayKey === todayKey() and entry is running;
properly cleaned up on dayKey change and component destroy
- oninvalidate() callback: called after every mutation so parent
(week view) can refetch and update the chip strip
- /today route: refactored to thin wrapper using DayDetail
- Week page: renders DayDetail below summary when today is in the
displayed week; oninvalidate triggers full week reload
- New DayChip.svelte component: weekday label, date number, progress
bar (worked/expected), kind badge (H/V/S), closed checkmark, today
highlight, selected state, accessible role=tab/aria-selected
- Week page: replace days-grid with horizontal scroll-snap chip strip
- Per-day workedMs: uses closed_days.worked_ms when closed, else sums
open entries for that day (so in-progress work shows immediately)
- dailyExpectedMs: evenly split hours_per_week across workdays; 0 for
weekends (no progress bar rendered for non-workdays)
- Progress bar turns amber when worked > expected (overtime)
- weekEntries stored in state (was discarded after computing set);
daysWithEntries now derived from weekEntries
- Install vitest + jsdom
- Add test/test:watch scripts to package.json
- Add test:web and test:all tasks to mise.toml
- Add dayCapabilities() to utils.ts — single source of truth for
what actions are permitted per day (future/today/past, open/closed)
- Add DayCapabilities interface to utils.ts
- 11 unit tests: dayCapabilities (5 cases), weekDayKeys (3 cases),
isWorkday (3 cases)
Settings configured mid-week (e.g. Thursday) have an effective_from
of that date. CloseWeek was looking up settings as-of Monday, which
predates the new settings row and fell back to the old default.
Now uses today's date for the settings lookup, so any settings change
made before closing the week is correctly reflected in expected_ms.
The naive formula used (jan4.Weekday() - time.Monday) produces -1
when Jan 4 is a Sunday (Weekday()==0), shifting the computed Monday
one week forward. 2026 is affected: Jan 4 is a Sunday, so every
week key in 2026 was mapped to the wrong 7-day range, causing
CloseWeek to look for closed_days on the wrong dates and finding
nothing — resulting in worked_ms=0 and a full -Nh delta.
Fix: use (weekday+6)%7 to get days-since-Monday (Mon=0…Sun=6),
which is always non-negative.
Adds table-driven TestWeekDayKeys covering 2024 (Thu), 2026 (Sun),
and 2023 (Wed) to prevent regression.
When a day is closed, re-closed, or reopened, DayService now
recomputes worked_ms and delta_ms on the closed week containing
that day (if the week is already closed). This prevents stale
delta values after editing entries and re-closing a day.
- DayService.recomputeWeek: sums worked_ms from all closed_days
in the week, updates closed_weeks row preserving expected_ms
- NewDayService now takes ClosedWeekStore
- WeekKeyForDayKey exported helper (used by DayService)
- TestWeekSnapshotUpdatesWhenDayReopened regression test
closedWeek.delta_ms is a snapshot taken at close time and goes stale
if entries are edited or days are re-closed afterward. The summary
rows above already use the live totalWorkedMs - expectedMs; the
closed-week banner now uses the same expression.
Fetch entries for the week alongside days/weeks in the week view.
canCloseWeek now mirrors the server rule: every past workday that
has at least one entry must have a closed_days record. Days with
no entries are still skipped (they count as 0h implicitly).
The frontend was blocking week close until every workday had a
closed_days record, which no longer matches the backend's rules
(untracked days are implicitly 0h). Replace the all-workdays-closed
guard with a simple check: week has started (Monday ≤ today) and
is not already closed. The server returns a clear error if a day
with entries still needs closing.
Also fixes a pre-existing TS type narrowing error on currentSettings
and removes the now-unused .hint CSS rule.
Previously any past workday without a closed_days record blocked week
close. Now only days that actually have entries require an explicit
close. Empty workdays count as 0h worked, which is reflected in the
weekly delta automatically.
- WeekService.CloseWeek: after finding no closed_days record, check
whether the day has any entries; only error if it does
- NewWeekService: takes EntryStore to support the above check
- Updated TestCloseWeekMissingDayFails to reflect the new semantic
(test now creates entries on Friday but leaves it unclosed)
CloseWeek was requiring every workday in the ISO week to have a
closed_days record, including days in the future. Now only workdays
up to and including today are checked; future workdays are skipped.
Adds TestCloseWeekMidWeek regression test.