27. Time-entry modes, segments, and facility timezone
Date: 2026-06-07
Status
Accepted
Context
Contractors originally logged one hours number per day (ADR-0016). The owner wants richer,
real time tracking — a live punch clock, from–to time pairs (multiple per day), a
weekly-table bulk entry, plus the original daily-total entry — and wants which methods are
available to be configurable, not hardcoded (the prod Setup-Menu principle). Times must be
shown in the assigned facility's timezone, which the schema did not store. The tz→business-day
conversion was deferred in ADR-0012/0024.
Decision
- Segments, not a new aggregate. Add
time_segments(a punch in/out or a from–to pair;ended_atnull = an open punch, with a partial unique index enforcing one open punch per contractor). A day'stime_entries.hoursis recomputed as Σ closed-segment durations byrecomputeDayEntry(src/server/services/timeEntry.ts).time_entriesstays the single daily aggregate every downstream consumer (pay, payroll, invoice variance, performance, overage, calendar) already reads — so no consumer changes. - Four configurable modes (
punch,weekly_table,calendar_breakdown,calendar_total) stored insettings.time_entry_modes(enabled set + default), edited on an Owner-only Setup Menu (/admin/setup). The contractor calendar offers only the enabled modes. - Per-facility timezone.
facilities.timezone(IANA; defaultAmerica/New_York/EST·EDT, also the companyoperating_timezonedefault now).src/lib/dates/tz.tsis the pure wall-clock↔UTC boundary:businessDayInTzdecides the day a punch belongs to; manual pairs are stored as UTC instants viazonedWallTimeToUtc. The rest of the date layer stays timezone-free UTC day math (ADR's existing invariant). - New week colours:
weekColor→ green = contracted met, yellow = overage, red = under (red as soon as a week is below contracted, including empty/future weeks).
Consequences
- All time-entry writes still go through vetted service-role server actions (ADR-0004); RLS on
time_segmentsmirrorstime_entriesas the read-path safety net. - The daily-total invariant means migrations to richer entry are non-breaking and reversible by
mode toggle. Legacy
hours-only days (and Hubstaff-fed in-house hours) keep working — they simply have no segments and display their total. - Overnight punches are attributed to the
started_atbusiness day; a day's hours clamp at 24. src/db/types.tsregenerated from the live schema after migration0020.