[Scheduling] Campaign-anchored timezone correctness #95

Closed
opened 2026-07-14 19:48:20 +00:00 by claude-bot · 0 comments
Contributor

Context / Motivation

The app stores user.timezone (webapp/backend/app/models/user.py:34) and campaign.timezone (webapp/backend/app/models/campaign.py:41) — both nullable free-text — but the frontend uses neither. DateTimePicker's combine() (webapp/frontend/src/components/DateTimePicker.jsx:34-40) emits a naive "YYYY-MM-DDTHH:MM" string, and every submit path runs it through new Date(...).toISOString(), which interprets the naive string in the browser's zone:

  • webapp/frontend/src/pages/SessionDetail.jsx:551 (reschedule_time), :582 (vote slot times)
  • webapp/frontend/src/pages/CampaignDetail.jsx:658, :682 (milestone dates), :716 (proposed times)

Every display then uses toLocaleString in the browser zone (16 occurrences across 8 files; main ones: SessionDetail.jsx, CampaignDetail.jsx, CampaignPlanning.jsx, components/VotingGrid.jsx). There is no date library in webapp/frontend/package.json — native Date only.

Consequence: a GM whose browser zone differs from the campaign zone schedules a different wall-clock time than intended, and players in other zones see other times with no shared anchor.

Spec

campaign.timezone is the canonical scheduling zone.

  1. Input: all scheduling inputs (session create/reschedule, vote slot creation) are labeled "campaign time ()" and the naive picker value is converted campaign-zone → UTC explicitly before submit. Implement a small conversion helper using Intl.DateTimeFormat with timeZone (or date-fns-tz if a dependency is preferred — keep bundle impact small; no moment/luxon).
  2. Display: session times render as campaign time with an explicit zone label; where useful (session detail page, voting grid) show the viewer's local time as a secondary line ("your time: …"), driven by user.timezone when set, otherwise the browser zone.
  3. Backend/bot verification: stored times are DateTime(timezone=True) UTC (models/session.py:89 confirmed_time, :92 end_time) — verify all write paths receive proper UTC instants. The bot currently formats times as Discord dynamic timestamps <t:UNIX:F> (bot/questboard_bot/cogs/notifications.py _add_time_field ~614-620; cogs/sessions.py:75-89), which render in each Discord viewer's own zone — that behavior is acceptable and should be kept, but any plain-text time strings the bot emits must use the campaign zone. Fix any path that treats naive datetimes as local.
  4. Fallbacks: if campaign.timezone is unset, fall back to the browser zone but surface a settings nudge on the campaign page. Campaign settings should offer an IANA zone picker (validate against Intl.supportedValuesOf('timeZone')).
  5. Milestone dates (CampaignDetail.jsx:658/:682) are date-only — decide and document whether they are zone-less dates (recommended: store as plain date, stop running them through toISOString).

Out of scope

  • Recurring sessions / DST-aware series expansion (separate issue in this milestone).
  • Reading availability from external calendars.
  • Per-user display-format preferences (12h/24h).

Acceptance criteria

  • With browser TZ ≠ campaign TZ, scheduling 19:00 campaign time stores the UTC instant corresponding to 19:00 in the campaign zone (regression test with mocked TZ, e.g. TZ env / vi.setSystemTime + explicit zone fixtures).
  • Session detail and the voting grid label which zone every displayed time is in; secondary "your time" line appears when viewer zone differs.
  • Vote slot creation and reschedule paths covered by tests; no remaining new Date(naiveString).toISOString() on scheduling inputs.
  • Bot embeds unchanged (Discord <t:> timestamps); any plain-text times use campaign zone.

References

  • webapp/frontend/src/components/DateTimePicker.jsx:34-40
  • webapp/frontend/src/pages/SessionDetail.jsx:51, :551, :582
  • webapp/frontend/src/pages/CampaignDetail.jsx:658, :682, :716
  • webapp/backend/app/models/user.py:34, webapp/backend/app/models/campaign.py:41
  • webapp/backend/app/models/session.py:89-92
  • bot/questboard_bot/cogs/notifications.py (_add_time_field), bot/questboard_bot/cogs/sessions.py:75-89

Filed from the July 2026 full-project review.

