From 6ff3babd61324d105a7efdc0686816c29850393d Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 12 Jun 2026 19:05:15 +0200 Subject: [PATCH 1/2] Compute day boundaries in the user's timezone, not the browser's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 6d7138584. 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) --- app/javascript/helpers/date_helpers.js | 42 +++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/app/javascript/helpers/date_helpers.js b/app/javascript/helpers/date_helpers.js index b8d9855f47..f8284152fc 100644 --- a/app/javascript/helpers/date_helpers.js +++ b/app/javascript/helpers/date_helpers.js @@ -7,9 +7,49 @@ export function signedDifferenceInDays(fromDate, toDate) { } export function beginningOfDay(date) { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const { year, month, day } = datePartsInTimezone(date) + return new Date(Date.UTC(year, month - 1, day)) } export function secondsToDate(seconds) { return new Date(seconds * 1000) } + +// Snap a timestamp to midnight using the user's timezone (from the `timezone` +// cookie the server reads too), so client day boundaries match the server's +// and don't follow the browser's resolved timezone, which can differ in a PWA. +function datePartsInTimezone(date) { + return dateFormatter().formatToParts(date).reduce((parts, { type, value }) => { + if (type !== "literal") parts[type] = parseInt(value, 10) + return parts + }, {}) +} + +let dateFormatterCache +let dateFormatterTimezone + +function dateFormatter() { + const timezone = currentTimezone() + + if (!dateFormatterCache || dateFormatterTimezone !== timezone) { + dateFormatterTimezone = timezone + dateFormatterCache = buildDateFormatter(timezone) + } + + return dateFormatterCache +} + +function buildDateFormatter(timezone) { + const options = { year: "numeric", month: "2-digit", day: "2-digit" } + + try { + return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timezone }) + } catch { + return new Intl.DateTimeFormat("en-US", options) + } +} + +function currentTimezone() { + const cookie = document.cookie.split("; ").find(entry => entry.startsWith("timezone=")) + return cookie ? decodeURIComponent(cookie.split("=")[1]) : undefined +} From 718f208543fdfe7725cef14a02056353e0e738a8 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 12 Jun 2026 19:12:48 +0200 Subject: [PATCH 2/2] Read the timezone from a meta tag instead of the cookie in JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/javascript/helpers/date_helpers.js | 15 ++++++--------- app/javascript/helpers/meta_helpers.js | 3 +++ app/views/layouts/shared/_head.html.erb | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 app/javascript/helpers/meta_helpers.js diff --git a/app/javascript/helpers/date_helpers.js b/app/javascript/helpers/date_helpers.js index f8284152fc..923a7b6ca2 100644 --- a/app/javascript/helpers/date_helpers.js +++ b/app/javascript/helpers/date_helpers.js @@ -1,3 +1,5 @@ +import { getMetaContent } from "helpers/meta_helpers" + export function differenceInDays(fromDate, toDate) { return Math.round(Math.abs((beginningOfDay(toDate) - beginningOfDay(fromDate)) / (1000 * 60 * 60 * 24))) } @@ -15,9 +17,9 @@ export function secondsToDate(seconds) { return new Date(seconds * 1000) } -// Snap a timestamp to midnight using the user's timezone (from the `timezone` -// cookie the server reads too), so client day boundaries match the server's -// and don't follow the browser's resolved timezone, which can differ in a PWA. +// Snap a timestamp to midnight using the timezone the server rendered with (the +// `timezone` meta tag), so client day boundaries match the server's instead of +// following the browser's resolved timezone, which can differ in a PWA. function datePartsInTimezone(date) { return dateFormatter().formatToParts(date).reduce((parts, { type, value }) => { if (type !== "literal") parts[type] = parseInt(value, 10) @@ -29,7 +31,7 @@ let dateFormatterCache let dateFormatterTimezone function dateFormatter() { - const timezone = currentTimezone() + const timezone = getMetaContent("timezone") if (!dateFormatterCache || dateFormatterTimezone !== timezone) { dateFormatterTimezone = timezone @@ -48,8 +50,3 @@ function buildDateFormatter(timezone) { return new Intl.DateTimeFormat("en-US", options) } } - -function currentTimezone() { - const cookie = document.cookie.split("; ").find(entry => entry.startsWith("timezone=")) - return cookie ? decodeURIComponent(cookie.split("=")[1]) : undefined -} diff --git a/app/javascript/helpers/meta_helpers.js b/app/javascript/helpers/meta_helpers.js new file mode 100644 index 0000000000..a53f7312a9 --- /dev/null +++ b/app/javascript/helpers/meta_helpers.js @@ -0,0 +1,3 @@ +export function getMetaContent(name) { + return document.querySelector(`meta[name="${name}"]`)?.getAttribute("content") +} diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 9ef153df2b..bddd34b4a4 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -11,6 +11,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %> + <%= tag.meta name: "timezone", content: Time.zone.name %> <%= tag.meta name: "vapid-public-key", content: Rails.configuration.x.vapid.public_key %> <% turbo_refreshes_with method: :morph, scroll: :preserve %>