fix(frontend): anchor session times to the campaign timezone (#95) #185

Merged
claude-bot merged 1 commit from feat/95-campaign-timezone into main 2026-07-17 16:17:10 +00:00
Contributor

Fixes #95. First of the v3.6.0 (Scheduling) milestone — and the correctness foundation the other three issues build on.

The bug

DateTimePicker emits a naive "YYYY-MM-DDTHH:MM" wall-clock string, and every submit path ran it through new Date(naive).toISOString() — which parses the string in the browser's zone. A GM whose browser zone differed from the campaign zone stored a different instant than the wall-clock time they typed, and every viewer then saw times in their own zone with no shared anchor.

The backend and bot were already correct (UTC DateTime columns; Discord <t:UNIX:F> dynamic timestamps that render per-viewer). So despite touching scheduling, the entire fix is frontend-side — confirmed by recon across all write/display/format paths.

Approach — one tested helper, native Intl, no new dependency

src/utils/datetime.js. I deliberately did not add date-fns-tz: the frontend has no date library, and I wasn't going to add one right after #105 cut the entry chunk 64%. Result: this whole fix adds ~0.25 kB (244.25 → 244.50 kB entry).

  • zonedNaiveToUtcIso — campaign-zone wall-clock → UTC, DST-correct via a two-pass offset resolution. 19:00 campaign-time is a different UTC instant in summer vs winter, and the tests prove both.
  • formatSessionTime / zoneShortLabel — render a stored UTC instant in the campaign zone with an explicit label (e.g. "Tue, Aug 4, 2026, 7:00 PM CDT"), so no displayed time is zone-ambiguous.
  • viewerSecondaryTime — a "your time" line, only when the viewer's zone differs.
  • Floating-date helpers — milestone dates are calendar dates, not instants. Anchored at UTC-noon and displayed in UTC, so the day never shifts across zones. (Legacy rows at UTC-midnight also display correctly under UTC — no migration.)

Wired every scheduling site

  • Write (campaign-zone → UTC): SessionDetail reschedule + reopen-for-voting; CampaignDetail new-session proposed times. Milestones use the floating-date helpers (create/update/read-back).
  • Display (campaign zone + label): SessionDetail confirmed time + slots; VotingGrid slot headers; CampaignDetail session lists; CampaignPlanning session labels. SessionDetail + VotingGrid also render the "your time" secondary.
  • Inputs labelled "campaign time (<zone>)"; campaign settings upgraded to the full Intl.supportedValuesOf('timeZone') list, plus an unset-zone nudge.

Acceptance criteria

  • Browser TZ ≠ campaign TZ stores the right UTC instant — the rewritten reopen test types 18:00 with the campaign in America/Chicago and asserts 23:00Z (CDT), not the host zone. Plus 22 helper tests with explicit-zone fixtures (host-independent).
  • Session detail + voting grid label the zone; "your time" appears when the viewer differs — page test: viewer in Tokyo, campaign in Chicago → primary shows 3:00 PM … CDT, secondary shows 5:00 AM JST.
  • Vote-slot creation and reschedule covered; no remaining new Date(naiveString).toISOString() on scheduling inputs — all five write sites converted.
  • Bot embeds unchanged — no bot changes; recon confirmed it already uses <t:> timestamps exclusively (no strftime anywhere in bot/).

Milestone dates — the documented decision

Per the issue's point 5: milestone dates are floating calendar dates, anchored UTC-noon and displayed in UTC. This keeps the fix frontend-only (the column stays DateTime) while making "the ball is on Aug 4" read Aug 4 for everyone.

Verification

node:20 (matching CI): npm run lint → 0 problems (exhaustive-deps enforced); npx vitest run129/129 (was 106; +22 helper, +1 display); npx vite build → succeeds, entry chunk essentially unchanged.

DST cases pinned by tests: America/Chicago summer (CDT/UTC-5) and winter (CST/UTC-6), Asia/Kolkata (UTC+5:30), Europe/Berlin both seasons, Pacific/Auckland, plus the fall-back overlap and spring-forward gap.

🤖 Generated with Claude Code

Fixes #95. First of the v3.6.0 (Scheduling) milestone — and the correctness foundation the other three issues build on. ## The bug `DateTimePicker` emits a naive `"YYYY-MM-DDTHH:MM"` wall-clock string, and every submit path ran it through `new Date(naive).toISOString()` — which parses the string in the **browser's** zone. A GM whose browser zone differed from the campaign zone stored a *different instant* than the wall-clock time they typed, and every viewer then saw times in their own zone with no shared anchor. **The backend and bot were already correct** (UTC `DateTime` columns; Discord `<t:UNIX:F>` dynamic timestamps that render per-viewer). So despite touching scheduling, the entire fix is **frontend-side** — confirmed by recon across all write/display/format paths. ## Approach — one tested helper, native `Intl`, no new dependency `src/utils/datetime.js`. I deliberately did **not** add `date-fns-tz`: the frontend has no date library, and I wasn't going to add one right after #105 cut the entry chunk 64%. Result: this whole fix adds **~0.25 kB** (244.25 → 244.50 kB entry). - **`zonedNaiveToUtcIso`** — campaign-zone wall-clock → UTC, **DST-correct** via a two-pass offset resolution. 19:00 campaign-time is a *different* UTC instant in summer vs winter, and the tests prove both. - **`formatSessionTime` / `zoneShortLabel`** — render a stored UTC instant in the campaign zone with an explicit label (e.g. "Tue, Aug 4, 2026, 7:00 PM CDT"), so no displayed time is zone-ambiguous. - **`viewerSecondaryTime`** — a "your time" line, only when the viewer's zone differs. - **Floating-date helpers** — milestone dates are *calendar dates, not instants*. Anchored at UTC-noon and displayed in UTC, so the day never shifts across zones. (Legacy rows at UTC-midnight also display correctly under UTC — **no migration**.) ## Wired every scheduling site - **Write (campaign-zone → UTC):** SessionDetail reschedule + reopen-for-voting; CampaignDetail new-session proposed times. Milestones use the floating-date helpers (create/update/read-back). - **Display (campaign zone + label):** SessionDetail confirmed time + slots; VotingGrid slot headers; CampaignDetail session lists; CampaignPlanning session labels. SessionDetail + VotingGrid also render the "your time" secondary. - **Inputs** labelled "campaign time (`<zone>`)"; campaign settings upgraded to the full `Intl.supportedValuesOf('timeZone')` list, plus an unset-zone nudge. ## Acceptance criteria - [x] **Browser TZ ≠ campaign TZ stores the right UTC instant** — the rewritten reopen test types `18:00` with the campaign in `America/Chicago` and asserts `23:00Z` (CDT), not the host zone. Plus 22 helper tests with explicit-zone fixtures (host-independent). - [x] **Session detail + voting grid label the zone; "your time" appears when the viewer differs** — page test: viewer in Tokyo, campaign in Chicago → primary shows `3:00 PM … CDT`, secondary shows `5:00 AM` JST. - [x] **Vote-slot creation and reschedule covered; no remaining `new Date(naiveString).toISOString()` on scheduling inputs** — all five write sites converted. - [x] **Bot embeds unchanged** — no bot changes; recon confirmed it already uses `<t:>` timestamps exclusively (no `strftime` anywhere in `bot/`). ## Milestone dates — the documented decision Per the issue's point 5: milestone dates are **floating calendar dates**, anchored UTC-noon and displayed in UTC. This keeps the fix frontend-only (the column stays `DateTime`) while making "the ball is on Aug 4" read Aug 4 for everyone. ## Verification node:20 (matching CI): `npm run lint` → 0 problems (exhaustive-deps enforced); `npx vitest run` → **129/129** (was 106; +22 helper, +1 display); `npx vite build` → succeeds, entry chunk essentially unchanged. DST cases pinned by tests: America/Chicago summer (CDT/UTC-5) and winter (CST/UTC-6), Asia/Kolkata (UTC+5:30), Europe/Berlin both seasons, Pacific/Auckland, plus the fall-back overlap and spring-forward gap. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(frontend): anchor session times to the campaign timezone (#95)
All checks were successful
CI / Docker image build (pull_request) Successful in 29s
CI / Backend lint (ruff) (pull_request) Successful in 51s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m35s
CI / Bot tests and audit (pull_request) Successful in 2m2s
CI / Backend migration, tests, and audit (pull_request) Successful in 3m31s
6c3144557e
DateTimePicker emits a naive "YYYY-MM-DDTHH:MM" wall-clock string, and every
submit path ran it through new Date(naive).toISOString(), which interprets the
string in the browser's zone. A GM whose browser zone differed from the campaign
zone therefore stored a different instant than the wall-clock time they typed,
and every viewer saw times in their own zone with no shared anchor. The backend
(UTC DateTime columns) and bot (Discord <t:UNIX:F> dynamic timestamps) were
already correct — the entire bug was frontend-side.

Add src/utils/datetime.js, a small native-Intl helper (no date library, so the
entry chunk is unchanged after #105's 64% cut):

- zonedNaiveToUtcIso: convert a campaign-zone wall-clock string to a UTC instant,
  DST-correct via a two-pass offset resolution (19:00 campaign-time is a
  different UTC instant in summer vs winter).
- formatSessionTime / zoneShortLabel: render a stored UTC instant in the campaign
  zone with an explicit zone label, so a time is never zone-ambiguous.
- viewerSecondaryTime: a "your time" rendering when the viewer's zone differs.
- floating-date helpers: milestone dates are calendar dates, not instants, so
  they anchor at UTC noon and display in UTC — the day can't shift across zones.
  (Legacy rows written at UTC midnight also display correctly under UTC.)

Wire every scheduling site onto it:

- Write paths (campaign-zone -> UTC): SessionDetail reschedule + reopen-for-
  voting, CampaignDetail new-session proposed times. Milestone create/update/
  read-back use the floating-date helpers.
- Display paths (campaign zone + label): SessionDetail confirmed time and slots,
  VotingGrid slot headers, CampaignDetail session lists, CampaignPlanning session
  labels. SessionDetail and VotingGrid also show the "your time" secondary.
- Scheduling inputs are labelled "campaign time (<zone>)"; campaign settings now
  offer the full Intl.supportedValuesOf('timeZone') list plus an unset-zone nudge.

Tests: 22 helper tests (DST both seasons, half-hour zones, fall-back/spring-
forward folds, round-trip invariant, floating dates), a page test asserting
campaign-zone display + the "your time" line, and the reopen test rewritten to
expect the campaign-zone conversion instead of the old browser-zone bug.
Verified in node:20: lint clean, 129/129 tests, build succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/95-campaign-timezone 2026-07-17 16:17:10 +00:00
Sign in to join this conversation.
No description provided.