mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge remote-tracking branch 'origin/fix/greeting' into integration/post-1385
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
1d218e0a31
17 files changed
+689
-88
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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\]\);/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user