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,