Skip to content
Chapter 85Lesson 3

Dates in profile tz, currency from data

Every invoice date should render in the wall-clock of the person reading it, and every amount in the currency the invoice was issued in, formatted for the viewer’s locale.

Last lesson localized every UI string, so headers, status labels, and the “N invoices” counter all reflow per locale. But the two cells carrying the data are still wrong. The amount prints as a raw USD 1234.56, the currency code glued to an unformatted decimal. The dates come off toLocaleDateString() with no timezone, so they render in whatever clock the runtime sits in: your local zone on your laptop, UTC on Vercel.

When you finish, the same rows reflow per viewer, as the three tabs below show.

The /invoices list for an (en-US, America/New_York) viewer: USD amounts as `$1,234.56`, New-York-zone dates, and the relative-due column.

No new dependencies: three small edits to files you touched last lesson.

Both the right wall-clock and the right currency are decided by data you hand the formatter, never by the machine the code runs on. A format.dateTime call with no timeZone falls back to the runtime zone, an accident of deployment; a currency inferred from the viewer’s locale would bill a French reader’s USD invoice in euros. Neither bug fails a test unless a fixture spans timezones or currencies, so the report comes from a confused customer instead of a red build.

The fix is a single formatting seam. The viewer’s timezone is a profile field (you met getCurrentUserTimeZone() in Timezone on the profile), so read it once on the server in the page and thread it to the client table as a prop, then pass it to every format.dateTime call. Read it in the page, not the request config: the config must stay prerender-safe, so it returns only { locale, messages, formats }. Because the stored value is a Temporal.Instant and the zone is a real IANA name, the formatter is DST-aware for free, with no DST branch to write.

Currency rides the same seam, split in two. The code lives on the invoice row (currency: row.currency): the invoice was issued in one currency that never changes with who reads it, so it travels as data at the call site. The display style — $ versus US$ versus USD — is presentation, so it lives in the shared formats.ts preset as currencyDisplay: 'narrowSymbol'. Keeping them apart means a “full symbol everywhere” change is one edit to the preset, and no presentation layer can override a row’s currency.

A few things stay out of scope. The relative-due column does not tick live: a server-rendered list arrives fresh on every navigation, so a per-minute client island would buy nothing. Amounts above Number.MAX_SAFE_INTEGER are named but not handled, since divide-by-100 is correct for every real invoice. Your only Temporal arithmetic is the single day-delta for the due column. Throughout, formatting goes through the useFormatter seam with presets in formats.ts — zero Date.prototype.toLocaleString, zero raw Intl.* inside app/[locale]/.

Every invoice date renders in the viewer’s profile timezone — an America/New_York user sees EDT in summer and EST in winter, never UTC.
tested
The two DST-spanning instants render the correct wall-clock for a Europe/London viewer — 2026-07-01T18:00:00Z as 7:00 PM BST and 2026-01-01T18:00:00Z as 6:00 PM GMT — and as 2:00 PM EDT / 1:00 PM EST for an America/New_York viewer.
tested
Each amount renders in the invoice’s stored currency formatted for the viewer’s locale: the same EUR datum shows 1 234,56 € in fr-FR; a USD datum shows $1,234.56 in en-US and 1 234,56 $ in fr-FR with the narrow symbol.
tested
The relative-due column reads naturally per locale: in 3 days / 5 days ago in en-US, dans 3 jours / il y a 5 jours in fr-FR.
tested
Switching the profile timezone in the inspector shifts the wall-clock of every date cell with no change to the underlying data.
untested
The (fr-FR, Pacific/Auckland) user renders French strings with dates in NZDT/NZST — locale and timezone are independent fields and the combination works with no combination-specific code.
untested
The structural audit stays green: no Date.prototype.toLocaleString and no raw Intl.* inside app/[locale]/.
untested

Three files change, each carrying a TODO(L3) marker from last lesson: src/i18n/formats.ts, the invoices page.tsx, and the invoices table.tsx. Build against the brief and the tests before you open the solution.

Reference solution and walkthrough

formats.ts already carries dateTime.short, dateTime.withTime, and number.compact from last lesson. The only addition is one currency preset.

src/i18n/formats.ts
import type { Formats } from 'next-intl';
// Shared formatter presets, referenced by name at `format.dateTime`/`format.number`
// call sites so a UI-wide change is one edit. S2 adds `number.currency`
// (narrow-symbol). There is NO `relativeTime` key — next-intl's `Formats` type has
// no slot for it (only dateTime/number/list/displayName), so adding one fails `tsc`.
export const formats = {
dateTime: {
short: { dateStyle: 'medium' },
withTime: { dateStyle: 'medium', timeStyle: 'short' },
},
number: {
compact: { notation: 'compact' },
// The narrow-symbol display lives here so a UI-wide currency tweak is one
// edit; the `currency` code stays at the call site because it is data on the
// invoice row, not a presentation choice. No `relativeTime` key — next-intl's
// `Formats` type has only dateTime/number/list/displayName.
currency: { style: 'currency', currencyDisplay: 'narrowSymbol' },
},
} as const satisfies Formats;

