fix(dashboard): resolve the aggregate greeting through the shared locale order

The 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 language cookie, so `User.locale` is the sole per-user
signal. The coercion here stopped at English when that column was NULL, so an
account that never opened the language picker was greeted in English on an
instance configured for German, while every string beside it was German.

`resolveJobLocale` is where the fallback order lives — stored preference, then
the operator default, then English — and every background writer was moved
onto it earlier. This was the last copy of the old coercion outside that tree.
The structural guard covers `lib/jobs` and `lib/daily` only, so it never saw
this one; the two tests added here pin the behaviour directly instead.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 05:49:18 +02:00
1 parent 4aaed5c312
commit 86f826351f
2 files changed
+62 -15

No files matched your search

@@ -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 };
}