Skip to content

Compute day boundaries in the user's timezone, not the browser's#2934

Open
rosa wants to merge 2 commits into
mainfrom
fix-day-boundary-in-user-timezone
Open

Compute day boundaries in the user's timezone, not the browser's#2934
rosa wants to merge 2 commits into
mainfrom
fix-day-boundary-in-user-timezone

Conversation

@rosa

@rosa rosa commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the recurring bug where cards (and other relative dates) display as one day earlier for users in UTC-negative timezones — most recently reported by a customer in America/Indianapolis who saw cards created earlier the same day flip to "yesterday" after their local 7 PM.

Card: https://app.basecamp.com/2914079/buckets/27/card_tables/cards/9749169131

Background

This is the third attempt at this bug:

  • OriginalbeginningOfDay() snapped to midnight with the browser's local Date methods.
  • Fix off-by-one date display for UTC-negative timezone users #2790 — swapped to UTC methods. Fixed UTC-negative users in one direction but broke the symmetric case (cards created earlier the same day showing "yesterday" after local 7 PM), so it was reverted in 6d7138584 (that revert also added Time.zone.name to the day-timeline fragment cache keys — kept here).

Root cause

Both prior attempts share the same flaw: the client chose a day boundary that could disagree with the server.

  • The server groups and renders days using the timezone resolved from the timezone cookie (CurrentTimezone#set_current_timezone).
  • The client (beginningOfDay) followed the browser's resolved timezone — which in a PWA/webview can resolve to UTC even when the cookie correctly holds the user's IANA zone.

So around the local-vs-UTC midnight boundary, client and server disagreed by a day. Picking local vs UTC just moved which side broke.

Fix

Make the server the single source of truth for the timezone and have the client snap day boundaries to that exact zone:

  • The server already resolves the cookie into Time.zone. Expose it as a <meta name="timezone"> tag in the shared head (Time.zone.name, which is the IANA identifier, or UTC when there's no cookie).
  • Add a small getMetaContent helper (mirrors the bc3 pattern) and have beginningOfDay() read the meta tag — no cookie parsing in JS.
  • beginningOfDay() uses Intl.DateTimeFormat({ timeZone })formatToParts to read Y/M/D in that zone, then anchors to Date.UTC(...) (both comparison anchors constructed the same way → exact, DST-proof day deltas). Falls back to the runtime timezone if the tag is missing or invalid.

The client now always agrees with the server, whatever zone the server rendered with.

Verification

No JS unit-test harness exists in this repo (importmap, no package.json), so the logic was checked standalone against the reported scenarios:

Scenario Result
Earlier card, now past midnight UTC (Lee's "before 7, update after 7") today ✓
Symmetric case the UTC fix broke today ✓
Genuinely yesterday's card yesterday ✓
No cookie (server + client both UTC) consistent ✓
Missing / invalid meta tag graceful fallback, no crash ✓

Confirmed ActiveSupport::TimeZone["America/New_York"].name returns the IANA identifier ("America/New_York"), so Time.zone.name is safe to feed to Intl.DateTimeFormat. Worth a manual check in the PWA before deploy.

🤖 Generated with Claude Code

The "today/yesterday" labels and other relative date displays compared
day boundaries using beginningOfDay(), which snapped to midnight with the
browser's local Date methods. #2790 swapped these for UTC methods, which
fixed UTC-negative users in one direction but broke the symmetric case
(cards created earlier the same day showing "yesterday" after local 7 PM),
so it was reverted in 6d71385.

Both attempts share a flaw: the client picked a day boundary that could
disagree with the server. The server groups and renders days using the
timezone from the `timezone` cookie (CurrentTimezone), but the client
followed the browser's resolved timezone — which in a PWA/webview can
resolve to UTC even when the cookie correctly holds the user's IANA zone.
The two then disagreed by a day around the local-vs-UTC midnight boundary.

Snap beginningOfDay() to midnight in the cookie's timezone instead, so the
client's day boundaries match the server's. Falls back to the runtime
timezone when the cookie is absent or invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 12, 2026 17:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates client-side “day boundary” calculations to align with the server’s timezone selection (from the timezone cookie), addressing off-by-one relative date labels for users whose browser-resolved timezone can differ from the server’s (notably in PWA/webview scenarios).

Changes:

  • Reworks beginningOfDay() to snap to midnight based on Y/M/D in the cookie timezone rather than the browser’s local Date methods.
  • Introduces a cached Intl.DateTimeFormat(...).formatToParts(...) formatter keyed by the current cookie timezone, with a fallback to the runtime timezone when invalid.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/javascript/helpers/date_helpers.js Outdated
Comment on lines +52 to +55
function currentTimezone() {
const cookie = document.cookie.split("; ").find(entry => entry.startsWith("timezone="))
return cookie ? decodeURIComponent(cookie.split("=")[1]) : undefined
}
The server already resolves the `timezone` cookie into Time.zone (in
CurrentTimezone) and renders day groupings with it. Rather than have the
client re-read and parse the cookie, expose the resolved zone in a
`timezone` meta tag and read it with getMetaContent — the same pattern bc3
uses. This makes the server the single source of truth: the client snaps
day boundaries to the exact zone the server rendered with, and falls back
to the runtime zone if the tag is missing or invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants