40 Commits

Author SHA1 Message Date
68e16fa4ca build: force vite-plugin-pwa to use project vite instance 2026-05-01 22:37:42 +02:00
905d668847 build: replace install task with mise deps for automatic dependency management 2026-05-01 22:34:46 +02:00
fe5603e206 build: add Containerfile for multi-stage container image
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
2026-05-01 22:05:29 +02:00
a372c17860 feat: sync status indicator in nav
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
2026-05-01 21:44:50 +02:00
57697ec2aa feat: offline-first client
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)
2026-05-01 16:35:02 +02:00
f4836a6fa2 feat: be more lenient on sync 2026-05-01 15:26:54 +02:00
f602e08b5a feat: responsive day chips via CSS container queries
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.
2026-05-01 13:29:07 +02:00
bf2c4a3301 build: exclude binaries from git 2026-05-01 12:11:54 +02:00
725df56cc8 feat: offline read fallback + online status indicator
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.
2026-05-01 09:47:14 +02:00
31535e944d fix: move embed to repo root; make build sequential
- 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.
2026-05-01 09:39:52 +02:00
a8a4ea0d4f M9.1: wire sync logging into all mutation paths
- 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
2026-04-30 22:57:02 +02:00
d8366f5c25 Add sync redesign with offline fallback (M9)
- 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)
2026-04-30 22:50:33 +02:00
3214f48a6f Add balance adjustments (M8)
- New balance_adjustments table with CRUD store, sync logging, and service methods
- SQL migrations restructured: embed fs.FS from internal/store/migrations/, apply in order via user_version
- WeekService.Balance combines closed-weeks delta + adjustments delta; BalanceSummary breakdown
- Four REST routes: GET/POST /api/balance/adjustments, PUT/DELETE /api/balance/adjustments/{id}
- Dexie schema v2 + sync apply cases for balance_adjustments
- API client: BalanceAdjustment type, balance namespace (list/create/update/delete)
- utils: composeDeltaMs / decomposeDeltaMs helpers + 8 new Vitest tests (19 total, all passing)
- History page: balance card breakdown line + full adjustments section with inline add/edit/delete
2026-04-30 21:50:57 +02:00
8ca838fa6e feat: overall overtime balance on history page
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
2026-04-30 20:01:24 +02:00
15bf3c3a18 feat: edit and delete settings history rows
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
2026-04-30 19:50:27 +02:00
b25340644b refactor(nav): Today tab shortcut to week view; delete /today route
- 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
2026-04-30 19:10:17 +02:00
bf0c728818 fix(entries): reject create/update of intervals in the future
- 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
2026-04-30 19:09:22 +02:00
4c2b220482 feat(week): day selection with URL-driven state
- 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)
2026-04-30 19:08:11 +02:00
d0c1f41c13 refactor: extract DayDetail component; render today inside week view
- 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
2026-04-30 19:06:55 +02:00
3e4e93a814 feat(week): add day strip with per-day progress bars and status
- 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
2026-04-30 19:04:38 +02:00
6c4f78d101 chore(web): add Vitest; add dayCapabilities helper with full test coverage
- 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)
2026-04-30 19:03:04 +02:00
73e5b01577 docs: update PLAN.md — merge Today+Week view stages M7, add decisions 2026-04-30 19:01:47 +02:00
47dd2c9779 fix: CloseWeek uses settings effective at close time, not week Monday
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.
2026-04-30 18:23:13 +02:00
e37458f513 fix: weekDayKeys formula breaks when Jan 4 falls on Sunday
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.
2026-04-30 18:20:37 +02:00
185bb0c6a9 revert: display closedWeek.delta_ms in closed-week banner 2026-04-30 18:17:19 +02:00
47c7a97d47 fix: keep closed week snapshot in sync when days change
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
2026-04-30 18:16:22 +02:00
78c2c7c8a5 fix: show live delta in closed-week banner instead of stale frozen value
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.
2026-04-30 18:12:34 +02:00
3fd1455704 fix: hide close-week button when a tracked day is still open
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).
2026-04-30 18:10:44 +02:00
563784d5fb fix: show Close week button without requiring all workdays closed
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.
2026-04-30 18:08:06 +02:00
c675a7b01d fix: skip untracked workdays when closing a week
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)
2026-04-30 17:59:04 +02:00
6fceda46b5 fix: allow closing current week when future workdays are not yet closed
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.
2026-04-30 17:54:38 +02:00
9d6233b116 feat: add manual interval creation and inline entry editing
- Service: CreateInterval() validates same-day, end>start, day not closed
- Service: Update() now rejects edits on closed-day entries (ErrDayAlreadyClosed)
- Handler: POST /api/entries creates a completed interval with explicit times
- API client: entries.createInterval(startMs, endMs, note)
- Utils: parseTimeInput / toTimeInput helpers for HH:MM <-> unix ms
- Today page: '+ Add interval' form (time pickers + optional note)
- Today page: pencil button on each entry opens inline edit row (start/end/note)
- Tests: TestCreateInterval, TestCreateIntervalEndBeforeStart,
         TestCreateIntervalCrossesMidnight, TestUpdateRejectsClosedDay
2026-04-30 17:45:02 +02:00
245edf1534 chore: add mise.toml for tool versions and tasks, thin Makefile shim 2026-04-30 17:35:28 +02:00
2055a46dee feat(m6): CSV export, Makefile, README, single-binary build 2026-04-30 16:48:44 +02:00
4a328ad6cc feat(m5): PWA service worker, offline Dexie store, outbox, sync endpoints 2026-04-30 16:47:27 +02:00
df04d9d7a9 feat(m4): SvelteKit frontend - today, week, history, settings views 2026-04-30 16:45:00 +02:00
d0ef0387f2 feat(m3): week close, overtime/undertime delta, frozen settings snapshot 2026-04-30 16:39:42 +02:00
4a0e0c8318 feat(m2): settings history, close day, holiday/vacation/sick marking 2026-04-30 16:37:56 +02:00
3aa068efd2 feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations 2026-04-30 16:35:06 +02:00
4905c6f570 docs: add implementation plan 2026-04-30 16:24:39 +02:00