From 65fe5c34e37ede072170cee11d31658f66064502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 09:13:48 +0100 Subject: [PATCH] fix(dashboard): welcome a new account instead of welcoming it back The header always said "welcome back", including to an account the setup flow had created a few minutes earlier. /api/auth/me now publishes the account's createdAt, and the greeting welcomes an account in its first day ("Welcome, name.") and welcomes back every other one, including an account whose older server does not send the field. Until the account is known, during the SSR pass and the hydration render, the line is the time-of-day greeting alone, so nothing claims "back" before the client knows it is true. --- docs/api/openapi.yaml | 7 ++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/nl.json | 5 +- messages/pl.json | 5 +- messages/pt-BR.json | 5 +- src/app/api/auth/me/route.ts | 3 + .../__tests__/dashboard-header.test.tsx | 67 ++++++++++++++++++- src/components/dashboard/dashboard-header.tsx | 57 +++++++++++++--- src/hooks/use-auth.ts | 5 ++ src/lib/openapi/routes/account-sharing.ts | 3 + 15 files changed, 165 insertions(+), 22 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 39ecc4374..d17701724 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -86362,6 +86362,12 @@ components: type: string username: type: string + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: When the signed-in account was created. The web greets an account in its first day as new rather than + welcoming it back. timezone: type: string description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the @@ -86437,6 +86443,7 @@ components: required: - id - username + - createdAt - timezone - glucoseUnit - features diff --git a/messages/de.json b/messages/de.json index cfd221364..62d66abfb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -789,7 +789,10 @@ "title": "Wiederherstellungs-Kit sichern", "body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.", "action": "Kit öffnen" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Willkommen.", + "welcomeNewWithName": "Willkommen, {name}." }, "measurements": { "title": "Messungen", diff --git a/messages/en.json b/messages/en.json index 308ec10b3..40930b705 100644 --- a/messages/en.json +++ b/messages/en.json @@ -789,7 +789,10 @@ "title": "Save your recovery kit", "body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.", "action": "Open the kit" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Welcome.", + "welcomeNewWithName": "Welcome, {name}." }, "measurements": { "title": "Measurements", diff --git a/messages/es.json b/messages/es.json index 150c8db31..f8877cd7f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -789,7 +789,10 @@ "title": "Guarda tu kit de recuperación", "body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.", "action": "Abrir el kit" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Te damos la bienvenida.", + "welcomeNewWithName": "Te damos la bienvenida, {name}." }, "measurements": { "title": "Mediciones", diff --git a/messages/fr.json b/messages/fr.json index 7665a38d8..6d95bfc01 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -789,7 +789,10 @@ "title": "Sauvegardez votre kit de récupération", "body": "Sans copies des clés utilisées par ce serveur, ses données ne peuvent pas être récupérées après une réinstallation. Ça prend quelques minutes.", "action": "Ouvrir le kit" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Bienvenue.", + "welcomeNewWithName": "Bienvenue, {name}." }, "measurements": { "title": "Mesures", diff --git a/messages/it.json b/messages/it.json index 85988398b..0196eeced 100644 --- a/messages/it.json +++ b/messages/it.json @@ -789,7 +789,10 @@ "title": "Salva il tuo kit di recupero", "body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.", "action": "Apri il kit" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Ti diamo il benvenuto.", + "welcomeNewWithName": "Ti diamo il benvenuto, {name}." }, "measurements": { "title": "Misurazioni", diff --git a/messages/ko.json b/messages/ko.json index b46410178..f1b5df923 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -789,7 +789,10 @@ "title": "복구 키트를 보관하세요", "body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.", "action": "키트 열기" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "환영합니다.", + "welcomeNewWithName": "{name}님, 환영합니다." }, "measurements": { "title": "측정", diff --git a/messages/nl.json b/messages/nl.json index 968571fce..772217c4e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -789,7 +789,10 @@ "title": "Bewaar je herstelkit", "body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.", "action": "Kit openen" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Welkom.", + "welcomeNewWithName": "Welkom, {name}." }, "measurements": { "title": "Metingen", diff --git a/messages/pl.json b/messages/pl.json index 7681100be..d0d5efacf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -789,7 +789,10 @@ "title": "Zapisz zestaw odzyskiwania", "body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.", "action": "Otwórz zestaw" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Witaj.", + "welcomeNewWithName": "Witaj, {name}." }, "measurements": { "title": "Pomiary", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index fedfb8d60..619751d54 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -789,7 +789,10 @@ "title": "Guarde seu kit de recuperação", "body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.", "action": "Abrir o kit" - } + }, + "greetingOnly": "{greeting}.", + "welcomeNew": "Boas-vindas.", + "welcomeNewWithName": "Boas-vindas, {name}." }, "measurements": { "title": "Medições", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index a4382d9bb..179658f3b 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -305,6 +305,9 @@ export const GET = apiHandler(async () => { return apiSuccess({ id: user.id, username: user.username, + // When the caller's account was created. The dashboard greeting says + // "welcome", not "welcome back", during an account's first day. + createdAt: user.createdAt, email: user.email, role: user.role ?? "USER", heightCm: user.heightCm, diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index e44fd4c94..680ec64e8 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -43,7 +43,7 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); -import { DashboardHeader } from "../dashboard-header"; +import { DashboardHeader, greetingLine } from "../dashboard-header"; import { CAPTURE_KIND_ORDER, visibleCaptureKinds, @@ -58,12 +58,73 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { ); } +describe("greetingLine", () => { + const now = Date.parse("2026-10-29T09:00:00Z"); + const base = { greeting: "Hello", nowMs: now }; + + it("welcomes an account in its first day, without a 'back'", () => { + expect( + greetingLine({ + ...base, + known: true, + name: "tester", + createdAt: "2026-10-29T08:50:00Z", + }), + ).toEqual({ + key: "dashboard.welcomeNewWithName", + values: { name: "tester" }, + }); + expect( + greetingLine({ + ...base, + known: true, + name: null, + createdAt: "2026-10-28T10:00:00Z", + }).key, + ).toBe("dashboard.welcomeNew"); + }); + + it("welcomes back an account older than a day", () => { + expect( + greetingLine({ + ...base, + known: true, + name: "tester", + createdAt: "2026-10-20T08:00:00Z", + }), + ).toEqual({ + key: "dashboard.welcomeBackWithName", + values: { greeting: "Hello", name: "tester" }, + }); + }); + + it("welcomes back when an older server does not say when the account began", () => { + expect( + greetingLine({ ...base, known: true, name: null, createdAt: undefined }) + .key, + ).toBe("dashboard.welcomeBack"); + }); + + it("says only the greeting while the account is not known", () => { + expect( + greetingLine({ + ...base, + known: false, + name: "tester", + createdAt: "2026-10-29T08:50:00Z", + }), + ).toEqual({ key: "dashboard.greetingOnly", values: { greeting: "Hello" } }); + }); +}); + describe(" — greeting", () => { it("renders the greeting line under the title", () => { const html = renderSSR( undefined} />); expect(html).toContain('data-slot="dashboard-header-greeting"'); - // SSR pass: name-less fallback (personalises post-hydration only). - expect(html).toContain("willkommen zurück."); + // SSR pass: the time-of-day greeting alone. Nothing says "welcome + // back" before the client knows the account is not new. + expect(html).toContain("Hallo."); + expect(html).not.toContain("willkommen zurück"); // Reserved line box keeps the header height stable through the // post-hydration personalisation swap. expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index de3814700..1a7308bff 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -42,6 +42,43 @@ import { } from "@/components/layout/capture-picker"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; +const FIRST_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * The greeting line, as an i18n key and its values. + * + * Until the account is known (the SSR pass and the hydration render) it is the + * time-of-day greeting alone, so nothing claims "welcome back" before the + * client knows it is true. An account in its first day (`createdAt` from + * `/api/auth/me`) is welcomed, not welcomed back: the setup flow lands a new + * account here minutes after it was created. Any other account, and one whose + * server does not say when it was created, is welcomed back. + */ +export function greetingLine(input: { + greeting: string; + known: boolean; + name: string | null; + createdAt: string | null | undefined; + /** The clock, for tests; the moment of the call otherwise. */ + nowMs?: number; +}): { key: string; values: Record } { + const { greeting, name } = input; + const nowMs = input.nowMs ?? Date.now(); + if (!input.known) { + return { key: "dashboard.greetingOnly", values: { greeting } }; + } + const created = input.createdAt ? Date.parse(input.createdAt) : Number.NaN; + const isNew = Number.isFinite(created) && nowMs - created < FIRST_DAY_MS; + if (isNew) { + return name + ? { key: "dashboard.welcomeNewWithName", values: { name } } + : { key: "dashboard.welcomeNew", values: {} }; + } + return name + ? { key: "dashboard.welcomeBackWithName", values: { greeting, name } } + : { key: "dashboard.welcomeBack", values: { greeting } }; +} + export function DashboardHeader({ onQuickEntry, }: { @@ -103,22 +140,22 @@ export function DashboardHeader({ : hour >= 12 && hour < 18 ? t("dashboard.greeting.day") : t("dashboard.greeting.evening"); - const welcomeText = - mounted && user?.username && user.username.trim().length > 0 - ? t("dashboard.welcomeBackWithName", { - greeting: timeGreeting, - name: user.username, - }) - : t("dashboard.welcomeBack", { greeting: timeGreeting }); + const greeting = greetingLine({ + greeting: timeGreeting, + known: mounted && user != null, + name: user?.username?.trim() ? user.username : null, + createdAt: user?.createdAt, + }); + const welcomeText = t(greeting.key, greeting.values); return (