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

@@ -12,10 +12,6 @@ import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsSectionId,
} from "@/lib/insights-layout";
import {
MANAGER_GROUP_ORDER,
SUB_PAGE_MANAGER_GROUP_SLUGS,
} from "@/lib/insights/sub-page-metric";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
@@ -28,31 +24,38 @@ function render(node: React.ReactNode) {
);
}
describe("<InsightsEditMode> — broadened sub-page manager (v1.15.14 W2)", () => {
describe("<InsightsEditMode> — detail-page manager retired (v1.15.20)", () => {
const noneGated: ReadonlySet<InsightsSectionId> = new Set();
it("relabels the Vitals disclosure to 'Manage detail pages'", () => {
const html = render(
function renderEditMode(): string {
return render(
<InsightsEditMode
layout={DEFAULT_INSIGHTS_LAYOUT}
gatedOffSectionIds={noneGated}
onClose={() => {}}
/>,
);
expect(html).toContain('data-slot="insights-edit-tiles-disclosure"');
expect(html).toContain("Manage detail pages");
}
it("no longer renders the per-detail-page manager disclosure", () => {
// Pill sorting + visibility moved to Settings → Insights; the in-card
// disclosure under the Vitals row duplicated that surface.
const html = renderEditMode();
expect(html).not.toContain('data-slot="insights-edit-tiles-disclosure"');
expect(html).not.toContain('data-slot="insights-edit-tile-row"');
expect(html).not.toContain('data-slot="insights-edit-tiles-nav-hint"');
});
it("groups every routed sub-page slug under exactly one manager group", () => {
// The disclosure body is collapsed in SSR (no effects fire), so this
// asserts the data the manager renders FROM rather than the open DOM:
// every group in the manager order carries at least one slug, and the
// three tab-strip-flat categories (sleep / mood / events) are present.
expect(MANAGER_GROUP_ORDER).toContain("mood");
expect(MANAGER_GROUP_ORDER).toContain("events");
expect(MANAGER_GROUP_ORDER).toContain("sleep");
// A non-vitals slug (`steps`) lands in the activity group so it is
// toggle-able from the manager — the round-trip the spec calls out.
expect(SUB_PAGE_MANAGER_GROUP_SLUGS.activity).toContain("steps");
it("links to the settings pill section instead", () => {
const html = renderEditMode();
expect(html).toContain('data-slot="insights-edit-manage-link"');
expect(html).toContain('href="/settings/insights#insights-pill-order"');
expect(html).toContain("Manage pills &amp; detail pages in Settings");
});
it("still renders the section rows with their eye toggles", () => {
const html = renderEditMode();
expect(html).toContain('data-slot="insights-edit-section-row"');
expect(html).toContain('data-slot="insights-edit-section-eye"');
});
});
+23 -260
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ChevronDown,
Eye,
EyeOff,
GripVertical,
@@ -33,22 +33,12 @@ import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
reorderById,
rebuildTilesWithReorderedVitals,
} from "@/lib/insights-layout-reorder";
import { reorderById } from "@/lib/insights-layout-reorder";
import {
type InsightsLayout,
type InsightsSectionConfig,
type InsightsSectionId,
type InsightsTileConfig,
} from "@/lib/insights-layout";
import {
MANAGER_GROUP_ORDER,
SUB_PAGE_MANAGER_GROUP_SLUGS,
type ManagerGroup,
} from "@/lib/insights/sub-page-metric";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
/**
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
@@ -61,14 +51,11 @@ import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
* and invalidates `queryKeys.insightsLayout()`; "Zurücksetzen" DELETEs to
* defaults.
*
* Tile-level depth (the Vitals section's per-metric tiles) ships as the
* spec-sanctioned DISCLOSURE fallback rather than nested cross-container drag:
* section rows drag in ONE top-level `SortableContext`; the Vitals row carries a
* "Kacheln verwalten" disclosure that opens a SEPARATE, independent
* `DndContext` + `SortableContext` for its tiles. The two drag contexts never
* share collision detection, so a tile drag can never cross-fire into a section
* drag — the failure mode flagged in the plan's Risks section. Still inline,
* still drag + eye per tile, robust on touch.
* Tile-level management (the per-metric detail pages + their nav pills)
* moved to Settings → Insights in v1.15.20 — the pill-order section there
* carries both sorting AND the eye toggles, so the disclosure this card used
* to nest under the Vitals row was a duplicate surface. The card keeps the
* draft's `tiles` untouched and links to the settings section instead.
*/
/** Localized title key per section id — used for the edit-card label. */
@@ -83,27 +70,6 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"rhythm-events": "insights.rhythmEvents.sectionTitle",
};
/**
* v1.15.14 W2 — group-header label key per manager group. Reuses the
* tab-strip parent-pill labels (`insights.tabStrip.<group>Parent.label`)
* so a section header in the manager reads exactly like the nav pill it
* governs; the three groups the tab strip never collapses (sleep / mood /
* events) get their own `insights.editMode.group*` keys. ONE source of
* labels keeps the customize surface and the nav in lockstep.
*/
const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
vitals: "insights.tabStrip.vitalsParent.label",
body: "insights.tabStrip.bodyParent.label",
activity: "insights.tabStrip.activityParent.label",
sleep: "insights.editMode.groupSleep",
cardiovascular: "insights.tabStrip.cardiovascularParent.label",
hearing: "insights.tabStrip.hearingParent.label",
environment: "insights.tabStrip.environmentParent.label",
metabolic: "insights.tabStrip.metabolicParent.label",
mood: "insights.editMode.groupMood",
events: "insights.editMode.groupEvents",
};
interface InsightsEditModeProps {
/** Resolved layout currently in effect (server copy, defaults while loading). */
layout: InsightsLayout;
@@ -131,9 +97,10 @@ export function InsightsEditMode({
const [draft, setDraft] = useState<InsightsLayout>(() => ({
version: layout.version,
sections: [...layout.sections].sort((a, b) => a.order - b.order),
// Tiles pass through the save verbatim — pill order + visibility are
// managed on Settings → Insights since v1.15.20.
tiles: [...layout.tiles].sort((a, b) => a.order - b.order),
}));
const [tilesOpen, setTilesOpen] = useState(false);
// v1.15.11 QA L5 — on mount move focus to the edit-card heading so keyboard /
// screen-reader users land on the surface they just opened (not the top of
@@ -208,34 +175,6 @@ export function InsightsEditMode({
);
const sectionIds = sections.map((s) => s.id);
// v1.15.14 W2 — the manager now lists EVERY sub-page slug grouped by
// category, not just the Vitals overview grid subset. Each group renders
// its slugs sorted by the draft's saved `order`; a slug the layout does
// not enumerate falls to the end. The `overview` tile is layout-only (the
// mother page, not a sub-page) so it never appears here. Labels reuse the
// tab-strip `SUB_PAGE_TABS[slug].labelKey` so the manager row reads like
// the nav pill it governs.
const tilesByGroup = useMemo(() => {
const byId = new Map<string, InsightsTileConfig>(
draft.tiles.map((tt) => [tt.id, tt]),
);
return MANAGER_GROUP_ORDER.map((group) => {
const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group]
.map((slug) => {
const cfg = byId.get(slug);
return {
id: slug as string,
labelKey: SUB_PAGE_TABS[slug].labelKey,
visible: cfg?.visible ?? false,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
})
.sort((a, b) => a.order - b.order);
return { group, rows };
}).filter((g) => g.rows.length > 0);
// Re-derive on any tile change.
}, [draft.tiles]);
function toggleSection(id: InsightsSectionId, visible: boolean) {
setDraft((d) => ({
...d,
@@ -254,47 +193,6 @@ export function InsightsEditMode({
setDraft((d) => ({ ...d, sections: reordered }));
}
function toggleTile(id: string, visible: boolean) {
setDraft((d) => ({
...d,
tiles: d.tiles.map((tt) => (tt.id === id ? { ...tt, visible } : tt)),
}));
}
/**
* v1.15.14 W2 — reorder the tiles WITHIN one manager group. Each group
* renders its own `SortableContext`, so a drag never crosses a group
* boundary; the group is identified from the dragged slug. We reorder the
* group's subset through the tested pure helper, then splice it back into
* the full `tiles` array (which also drives the tab strip + overview grid),
* re-densifying order only across that group's slots while every other
* tile keeps its relative order.
*/
function handleGroupTileDragEnd(group: ManagerGroup, event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? [];
const groupMemberIds = new Set(groupRows.map((r) => r.id));
const subset = groupRows.map((tt) => ({
id: tt.id,
order: tt.order,
visible: tt.visible,
}));
// Reorder ONLY this group's subset through the tested pure helper — a
// single total-order sort, never a mixed-key comparator (QA M2).
const reordered = reorderById(subset, String(active.id), String(over.id));
const reorderedIds = reordered.map((r) => r.id);
setDraft((d) => ({
...d,
// Substitute this group's slots in their new relative order while leaving
// every other tile untouched. Pure, total-order helper (QA M2).
tiles: rebuildTilesWithReorderedVitals(d.tiles, reorderedIds, (id) =>
groupMemberIds.has(id),
),
}));
}
const allHidden = draft.sections.every((s) => !s.visible);
return (
@@ -365,7 +263,6 @@ export function InsightsEditMode({
<div className="space-y-2">
{sections.map((section) => {
const gatedOff = gatedOffSectionIds.has(section.id);
const isVitals = section.id === "vitals";
return (
<SortableSectionRow
key={section.id}
@@ -380,94 +277,25 @@ export function InsightsEditMode({
gatedHint: t("insights.editMode.gatedHint"),
}}
onToggle={toggleSection}
>
{isVitals && (
<div className="mt-2 border-t pt-2">
<button
type="button"
onClick={() => setTilesOpen((o) => !o)}
disabled={busy}
aria-expanded={tilesOpen}
data-slot="insights-edit-tiles-disclosure"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform motion-reduce:transition-none",
tilesOpen && "rotate-180",
)}
aria-hidden="true"
/>
{t("insights.editMode.manageSubPages")}
</button>
{/* v1.15.14 — flag that hiding a detail page also drops
its top-nav pill, so a user isn't surprised a metric
vanished from the navigation. */}
<p
data-slot="insights-edit-tiles-nav-hint"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.editMode.manageSubPagesHint")}
</p>
{tilesOpen && (
// v1.15.14 W2 — the manager now lists EVERY sub-page
// slug grouped by category, the same `tiles` layout the
// tab strip + overview Vitals grid read. Each group is
// its OWN `DndContext` + `SortableContext` so a drag
// never crosses a group boundary (and never cross-fires
// into the section drag). The whole list scrolls inside
// one capped container; `overscroll-contain` keeps the
// scroll local on touch.
<div className="mt-2 max-h-[60vh] space-y-3 overflow-y-auto overscroll-contain">
{tilesByGroup.map(({ group, rows }) => (
<div key={group} data-slot="insights-edit-tile-group" data-group={group}>
<p className="text-muted-foreground px-1 py-1 text-[11px] font-semibold tracking-wide uppercase">
{t(MANAGER_GROUP_HEADER_KEYS[group])}
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) =>
handleGroupTileDragEnd(group, event)
}
>
<SortableContext
items={rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5">
{rows.map((tile) => (
<SortableTileRow
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
labels={{
dragHandle: t(
"insights.editMode.dragHandle",
),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
}}
onToggle={toggleTile}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
))}
</div>
)}
</div>
)}
</SortableSectionRow>
/>
);
})}
</div>
</SortableContext>
</DndContext>
{/* v1.15.20 — the per-detail-page manager (sort + show/hide) lives on
Settings → Insights; the disclosure this card used to nest under
the Vitals row duplicated it. Keep a quiet pointer instead. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/insights#insights-pill-order"
data-slot="insights-edit-manage-link"
className="hover:text-foreground focus-visible:ring-ring rounded underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.editMode.manageInSettings")}
</Link>
</p>
</div>
);
}
@@ -523,7 +351,6 @@ interface SortableSectionRowProps {
disabled: boolean;
labels: RowLabels & { gatedHint: string };
onToggle: (id: InsightsSectionId, visible: boolean) => void;
children?: React.ReactNode;
}
function SortableSectionRow({
@@ -533,7 +360,6 @@ function SortableSectionRow({
disabled,
labels,
onToggle,
children,
}: SortableSectionRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: section.id });
@@ -598,70 +424,7 @@ function SortableSectionRow({
slot="insights-edit-section-eye"
/>
</div>
{children}
</div>
);
}
interface SortableTileRowProps {
id: string;
title: string;
visible: boolean;
disabled: boolean;
labels: RowLabels;
onToggle: (id: string, visible: boolean) => void;
}
function SortableTileRow({
id,
title,
visible,
disabled,
labels,
onToggle,
}: SortableTileRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="insights-edit-tile-row"
data-tile={id}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5",
isDragging && "ring-primary z-10 opacity-90 shadow-md ring-2",
)}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-tile-handle"
className={DRAG_HANDLE_CLASS}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<EyeToggle
visible={visible}
disabled={disabled}
label={`${visible ? labels.hide : labels.show} — ${title}`}
onClick={() => onToggle(id, !visible)}
slot="insights-edit-tile-eye"
/>
</div>
);
}
@@ -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>
);
}