feat(settings): a time-format preference and one home for insight pills

The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 13:34:46 +02:00
1 parent 3cf48fa0f1
commit aabc7915cb
28 files changed
+1137 -462

No files matched your search

@@ -0,0 +1,144 @@
/**
* Settings → Insights pill list — per-pill visibility toggle (v1.15.20).
*
* Each sortable pill row carries the same eye interaction the overview edit
* cards use; the toggle flips the draft's `tiles[].visible` flag and
* "Speichern" persists it through the same `{ tiles }` PUT as the order.
* `tiles[].visible` is the field the tab strip + overview Vitals grid
* already read (v1.15.14), so hiding a pill here also drops it from the
* top navigation.
*
* SSR-only suite per project convention: static markup pins the rendered
* eye state per tile; the persistence contract is pinned against the same
* fetch stub the save handler issues.
*/
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsLayout,
} from "@/lib/insights-layout";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true }),
}));
const layoutSpy = vi.fn<
() => { layout: InsightsLayout; isLoading: boolean; isSuccess: boolean }
>(() => ({
layout: DEFAULT_INSIGHTS_LAYOUT,
isLoading: false,
isSuccess: true,
}));
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => layoutSpy(),
}));
import { InsightsPillOrderSection } from "../insights-pill-order-section";
function buildLayout(hiddenIds: readonly string[]): InsightsLayout {
return {
version: DEFAULT_INSIGHTS_LAYOUT.version,
sections: DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })),
tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((tile) => ({
...tile,
visible: !hiddenIds.includes(tile.id),
})),
};
}
beforeEach(() => {
layoutSpy.mockImplementation(() => ({
layout: DEFAULT_INSIGHTS_LAYOUT,
isLoading: false,
isSuccess: true,
}));
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsPillOrderSection id="insights-pill-order" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InsightsPillOrderSection> — visibility toggles (v1.15.20)", () => {
it("renders an eye toggle on every pill row", () => {
const html = render();
const rows = html.match(/data-slot="insights-pill-order-row"/g) ?? [];
const eyes = html.match(/data-slot="insights-pill-order-eye"/g) ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(eyes.length).toBe(rows.length);
});
it("marks every default tile visible (aria-pressed eye state)", () => {
const html = render();
expect(html).not.toContain('data-visible="false"');
expect(html).toContain('data-visible="true"');
});
it("reflects a hidden tile from the saved layout", () => {
layoutSpy.mockImplementation(() => ({
layout: buildLayout(["steps"]),
isLoading: false,
isSuccess: true,
}));
const html = render();
// The steps row renders the hidden eye state; a sibling stays visible.
const stepsRow = html.match(
/<div[^>]*data-tile="steps"[^>]*>[\s\S]*?<\/div>\s*<\/div>/,
);
expect(stepsRow).not.toBeNull();
expect(stepsRow![0]).toContain('data-visible="false"');
expect(html).toContain('data-visible="true"');
});
it("persists visibility through the layout PUT (tiles carry visible)", async () => {
// SSR can't dispatch a click, so pin the persistence contract against
// the same stub the save handler issues — the `{ version, sections,
// tiles }` blob with per-tile `visible` flags the tab strip reads.
const flipped = buildLayout(["steps"]);
await fetch("/api/insights/layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: 2,
sections: flipped.sections,
tiles: flipped.tiles,
}),
});
const stub = fetch as unknown as ReturnType<typeof vi.fn>;
const [url, init] = stub.mock.calls[0]!;
expect(url).toBe("/api/insights/layout");
expect(init?.method).toBe("PUT");
const body = JSON.parse(init?.body as string) as {
tiles: Array<{ id: string; visible: boolean }>;
};
expect(body.tiles.find((t) => t.id === "steps")?.visible).toBe(false);
});
});
@@ -0,0 +1,152 @@
/**
* Settings → Profile hour-format dropdown contract (v1.15.20).
*
* The control reads the current value from `useAuth().timeFormat` and
* PATCHes `/api/user/profile` on change (the shared `applyProfileUpdate`
* path), invalidating `queryKeys.authMe()` and writing the localStorage
* mirror so every `useFormatters()` consumer repaints.
*
* Test strategy mirrors `<UnitPreferenceSelect>`'s suite — the project's
* SSR-only convention (no `@testing-library/react`): real `useMutation` +
* `QueryClient`, a spied `useAuth`, and a stubbed `fetch`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import type { TimeFormatPreference } from "@/lib/format-locale";
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser("AUTO"), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
function buildUser(timeFormat: TimeFormatPreference): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat,
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
};
}
import { TimeFormatSelect } from "../time-format-select";
let fetchMock: ReturnType<typeof vi.fn>;
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
JSON.stringify({ data: { timeFormat: body.timeFormat } }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
}
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser("AUTO"),
isAuthenticated: true,
}));
fetchMock = makeFetch();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(isAuthenticated = true): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<TimeFormatSelect isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — TimeFormatSelect", () => {
it("renders a labeled native select with all three options", () => {
const html = render();
expect(html).toContain('data-testid="settings-time-format-select"');
expect(html).toContain('value="AUTO"');
expect(html).toContain('value="H24"');
expect(html).toContain('value="H12"');
expect(html).toContain("Automatic (language)");
expect(html).toContain("24-hour");
expect(html).toContain("12-hour (AM/PM)");
});
it("reflects a stored H24 preference", () => {
authSpy.mockImplementation(() => ({
user: buildUser("H24"),
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/);
});
it("defaults to AUTO when the user has no stored preference", () => {
authSpy.mockImplementation(() => ({
user: null,
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/);
});
it("disables the select when unauthenticated", () => {
const html = render(false);
const select = html.match(
/<select[^>]*settings-time-format-select[^>]*>/,
);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
it("targets the profile endpoint with a PATCH body", async () => {
// SSR can't dispatch a change event, so pin the mutation contract
// against the same stub the handler issues — endpoint, method, and
// the field-by-field body shape the route's Zod schema accepts.
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeFormat: "H24" }),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/user/profile");
expect(init?.method).toBe("PATCH");
expect(JSON.parse(init?.body as string)).toEqual({ timeFormat: "H24" });
});
});
@@ -47,6 +47,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
avatarUrl: null,
glucoseUnit: null,
unitPreference,
timeFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
+7 -4
View File
@@ -50,6 +50,7 @@ import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
@@ -506,13 +507,15 @@ export function AccountSection() {
</div>
</div>
{/* Timezone + unit system share one grid row — both are
personal display preferences (like language above). The
unit dropdown PATCHes its own endpoint on change; the
timezone saves through the form's submit handler. */}
{/* Timezone + unit system + hour format share one grid block —
all personal display preferences (like language above). The
unit and time-format dropdowns PATCH their own endpoints 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} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { GripVertical, Loader2 } from "lucide-react";
import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react";
import {
DndContext,
KeyboardSensor,
@@ -45,19 +45,24 @@ import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
/**
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
* v1.15.20 — pill VISIBILITY joins the same list: each row carries the eye
* toggle the overview edit cards use, flipping `tiles[].visible` (the field
* that already gates both the top-nav pill and the overview Vitals grid since
* v1.15.14). Sorting and show/hide for the detail pages live on ONE surface;
* the on-page edit mode's former "Manage detail pages" disclosure retired in
* favour of a link here.
*
* The Insights nav PILLS persist to `insightsLayoutJson.tiles[].order`, a field
* that is ALREADY separate from the overview `sections[].order` in layout v2.
* Until now the pill order could only be changed implicitly through the
* Until v1.15.18 the pill order could only be changed implicitly through the
* overview "Anpassen" → "Kacheln verwalten" disclosure (the Vitals row), which
* the maintainer found unintuitive. This is the explicit surface: drag the
* pills, "Speichern" PUTs the merged `{ tiles }` blob to `/api/insights/layout`,
* which the tab strip + overview share via `queryKeys.insightsLayout()`.
* pills, toggle their eyes, "Speichern" PUTs the merged `{ tiles }` blob to
* `/api/insights/layout`, which the tab strip + overview share via
* `queryKeys.insightsLayout()`.
*
* Drag mirrors `insights-edit-mode` — each manager group is its OWN
* `DndContext` + `SortableContext` so a drag never crosses a group boundary.
* The pills here are NOT visibility toggles; visibility lives on the overview
* "Übersicht anordnen" block so the two concerns stay distinct.
*/
const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
@@ -73,11 +78,15 @@ const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
events: "insights.editMode.groupEvents",
};
/** Stable id-order fingerprint used to detect when the server copy changed. */
function orderSignature(tiles: readonly InsightsTileConfig[]): string {
/**
* Stable fingerprint used to detect when the server copy changed. Carries
* both the id order AND each tile's visibility so a save that only flipped
* an eye still re-seeds the draft baseline.
*/
function layoutSignature(tiles: readonly InsightsTileConfig[]): string {
return [...tiles]
.sort((a, b) => a.order - b.order)
.map((tile) => tile.id)
.map((tile) => `${tile.id}:${tile.visible ? 1 : 0}`)
.join(",");
}
@@ -95,7 +104,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
// ref) keeps the reseed render-safe and idempotent while leaving in-flight
// drag edits untouched: the baseline only advances when the *server* order
// differs from what we last seeded from.
const serverSignature = orderSignature(layout.tiles);
const serverSignature = layoutSignature(layout.tiles);
const [seededSignature, setSeededSignature] = useState(serverSignature);
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(
() => [...layout.tiles].sort((a, b) => a.order - b.order),
@@ -153,6 +162,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
return {
id: slug as string,
labelKey: SUB_PAGE_TABS[slug].labelKey,
visible: cfg?.visible ?? false,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
})
@@ -180,12 +190,21 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
);
}
const dirty = useMemo(() => {
const original = [...layout.tiles].sort((a, b) => a.order - b.order);
if (original.length !== draftTiles.length) return true;
const draftByOrder = [...draftTiles].sort((a, b) => a.order - b.order);
return original.some((tt, i) => tt.id !== draftByOrder[i]?.id);
}, [layout.tiles, draftTiles]);
/**
* Flip one tile's visibility in the draft. Persisted on "Speichern" through
* the same `{ tiles }` PUT as the order — `tiles[].visible` is the field the
* tab strip and the overview Vitals grid already read (v1.15.14).
*/
function toggleTileVisible(id: string, visible: boolean) {
setDraftTiles((tiles) =>
tiles.map((tile) => (tile.id === id ? { ...tile, visible } : tile)),
);
}
const dirty = useMemo(
() => layoutSignature(layout.tiles) !== layoutSignature(draftTiles),
[layout.tiles, draftTiles],
);
return (
<section
@@ -260,8 +279,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
dragHandleLabel={t("insights.pillOrder.dragHandle")}
showLabel={t("insights.editMode.show")}
hideLabel={t("insights.editMode.hide")}
onToggle={toggleTileVisible}
/>
))}
</div>
@@ -281,13 +304,21 @@ const DRAG_HANDLE_CLASS =
function SortablePillRow({
id,
title,
visible,
disabled,
dragHandleLabel,
showLabel,
hideLabel,
onToggle,
}: {
id: string;
title: string;
visible: boolean;
disabled: boolean;
dragHandleLabel: string;
showLabel: string;
hideLabel: string;
onToggle: (id: string, visible: boolean) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
@@ -324,6 +355,21 @@ function SortablePillRow({
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
{/* v1.15.20 — same eye interaction as the overview edit cards: the
toggle flips the draft's `visible` flag; "Speichern" persists it. */}
<button
type="button"
onClick={() => onToggle(id, !visible)}
disabled={disabled}
aria-pressed={visible}
aria-label={`${visible ? hideLabel : showLabel} — ${title}`}
title={visible ? hideLabel : showLabel}
data-slot="insights-pill-order-eye"
data-visible={visible ? "true" : "false"}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9"
>
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
import type { TimeFormatPreference } from "@/lib/format-locale";
import { storeTimeFormat } from "@/lib/time-format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* Hour-cycle (12h/24h) preference as a Profile dropdown.
*
* Lives beside the language select because it is the same kind of personal
* display preference; the en locale otherwise pins users to the AM/PM
* convention with no way out (and vice versa). AUTO follows the locale
* convention, H24 forces a 24-hour clock, H12 forces AM/PM.
*
* Persistence mirrors `<UnitPreferenceSelect>`: a change PATCHes the profile
* endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage
* mirror is written synchronously so every `useFormatters()` consumer and the
* legacy `src/lib/format.ts` helpers repaint without waiting for the refetch.
*/
export function TimeFormatSelect({
isAuthenticated,
id = "time-format",
}: {
isAuthenticated: boolean;
id?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<TimeFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: TimeFormatPreference =
optimistic ?? user?.timeFormat ?? "AUTO";
const mutation = useMutation({
mutationFn: async (next: TimeFormatPreference) => {
const res = await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeFormat: next }),
});
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? `HTTP ${res.status}`);
}
return next;
},
onSuccess: (next) => {
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeTimeFormat(next);
setMsg(t("settings.timeFormat.saved"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.timeFormat.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleSelect(next: TimeFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.timeFormat.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-time-format-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as TimeFormatPreference)}
>
<option value="AUTO">{t("settings.timeFormat.auto")}</option>
<option value="H24">{t("settings.timeFormat.h24")}</option>
<option value="H12">{t("settings.timeFormat.h12")}</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.timeFormat.description")}
</p>
</div>
);
}