Merge remote-tracking branch 'origin/fix/greeting' into integration/post-1385

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 06:35:34 +02:00
commit 1d218e0a31
17 files changed
+689 -88

No files matched your search

+59
View File
@@ -2,6 +2,13 @@
## [Unreleased]
### Changed
- `greetingHour` is no longer part of the dashboard snapshot response. Any
client that decoded it as a required field needs the field made optional or
dropped; the `timezone` it sits beside is unchanged and is what the greeting
should be derived from.
### Fixed
- **A misspelled field in a request is no longer accepted and thrown away.**
@@ -28,6 +35,58 @@
endpoints are required by their specifications to ignore parameters they do
not recognise, and an import file comes from another version or another tool
by definition.
- **The greeting could contradict the rest of the page.** Three separate
pieces of code answered the question "what hour is it" for the time-of-day
salutation. The dashboard snapshot computed one server-side and put it on
the payload so that clients would not have to; no client ever read it. The
insights hero worked it out from the profile timezone. The dashboard header
worked it out from a helper of its own that quietly fell back to the
device's clock whenever the runtime could not resolve the configured zone,
which is what happens to a zone the browser's timezone data does not yet
carry. At one instant, for one account, those three returned 20, 21 and 05,
so the header said good morning while the hero said good evening and every
date beneath them belonged to a third zone. The two greetings now read the
same helper, which stays on the configured zone and falls back to the same
default as the rest of the app rather than to the device. The server value
is gone: it is a clock reading, not a fact about the record, and the
snapshot body is served from cache for up to an hour, long enough for a
stored hour to name a salutation the clock had already left behind. What
the server resolves and every client reads is the timezone itself, which
travels on the same payload as before.
- **Some accounts had their generated text written in a language they do not
read.** Everything the app writes without a request in front of it — the
nightly briefing, the Coach nudge, the reminder messages — resolves its
language from one column on the user row, because a background pass has no
cookie and no browser to ask. Everything the user looks at resolves from the
request instead, and the request prefers the language cookie, then the
column, then what the browser asked for. So the two can disagree, and while
they do, nothing on screen looks wrong: the menus, the buttons and the
labels are all correct, and the disagreement shows up as a single generated
paragraph in the wrong language sitting inside an otherwise correct page.
That is how it was reported, on a page where the sentence above the health
score came back in English on an account read entirely in German.
Keeping the column in step was the browser's job, through a best-effort
request sent once when a page mounted and never checked. Anything that
interrupts it — the paint before sign-in, an offline moment, a tab closed
while it was still in flight — loses the write, and the next page load makes
the same unchecked attempt rather than noticing the column is still wrong,
so the wrong language can stand for the life of the account. The server now
keeps the two in step itself: when a request arrives carrying a language the
column does not agree with, the column is corrected, once, on the spot. It
needs nothing from the browser, it cannot be dropped in transit, and it
fixes accounts that have been diverged for months on their next page load.
Accounts that never opened the language picker at all are covered the same
way. Nobody's stored preference is guessed at: only a language the app was
actually being read in can reach the column.
- **The dashboard aggregate greeted some users in English on a German
instance.** The salutation and the streak label are translated on the
server for the native client, and that request carries no language cookie,
so the stored preference is the only signal. When an account had never
opened the language picker the column is empty, and the fallback stopped at
English instead of asking what language the instance is configured for.
It now follows the same order every background writer uses: the user's own
preference, then the operator's default, then English. This was the last
copy of the fallback that the nightly writers were moved off earlier.
### Internal
-5
View File
@@ -48650,10 +48650,6 @@ components:
- type: "null"
onboardingTourCompleted:
type: boolean
greetingHour:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- username
- timezone
@@ -48662,7 +48658,6 @@ components:
- gender
- glucoseUnit
- onboardingTourCompleted
- greetingHour
additionalProperties: false
layout:
type: object
-1
View File
@@ -258,7 +258,6 @@ export function buildMockSnapshot(
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
@@ -0,0 +1,131 @@
/**
* The time-of-day greeting has ONE evaluator.
*
* The dashboard header and the insights hero both open with a salutation
* bucketed by the wall-clock hour. For a long time three different pieces
* of code answered "what hour is it":
*
* - the dashboard snapshot builder, which computed a `greetingHour` in
* the user's zone and shipped it on the payload "so the client never
* has to run its own Intl" — and which no client ever read;
* - the insights hero, through the shared `hourInTz` (profile zone,
* Europe/Berlin when the zone will not resolve);
* - the dashboard header, through a local helper that fell back to the
* DEVICE clock whenever the zone would not resolve.
*
* At one instant, for one account, those three returned 20, 21 and 05.
*
* The server value lost. It is not a derived fact about the record, it is
* a clock read, and the snapshot body is served stale-while-revalidate for
* up to an hour — long enough for a baked-in hour to name a salutation the
* clock had left behind. What the server IS authoritative for is the zone,
* and that already reaches every client on the same payload.
*
* This guard fails if either end drifts back:
* T1 — the snapshot builder or its OpenAPI schema starts carrying a
* greeting hour again.
* T2 — a greeting surface reads a clock that is not `hourInTz` — the
* device zone via `Date#getHours`, or a hand-rolled `Intl` walk.
* T3 — behaviourally, the shared helper stays on the profile zone even
* when the device sits elsewhere and the zone will not resolve.
*
* T1/T2 are grep-shaped and assert a non-zero match count, so an empty
* sweep fails loudly instead of passing silently.
*/
import { describe, it, expect, afterAll, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
const SRC = join(process.cwd(), "src");
const read = (rel: string) => readFileSync(join(SRC, rel), "utf8");
const SERVER_FILES = [
"lib/dashboard/snapshot.ts",
"lib/openapi/routes/insights/schemas.ts",
] as const;
/** Every surface that paints a time-of-day salutation. */
const GREETING_SURFACES = [
"components/dashboard/dashboard-header.tsx",
"components/insights/hero-strip.tsx",
] as const;
describe("T1 — the snapshot payload carries no wall-clock hour", () => {
it.each(SERVER_FILES)("%s declares no greeting hour", (rel) => {
const src = read(rel);
expect(src.length).toBeGreaterThan(0);
expect(src).not.toMatch(/greetingHour/);
expect(src).not.toMatch(/hourInTimezone/);
});
it("still resolves the zone the clients read", () => {
const src = read("lib/dashboard/snapshot.ts");
expect(src).toMatch(/timezone:\s*userTz/);
});
});
describe("T2 — every greeting surface reads the shared helper", () => {
it.each(GREETING_SURFACES)("%s derives its hour from hourInTz", (rel) => {
const src = read(rel);
expect(src.length).toBeGreaterThan(0);
// Imported from the shared module, not re-implemented next door.
expect(src).toMatch(
/import[\s\S]*?\bhourInTz\b[\s\S]*?from "@\/lib\/tz\/format"/,
);
const calls = src.match(/\bhourInTz\s*\(/g) ?? [];
expect(calls.length).toBeGreaterThan(0);
});
it.each(GREETING_SURFACES)("%s reads no device-local clock", (rel) => {
const src = read(rel);
// `getHours()` is the device zone by definition; a hand-rolled
// `Intl.DateTimeFormat` for the hour is the second evaluator this
// guard exists to prevent.
expect(src).not.toMatch(/\.getHours\s*\(/);
expect(src).not.toMatch(/Intl\.DateTimeFormat/);
expect(src).not.toMatch(/getHourForTimeZone/);
});
it("the retired header helper is gone from the tree", () => {
const src = read("components/dashboard/range-display.tsx");
expect(src.length).toBeGreaterThan(0);
expect(src).not.toMatch(/getHourForTimeZone/);
});
});
describe("T3 — the profile zone beats the device zone", () => {
const INSTANT = new Date("2026-03-01T20:30:00Z");
const originalTz = process.env.TZ;
afterAll(() => {
vi.useRealTimers();
process.env.TZ = originalTz;
});
it("follows the configured zone while the device sits ten hours away", () => {
process.env.TZ = "Asia/Tokyo";
vi.useFakeTimers();
vi.setSystemTime(INSTANT);
// The device says 05:30 (morning). The account is configured for
// Berlin, where it is 21:30 (evening).
expect(new Date().getHours()).toBe(5);
expect(hourInTz(new Date(), "Europe/Berlin")).toBe(21);
});
it("falls back to the shared default, never to the device clock", () => {
process.env.TZ = "Asia/Tokyo";
vi.useFakeTimers();
vi.setSystemTime(INSTANT);
// A zone this runtime cannot resolve — a recently added IANA entry on
// an older engine is the realistic case. The header helper used to
// answer with the device hour here, splitting the two greetings by
// sixteen hours for the same account at the same instant.
const unresolvable = hourInTz(new Date(), "Europe/Atlantis");
expect(unresolvable).toBe(hourInTz(new Date(), DEFAULT_TIMEZONE));
expect(unresolvable).not.toBe(new Date().getHours());
});
});
@@ -76,7 +76,6 @@ const SNAPSHOT_BODY = {
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: { version: 1, widgets: [] },
tiles: {
@@ -121,7 +120,12 @@ describe("GET /api/dashboard/snapshot", () => {
expect(json.data.briefingState).toBe("preparing");
expect(json.data.tiles).toBeDefined();
expect(json.data.extras).toBeNull();
expect(json.data.user.greetingHour).toBe(9);
// Inverted 2026-09: the payload no longer carries a wall-clock hour.
// The body is served stale for up to an hour, so a baked-in hour could
// name a salutation the clock had long left behind. Clients read the
// resolved `timezone` and their own live clock instead.
expect(json.data.user).not.toHaveProperty("greetingHour");
expect(json.data.user.timezone).toBe("Europe/Berlin");
// bfcache-friendly directive present.
expect(res.headers.get("Cache-Control")).toContain("private");
});
@@ -45,6 +45,12 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
},
// The greeting salutation is one of the two strings this route still
// translates server-side; `resolveJobLocale` reads the operator's
// configured default from here when the user's column is NULL.
appSettings: {
findUnique: vi.fn(async () => null),
},
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
// latest reading, the 7-day rollup-bucket sparkline, and the
// 365-day distinct-day streak set. Three raw calls per request,
@@ -1089,6 +1095,46 @@ describe("GET /api/dashboard/summary", () => {
* source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the
* client consumes a finished figure instead of re-deriving one.
*/
describe("GET /api/dashboard/summary — greeting locale", () => {
// The salutation and the streak label are translated on the server, and
// the native client is this route's only caller: a Bearer request carries
// no locale cookie, so `User.locale` is the sole per-user signal. When it
// is NULL the operator's configured default is the answer — the same
// fallback order every background writer uses. The coercion this replaces
// stopped at English, so a German instance greeted a user who never opened
// the language picker in English while the rest of their screen was German.
it("falls back to the operator default, not to English", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
defaultLocale: "de",
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { greeting: { salutation: string } };
};
expect(body.data.greeting.salutation).toBe("Hallo, testuser");
});
it("still prefers the user's own stored locale", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, locale: "en" },
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
defaultLocale: "de",
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { greeting: { salutation: string } };
};
expect(body.data.greeting.salutation).toBe("Hi, testuser");
});
});
describe("GET /api/dashboard/summary — mood + BMI cards", () => {
function cardsOf(body: unknown): Array<Record<string, unknown>> {
return (body as { data: { metrics: Array<Record<string, unknown>> } }).data
+16 -15
View File
@@ -61,7 +61,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
@@ -343,7 +344,8 @@ export const GET = apiHandler(async () => {
Awaited<ReturnType<typeof buildDashboardSummary>>
>,
`${user.id}|dashboard-summary`,
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
async () =>
buildDashboardSummary(user.id, userTz, await buildContext(user)),
annotate,
);
@@ -383,23 +385,22 @@ interface SummaryBuilderContext {
locale: Locale;
}
function buildContext(user: {
async function buildContext(user: {
displayName: string | null;
username: string;
locale: string | null;
}): SummaryBuilderContext {
}): Promise<SummaryBuilderContext> {
const greetingName = user.displayName ?? user.username;
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
// so the greeting + streak label resolve against the user's
// configured language. The legacy narrowing dropped fr/es/it/pl back
// to the default; that bug is masked for the title/unit fields by
// the new key-based wire shape but still mattered for the strings
// that stay translated server-side.
const locale: Locale = (locales as readonly string[]).includes(
user.locale ?? "",
)
? (user.locale as Locale)
: defaultLocale;
// The greeting salutation and the streak label are the two strings this
// route still translates server-side, and the native client is its only
// caller — a Bearer request carries no locale cookie and no meaningful
// Accept-Language, so the stored column is the only per-user signal.
// That is exactly the shape `resolveJobLocale` exists for: stored user
// locale, then the operator's configured default, then English. The
// hand-rolled coercion this replaces stopped at English, so a German
// instance whose user never touched the language picker greeted them in
// English while every other string on the same screen was German.
const locale: Locale = await resolveJobLocale(user.locale);
return { greetingName, locale };
}
@@ -25,7 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import { hourInTz } from "@/lib/tz/format";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
@@ -47,9 +47,16 @@ export function DashboardHeader({
// hydrates — the text must match the name-less SSR output during
// hydration (React #418) and may only personalise from the first
// client re-render.
//
// The hour comes from the shared `hourInTz`, the same helper the
// insights hero greeting uses. Its predecessor fell back to the
// DEVICE clock whenever the runtime could not resolve the profile
// zone, so a zone this browser's ICU does not carry (a recently added
// one, an older engine) split the two greetings by hours while every
// other date on the page stayed on the profile zone.
const userTimezone = mounted ? user?.timezone : undefined;
const hour = useMemo(
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
() => (userTimezone ? hourInTz(new Date(), userTimezone) : null),
[userTimezone],
);
const timeGreeting =
@@ -46,24 +46,6 @@ export function toHoursSummary(s: DataSummary): DataSummary {
};
}
export function getHourForTimeZone(timeZone?: string): number {
const now = new Date();
if (!timeZone) return now.getHours();
try {
const parts = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
hour12: false,
timeZone,
}).formatToParts(now);
const hourPart = parts.find((part) => part.type === "hour")?.value;
const parsed = hourPart ? Number(hourPart) : Number.NaN;
return Number.isNaN(parsed) ? now.getHours() : parsed;
} catch {
return now.getHours();
}
}
export function getRangeColorClass(
value: number | null | undefined,
config: RangeDisplayConfig,
@@ -0,0 +1,171 @@
/**
* `getSession` keeps `User.locale` in step with the language the browser is
* actually reading.
*
* The column is the only signal a cookie-blind path has — the nightly
* briefing warm, the Coach nudge, the reminder senders, the native dashboard
* aggregate. The screen in front of the user resolves from the
* `healthlog-locale` cookie first. When those two disagree nothing looks
* wrong until a job writes a sentence, and then one paragraph of an
* otherwise correct page comes back in the wrong language.
*
* Closing that used to be a fire-and-forget PUT from a mount effect, so a
* lost request left the divergence standing for the life of the account.
* These pin the server-side reconcile that replaced it as the mechanism.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const cookieState = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
locale: undefined as string | undefined,
set: vi.fn(),
delete: vi.fn(),
}));
const store = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
}));
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(async () => store.row),
update: vi.fn(async () => ({})),
deleteMany: vi.fn(async () => ({})),
},
user: { update: vi.fn(async () => ({})) },
},
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ getEvent: () => undefined }));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: (raw: string) => `hash:${raw}`,
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({
get: (name: string) => {
if (name === "healthlog_session" && cookieState.sessionId) {
return { value: cookieState.sessionId };
}
if (name === "healthlog-locale" && cookieState.locale !== undefined) {
return { value: cookieState.locale };
}
return undefined;
},
set: cookieState.set,
delete: cookieState.delete,
})),
}));
import { prisma } from "@/lib/db";
import { getSession } from "../session";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function seedSession(userLocale: string | null) {
cookieState.sessionId = "hls_raw-secret";
store.row = {
id: "sess-1",
userId: "user-1",
tokenHash: "hash:hls_raw-secret",
// Far enough out that the sliding-expiry refresh stays quiet.
expiresAt: new Date(Date.now() + THIRTY_DAYS_MS),
lastActiveAt: new Date(),
actingAsUserId: null,
recordEpoch: 0,
user: { id: "user-1", locale: userLocale },
};
}
beforeEach(() => {
vi.clearAllMocks();
cookieState.locale = undefined;
store.row = null;
});
describe("getSession — locale column reconcile", () => {
it("writes the column when the browser reads a different language", async () => {
// The row the production instance actually held: a German reader whose
// column said English, so every generated sentence came back English.
seedSession("en");
cookieState.locale = "de";
const result = await getSession();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { locale: "de" },
});
// The same request must not answer with the value just replaced.
expect(result?.user.locale).toBe("de");
});
it("backfills a column that was never written at all", async () => {
// The account that never opened the language picker: the cookie is the
// client's own mount-time write of the Accept-Language paint.
seedSession(null);
cookieState.locale = "de";
await getSession();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { locale: "de" },
});
});
it("writes nothing once the two agree", async () => {
seedSession("de");
cookieState.locale = "de";
await getSession();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("ignores a cookie value that is not a shipped locale", async () => {
// The cookie is not HttpOnly, so anything can be in it. A value the
// resolver would later have to defend against never reaches the column.
seedSession("de");
cookieState.locale = "tlh";
const result = await getSession();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(result?.user.locale).toBe("de");
});
it("re-emits the cookie instead of writing when the cookie is gone", async () => {
// Safari ITP expired the script-written copy; the column is the
// survivor, so it is the one that seeds the cookie, not the reverse.
seedSession("de");
cookieState.locale = undefined;
await getSession();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(cookieState.set).toHaveBeenCalledWith(
"healthlog-locale",
"de",
expect.objectContaining({ httpOnly: false }),
);
});
it("does not block the request on the write", async () => {
// Fire-and-forget like the `lastActiveAt` stamp: a failing update must
// not turn locale drift into a failed session resolution.
seedSession("en");
cookieState.locale = "de";
vi.mocked(prisma.user.update).mockRejectedValueOnce(
new Error("db down") as never,
);
await expect(getSession()).resolves.not.toBeNull();
});
});
+55 -11
View File
@@ -325,26 +325,70 @@ export async function getSession(): Promise<{
.catch(() => {});
}
// Locale-cookie refresh: Safari's ITP expires the script-written
// `healthlog-locale` cookie after 7 days, so a returning user's first
// paint fell back to the browser language once a week. When the user
// has a persisted locale but the cookie is gone, re-emit it here —
// Set-Cookie is exempt from the ITP cap. Absent-only on purpose: a
// locale switch writes the cookie client-side before the PUT persists
// the column, and overwriting a *differing* cookie here would revert
// that fresh choice mid-flight. Fail-soft: in a server-component
// render the cookie store is read-only and `set` throws — the next
// route-handler request re-attempts.
const rawLocaleCookie = cookieStore.get(LOCALE_COOKIE)?.value;
const cookieLocale =
rawLocaleCookie && (locales as readonly string[]).includes(rawLocaleCookie)
? (rawLocaleCookie as Locale)
: null;
if (
session.user.locale &&
(locales as readonly string[]).includes(session.user.locale) &&
!cookieStore.get(LOCALE_COOKIE)
!cookieLocale
) {
// Locale-cookie refresh: Safari's ITP expires the script-written
// `healthlog-locale` cookie after 7 days, so a returning user's first
// paint fell back to the browser language once a week. When the user
// has a persisted locale but the cookie is gone, re-emit it here —
// Set-Cookie is exempt from the ITP cap. Fail-soft: in a
// server-component render the cookie store is read-only and `set`
// throws — the next route-handler request re-attempts.
try {
setLocaleCookie(cookieStore, session.user.locale as Locale);
} catch {
// Read-only cookie context — skip; nothing depends on this write.
}
} else if (cookieLocale && cookieLocale !== session.user.locale) {
// The column follows the language the browser is actually reading.
//
// `User.locale` is the ONLY signal every cookie-blind path has: the
// nightly briefing warm, the Coach nudge, the reminder senders and the
// native aggregate all resolve from the column alone, while the screen
// in front of the user resolves from this cookie first. The two can
// therefore disagree indefinitely, and the disagreement is invisible —
// it surfaces as one generated paragraph in the wrong language inside
// an otherwise correct page.
//
// Keeping them in step used to be the client's job: a fire-and-forget
// `PUT /api/auth/me/locale` from a mount effect, best-effort by
// construction. A 401 on the paint before sign-in, an offline blip, a
// tab closed mid-flight — every one of those loses the write silently,
// and the next mount repeats the same unreliable attempt rather than
// noticing the column is still wrong. So the column stayed on a
// language the user had left behind, for as long as the account existed.
//
// This arm closes it from the side that cannot miss. The cookie is only
// ever written alongside a committed UI locale (client `persistLocale`,
// or this function's own refresh above), so it is a statement about what
// the person is reading, not a guess: Accept-Language never reaches the
// column this way. Fire-and-forget like the `lastActiveAt` stamp, and
// self-limiting — once the two agree the condition is false and no
// further write is attempted.
//
// A second browser whose cookie still carries the older choice will move
// the column back. That is the same last-writer-wins the client PUT
// already had, and it is coherent: that browser is showing the older
// language too, because the cookie outranks the column on its first
// paint as well.
void prisma.user
.update({
where: { id: session.user.id },
data: { locale: cookieLocale },
})
.catch(() => {});
// Keep THIS request coherent — a handler resolving the locale from the
// returned row must not answer with the value we just replaced.
session.user.locale = cookieLocale;
}
return {
+5 -3
View File
@@ -258,9 +258,11 @@ describe("buildDashboardSnapshot — envelope shape", () => {
expect(snap.user.timezone).toBe("Europe/Berlin");
expect(snap.user.heightCm).toBe(180);
expect(snap.user.gender).toBe("MALE");
expect(typeof snap.user.greetingHour).toBe("number");
expect(snap.user.greetingHour).toBeGreaterThanOrEqual(0);
expect(snap.user.greetingHour).toBeLessThan(24);
// Inverted 2026-09: the builder used to compute a wall-clock hour for
// the greeting that no client ever read, and that a stale-while-
// revalidate serve could carry an hour past its bucket. The zone is
// the resolved value; the hour belongs to whoever holds a live clock.
expect(snap.user).not.toHaveProperty("greetingHour");
// tiles (fast phase) always present
expect(snap.tiles.summaries.WEIGHT.latest).toBe(80);
+8 -23
View File
@@ -212,6 +212,14 @@ export type BriefingState = "ready" | "preparing" | "disabled" | "no-provider";
export interface DashboardSnapshotUser {
username: string;
/**
* The user's configured zone — the ONE server-resolved input the
* greeting needs. The wall-clock hour itself is deliberately not on
* this payload: the body is served stale-while-revalidate for up to an
* hour, so a baked-in hour can name the wrong salutation long after
* the boundary passed, and every client already reads a live clock.
* Resolve the zone here, read the hour there.
*/
timezone: string;
heightCm: number | null;
dateOfBirth: string | null;
@@ -223,12 +231,6 @@ export interface DashboardSnapshotUser {
gender: ProfileSex;
glucoseUnit: string | null;
onboardingTourCompleted: boolean;
/**
* Server-computed wall-clock hour in the user's timezone so the
* client greeting never has to run its own `Intl.DateTimeFormat`
* before first paint.
*/
greetingHour: number;
}
/**
@@ -530,22 +532,6 @@ export interface SnapshotUserInput {
healthScoreConfigJson: unknown;
}
/** Wall-clock hour in `tz`, used for the server-side greeting. */
function hourInTimezone(now: Date, tz: string): number {
try {
const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
hour12: false,
timeZone: tz,
});
const parsed = parseInt(fmt.format(now), 10);
// Intl emits "24" for midnight under hour12:false on some ICU builds.
return Number.isFinite(parsed) ? parsed % 24 : now.getUTCHours();
} catch {
return now.getUTCHours();
}
}
function enrichLastSeen(
raw: Record<string, { lastSeenAt: string } | null>,
nowMs: number,
@@ -1457,7 +1443,6 @@ export async function buildDashboardSnapshot(
gender,
glucoseUnit: user.glucoseUnit ?? null,
onboardingTourCompleted: user.onboardingTourCompleted,
greetingHour: hourInTimezone(now, userTz),
},
layout,
layoutCatalogue: buildLayoutCatalogue(layout),
@@ -0,0 +1,100 @@
/**
* The UI language and the stored language are resolved from different
* evidence, and only one of them is ever shown to the person reading.
*
* `resolveServerLocale` / `resolveInitialLocale` answer a REQUEST: the
* `healthlog-locale` cookie first, then `User.locale`, then Accept-Language.
* Two of those three rungs are invisible to anything without a request, so
* a browser can render the whole app in German while the column says
* English.
*
* Everything written outside a request reads the column alone —
* `resolveJobLocale` for the nightly writers, `resolveLocaleForUser` for the
* Coach tool executor and the MCP reads. So a diverged column does not show
* up as a wrong menu or a wrong button. It shows up as one paragraph of
* generated prose in the wrong language, sitting in a page that is otherwise
* correct, which is exactly how it was reported.
*
* These are the two ways a browser gets a language the column never hears
* about. They are the reason `getSession` reconciles the column from the
* cookie: the client's fire-and-forget PUT is the only thing that ever
* closed this gap, and a request that never arrives leaves the divergence
* standing for as long as the account exists.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCookies = vi.fn();
const mockHeaders = vi.fn();
vi.mock("next/headers", () => ({
cookies: (...args: unknown[]) => mockCookies(...args),
headers: (...args: unknown[]) => mockHeaders(...args),
}));
const mockGetSessionUserLocale = vi.fn();
vi.mock("@/lib/auth/session", () => ({
getSessionUserLocale: (...args: unknown[]) =>
mockGetSessionUserLocale(...args),
}));
vi.mock("@/lib/app-settings", () => ({
getOperatorDefaultLocale: vi.fn(async () => null),
}));
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
function cookieStoreWith(value: string | undefined) {
return {
get: (name: string) =>
name === "healthlog-locale" && value !== undefined
? { name, value }
: undefined,
};
}
function headersWith(acceptLanguage: string | null) {
return {
get: (name: string) =>
name.toLowerCase() === "accept-language" ? acceptLanguage : null,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockCookies.mockResolvedValue(cookieStoreWith(undefined));
mockHeaders.mockResolvedValue(headersWith(null));
mockGetSessionUserLocale.mockResolvedValue(null);
});
describe("a browser can read German while the column says English", () => {
it("Accept-Language alone: German UI, English background prose", async () => {
// The account has never opened the language picker, so the column is
// NULL, and the browser asks for German.
mockGetSessionUserLocale.mockResolvedValue(null);
mockHeaders.mockResolvedValue(headersWith("de-DE,de;q=0.9,en;q=0.5"));
await expect(resolveInitialLocale()).resolves.toBe("de");
await expect(resolveJobLocale(null)).resolves.toBe("en");
});
it("cookie alone: German UI, English background prose", async () => {
// The picker was used at some point, so the cookie carries German —
// but the column was left holding the language of the paint that
// preceded the choice.
mockCookies.mockResolvedValue(cookieStoreWith("de"));
mockGetSessionUserLocale.mockResolvedValue("en");
await expect(resolveInitialLocale()).resolves.toBe("de");
await expect(resolveJobLocale("en")).resolves.toBe("en");
});
it("the cookie outranks the column on every request that has one", async () => {
// Which is why the divergence is invisible: nothing the user looks at
// reads the column, so a wrong column produces no wrong screen until a
// job writes a sentence with it.
mockCookies.mockResolvedValue(cookieStoreWith("de"));
mockGetSessionUserLocale.mockResolvedValue("en");
await expect(resolveInitialLocale()).resolves.toBe("de");
expect(mockGetSessionUserLocale).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,70 @@
/**
* Why the obvious suspect is not the cause.
*
* The tempting explanation for a column stuck on the wrong language is a
* race: first paint renders the default, the mount effect persists THAT,
* the client then flips to the real locale and persists again, and two
* unordered `keepalive` PUTs land in the wrong order. It would explain the
* symptom exactly, and it would mean the write needs ordering.
*
* It is not what happens. The provider takes the locale the SERVER already
* resolved — cookie, then column, then Accept-Language — and nothing on the
* mount path changes it afterwards, so the effect fires once, with the right
* value, and there is no second write to race with. The write is lost a
* simpler way: it is fire-and-forget, so a 401 on the paint before sign-in,
* an offline blip or a tab closed mid-flight drops it, and the next mount
* repeats the same unreliable attempt rather than noticing.
*
* These pin the two properties that rule the race out, so that a future
* change which does introduce a mount-time flip has to face this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import deMessages from "../../../../messages/de.json";
function Read({ k }: { k: string }) {
const { t } = useTranslations();
return <span>{t(k)}</span>;
}
describe("first paint carries the resolved locale, not a default", () => {
it("renders the server-resolved locale on the very first pass", () => {
// If this ever rendered English first, the mount effect would persist
// English before the flip, and the race above would be real.
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
<Read k="common.save" />
</I18nProvider>,
);
expect(html).toContain("Speichern");
expect(html).not.toContain(">Save<");
});
it("changes the active locale only through the switcher", () => {
const src = readFileSync(
join(process.cwd(), "src/lib/i18n/context.tsx"),
"utf8",
);
expect(src.length).toBeGreaterThan(0);
// Every `setActive` call in the file. One belongs to `setLocale` (the
// explicit switch); the others are the bundle backfill, which must
// carry the locale it was given and never a different one.
const setActiveCalls = src.match(/setActive\(/g) ?? [];
expect(setActiveCalls.length).toBeGreaterThan(0);
// The backfill keeps `prev` whenever the locale moved under it, so it
// can only ever swap MESSAGES, never the locale.
expect(src).toMatch(
/prev\.locale === locale \? \{ locale, messages: loaded \} : prev/,
);
// And the persistence effect keys on the locale alone, so one mount
// with one locale means one write.
expect(src).toMatch(/persistLocaleToServer\(locale\);\s*\}, \[locale\]\);/);
});
});
+13 -7
View File
@@ -109,13 +109,19 @@ function persistLocale(newLocale: Locale) {
document.documentElement.lang = newLocale;
}
// v1.25.0 — mirror the active locale onto `User.locale` so server-side
// background work (the proactive Coach nudge, the Telegram test message)
// renders in the user's language. The cookie/localStorage choice is
// invisible to a cron that has no request context; without this write the
// column stays null and those messages fall back to English. Fire-and-forget
// and idempotent on the server: a 401 on a public page, an offline blip or a
// no-op equal value all fail silently and never block the UI flip.
// Mirror the active locale onto `User.locale` so server-side background work
// (the proactive Coach nudge, the Telegram test message) renders in the
// user's language. The cookie/localStorage choice is invisible to a cron that
// has no request context.
//
// This is no longer what the column depends on. Being fire-and-forget, it
// loses the write on a 401, an offline blip or a tab closed mid-flight, and
// nothing retried — the column then held a language the user had left behind
// for good. `getSession` reconciles the column from the locale cookie on the
// next request instead, which is ordered by the request pipeline and needs no
// cooperation from the client. What this call still earns its place for is
// the server-set cookie the route emits in reply: Safari's ITP caps the
// script-written copy at 7 days, and a Set-Cookie is exempt.
function persistLocaleToServer(newLocale: Locale) {
void apiFetchRaw("/api/auth/me/locale", {
method: "PUT",
@@ -1030,7 +1030,6 @@ export const dashboardSnapshotResponse = z
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
glucoseUnit: z.string().nullable(),
onboardingTourCompleted: z.boolean(),
greetingHour: z.number().int(),
}),
layout: z.record(z.string(), z.unknown()),
// v1.7.0 — full 27-id widget catalogue (16 server-known + 11