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 (