22 Commits

Author SHA1 Message Date
68e16fa4ca build: force vite-plugin-pwa to use project vite instance 2026-05-01 22:37:42 +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
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
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
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
185bb0c6a9 revert: display closedWeek.delta_ms in closed-week banner 2026-04-30 18:17:19 +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
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
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