51. Viewer-local time gloss on the contractor portal
Date: 2026-08-04
Status
Accepted
Context
Contractors work in the Philippines (Asia/Manila, UTC+8, no DST) for facilities in the US Eastern
zone (America/New_York, UTC−4/−5). ADR-0027 made the facility business day the day a shift is
filed under, and the portal renders every time in facility time with a single line of microcopy
("Times are in facility time (EDT)").
That leaves the contractor doing the conversion by hand on every row — and the conversion is not a constant. The gap is 12h under EDT and 13h under EST, because Manila has no DST. Worse, a normal 09:00–17:00 ET shift runs 9:00 PM → 5:00 AM the next day in Manila, so half a contractor's logged hours appear to sit on the "wrong" date against their own phone. At a bi-monthly period boundary this reads as a payroll error: a shift starting the evening of the 15th ends on the 16th locally but is still period 1.
Decision
- One canonical day, never two calendars. The facility business day stays the sole spine for entry, aggregation, periods, and pay. There is deliberately no "show in my timezone" toggle — it would create a second truth that disagrees with the payslip and doubles support load.
- Render the viewer's clock alongside it, as display only.
src/components/contractor/LocalTime.tsxaddsLocalSpan(per row:your time Tue, 9:00 PM → Wed, 5:00 AM),DayCrossingFlag(compact+1don a month cell), andTimezoneNote(the rule, stated once). The punch widget gains a second clock. None of it feeds any write path. - The viewer's zone comes from the device, via
deviceTimeZone()— no per-contractor column, no setting, and it stays right if they travel. It is unknown during SSR, so every gloss rendersnulluntil mount (useViewerTz), and renders nothing at all when viewer and facility share a zone. - Never hardcode the offset.
crossesViewerMidnightinsrc/lib/dates/tz.tsderives everything fromIntl, so the two DST changeover weeks each year are handled silently. - State the rule where trust breaks: the payslips page says periods run on facility days and a
shift counts on the day it started — which is already the behaviour ADR-0027 chose, just never
said out loud. (
docs/ux-ui-guidelines.md→ "Trust & credibility": show provenance on payroll values.)
Consequences
- Two pre-existing UTC bugs fixed as a precondition: the calendar's default month and the home
page's current pay period were both computed in UTC — neither the facility's clock nor the
contractor's — so each rolled over hours early or late at a month/period boundary. Both now derive
from
businessDayInTzon the assigned facility's timezone, which is the same clockpunchInuses to stampentry_date. Any "what day/period is it for this contractor?" question must read that clock (activeAssignmentTimezone) or it can name period N while a punch made in the same minute files under N+1; the companyoperating_timezoneis only a fallback for a contractor with no active assignment, for whom nothing is being bucketed yet. facilities.timezoneandsettings.operating_timezoneare constrained only by the admin<select>, not by their schemas. Since the home page now resolves a pay period through them, both reads fall back viaisValidTimeZonerather than throwing out ofIntl. TighteningfacilitySchema.timezoneto az.enum(COMMON_TIMEZONES)is the real fix and is left as follow-up.SegmentRowsandWeekTablenow requiredateISO+facilityTzprops; they are display inputs only and are stripped from the server-action payload bytimePairSchemaas before.- Adding a facility outside the US, or a contractor outside PH, needs no change — the gloss is computed from whatever the two zones actually are.