narrowSymbol renders $ and rather than the heavier 'name' (“US dollars”) or 'code' (“USD”), which crowd a dense table cell. The preset carries the style but no currency field and no relativeTime key: the currency code is per-row data that arrives at the call site, and relative-time options ride there too, as you’ll see in the table.

Reading the timezone and the day delta on the server

Section titled “Reading the timezone and the day delta on the server”

The page is a Server Component that already reads the session, runs listInvoices, and renders the count. The TODO(L3) work reads the viewer’s timezone, computes the per-row due delta, and threads both into the client table.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Read the viewer’s profile timezone once, here on the server. It’s a profile field, not a request header, and the page reads it because the request config can’t: the config stays prerender-safe so the static locale shell builds.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

One stable clock for the whole render. Reading it after the dynamic tz read keeps it Cache Components safe, and anchoring the relative-due column to a single now stops it drifting between the server render and the client paint.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

The lesson’s one piece of Temporal arithmetic: the duration from today (in the viewer’s zone) to each row’s due date. largestUnit: 'day' is mandatory — omit it and the duration splits into months and days, so .days returns only the leftover days and the column lies.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Build a plain Record<id, number> of the deltas, one per row. A serializable object crosses the server-to-client boundary cleanly; a Temporal.Duration would not.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Thread it all into the client table. toInvoiceRow projects each row to a serializable shape — createdAtMs epoch millis and a dueDateISO string — since a Temporal.Instant can’t cross the RSC-to-Client boundary. The tz, the stable now, and the delta map ride alongside.

1 / 1

Here is the full page.tsx: imports and list rendering carried in from last lesson, plus the TODO(L3) block.

src/app/[locale]/(app)/invoices/page.tsx
16 collapsed lines
import { notFound } from 'next/navigation';
import { hasLocale } from 'next-intl';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { SearchParams } from 'nuqs/server';
import { ActiveFilterChips } from '@/app/[locale]/(app)/invoices/active-filter-chips';
import { Pagination } from '@/app/[locale]/(app)/invoices/pagination';
import { InvoicesTable } from '@/app/[locale]/(app)/invoices/table';
import { Toolbar } from '@/app/[locale]/(app)/invoices/toolbar';
import { ViewTabs } from '@/app/[locale]/(app)/invoices/view-tabs';
import { routing } from '@/i18n/routing';
import { listInvoices, toInvoiceRow } from '@/lib/invoices/queries';
import { invoiceListSearchParamsCache } from '@/lib/invoices/search-params';
import { Temporal } from '@/lib/temporal';
import { getCurrentUserTimeZone } from '@/lib/user-time';
import { getSession } from '@/server/session';
22 collapsed lines
type PageProps = {
params: Promise<{ locale: string }>;
searchParams: Promise<SearchParams>;
};
const InvoicesPage = async ({ params, searchParams }: PageProps) => {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
const t = await getTranslations('invoices.list');
const parsed = await invoiceListSearchParamsCache.parse(searchParams);
const session = await getSession();
const { rows, nextCursor, hasPrev } = listInvoices({
orgId: session.orgId,
role: session.role,
...parsed,
});
// The viewer's profile tz drives every wall-clock cell; read it once. A stable
// per-render `now` (read after the dynamic tz, so the clock trails a request
// source — Cache Components safe) anchors the relative-due column. The day
// delta is integer days between today (in the profile tz) and the calendar
// due date — the lesson's single Temporal arithmetic call.
const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
19 collapsed lines
return (
<div data-testid="invoices-page" className="space-y-4">
<h1 className="text-xl font-semibold">{t('title')}</h1>
<div
data-testid="invoices-grid"
className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]"
>
<div data-testid="invoices-list" className="space-y-4">
<p
data-testid="invoice-count"
className="text-sm text-muted-foreground"
>
{t('count', { count: rows.length })}
</p>
<ViewTabs parsed={parsed} role={session.role} />
<Toolbar parsed={parsed} />
<ActiveFilterChips parsed={parsed} />
{/* Project rows to a serializable shape: Temporal instances can't
cross the RSC → Client boundary. The tz, the stable `now`, and the
per-row day delta ride alongside so the client formatter renders
the right wall-clock and relative phrase. */}
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
16 collapsed lines
<Pagination
cursor={parsed.cursor}
nextCursor={nextCursor}
hasPrev={hasPrev}
/>
</div>
<aside className="rounded-lg border p-4 text-sm text-muted-foreground">
{t('selectPrompt')}
</aside>
</div>
</div>
);
};
export default InvoicesPage;

The table is the client component, so useFormatter runs here. It’s already wired for useTranslations; the TODO(L3) work adds const format = useFormatter();, a tiny addDays helper, and three formatted cells.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

