Repository navigation
A server page renders the family's date: 24 zone-less date renders on 13 family server files, and the three emails take the family's zone - #942
Conversation
… 13 family server files, and the three emails take the family's zone
`toLocaleDateString(locale, { month, day })` with no `timeZone` renders in the
zone of the machine running it. In a browser that is the reader's own clock.
On the UTC host that renders a server component or runs a cron it is
Greenwich's, and from 5pm in California Greenwich is already on tomorrow.
tests/a-server-label-reads-the-familys-clock.test.ts (#932) ratchets the API
routes and server libraries; this unit covers the two roots it does not — the
family-facing server pages and actions under app/(app), and the email
templates a cron renders for a family in another zone.
Pages (each already knows the family's zone; most already held a formatter
bound to it, whose `fmtDate` also renders a DATE column in no zone at all):
- home: the Coming Up badge's month AND day (`d.getDate()` put a 7pm
Californian event on tomorrow's square), a task's due day, a photo's day →
the bound `fmtDate` ('MMM', 'd', 'MMM d').
- memories (album and event dates), planning (five day labels), food (two) →
`getFormat(tz).fmtDate`; the helpers take the formatter, not a locale.
- conflicts: both ends of a conflict carry `timeZone: tz`, and "same day" is
decided with `dayKeyInTz` instead of the host's `toDateString()`.
- concierge run history: `timeZone: tz` on when a run finished.
- playbook action: the month a tradition falls in, `{ ...MONTH_FMT, timeZone: tz }`.
- sync history, a sync account's "last synced", a social post's attempts: a
bare `toLocaleString()` — the host's zone in the host's LOCALE — becomes
`fmtDate(…, 'MMM d, yyyy h:mm a')` in the family's zone and locale.
- treasury: the six-month trend also BUCKETED by the host's months; it now
buckets by `dayKeyInTz(created_at, tz).slice(0, 7)`, starts "this month" at
the family's first, and labels each month from a DATE-only key.
- display: a month-and-day built and rendered in one explicit zone (UTC) —
right by accident before, explicit now.
Emails (`timeZone` prop, from callers that read `families.timezone`):
- weekly digest "Week of …" in the family's zone; an event's DAY KEY is
rendered in UTC so it is that day in every zone (the route already resolved
it in `tz`); the route passes `timeZone: tz`.
- notification digest's weekday line: the sender reads each family's zone once
per batch (`readInChunks` over `families`) and dates each recipient's digest
in the zone of their first notification's family; a failed read logs and
dates in an explicit UTC rather than holding the digests.
- chore reminder "Due …": the route selects `timezone` with the family name;
the template names 'en-US' and the zone (it was a bare
`toLocaleDateString()`, the host's locale AND zone — the last browser-locale
site tests/one-time-ago-and-it-follows-the-reader.test.ts was counting).
tests/a-server-page-renders-the-familys-date.test.ts (17 cases): the
formatter contract the pages rely on (an instant in the family's zone, a DATE
in none); the three emails rendered at 2026-10-04T00:30Z in Los Angeles and
UTC (Saturday 3 October vs Sunday 4 October; a chore due 7pm Sunday in
California is "Due Oct 4", not Oct 5); source pins per caller and per page;
and a ratchet over the server files (no 'use client') under app/(app) and
lib/emails — the admin tree is excluded on purpose for its own unit, and a
case fails the moment that exclusion stops excluding anything.
Mutation controls, each against the unmodified test with the file restored
byte-for-byte: zone dropped from the weekly digest (2 cases fail), the
notification digest (2), the chore reminder (2), one conflicts label (2), one
food label reverted to toLocaleDateString (2), the digest sender dating every
recipient in UTC (1).
Tests adjusted for the new required prop and the completed mocks: four tests
whose `requireUserContext` stubs lacked `active.family` (the playbook action
and the social post page now read its zone), the chore-reminder membership
pins (+ `timeZone: 'UTC'`), the react-email render test's props, and
one-time-ago's browser-locale count, which is now zero and proves its
instrument on planted lines (`BARE_TO_LOCALE` exported for that).
Validation: the 110 touching test files (1636 cases) green under UTC and under
TZ=America/Los_Angeles except the react-email render file, which fails
identically on clean main in this container; ten repo-wide locale and zone
ratchets (56) green; tsc and eslint clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…ily, as the app's does
The post detail page now renders its dates in the family's zone
(`ctx.active.family.timezone`), which `requireUserContext` always supplies —
`FamilyMembership.family` is not optional, and sixty-odd server pages on main
read it. This spec's stub answered `active: { familyId }` alone, so four of
its cases threw "Cannot read properties of undefined (reading 'timezone')"
on CI. The stub gains `family: { timezone: 'UTC' }`; nothing in the
product changes. Not run here: this container's react-dom lacks the
development client build the spec loads (the same gap the unit suite's
react-dom cases hit), so CI is the arbiter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
Review finding: a user digest can apply one family's timezone to notifications from another family.
The added test currently asserts the first-row family choice and renders a single-family email, so it does not exercise this case. Please group/render per family or avoid a family-specific date header for mixed-family digests, and add the two-family regression case. |
…of them Review finding on #942. groupByUser folds every permitted row for one user into one digest, and a user can belong to several families; the sender then dated the whole digest in the FIRST row's family's zone. A parent in a Los Angeles family and a Kiritimati family, with one row from each around a UTC date boundary, got the Kiritimati rows under Los Angeles's calendar day. The date line is a family's day, so it is honest only when every family in the digest keeps the same zone. digestTimeZone (lib/notifications/digest.ts, pure) returns that zone, or null when the families differ; the template then renders the greeting with no date at all rather than the wrong family's. A batch whose zones could not be read still dates in an explicit UTC, as before. Regressions: the pure helper's four cases; the two-family case against the real sender in the membership test, whose fixture now serves `families` and captures the rendered element (one digest, settled once, no date, neither family's day; row order does not pick the zone; same-zone families stay dated; one family stays dated in its own); and the render-level case for a null zone. The source pin in the date ratchet names the new expression and forbids the first-row lookup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
Confirmed and fixed in 138f9ea. The fix. The digest's date line is a family's day, so it is only honest when every family in the digest keeps the same zone. A new pure helper, Regressions.
The fix is merged up the stack into #943, #944 and #945. Generated by Claude Code |
… netting One conflict, app/(app)/wallet/treasury/page.tsx, in the 6-month trend loop. #942 (merged as 1e8e8e7) keys each transaction by the family's month; #925 counts it through moneyFlow, so a card refund nets against out. The resolution keeps both: if (t.status !== 'completed' || monthKeyOf(t.created_at) !== key) continue; const flow = moneyFlow(t); credits += flow.inCents; debits += flow.outCents; The this-month loop and the per-child lines merged without conflict: #942's family-zone monthStart with #925's moneyFlow sums. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…renders-an-explicit-zone-20261004 Re-roots this unit on main after its parent (#942) was rebased and merged (1e8e8e7). The one add/add conflict, the parent's own test file, is resolved three-way against the parent's last tip: main's copy equals that tip, so this branch's edits stand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
… into claude/a-server-side-age-and-days-ago-read-the-familys-day-20261004 Carries the parent's re-root onto main (#942 merged as 1e8e8e7) down the stack. No conflicts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…day-20261004' into claude/a-server-path-does-not-read-the-hosts-calendar-20261004 Carries the stack's re-root onto main (#942 merged as 1e8e8e7) down to this unit. No conflicts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
The merge that carried the family-zone formatters down this stack left the page with two `tz` declarations: the one #942 added on main and the one this branch added for the series bounds. One is enough. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
What
A family-facing server page, server action or email now renders a date in the family's zone, not the host's.
toLocaleDateString(locale, { month, day })with notimeZonerenders in the zone of the machine running it. In a browser that is the reader's own clock. On the UTC host that renders a server component or runs a cron it is Greenwich's, and from 5pm in California Greenwich is already on tomorrow. #932 ratchets the API routes and server libraries; this PR covers the two roots it does not: the server pages and actions underapp/(app), and the email templates underlib/emails. Twenty-four renders across thirteen page files plus the three emails' "today" / "due" lines read the host's clock.How
d.getDate(), due day, photo day viatoLocaleDateString(locale.code, …)tz:fmtDate(…, 'MMM' / 'd' / 'MMM d')fmtDay(x, locale)helpers ontoLocaleDateStringgetFormat(tz).fmtDate; a DATE column renders in no zone, an instant in the family'stoDateString()timeZone: tzon each; same day bydayKeyInTztimeZone: tztoLocaleString()(host zone and host locale)fmtDate(…, 'MMM d, yyyy h:mm a')dayKeyInTz(created_at, tz).slice(0, 7), "this month" from the family's first, DATE-only labelstimeZoneprop from the route'stz; day keys rendered in UTC so they stay that dayfamilies.timezoneonce per batch; a failed read logs and dates in explicit UTC rather than holding the digeststoLocaleDateString()'en-US'+ the family's zone, selected with the family nameTests
tests/a-server-page-renders-the-familys-date.test.ts(17 cases): the formatter contract the pages rely on; the three emails rendered at 2026-10-04T00:30Z in Los Angeles and UTC (a chore due 7pm Sunday in California is "Due Oct 4", not Oct 5); per-caller and per-page pins; and a ratchet over the server files underapp/(app)andlib/emails. The admin tree (8 sites) is excluded on purpose for its own unit, and a case fails the moment the exclusion stops excluding anything.Mutation controls, each against the unmodified test with the file restored byte-for-byte:
timeZone: tzdropped from one conflicts labeltoLocaleDateStringTests adjusted: four tests whose
requireUserContextstubs lackedactive.family(the playbook action and the social post page now read its zone), the chore-reminder membership pins, the react-email render test's props, and the one-time-ago browser-locale count, now zero with its instrument proved on planted lines.Validation: the 110 touching test files (1636 cases) green under UTC and
TZ=America/Los_Angelesexcept the react-email render file, which fails identically on clean main in this container; ten repo-wide locale and zone ratchets (56) green;tscandeslintclean. The full suite in this container is 1866 files passed, 8 failed (34 cases), and those 34 cases are identical, by name, to the 34 that fail on clean main 40bf5ff in the same container (the Node version pin, the stream-cancellation runtime, the react-email render path, the react-dom production hook, and four marketing pages on the same render stack): environment, not this change; CI is the arbiter. No migration, no schedule, no credential, no live send.Follow-ups (not in this PR)
🤖 Generated with Claude Code
https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Generated by Claude Code