## Context / Motivation The app stores `user.timezone` (`webapp/backend/app/models/user.py:34`) and `campaign.timezone` (`webapp/backend/app/models/campaign.py:41`) — both nullable free-text — but the frontend uses **neither**. `DateTimePicker`'s `combine()` (`webapp/frontend/src/components/DateTimePicker.jsx:34-40`) emits a naive `"YYYY-MM-DDTHH:MM"` string, and every submit path runs it through `new Date(...).toISOString()`, which interprets the naive string in the **browser's** zone: - `webapp/frontend/src/pages/SessionDetail.jsx:551` (`reschedule_time`), `:582` (vote slot times) - `webapp/frontend/src/pages/CampaignDetail.jsx:658`, `:682` (milestone dates), `:716` (proposed times) Every display then uses `toLocaleString` in the browser zone (16 occurrences across 8 files; main ones: `SessionDetail.jsx`, `CampaignDetail.jsx`, `CampaignPlanning.jsx`, `components/VotingGrid.jsx`). There is no date library in `webapp/frontend/package.json` — native `Date` only. Consequence: a GM whose browser zone differs from the campaign zone schedules a different wall-clock time than intended, and players in other zones see other times with no shared anchor. ## Spec **`campaign.timezone` is the canonical scheduling zone.** 1. **Input**: all scheduling inputs (session create/reschedule, vote slot creation) are labeled "campaign time (<zone>)" and the naive picker value is converted campaign-zone → UTC explicitly before submit. Implement a small conversion helper using `Intl.DateTimeFormat` with `timeZone` (or `date-fns-tz` if a dependency is preferred — keep bundle impact small; no moment/luxon). 2. **Display**: session times render as campaign time with an explicit zone label; where useful (session detail page, voting grid) show the viewer's local time as a secondary line ("your time: …"), driven by `user.timezone` when set, otherwise the browser zone. 3. **Backend/bot verification**: stored times are `DateTime(timezone=True)` UTC (`models/session.py:89` `confirmed_time`, `:92` `end_time`) — verify all write paths receive proper UTC instants. The bot currently formats times as Discord dynamic timestamps `<t:UNIX:F>` (`bot/questboard_bot/cogs/notifications.py` `_add_time_field` ~614-620; `cogs/sessions.py:75-89`), which render in each Discord viewer's own zone — that behavior is acceptable and should be kept, but any plain-text time strings the bot emits must use the campaign zone. Fix any path that treats naive datetimes as local. 4. **Fallbacks**: if `campaign.timezone` is unset, fall back to the browser zone but surface a settings nudge on the campaign page. Campaign settings should offer an IANA zone picker (validate against `Intl.supportedValuesOf('timeZone')`). 5. Milestone dates (`CampaignDetail.jsx:658/:682`) are date-only — decide and document whether they are zone-less dates (recommended: store as plain date, stop running them through `toISOString`). ## Out of scope - Recurring sessions / DST-aware series expansion (separate issue in this milestone). - Reading availability from external calendars. - Per-user display-format preferences (12h/24h). ## Acceptance criteria - With browser TZ ≠ campaign TZ, scheduling 19:00 campaign time stores the UTC instant corresponding to 19:00 in the campaign zone (regression test with mocked TZ, e.g. `TZ` env / `vi.setSystemTime` + explicit zone fixtures). - Session detail and the voting grid label which zone every displayed time is in; secondary "your time" line appears when viewer zone differs. - Vote slot creation and reschedule paths covered by tests; no remaining `new Date(naiveString).toISOString()` on scheduling inputs. - Bot embeds unchanged (Discord `<t:>` timestamps); any plain-text times use campaign zone. ## References - `webapp/frontend/src/components/DateTimePicker.jsx:34-40` - `webapp/frontend/src/pages/SessionDetail.jsx:51`, `:551`, `:582` - `webapp/frontend/src/pages/CampaignDetail.jsx:658`, `:682`, `:716` - `webapp/backend/app/models/user.py:34`, `webapp/backend/app/models/campaign.py:41` - `webapp/backend/app/models/session.py:89-92` - `bot/questboard_bot/cogs/notifications.py` (`_add_time_field`), `bot/questboard_bot/cogs/sessions.py:75-89` _Filed from the July 2026 full-project review._
rbrooks referenced this issue from a commit 2026-07-17 22:29:39 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#95
No description provided.