useFormatter() reads the locale and the shared formats presets from context, so it’s built once and reused for every cell. Reconstruct now from the nowMs the server sent so the relative column anchors to the exact instant the server used.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

The date cell. The row arrived as createdAtMs, so rebuild a Date and pass it to format.dateTime with the explicit timeZone. That argument is the whole lesson: it resolves 18:00Z to 2:00 PM in New York and 7:00 PM in London. Drop it and the formatter falls back to the runtime zone — UTC on Vercel — silently formatting everyone’s data in the wrong clock.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

The due cell. The server already computed the day delta, so addDays(now, delta) builds the target date and format.relativeTime phrases the gap. next-intl applies CLDR numeric: 'auto' internally: “in 3 days” / “5 days ago” in English, “dans 3 jours” / “il y a 5 jours” in French. The ?? 0 is a safe fallback for a row with no delta.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

The amount cell. Money is stored in minor units, so divide by 100 at display. 'currency' names the preset from formats.ts (which carries narrowSymbol); currency: row.currency supplies the code from the row. That split is the seam: shared style, per-row currency.

1 / 1

addDays lives at module scope above the component: it’s a pure date-shift with no React in it, so keeping it out of the render keeps the cell readable.

// Shift a `Date` by whole days — the relative-due anchor builds the target date
// `now + days` so `format.relativeTime` reads the delta against the stable `now`.
const addDays = (now: Date, days: number): Date =>
new Date(now.getTime() + days * 86_400_000);

The Archived {date} caption in the archived view moves onto the seam too. Last lesson it was a raw toLocaleDateString(); routing it through format.dateTime with the same timeZone closes the one spot a UTC date could sneak back in.

{format.dateTime(new Date(row.archivedAt), {
dateStyle: 'medium',
timeZone,
})}

Routing all formatting through useFormatter and forbidding raw Intl.* inside app/[locale]/ is what makes requirement 7 mechanical to check — the kind of rule you’d enforce with a linter on a real team.

That seam also pays off requirements 5 and 6: nothing in these three files knows which locale or timezone is active. The page reads whatever the session says, the table formats whatever it’s handed. So a (fr-FR, Pacific/Auckland) user works with zero combination-specific code, and flipping the timezone in the inspector reflows every date cell without touching a byte of invoice data — the combinations you never tested are the ones the architecture covers for free.

To make the central bug visible before you hit it, here is the date cell with and without the timeZone argument:

{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}

Renders in the viewer’s clock. 18:00Z becomes 2:00 PM in New York and 7:00 PM in London, and the seeded January instant shifts to 1:00 PM / 6:00 PM because the IANA zone knows about DST.

For the Temporal.Instant and Temporal.PlainDate codecs behind createdAt and dueDate, see Storage, domain, edge; for the profile-timezone seam, Timezone on the profile; and for why useFormatter constructs once over the Intl.* family, The Intl.* formatter family.

Run the tests:

pnpm test:lesson 3

A green run confirms four behaviors. Each test renders the real client table in a NextIntlClientProvider and reads each value cell’s visible text, so it asserts the wall-clock and currency output, not how you wired the formatter.

pnpm test:lesson 3
✓ Requirement 1 — invoice dates render in the viewer profile timezone (1)
✓ Requirement 2 — the DST-spanning instants render the right wall-clock (2)
✓ Requirement 3 — amounts render in the stored currency for the viewer locale (2)
✓ Requirement 4 — the relative-due column reads naturally per locale (2)
Test Files 1 passed (1)
Tests 7 passed (7)

Confirm the rest by hand in the inspector and the running app — the behaviors a node-environment test can’t see, plus two deliberate-misuse rehearsals:

As the (en-US, America/New_York) user, /invoices shows USD as $1,234.56 and dates in EDT/EST; switching to fr-FR reflows the same rows to 1 234,56 € (EUR) and 1 234,56 $ (USD).
untested
Inspector DST panel, Europe/London: the July instant shows 7:00 PM BST, January 6:00 PM GMT; America/New_York shows 2:00 PM EDT and 1:00 PM EST.
untested
Inspector currency-by-data panel: each of the nine cells (three currencies × three locales) shows the same amount and currency tag, formatted for its locale.
untested
The relative-due column reads naturally in both locales, for future and past dates.
untested
The (fr-FR, Pacific/Auckland) user renders French strings with NZDT/NZST dates: locale and timezone are independent, and the combination needs no combination-specific code.
untested
As the Europe/London user, delete timeZone from the date cell’s format.dateTime call. The column falls back to the runtime clock, so the July and January rows no longer read 7:00 PM BST / 6:00 PM GMT — on Vercel both collapse to UTC 6:00 PM. Revert it: that one missing argument is the bug the seam prevents.
untested
Hard-code currency: 'USD' at the amount call site. Every row now renders as dollars, whatever currency the invoice was issued in. Revert it: currency is data on the row, never a constant.
untested

The next lesson wires the public SEO shape — hreflang, the locale canonical, and per-locale OG images — onto the marketing pages.