mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
107db1bb55
commit
65fe5c34e3
15 files changed
+165
-22
No files matched your search
@@ -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
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -789,7 +789,10 @@
|
||||
"title": "복구 키트를 보관하세요",
|
||||
"body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.",
|
||||
"action": "키트 열기"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "환영합니다.",
|
||||
"welcomeNewWithName": "{name}님, 환영합니다."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "측정",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("<DashboardHeader> — greeting", () => {
|
||||
it("renders the greeting line under the title", () => {
|
||||
const html = renderSSR(<DashboardHeader onQuickEntry={() => 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"[^>]*>/);
|
||||
|
||||
@@ -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<string, string> } {
|
||||
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 (
|
||||
<PageHeader
|
||||
title={t("dashboard.title")}
|
||||
description={
|
||||
/* `min-h-5` reserves the greeting's line box from the SSR
|
||||
pass on: the text personalises (name appended) on the first
|
||||
client re-render after hydration, and the reserved line
|
||||
keeps the header from collapsing/growing around that swap. */
|
||||
pass on: the text personalises (name and welcome appended) on
|
||||
the first client re-render after hydration, and the reserved
|
||||
line keeps the header from collapsing/growing around that swap. */
|
||||
<span
|
||||
data-slot="dashboard-header-greeting"
|
||||
className="block min-h-5 truncate"
|
||||
|
||||
@@ -65,6 +65,11 @@ export type AuthTourProgress = TourProgress;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
/**
|
||||
* When the account was created (ISO). Absent from an older server image,
|
||||
* which every reader treats as an established account.
|
||||
*/
|
||||
createdAt?: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
heightCm: number | null;
|
||||
|
||||
@@ -508,6 +508,9 @@ const accountPayload = z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
createdAt: isoDateTime().describe(
|
||||
"When the signed-in account was created. The web greets an account in its first day as new rather than welcoming it back.",
|
||||
),
|
||||
// Enumerated because clients cut days and convert glucose by them: the
|
||||
// day key and the unit must be the server's, not the device's.
|
||||
timezone: z
|
||||
|
||||
Reference in new issue
Block a user