Skip to content

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

Merged
NewWorldVenture merged 3 commits into
mainfrom
claude/a-server-page-renders-the-familys-date-20261004
Oct 4, 2026
Merged

NewWorldVenture merged 3 commits into
mainfrom
claude/a-server-page-renders-the-familys-date-20261004

Conversation

@NewWorldVenture

Copy link
Copy Markdown
Owner

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 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. #932 ratchets the API routes and server libraries; this PR covers the two roots it does not: the server pages and actions under app/(app), and the email templates under lib/emails. Twenty-four renders across thirteen page files plus the three emails' "today" / "due" lines read the host's clock.

How

Surface Before Now
home badge month + d.getDate(), due day, photo day via toLocaleDateString(locale.code, …) the formatter the page already binds to tz: fmtDate(…, 'MMM' / 'd' / 'MMM d')
memories, planning, food fmtDay(x, locale) helpers on toLocaleDateString getFormat(tz).fmtDate; a DATE column renders in no zone, an instant in the family's
conflicts both ends host-zoned; "same day" by toDateString() timeZone: tz on each; same day by dayKeyInTz
concierge run history, playbook tradition month host zone timeZone: tz
sync history, sync account "last synced", social post attempts bare toLocaleString() (host zone and host locale) fmtDate(…, 'MMM d, yyyy h:mm a')
treasury 6-month trend bucketed and labelled by the host's months buckets by dayKeyInTz(created_at, tz).slice(0, 7), "this month" from the family's first, DATE-only labels
display birthday built and rendered in the host zone (right by accident) built and rendered in one explicit zone
weekly digest "Week of" host date; event day key re-parsed in host zone timeZone prop from the route's tz; day keys rendered in UTC so they stay that day
notification digest host date sender reads families.timezone once per batch; a failed read logs and dates in explicit UTC rather than holding the digests
chore reminder bare toLocaleDateString() 'en-US' + the family's zone, selected with the family name

Tests

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 under app/(app) and lib/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:

Mutation Cases that fail
zone dropped from the weekly digest 2
zone dropped from the notification digest 2
zone dropped from the chore reminder 2
timeZone: tz dropped from one conflicts label 2
one food label reverted to toLocaleDateString 2
digest sender dating every recipient in UTC 1

Tests adjusted: 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, 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_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. 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)

  • The admin tree's 8 sites, then the ratchet's exclusion and its guard case are deleted together.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K


Generated by Claude Code

… 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
@vercel

vercel Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 4, 2026 5:01am UTC

Request Review

…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
@NewWorldVenture

Copy link
Copy Markdown
Owner Author

Review finding: a user digest can apply one family's timezone to notifications from another family.

groupByUser(permitted) combines all permitted rows for the same user, then the email formatter picks the zone from notifs[0].family_id for the whole digest. Repro: give one user active memberships in Family A (America/Los_Angeles) and Family B (Pacific/Kiritimati), add one permitted notification per family around a UTC date boundary, and render the combined digest with A's notification first. B's date is rendered in A's zone and can show the wrong calendar day.

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

Copy link
Copy Markdown
Owner Author

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, digestTimeZone(notifs, zoneByFamily) in lib/notifications/digest.ts, returns that zone, or null when the recipient's families differ. The sender passes that instead of zoneByFamily.get(notifs[0].family_id), and the template takes timeZone: string | null: with null the greeting reads "here's what's coming up." with no date at all, rather than one family's day over another family's rows. A batch whose zones could not be read still dates in an explicit UTC, as before. I chose "no date" over per-family rendering so a recipient keeps getting one digest, one send and one idempotency key.

Regressions.

  • tests/notification-email-membership.test.ts: the two-family case against the real sender, with the fixture now serving families and capturing the rendered element. Family A in America/Los_Angeles, Family B in Pacific/Kiritimati, one permitted row each, clock at 2026-10-04T00:30Z (Saturday the 3rd in LA, Sunday the 4th in Kiritimati), A's row first: one digest, both rows settled, timeZone null, and the HTML carries neither "October 3" nor "October 4". Also: reversing the row order does not pick a zone; two families in the same zone stay dated in it; one family stays dated in its own; an unreadable zone is an explicit UTC.
  • tests/notification-digest.test.ts: the helper's four cases.
  • tests/a-server-page-renders-the-familys-date.test.ts: the render-level case for a null zone, and the source pin now names the new expression and forbids the notifs[0].family_id lookup.

The fix is merged up the stack into #943, #944 and #945.


Generated by Claude Code

@NewWorldVenture
NewWorldVenture marked this pull request as ready for review October 4, 2026 11:57
@NewWorldVenture
NewWorldVenture merged commit 1e8e8e7 into main Oct 4, 2026
7 checks passed
NewWorldVenture pushed a commit that referenced this pull request Oct 4, 2026
… 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
NewWorldVenture pushed a commit that referenced this pull request Oct 4, 2026
…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
NewWorldVenture pushed a commit that referenced this pull request Oct 4, 2026
… 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
NewWorldVenture pushed a commit that referenced this pull request Oct 4, 2026
…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
NewWorldVenture pushed a commit that referenced this pull request Oct 4, 2026
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
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