Skip to main content

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.tsx adds LocalSpan (per row: your time Tue, 9:00 PM → Wed, 5:00 AM), DayCrossingFlag (compact +1d on a month cell), and TimezoneNote (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 renders null until mount (useViewerTz), and renders nothing at all when viewer and facility share a zone.
  • Never hardcode the offset. crossesViewerMidnight in src/lib/dates/tz.ts derives everything from Intl, 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 businessDayInTz on the assigned facility's timezone, which is the same clock punchIn uses to stamp entry_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 company operating_timezone is only a fallback for a contractor with no active assignment, for whom nothing is being bucketed yet.
  • facilities.timezone and settings.operating_timezone are 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 via isValidTimeZone rather than throwing out of Intl. Tightening facilitySchema.timezone to a z.enum(COMMON_TIMEZONES) is the real fix and is left as follow-up.
  • SegmentRows and WeekTable now require dateISO + facilityTz props; they are display inputs only and are stripped from the server-action payload by timePairSchema as 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.