Give the account page's save-on-change preferences their own card

Language, units, glucose unit and the hour and date formats each save
the moment they change, yet sat inside the profile form as if its Save
covered them. They move to a Display card with no save button; the
profile card keeps its fields, the timezone (which saves with the form)
and its Save.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:41:50 +02:00
1 parent 51374274aa
commit 2d79b80298
9 files changed
+152 -52

No files matched your search

+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
},
"passwordTitle": "Passwort",
"tokenNameLabel": "Token-Name"
"tokenNameLabel": "Token-Name",
"displayCard": {
"title": "Darstellung",
"description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst."
}
},
"admin": {
"title": "Administration",
+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "That did not work. Check what you entered and try again."
},
"passwordTitle": "Password",
"tokenNameLabel": "Token name"
"tokenNameLabel": "Token name",
"displayCard": {
"title": "Display",
"description": "Language, units and formats, saved as soon as you change them."
}
},
"admin": {
"title": "Administration",
+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
},
"passwordTitle": "Contraseña",
"tokenNameLabel": "Nombre del token"
"tokenNameLabel": "Nombre del token",
"displayCard": {
"title": "Visualización",
"description": "Idioma, unidades y formatos; se guardan en cuanto los cambias."
}
},
"admin": {
"title": "Administration",
+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez."
},
"passwordTitle": "Mot de passe",
"tokenNameLabel": "Nom du jeton"
"tokenNameLabel": "Nom du jeton",
"displayCard": {
"title": "Affichage",
"description": "Langue, unités et formats, enregistrés dès que vous les modifiez."
}
},
"admin": {
"title": "Administration",
+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
},
"passwordTitle": "Password",
"tokenNameLabel": "Nome del token"
"tokenNameLabel": "Nome del token",
"displayCard": {
"title": "Visualizzazione",
"description": "Lingua, unità e formati, salvati appena li modifichi."
}
},
"admin": {
"title": "Administration",
+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
},
"passwordTitle": "비밀번호",
"tokenNameLabel": "토큰 이름"
"tokenNameLabel": "토큰 이름",
"displayCard": {
"title": "표시",
"description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요."
}
},
"admin": {
"title": "관리자",
+5 -1
View File
@@ -7173,7 +7173,11 @@
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
},
"passwordTitle": "Hasło",
"tokenNameLabel": "Nazwa tokena"
"tokenNameLabel": "Nazwa tokena",
"displayCard": {
"title": "Wyświetlanie",
"description": "Język, jednostki i formaty zapisywane od razu po zmianie."
}
},
"admin": {
"title": "Administracja",
@@ -0,0 +1,53 @@
/**
* Settings → Account: the controls that save on change (language, units,
* glucose unit, hour and date format) sit in their own card with no save
* button, apart from the profile form whose Save does not cover them.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { DisplayPreferencesCard } from "../index";
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0, enabled: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<DisplayPreferencesCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings → Account display card", () => {
it("holds the language select and carries no form or save button", () => {
const html = render();
expect(html).toContain('data-testid="settings-display-card"');
expect(html).toContain('id="language-select"');
expect(html).not.toContain("<form");
expect(html).not.toContain('type="submit"');
});
it("leaves the save-on-change controls out of the profile form", () => {
const src = readFileSync(join(__dirname, "../index.tsx"), "utf8");
const form = src.slice(src.indexOf("<form"), src.indexOf("</form>"));
expect(form.length).toBeGreaterThan(0);
for (const control of [
"language-select",
"UnitPreferenceSelect",
"GlucoseUnitSelect",
"TimeFormatSelect",
"DateFormatSelect",
]) {
expect(form).not.toContain(control);
}
// The timezone saves with the form, so it stays inside it.
expect(form).toContain("TimezonePicker");
});
});
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, User } from "lucide-react";
import { Languages, Loader2, Save, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -62,7 +62,7 @@ import { dateOnlyKey } from "@/lib/tz/date-only";
export { resolveInitialTimezone } from "./account-section-utils";
export function AccountSection() {
const { t, locale, setLocale, pendingLocale } = useTranslations();
const { t } = useTranslations();
const { user, isLoading, isAuthenticated, refetch } = useAuth();
// v1.16.4 — see `useMounted`: keeps the hydration render identical to
// the SSR HTML when this boundary hydrates after `/api/auth/me`
@@ -517,12 +517,10 @@ export function AccountSection() {
</div>
</div>
{/* Date of birth + language share one paired grid row so the
profile form keeps a single rhythm (every row two cells
wide on sm+). Date of birth is the bottom of the
biological-profile block; language is the only UI
preference on this card. They sit together to close the
"single-cell row" gap that broke the form's grid. */}
{/* Date of birth + timezone share one paired grid row. The
timezone stays on this card because it saves with the form
(its own PUT runs beside the profile PUT); the display
preferences that save on change live in their own card below. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
@@ -544,44 +542,7 @@ export function AccountSection() {
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
{/* While a switch waits on its message bundle (dynamic
import per locale), show the target value and lock the
control — the context only flips locale + strings
together once the bundle arrived. */}
<NativeSelect
id="language-select"
value={pendingLocale ?? locale}
disabled={pendingLocale !== null}
aria-busy={pendingLocale !== null}
onChange={(e) => setLocale(e.target.value as Locale)}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
</div>
{/* Timezone, unit system, glucose unit and the date/hour formats
share one grid block — all personal display preferences (like
language above). The glucose unit is its own dropdown rather
than a branch of the unit system because metric countries are
split on which unit they read glucose in. Every dropdown here
PATCHes its own endpoint on change; the timezone saves through
the form's submit handler. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -702,6 +663,8 @@ export function AccountSection() {
{recentProof.dialog}
</SettingsCard>
<DisplayPreferencesCard isAuthenticated={isAuthenticated} />
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
Medikamente settings section, where every medication-specific
preference now lives. */}
@@ -734,3 +697,59 @@ export function AccountSection() {
</div>
);
}
/**
* Language, units and formats: every control here saves the moment it
* changes (each PATCHes its own endpoint), so they sit in a card with no save
* button. Inside the profile form they read as if the form's Save covered
* them, and a person who changed the language and then cancelled the form
* found it changed anyway.
*/
export function DisplayPreferencesCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t, locale, setLocale, pendingLocale } = useTranslations();
return (
<SettingsCard data-testid="settings-display-card">
<SettingsCardHeader
icon={Languages}
title={t("settings.displayCard.title")}
description={t("settings.displayCard.description")}
/>
{/* The glucose unit is its own dropdown rather than a branch of the
unit system because metric countries are split on which unit they
read glucose in. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
{/* While a switch waits on its message bundle (dynamic import per
locale), show the target value and lock the control — the
context only flips locale + strings together once the bundle
arrived. */}
<NativeSelect
id="language-select"
value={pendingLocale ?? locale}
disabled={pendingLocale !== null}
aria-busy={pendingLocale !== null}
onChange={(e) => setLocale(e.target.value as Locale)}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
</SettingsCard>
);
}