Merge pull request #290 from MBombeck/release/v1.15.11

v1.15.11 — a customizable Insights overview
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 17:18:39 +02:00
commit 2851776af6
20 files changed
+2195 -138

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
## [1.15.11] — 2026-06-07 — a customizable overview
### Added
- **Arrange your overview.** Tap **Anpassen** on the Insights overview to rearrange it: drag sections into the order you want, hide the ones you don't, and — inside Vitals — reorder or hide individual metric tiles. Your layout is saved to your account and carries across devices. Tap **Fertig** to apply, **Zurücksetzen** to return to the default arrangement.
### Fixed
- **Self-hosting docs now generate a URL-safe Postgres password.** The setup examples used `openssl rand -base64 24`, which can emit characters (`/`, `+`, `=`) that break the unescaped `DATABASE_URL` and stop a fresh instance from connecting to its database. Every example now uses `openssl rand -hex 32`.
+92 -19
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.15.10
version: 1.15.11
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4555,8 +4555,38 @@ components:
type: object
properties:
version:
type: number
const: 1
anyOf:
- type: number
const: 1
- type: number
const: 2
sections:
maxItems: 50
type: array
items:
type: object
properties:
id:
type: string
enum:
- wellness-scores
- daily-briefing
- vitals
- trends
- period-review
- cycle-summary
- signals
- rhythm-events
visible:
type: boolean
order:
type: integer
minimum: 0
maximum: 99
required:
- id
- visible
- order
tiles:
minItems: 1
maxItems: 50
@@ -4636,13 +4666,19 @@ components:
- order
required:
- version
- tiles
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed
`/insights/<slug>` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht,
aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and
are normalised to their English equivalents before persisting; GET responses always carry the canonical English
ids. The legacy ids are deprecated and will be removed in a future major version."
description: "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag);
`sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing,
vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version`
is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`);
the server always normalises to the canonical v2 blob before persisting, and GET responses always carry
`version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still
validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all
sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed
`/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur,
gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
future major version."
CoachMessageFeedbackRequest:
type: object
properties:
@@ -11443,8 +11479,39 @@ components:
type: object
properties:
version:
type: number
const: 1
anyOf:
- type: number
const: 1
- type: number
const: 2
sections:
maxItems: 50
type: array
items:
type: object
properties:
id:
type: string
enum:
- wellness-scores
- daily-briefing
- vitals
- trends
- period-review
- cycle-summary
- signals
- rhythm-events
visible:
type: boolean
order:
type: integer
minimum: 0
maximum: 99
required:
- id
- visible
- order
additionalProperties: false
tiles:
minItems: 1
maxItems: 50
@@ -11525,14 +11592,20 @@ components:
additionalProperties: false
required:
- version
- tiles
additionalProperties: false
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed
`/insights/<slug>` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht,
aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and
are normalised to their English equivalents before persisting; GET responses always carry the canonical English
ids. The legacy ids are deprecated and will be removed in a future major version."
description: "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag);
`sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing,
vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version`
is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`);
the server always normalises to the canonical v2 blob before persisting, and GET responses always carry
`version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still
validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all
sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed
`/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur,
gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
future major version."
Medication:
type: object
properties:
+18
View File
@@ -1584,6 +1584,24 @@
"emptyTitle": "Noch nicht genug Daten",
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
"emptyAddMeasurement": "Messwert erfassen",
"editMode": {
"open": "Anpassen",
"done": "Fertig",
"reset": "Zurücksetzen",
"title": "Übersicht anpassen",
"description": "Zum Sortieren ziehen, zum Ein- oder Ausblenden auf das Auge tippen.",
"dragHandle": "Zum Sortieren ziehen",
"show": "Einblenden",
"hide": "Ausblenden",
"manageTiles": "Kacheln verwalten",
"gatedHint": "Erscheint, sobald Daten oder die Funktion verfügbar sind",
"allHiddenHint": "Alle Bereiche sind ausgeblendet. Blende mindestens einen ein, um deine Übersicht zu sehen.",
"saveSuccess": "Übersicht gespeichert",
"saveError": "Übersicht konnte nicht gespeichert werden",
"resetSuccess": "Übersicht auf Standard zurückgesetzt",
"emptyTitle": "Alle Karten ausgeblendet",
"emptyDescription": "Du hast alle Bereiche ausgeblendet. Öffne „Anpassen“, um Karten wieder einzublenden."
},
"bmiEmptyTitle": "Größe hinzufügen für BMI",
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
"bmiEmptyAction": "Account-Einstellungen öffnen",
+18
View File
@@ -1586,6 +1586,24 @@
"emptyTitle": "Not enough data yet",
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
"emptyAddMeasurement": "Add a measurement",
"editMode": {
"open": "Customize",
"done": "Done",
"reset": "Reset",
"title": "Customize overview",
"description": "Drag to reorder, tap the eye to show or hide.",
"dragHandle": "Drag to reorder",
"show": "Show",
"hide": "Hide",
"manageTiles": "Manage tiles",
"gatedHint": "Appears once data or the feature is available",
"allHiddenHint": "Every section is hidden. Show at least one to see your overview.",
"saveSuccess": "Overview layout saved",
"saveError": "Could not save your overview layout",
"resetSuccess": "Overview reset to defaults",
"emptyTitle": "Every card is hidden",
"emptyDescription": "You've hidden every section. Open customize to bring cards back."
},
"bmiEmptyTitle": "Set your height to see BMI",
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
"bmiEmptyAction": "Open account settings",
+18
View File
@@ -1586,6 +1586,24 @@
"emptyTitle": "Aún no hay datos suficientes",
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
"emptyAddMeasurement": "Registrar medición",
"editMode": {
"open": "Personalizar",
"done": "Listo",
"reset": "Restablecer",
"title": "Personalizar resumen",
"description": "Arrastra para reordenar, toca el ojo para mostrar u ocultar.",
"dragHandle": "Arrastra para reordenar",
"show": "Mostrar",
"hide": "Ocultar",
"manageTiles": "Gestionar tarjetas",
"gatedHint": "Aparece cuando haya datos o la función esté disponible",
"allHiddenHint": "Todas las secciones están ocultas. Muestra al menos una para ver tu resumen.",
"saveSuccess": "Resumen guardado",
"saveError": "No se pudo guardar tu resumen",
"resetSuccess": "Resumen restablecido a los valores predeterminados",
"emptyTitle": "Todas las tarjetas están ocultas",
"emptyDescription": "Has ocultado todas las secciones. Abre Personalizar para volver a mostrarlas."
},
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
"bmiEmptyAction": "Abrir ajustes de cuenta",
+18
View File
@@ -1586,6 +1586,24 @@
"emptyTitle": "Pas encore assez de données",
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
"emptyAddMeasurement": "Saisir une mesure",
"editMode": {
"open": "Personnaliser",
"done": "Terminé",
"reset": "Réinitialiser",
"title": "Personnaliser l’aperçu",
"description": "Fais glisser pour réorganiser, touche l’œil pour afficher ou masquer.",
"dragHandle": "Fais glisser pour réorganiser",
"show": "Afficher",
"hide": "Masquer",
"manageTiles": "Gérer les tuiles",
"gatedHint": "Apparaît dès que des données ou la fonction sont disponibles",
"allHiddenHint": "Toutes les sections sont masquées. Affiches-en au moins une pour voir ton aperçu.",
"saveSuccess": "Aperçu enregistré",
"saveError": "Impossible d’enregistrer ton aperçu",
"resetSuccess": "Aperçu réinitialisé aux valeurs par défaut",
"emptyTitle": "Toutes les cartes sont masquées",
"emptyDescription": "Tu as masqué toutes les sections. Ouvre Personnaliser pour les réafficher."
},
"bmiEmptyTitle": "Ajoute ta taille pour l’IMC",
"bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.",
"bmiEmptyAction": "Ouvrir les paramètres du compte",
+18
View File
@@ -1586,6 +1586,24 @@
"emptyTitle": "Dati ancora insufficienti",
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
"emptyAddMeasurement": "Registra misurazione",
"editMode": {
"open": "Personalizza",
"done": "Fine",
"reset": "Reimposta",
"title": "Personalizza panoramica",
"description": "Trascina per riordinare, tocca l’occhio per mostrare o nascondere.",
"dragHandle": "Trascina per riordinare",
"show": "Mostra",
"hide": "Nascondi",
"manageTiles": "Gestisci riquadri",
"gatedHint": "Compare quando i dati o la funzione sono disponibili",
"allHiddenHint": "Tutte le sezioni sono nascoste. Mostrane almeno una per vedere la panoramica.",
"saveSuccess": "Panoramica salvata",
"saveError": "Impossibile salvare la panoramica",
"resetSuccess": "Panoramica reimpostata ai valori predefiniti",
"emptyTitle": "Tutte le schede sono nascoste",
"emptyDescription": "Hai nascosto tutte le sezioni. Apri Personalizza per mostrarle di nuovo."
},
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
"bmiEmptyAction": "Apri impostazioni account",
+18
View File
@@ -1586,6 +1586,24 @@
"emptyTitle": "Jeszcze za mało danych",
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
"emptyAddMeasurement": "Zarejestruj pomiar",
"editMode": {
"open": "Dostosuj",
"done": "Gotowe",
"reset": "Przywróć",
"title": "Dostosuj przegląd",
"description": "Przeciągnij, aby zmienić kolejność, dotknij oka, aby pokazać lub ukryć.",
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"show": "Pokaż",
"hide": "Ukryj",
"manageTiles": "Zarządzaj kafelkami",
"gatedHint": "Pojawi się, gdy dane lub funkcja będą dostępne",
"allHiddenHint": "Wszystkie sekcje są ukryte. Pokaż co najmniej jedną, aby zobaczyć przegląd.",
"saveSuccess": "Przegląd zapisany",
"saveError": "Nie udało się zapisać przeglądu",
"resetSuccess": "Przegląd przywrócony do ustawień domyślnych",
"emptyTitle": "Wszystkie karty są ukryte",
"emptyDescription": "Ukryto wszystkie sekcje. Otwórz Dostosuj, aby je ponownie pokazać."
},
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
"bmiEmptyAction": "Otwórz ustawienia konta",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.15.10",
"version": "1.15.11",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -103,7 +103,7 @@ describe("GET /api/insights/layout", () => {
const body = (await res.json()) as {
data: typeof DEFAULT_INSIGHTS_LAYOUT;
};
expect(body.data.version).toBe(1);
expect(body.data.version).toBe(2);
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
expect(body.data.tiles[0]?.id).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles[0]?.id);
});
@@ -185,7 +185,7 @@ describe("PUT /api/insights/layout — happy path", () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const payload = {
version: 1,
version: 2,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blood-pressure", visible: true, order: 1 },
@@ -219,7 +219,7 @@ describe("PUT /api/insights/layout — happy path", () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const payload = {
version: 1,
version: 2,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
@@ -253,6 +253,50 @@ describe("PUT /api/insights/layout — happy path", () => {
});
});
describe("PUT /api/insights/layout — accepts version 1 (iOS contract)", () => {
// v1.15.11 QA C1 — the live iOS client still PUTs `version: 1`. The schema
// accepts both 1 and 2; the serializer always normalises to the canonical
// v2 blob, so a v1 body persists a resolved v2 layout rather than 422ing.
it("accepts a PUT with version 1 and no sections, persisting a resolved v2 layout", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blood-pressure", visible: true, order: 1 },
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
version: number;
sections: Array<{ id: string; visible: boolean }>;
tiles: Array<{ id: string }>;
};
};
// Stored + returned blob is canonical v2 regardless of the input version.
expect(body.data.version).toBe(2);
// Sections were absent on input → filled from defaults, all visible.
expect(body.data.sections.length).toBeGreaterThan(0);
for (const s of body.data.sections) expect(s.visible).toBe(true);
expect(body.data.tiles.map((t) => t.id)).toContain("overview");
// The persisted column is the canonical v2 blob too.
const updateCall = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { version: number; sections: unknown[] } };
};
expect(updateCall.data.insightsLayoutJson.version).toBe(2);
expect(
Array.isArray(updateCall.data.insightsLayoutJson.sections),
).toBe(true);
});
});
describe("PUT /api/insights/layout — full metric-slug universe", () => {
// v1.8.7.1 — the layout tile-id enum derives from `SUB_PAGE_SLUGS`, so
// the long-tail HealthKit + body-composition + mobility + audio slugs
@@ -279,7 +323,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
visible: order % 2 === 0,
order,
}));
const res = await callPut(makeReq({ version: 1, tiles }));
const res = await callPut(makeReq({ version: 2, tiles }));
// No 422 — these are all canonical sub-page slugs now.
expect(res.status).toBe(200);
@@ -300,7 +344,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
visible: true,
order,
}));
const res = await callPut(makeReq({ version: 1, tiles }));
const res = await callPut(makeReq({ version: 2, tiles }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
@@ -341,7 +385,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
it("still rejects a genuinely-unknown id even with the wider set", async () => {
const res = await callPut(
makeReq({
version: 1,
version: 2,
tiles: [
{ id: "blood-glucose", visible: true, order: 0 },
{ id: "not-a-metric-at-all", visible: true, order: 1 },
@@ -360,7 +404,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
const res = await callPut(makeReq({ version: 2, tiles: [] }));
const res = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
@@ -384,7 +428,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
it("rejects an unknown tile id", async () => {
const res = await callPut(
makeReq({
version: 1,
version: 2,
tiles: [{ id: "not-a-real-tile", visible: true, order: 0 }],
}),
);
@@ -398,7 +442,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
});
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
const res = await callPut(makeReq({ version: 2, tiles: [] }));
const res = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
@@ -422,9 +466,9 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
});
it("dedups the audit-ledger write across two sequential 422s for the same user", async () => {
const res1 = await callPut(makeReq({ version: 2, tiles: [] }));
const res1 = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res1.status).toBe(422);
const res2 = await callPut(makeReq({ version: 2, tiles: [] }));
const res2 = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res2.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
@@ -440,7 +484,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta", async () => {
const payload = { version: 2, tiles: [], extraGarbage: "from-ios" };
const payload = { version: 99, tiles: [], extraGarbage: "from-ios" };
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
@@ -508,7 +552,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
new Error("db down"),
);
const res = await callPut(makeReq({ version: 2, tiles: [] }));
const res = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
@@ -517,6 +561,102 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
});
});
describe("PUT /api/insights/layout — v2 sections", () => {
it("persists a sections array alongside tiles", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const payload = {
version: 2,
sections: [
{ id: "vitals", visible: true, order: 0 },
{ id: "daily-briefing", visible: false, order: 1 },
{ id: "wellness-scores", visible: true, order: 2 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
};
const res = await callPut(makeReq(payload));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { sections: Array<{ id: string; visible: boolean; order: number }> };
};
// Saved sections surface in saved order, dense 0-based.
expect(body.data.sections[0]).toEqual({
id: "vitals",
visible: true,
order: 0,
});
expect(body.data.sections[1]).toEqual({
id: "daily-briefing",
visible: false,
order: 1,
});
// PUT persists exactly the sent sections (serialize, not resolve);
// the read path merges any missing defaults back in.
expect(body.data.sections.map((s) => s.id)).toEqual([
"vitals",
"daily-briefing",
"wellness-scores",
]);
});
it("succeeds when the body omits sections (backward-compat iOS client)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 2,
tiles: [{ id: "overview", visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { sections: Array<{ id: string; visible: boolean }> };
};
// Defaults fill in, all visible.
expect(body.data.sections.length).toBeGreaterThan(0);
for (const s of body.data.sections) expect(s.visible).toBe(true);
});
it("succeeds when the body omits tiles (section-only PUT)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 2,
sections: [{ id: "vitals", visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
sections: Array<{ id: string }>;
tiles: Array<{ id: string }>;
};
};
// tiles fall back to the canonical default set.
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
expect(body.data.sections.some((s) => s.id === "vitals")).toBe(true);
});
it("rejects an unknown section id", async () => {
const res = await callPut(
makeReq({
version: 2,
sections: [{ id: "not-a-section", visible: true, order: 0 }],
tiles: [{ id: "overview", visible: true, order: 0 }],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(
body.details.issues.some((i) => i.path.startsWith("sections")),
).toBe(true);
});
});
describe("DELETE /api/insights/layout", () => {
it("clears the saved row and returns the default layout", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+28 -2
View File
@@ -28,6 +28,7 @@ import {
serializeInsightsLayout,
DEFAULT_INSIGHTS_LAYOUT,
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
type InsightsLayout,
} from "@/lib/insights-layout";
import { Prisma } from "@/generated/prisma/client";
@@ -51,8 +52,32 @@ import type { NextRequest } from "next/server";
// always canonical regardless of which id the client sent.
const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
// v1.15.11 — section ids are new in layout v2, English from birth, so the
// enum is the canonical id universe with no legacy-alias widening.
const sectionIdEnum = z.enum(INSIGHTS_SECTION_IDS);
const layoutSchema = z.object({
version: z.literal(1),
// v1.15.11 QA C1 — accept BOTH version 1 and 2 on input. The live iOS
// client still PUTs `version: 1`; `serializeInsightsLayout` hardcodes the
// stored version to the canonical `INSIGHTS_LAYOUT_VERSION` (2) regardless,
// so accepting a v1 body writes a canonical v2 blob with zero downside.
version: z.union([z.literal(1), z.literal(2)]),
// Additive + optional so a current iOS client PUTting only `tiles`
// (no `sections` key) still validates; `serializeInsightsLayout` fills
// the section defaults when the field is absent.
sections: z
.array(
z.object({
id: sectionIdEnum,
visible: z.boolean(),
order: z.number().int().min(0).max(99),
}),
)
.max(50)
.optional(),
// Optional too — a section-only PUT (no `tiles` key) is valid; the
// serializer fills the canonical default tile set. When present it must
// still carry at least one tile so an empty `[]` is a clear client bug.
tiles: z
.array(
z.object({
@@ -62,7 +87,8 @@ const layoutSchema = z.object({
}),
)
.min(1)
.max(50),
.max(50)
.optional(),
});
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
+173 -42
View File
@@ -1,13 +1,19 @@
"use client";
import { Fragment, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { RefreshCw, TrendingUp } from "lucide-react";
import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import {
orderedVisibleSectionIds,
type InsightsSectionId,
} from "@/lib/insights-layout";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
@@ -15,6 +21,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -271,6 +278,33 @@ export default function InsightsPage() {
// single query instance, so the wellness lift adds no second request.
const dashboardDerived = useDashboardDerived(isAuthenticated);
// v1.15.11 W2 — the resolved overview layout (sections + tiles). Drives
// both the section render order/visibility below and the Vitals grid's
// per-tile order/visibility (passed into <VitalsDashboard>). Defaults to
// the canonical layout while in-flight so the first paint matches the
// default order with no flicker.
const {
layout,
isLoading: layoutLoading,
} = useInsightsLayoutQuery(isAuthenticated);
// v1.15.11 W3 — inline "Anpassen" edit mode. When on, the customizable
// region swaps the live (heavy) sections for lightweight edit cards; the
// section data is never refetched (the queries stay mounted via the page's
// own hooks but the section JSX is replaced). HeroStrip stays anchored.
const [editMode, setEditMode] = useState(false);
// v1.15.11 QA L5 — focus restoration. On entering edit mode focus moves to
// the edit-card heading; on close/save focus returns to the "Anpassen"
// toggle so keyboard users are not dropped at the top of the document.
const customizeToggleRef = useRef<HTMLButtonElement | null>(null);
// v1.15.11 QA L1 — never let a "Fertig" save flush DEFAULT_INSIGHTS_LAYOUT
// over the user's real saved layout. The editor seeds its draft once from
// the `layout` prop, which is the default while the GET is in-flight, so the
// edit surface stays gated (the toggle disabled) until the query has settled.
const canEdit = !layoutLoading;
// Error branch — a transient 500 / network drop (after the query's
// retries are exhausted) settles the comprehensive query with no data.
// Without this gate the page would fall through to the "no data yet —
@@ -360,6 +394,80 @@ export default function InsightsPage() {
// correlation row. The duplicate footer "prepare assessments" control was
// removed in v1.12.4 — the tab-strip regenerate button is the single
// affordance. The generic disclaimer lives once in the layout-shell footer.
// v1.15.11 W2 — decouple the section render order from the JSX. Each section
// id maps to its existing node here; the customizable region below renders
// `orderedVisibleSectionIds(layout)` against this registry. Every existing
// feature/data gate is preserved INSIDE each entry: a section the layout
// marks visible but whose gate is off (briefing flag, cycle-enabled) still
// resolves to `null`, exactly as today. With the default layout the order +
// gates reproduce the pre-v1.15.11 page byte-for-byte. HeroStrip stays
// anchored above this region, OUTSIDE the customizable set.
const SECTION_REGISTRY: Record<InsightsSectionId, ReactNode> = {
"wellness-scores": (
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined}
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
hideStrain={user?.cycleTrackingEnabled === true}
/>
),
"daily-briefing": flags.briefing ? (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
) : null,
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
trends: (
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
),
"period-review": flags.briefing ? (
<PeriodNarrativeCard enabled={isAuthenticated} />
) : null,
// v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account;
// for everyone else this is nothing (no card, no layout gap). The card
// itself stays silent until its reads resolve.
"cycle-summary": user?.cycleTrackingEnabled ? (
<CycleInsightSummaryCard />
) : null,
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
};
// v1.15.11 W3 — sections whose feature/data gate is currently OFF. The edit
// row for these renders disabled with a hint so a toggle that does nothing
// never confuses the user (it stays orderable, just not enable-able past the
// gate). Mirrors the `null` registry entries above: `daily-briefing` +
// `period-review` ride the briefing flag; `cycle-summary` rides the cycle
// opt-in. Every other section is always available.
const gatedOffSectionIds = new Set<InsightsSectionId>();
if (!flags.briefing) {
gatedOffSectionIds.add("daily-briefing");
gatedOffSectionIds.add("period-review");
}
if (!user?.cycleTrackingEnabled) {
gatedOffSectionIds.add("cycle-summary");
}
const orderedSectionIds = orderedVisibleSectionIds(layout);
const everySectionHidden = orderedSectionIds.length === 0;
return (
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
// page used `space-y-8` (32 px) between top-level blocks while the vitals
@@ -389,49 +497,72 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined}
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
hideStrain={user?.cycleTrackingEnabled === true}
/>
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
{/* v1.15.11 W3 — the "Anpassen" toggle sits at the top of the
customizable region, below the anchored HeroStrip. v1.15.11 QA
L2-design: the toggle is HIDDEN while editing — the InsightsEditMode
card owns save (Fertig) + reset (Zurücksetzen), so a second top-right
"Fertig" that only closes-without-saving (a label-collision trap) no
longer exists. The toggle only ENTERS edit mode; the component's
Fertig/Zurücksetzen → onClose returns here. QA L1: disabled until the
layout query settles so a save can never flush defaults. */}
{!editMode && (
<div className="flex justify-end">
<Button
ref={customizeToggleRef}
type="button"
variant="outline"
size="sm"
onClick={() => setEditMode(true)}
disabled={!canEdit}
data-slot="insights-customize-toggle"
aria-pressed={editMode}
className="gap-1.5"
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.editMode.open")}</span>
</Button>
</div>
)}
<VitalsDashboard batch={dashboardDerived} />
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
{/* v1.15.2 — gated cycle teaser. Render only for a cycle-tracking
account; for everyone else this is nothing (no card, no layout gap).
The card itself stays silent until its reads resolve. */}
{user?.cycleTrackingEnabled ? <CycleInsightSummaryCard /> : null}
<CoincidentDeviationCard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
{editMode ? (
/* v1.15.11 W3 — edit mode swaps the live, heavy sections for the
lightweight edit cards. The section data queries stay mounted on the
page, but their JSX is not rendered, so toggling into edit mode never
refetches. */
<InsightsEditMode
layout={layout}
gatedOffSectionIds={gatedOffSectionIds}
onClose={() => {
setEditMode(false);
// v1.15.11 QA L5 — return focus to the toggle that reappears.
requestAnimationFrame(() => customizeToggleRef.current?.focus());
}}
/>
) : everySectionHidden ? (
/* v1.15.11 W3 — empty-state: every section hidden. The page is never
blank — surface a hint + a button that opens edit mode so the user
can bring sections back. */
<EmptyState
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
action={
<Button size="sm" onClick={() => setEditMode(true)}>
{t("insights.editMode.open")}
</Button>
}
/>
) : (
/* v1.15.11 W2 — the customizable region: sections render in the
resolved layout order, skipping any the user has hidden. A
layout-visible-but-gate-off section resolves to `null` from the
registry, so the `space-y-6` rhythm closes the gap with no hole.
Each registry node is wrapped in a keyed Fragment so React keeps a
stable identity across a reorder. */
orderedSectionIds.map((id) => (
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
))
)}
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, type ReactNode } from "react";
import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -23,6 +23,10 @@ import {
SECTION_MOBILITY,
type DashboardDerived,
} from "./use-dashboard-derived";
import {
resolveTileLayout,
type InsightsLayout,
} from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
@@ -99,9 +103,71 @@ interface DashboardProps {
* full-width section above the daily briefing.
*/
batch: DashboardDerived;
/**
* v1.15.11 W2c — the resolved insights layout, passed down from the page
* (which already mounts `useInsightsLayoutQuery()`) so this grid honours the
* user's per-tile order + visibility without a second fetch. Each grid tile
* maps to a layout tile id (see `VITAL_TILE_LAYOUT_ID` /
* `MOBILITY_TILE_LAYOUT_ID`); a tile the layout marks `visible: false` does
* not render even if it has data, and visible tiles render in `order`. Tiles
* still self-gate on data availability on top of that. Optional so a caller
* that has no layout yet falls back to the default (everything shown,
* data-gated as before).
*/
layout?: InsightsLayout;
className?: string;
}
/**
* v1.15.11 W2c — the dashboard's vitals-grid tile concepts mapped onto their
* layout tile id (the routed sub-page slug). The four derived re-frames map to
* the slug of the metric they re-frame; the per-vital baseline tiles map by
* their `MeasurementType`. A tile concept absent from this map (none today)
* would fall through `resolveTileLayout` as always-on, so the grid never drops
* a tile it cannot place.
*
* FitnessAge (VO₂max re-frame) → `cardio-fitness`
* VascularAge → `vascular-age`
* HrvBalance → `hrv`
* Bmi → `bmi`
*/
const VITAL_BASELINE_TILE_LAYOUT_ID: Record<string, string> = {
RESTING_HEART_RATE: "resting-pulse",
RESPIRATORY_RATE: "respiratory-rate",
OXYGEN_SATURATION: "oxygen",
BODY_TEMPERATURE: "body-temperature",
BLOOD_GLUCOSE: "blood-glucose",
WEIGHT: "weight",
};
/** Mobility-section `MeasurementType` → layout tile id (routed slug). */
const MOBILITY_TILE_LAYOUT_ID: Record<string, string> = {
STAIR_ASCENT_SPEED: "stair-ascent-speed",
STAIR_DESCENT_SPEED: "stair-descent-speed",
WRIST_TEMPERATURE: "wrist-temperature",
};
/**
* v1.15.11 W2c — `true` when the layout marks this tile id visible (or the
* layout does not enumerate the id, in which case the tile is always-on). A
* `null` layout (caller passed none) is treated as everything-visible so the
* default behaviour is unchanged.
*/
function tileVisible(layout: InsightsLayout | undefined, tileId: string): boolean {
if (!layout) return true;
return resolveTileLayout(layout, tileId).visible;
}
/**
* v1.15.11 W2c — the layout `order` for a tile id, used to sort the grid.
* Falls back to `MAX_SAFE_INTEGER` (render last) for an unenumerated id and to
* a stable 0 when no layout is present (callers keep their source order).
*/
function tileOrder(layout: InsightsLayout | undefined, tileId: string): number {
if (!layout) return 0;
return resolveTileLayout(layout, tileId).order;
}
/** The provenance ⓘ explainer for one derived metric, wired from the map. */
function MetricProvenance({
metric,
@@ -465,28 +531,42 @@ function VitalsTileSkeleton() {
* the CLS the load-state QoL pass closes). Kept in sync with the tile gates
* above — if a new tile gate changes, reflect it here.
*/
function hasRenderableVital(read: DerivedBatchRead): boolean {
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
if (fitness?.status === "ok" && fitness.value) return true;
function hasRenderableVital(
read: DerivedBatchRead,
layout: InsightsLayout | undefined,
): boolean {
if (tileVisible(layout, "cardio-fitness")) {
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
if (fitness?.status === "ok" && fitness.value) return true;
}
const vascular = read<VascularAgeDeltaValue>({
metric: "VASCULAR_AGE_DELTA",
});
if (vascular?.status === "ok" && vascular.value) return true;
if (tileVisible(layout, "vascular-age")) {
const vascular = read<VascularAgeDeltaValue>({
metric: "VASCULAR_AGE_DELTA",
});
if (vascular?.status === "ok" && vascular.value) return true;
}
const bmi = read<BmiValue>({ metric: "BMI" });
if (bmi?.status === "ok" && bmi.value) return true;
if (tileVisible(layout, "bmi")) {
const bmi = read<BmiValue>({ metric: "BMI" });
if (bmi?.status === "ok" && bmi.value) return true;
}
const hrv = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
if (
hrv &&
!(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window")
) {
return true;
if (tileVisible(layout, "hrv")) {
const hrv = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
if (
hrv &&
!(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window")
) {
return true;
}
}
for (const type of SECTION_VITALS) {
if (type === "HEART_RATE_VARIABILITY") continue;
if (!tileVisible(layout, VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type)) {
continue;
}
const baseline = read<VitalsBaselineValue>({
metric: "VITALS_BASELINE",
type,
@@ -511,11 +591,17 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
* only rendered once there is content under it (no stranded heading over
* blank space). Most users carry none of these → the whole section hides.
*/
function hasRenderableMobility(read: DerivedBatchRead): boolean {
const sixmw = read<SixMinuteWalkValue>({ metric: "SIX_MINUTE_WALK_BAND" });
if (sixmw?.status === "ok" && sixmw.value) return true;
function hasRenderableMobility(
read: DerivedBatchRead,
layout: InsightsLayout | undefined,
): boolean {
if (tileVisible(layout, "six-minute-walk")) {
const sixmw = read<SixMinuteWalkValue>({ metric: "SIX_MINUTE_WALK_BAND" });
if (sixmw?.status === "ok" && sixmw.value) return true;
}
for (const { metric } of SECTION_MOBILITY) {
for (const { metric, type } of SECTION_MOBILITY) {
if (!tileVisible(layout, MOBILITY_TILE_LAYOUT_ID[type] ?? type)) continue;
const band = read<VitalsBaselineValue>({ metric });
if (
band &&
@@ -531,9 +617,8 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean {
return false;
}
export function VitalsDashboard({ batch, className }: DashboardProps) {
export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
const { t } = useTranslations();
const vitals = useMemo(() => SECTION_VITALS, []);
const read = batch.read;
const isLoading = batch.isLoading;
@@ -541,6 +626,89 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
const refetch = batch.refetch;
const tileProps: TileProps = { read, isLoading };
// v1.15.11 W2c — the vitals-grid tiles in resolved layout order, dropping
// any the user has hidden. Each tile is `{ id, order, node }`; the tile's
// own component still self-gates on data, so a visible tile with no readings
// still renders nothing. The four derived re-frames map to the slug of the
// metric they re-frame; the per-vital baseline tiles map by `MeasurementType`.
const vitalTiles = useMemo(() => {
const entries: { id: string; order: number; node: ReactNode }[] = [
{
id: "cardio-fitness",
order: tileOrder(layout, "cardio-fitness"),
node: <FitnessAgeTile key="cardio-fitness" {...tileProps} />,
},
{
id: "vascular-age",
order: tileOrder(layout, "vascular-age"),
node: <VascularAgeTile key="vascular-age" {...tileProps} />,
},
{
id: "hrv",
order: tileOrder(layout, "hrv"),
node: <HrvBalanceTile key="hrv" {...tileProps} />,
},
{
id: "bmi",
order: tileOrder(layout, "bmi"),
node: <BmiTile key="bmi" {...tileProps} />,
},
...SECTION_VITALS.filter(
(type) => type !== "HEART_RATE_VARIABILITY",
).map((type) => {
const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
),
};
}),
];
return entries
.filter((e) => tileVisible(layout, e.id))
.sort((a, b) => a.order - b.order);
// `tileProps` is rebuilt every render (read/isLoading); the node closures
// capture the current values, so memoising on the layout + load state keeps
// the ordered list stable across a no-op rerender without staleness.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, read, isLoading]);
const mobilityTiles = useMemo(() => {
const entries: { id: string; order: number; node: ReactNode }[] = [
{
id: "six-minute-walk",
order: tileOrder(layout, "six-minute-walk"),
node: <SixMinuteWalkTile key="six-minute-walk" {...tileProps} />,
},
...SECTION_MOBILITY.map(({ metric, type }) => {
const id = MOBILITY_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={metric}
metric={metric}
type={type}
{...tileProps}
/>
),
};
}),
];
return entries
.filter((e) => tileVisible(layout, e.id))
.sort((a, b) => a.order - b.order);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, read, isLoading]);
// A failed batch (retry:0 + the 8 s client ceiling) must read as an error,
// not as "no data" — otherwise the whole Vitals + Wellness surface silently
// vanishes. Surface a compact message + a Retry that refetches the one
@@ -587,8 +755,8 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
// skeleton row reserves the final height). When the batch resolves to zero
// vitals the heading would otherwise strand over blank space, then content
// pops in below it — the CLS this pass removes.
const showSection = isLoading || hasRenderableVital(read);
const showMobility = !isLoading && hasRenderableMobility(read);
const showSection = isLoading || hasRenderableVital(read, layout);
const showMobility = !isLoading && hasRenderableMobility(read, layout);
return (
<div
@@ -614,28 +782,11 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
))
) : (
<>
<FitnessAgeTile {...tileProps} />
<VascularAgeTile {...tileProps} />
<HrvBalanceTile {...tileProps} />
<BmiTile {...tileProps} />
{vitals
.filter((type) => type !== "HEART_RATE_VARIABILITY")
.map((type) => (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
))}
</>
)}
{isLoading
? Array.from({ length: 6 }).map((_, i) => (
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
))
: vitalTiles.map((tile) => tile.node)}
</div>
</section>
)}
@@ -653,15 +804,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
data-slot="vitals-mobility-grid"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
<SixMinuteWalkTile {...tileProps} />
{SECTION_MOBILITY.map(({ metric, type }) => (
<BaselineTile
key={metric}
metric={metric}
type={type}
{...tileProps}
/>
))}
{mobilityTiles.map((tile) => tile.node)}
</div>
</section>
)}
@@ -0,0 +1,639 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ChevronDown,
Eye,
EyeOff,
GripVertical,
Loader2,
RotateCcw,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
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 {
type InsightsLayout,
type InsightsSectionConfig,
type InsightsSectionId,
type InsightsTileConfig,
} from "@/lib/insights-layout";
/**
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
* overview.
*
* Renders LIGHTWEIGHT edit cards (drag handle + localized title + eye toggle)
* instead of the live, heavy section content, so toggling into edit mode never
* refetches the section data — it swaps the JSX, not the queries. Edits mutate a
* local draft; "Fertig" PUTs the merged `{ version: 2, sections, tiles }` blob
* 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.
*/
/** Localized title key per section id — used for the edit-card label. */
const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"wellness-scores": "insights.derived.scores.sectionTitle",
"daily-briefing": "insights.dailyBriefing.title",
vitals: "insights.derived.vitals.sectionTitle",
trends: "insights.trendsRow.title",
"period-review": "insights.narrativeTitle",
"cycle-summary": "cycle.insightsSummary.title",
signals: "insights.derived.coincident.cardTitle",
"rhythm-events": "insights.rhythmEvents.sectionTitle",
};
/**
* The per-metric tile ids the Vitals section renders on the overview grid,
* paired with their localized measurement label key. This is the subset of the
* full tile-id universe that the Vitals dashboard actually surfaces (the four
* derived re-frames + the per-vital baseline tiles + the mobility sub-grid);
* the other layout tile ids drive the routed sub-page strip, not this grid, so
* they stay out of the inline tile editor. Order here is the default grid order
* (see `vitals-dashboard.tsx`); the user's saved `order` overrides it.
*/
const VITALS_TILE_LABEL_KEYS: { id: string; labelKey: string }[] = [
{ id: "cardio-fitness", labelKey: "measurements.typeVo2Max" },
{ id: "vascular-age", labelKey: "measurements.typeVascularAge" },
{ id: "hrv", labelKey: "measurements.typeHeartRateVariability" },
// v1.15.11 QA M1 — the Vitals grid renders a default-visible Weight tile;
// without this entry the inline editor could not hide/reorder it like every
// other grid tile.
{ id: "weight", labelKey: "measurements.typeWeight" },
{ id: "bmi", labelKey: "measurements.typeBodyMassIndex" },
{ id: "resting-pulse", labelKey: "measurements.typeRestingHeartRate" },
{ id: "respiratory-rate", labelKey: "measurements.typeRespiratoryRate" },
{ id: "oxygen", labelKey: "measurements.typeOxygenSaturation" },
{ id: "body-temperature", labelKey: "measurements.typeBodyTemperature" },
{ id: "blood-glucose", labelKey: "measurements.typeBloodGlucose" },
{ id: "six-minute-walk", labelKey: "measurements.typeSixMinuteWalkDistance" },
{ id: "stair-ascent-speed", labelKey: "measurements.typeStairAscentSpeed" },
{ id: "stair-descent-speed", labelKey: "measurements.typeStairDescentSpeed" },
{ id: "wrist-temperature", labelKey: "measurements.typeWristTemperature" },
];
const VITALS_TILE_IDS = new Set(VITALS_TILE_LABEL_KEYS.map((t) => t.id));
interface InsightsEditModeProps {
/** Resolved layout currently in effect (server copy, defaults while loading). */
layout: InsightsLayout;
/**
* Which section ids are gated off right now (feature flag / data gate), so
* the edit row renders disabled with a hint rather than offering a toggle
* that does nothing.
*/
gatedOffSectionIds: ReadonlySet<InsightsSectionId>;
/** Close edit mode (the "Fertig" / save-success path calls this). */
onClose: () => void;
}
export function InsightsEditMode({
layout,
gatedOffSectionIds,
onClose,
}: InsightsEditModeProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Local draft seeded from the resolved layout. Edits mutate the draft only;
// "Fertig" flushes it via the PUT mutation. Sections/tiles are sorted by
// order for stable rendering.
const [draft, setDraft] = useState<InsightsLayout>(() => ({
version: layout.version,
sections: [...layout.sections].sort((a, b) => a.order - b.order),
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
// the document). Inline, not modal, so no focus trap — focus returns to the
// "Anpassen" toggle on close via the page's onClose handler.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
headingRef.current?.focus();
}, []);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
const res = await fetch("/api/insights/layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: 2,
sections: next.sections,
tiles: next.tiles,
}),
});
if (!res.ok) throw new Error("save failed");
return (await res.json()).data as InsightsLayout;
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
// shared cache so the overview repaints in the new order, then close.
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
toast.success(t("insights.editMode.saveSuccess"));
onClose();
},
onError: () => toast.error(t("insights.editMode.saveError")),
});
const resetMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/insights/layout", { method: "DELETE" });
if (!res.ok) throw new Error("reset failed");
return (await res.json()).data as InsightsLayout;
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
// Re-seed the draft so the open editor reflects the restored defaults.
setDraft({
version: saved.version,
sections: [...saved.sections].sort((a, b) => a.order - b.order),
tiles: [...saved.tiles].sort((a, b) => a.order - b.order),
});
toast.success(t("insights.editMode.resetSuccess"));
},
onError: () => toast.error(t("insights.editMode.saveError")),
});
const busy = saveMutation.isPending || resetMutation.isPending;
const sections = useMemo(
() => [...draft.sections].sort((a, b) => a.order - b.order),
[draft.sections],
);
const sectionIds = sections.map((s) => s.id);
const vitalsTiles = useMemo(() => {
const byId = new Map<string, InsightsTileConfig>(
draft.tiles.map((tt) => [tt.id, tt]),
);
return VITALS_TILE_LABEL_KEYS.map(({ id, labelKey }) => {
const cfg = byId.get(id);
return {
id,
labelKey,
visible: cfg?.visible ?? false,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
}).sort((a, b) => a.order - b.order);
// Re-derive on any tile change.
}, [draft.tiles]);
const vitalsTileIds = vitalsTiles.map((tt) => tt.id);
function toggleSection(id: InsightsSectionId, visible: boolean) {
setDraft((d) => ({
...d,
sections: d.sections.map((s) => (s.id === id ? { ...s, visible } : s)),
}));
}
function handleSectionDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const reordered = reorderById<InsightsSectionConfig>(
draft.sections,
String(active.id),
String(over.id),
);
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)),
}));
}
/**
* Reorder the Vitals tiles WITHIN the draft's full tile list. The drag
* operates only over the Vitals subset, but the persisted `tiles` array
* carries every tile id (the routed sub-page strip reads it too), so we
* splice the reordered subset back into the full array, re-densifying the
* order only across the Vitals slots while leaving the non-Vitals tiles'
* relative order intact.
*/
function handleTileDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const subset = vitalsTiles.map((tt) => ({
id: tt.id,
order: tt.order,
visible: tt.visible,
}));
// Reorder ONLY the Vitals subset through the tested pure helper — a single
// total-order sort, never the mixed-key comparator that used to break
// transitivity (QA M2).
const reordered = reorderById(subset, String(active.id), String(over.id));
const reorderedVitalsIds = reordered.map((r) => r.id);
setDraft((d) => ({
...d,
// Substitute the Vitals slots in their new relative order while leaving
// the sub-page strip (non-Vitals tiles) untouched. Pure, total-order
// helper — no mixed-key comparator (QA M2).
tiles: rebuildTilesWithReorderedVitals(
d.tiles,
reorderedVitalsIds,
(id) => VITALS_TILE_IDS.has(id),
),
}));
}
const allHidden = draft.sections.every((s) => !s.visible);
return (
<div
data-slot="insights-edit-mode"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div>
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold focus-visible:outline-none"
>
{t("insights.editMode.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.editMode.description")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
data-slot="insights-edit-reset"
>
{resetMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
{t("insights.editMode.reset")}
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate(draft)}
disabled={busy}
data-slot="insights-edit-done"
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.editMode.done")}
</Button>
</div>
</div>
{allHidden && (
<p
className="text-muted-foreground text-sm"
data-slot="insights-edit-all-hidden"
>
{t("insights.editMode.allHiddenHint")}
</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleSectionDragEnd}
>
<SortableContext
items={sectionIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sections.map((section) => {
const gatedOff = gatedOffSectionIds.has(section.id);
const isVitals = section.id === "vitals";
return (
<SortableSectionRow
key={section.id}
section={section}
title={t(SECTION_TITLE_KEYS[section.id])}
gatedOff={gatedOff}
disabled={busy}
labels={{
dragHandle: t("insights.editMode.dragHandle"),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
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.manageTiles")}
</button>
{tilesOpen && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleTileDragEnd}
>
<SortableContext
items={vitalsTileIds}
strategy={verticalListSortingStrategy}
>
{/* v1.15.11 QA M2-design — cap the disclosed tile
list height and let it scroll inside its own
container so dragging a tile on a phone doesn't
fight the page scroll. `overscroll-contain` keeps
the scroll local; the container is full-width and
only clips on the y-axis, so the drag ghost
(which translates within the row) is not cut. */}
<div className="mt-2 max-h-[60vh] space-y-1.5 overflow-y-auto overscroll-contain">
{vitalsTiles.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>
)}
</SortableSectionRow>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
);
}
interface RowLabels {
dragHandle: string;
show: string;
hide: string;
}
const DRAG_HANDLE_CLASS =
"text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']";
function EyeToggle({
visible,
disabled,
label,
onClick,
slot,
}: {
visible: boolean;
disabled: boolean;
label: string;
onClick: () => void;
slot: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-disabled={disabled || undefined}
aria-pressed={visible}
aria-label={label}
title={label}
data-slot={slot}
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>
);
}
interface SortableSectionRowProps {
section: InsightsSectionConfig;
title: string;
gatedOff: boolean;
disabled: boolean;
labels: RowLabels & { gatedHint: string };
onToggle: (id: InsightsSectionId, visible: boolean) => void;
children?: React.ReactNode;
}
function SortableSectionRow({
section,
title,
gatedOff,
disabled,
labels,
onToggle,
children,
}: SortableSectionRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: section.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="insights-edit-section-row"
data-section={section.id}
data-dragging={isDragging ? "true" : undefined}
data-gated={gatedOff ? "true" : undefined}
className={cn(
"border-border bg-background/30 w-full rounded-md border px-2 py-2 sm:px-3",
isDragging && "ring-primary z-10 opacity-90 shadow-lg ring-2",
gatedOff && "opacity-60",
)}
>
<div className="flex items-center gap-2">
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-section-handle"
className={DRAG_HANDLE_CLASS}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium" title={title}>
{title}
</span>
{/* v1.15.11 QA M1-design — the gated hint reads as a caption BELOW the
title so the row's right-edge control position stays stable whether
the section is gated or not. */}
{gatedOff && (
<span
className="text-muted-foreground truncate text-xs"
data-slot="insights-edit-section-gated-hint"
>
{labels.gatedHint}
</span>
)}
</div>
{/* v1.15.11 QA M1-design — a gated section keeps a DISABLED eye toggle in
the SAME position rather than swapping it for a text span, so the row
layout never jumps. The section stays reorderable; only the toggle is
inert until the gate opens. */}
<EyeToggle
visible={section.visible}
disabled={disabled || gatedOff}
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
onClick={() => onToggle(section.id, !section.visible)}
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>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_INSIGHTS_LAYOUT,
resolveInsightsLayout,
type InsightsLayout,
} from "@/lib/insights-layout";
/**
* v1.15.11 — the resolved insights overview layout (sections + tiles) plus the
* query load status.
*
* Reads `GET /api/insights/layout` through the centralised query-key factory
* (`queryKeys.insightsLayout()` → `["user","insightsLayout"]`), the same key
* the inline-edit PUT mutation invalidates on save so the overview repaints in
* lockstep with the edit surface. The response is already the server-resolved
* v2 layout; we pass it back through `resolveInsightsLayout` defensively so a
* partial / legacy blob from a stale cache still normalises to a valid
* `InsightsLayout`.
*
* Returns `DEFAULT_INSIGHTS_LAYOUT` while the query is in-flight so the page
* renders in the default section + tile order on first paint — no layout
* flicker, no empty state, no "everything hidden" flash before the saved
* layout lands.
*
* `isLoading` lets a caller gate a write surface (the inline "Anpassen" edit
* mode) until the GET has settled: without that gate a user who enters edit
* mode while the layout is still in-flight would seed the editor from
* `DEFAULT_INSIGHTS_LAYOUT` and a "Fertig" save would PUT defaults over their
* real saved layout.
*/
export function useInsightsLayoutQuery(enabled: boolean): {
layout: InsightsLayout;
isLoading: boolean;
isSuccess: boolean;
} {
const { data, isLoading, isSuccess } = useQuery({
queryKey: queryKeys.insightsLayout(),
queryFn: async () => {
const res = await fetch("/api/insights/layout");
if (!res.ok) throw new Error("Failed to load insights layout");
return resolveInsightsLayout((await res.json()).data);
},
enabled,
});
return {
layout: data ?? DEFAULT_INSIGHTS_LAYOUT,
// `enabled: false` keeps the query in a pending-but-idle state; treat a
// disabled (unauthenticated) query as not-loading so the gate never sticks.
isLoading: enabled && isLoading,
isSuccess,
};
}
@@ -0,0 +1,137 @@
import { describe, it, expect } from "vitest";
import {
reorderById,
rebuildTilesWithReorderedVitals,
} from "@/lib/insights-layout-reorder";
describe("reorderById", () => {
const rows = [
{ id: "a", order: 0, visible: true },
{ id: "b", order: 1, visible: false },
{ id: "c", order: 2, visible: true },
];
it("moves a row forward and renumbers densely", () => {
const next = reorderById(rows, "a", "c");
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
});
it("moves a row backward and renumbers densely", () => {
const next = reorderById(rows, "c", "a");
expect(next.map((r) => r.id)).toEqual(["c", "a", "b"]);
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
});
it("preserves every non-order field on each row", () => {
const next = reorderById(rows, "a", "b");
const moved = next.find((r) => r.id === "a");
expect(moved?.visible).toBe(true);
const hidden = next.find((r) => r.id === "b");
expect(hidden?.visible).toBe(false);
});
it("is a no-op (but still densely renumbered) when ids match", () => {
const next = reorderById(rows, "b", "b");
expect(next.map((r) => r.id)).toEqual(["a", "b", "c"]);
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
});
it("returns a densely renumbered copy when an id is missing", () => {
const sparse = [
{ id: "a", order: 5 },
{ id: "b", order: 9 },
];
const next = reorderById(sparse, "a", "zzz");
expect(next).toEqual([
{ id: "a", order: 0 },
{ id: "b", order: 1 },
]);
});
it("does not mutate the input array or its rows", () => {
const input = [
{ id: "a", order: 0 },
{ id: "b", order: 1 },
];
const snapshot = JSON.parse(JSON.stringify(input));
reorderById(input, "a", "b");
expect(input).toEqual(snapshot);
});
it("sorts an unsorted input by order before reordering", () => {
const unsorted = [
{ id: "c", order: 2 },
{ id: "a", order: 0 },
{ id: "b", order: 1 },
];
const next = reorderById(unsorted, "a", "c");
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
});
});
describe("rebuildTilesWithReorderedVitals", () => {
// v1.15.11 QA M2 — the inline edit mode reorders only the Vitals subset; the
// non-Vitals sub-page strip must keep its relative order. The full list is
// interleaved: overview (non-vitals), then vitals tiles, then a couple of
// sub-page slugs (non-vitals).
const VITALS = new Set(["v1", "v2", "v3"]);
const isVitals = (id: string) => VITALS.has(id);
const tiles = [
{ id: "overview", order: 0, visible: true }, // non-vitals
{ id: "v1", order: 1, visible: true },
{ id: "v2", order: 2, visible: false },
{ id: "sub-a", order: 3, visible: true }, // non-vitals
{ id: "v3", order: 4, visible: true },
{ id: "sub-b", order: 5, visible: false }, // non-vitals
];
it("substitutes the Vitals slots in their new relative order", () => {
// Vitals reordered to v3, v1, v2.
const next = rebuildTilesWithReorderedVitals(
tiles,
["v3", "v1", "v2"],
isVitals,
);
// Vitals positions (originally slots 1, 2, 4) now carry v3, v1, v2.
expect(next.map((t) => t.id)).toEqual([
"overview",
"v3",
"v1",
"sub-a",
"v2",
"sub-b",
]);
// Densely renumbered.
expect(next.map((t) => t.order)).toEqual([0, 1, 2, 3, 4, 5]);
});
it("keeps the non-Vitals tiles in their original relative order", () => {
const next = rebuildTilesWithReorderedVitals(
tiles,
["v3", "v2", "v1"],
isVitals,
);
const nonVitals = next.filter((t) => !isVitals(t.id)).map((t) => t.id);
expect(nonVitals).toEqual(["overview", "sub-a", "sub-b"]);
});
it("carries each substituted tile's own visible flag (not the slot's)", () => {
// v2 is hidden; after moving v2 into the first vitals slot it must still be
// hidden — the flag travels with the tile id, not the position.
const next = rebuildTilesWithReorderedVitals(
tiles,
["v2", "v1", "v3"],
isVitals,
);
const moved = next.find((t) => t.id === "v2");
expect(moved?.visible).toBe(false);
expect(moved?.order).toBe(1);
});
it("does not mutate the input", () => {
const snapshot = JSON.parse(JSON.stringify(tiles));
rebuildTilesWithReorderedVitals(tiles, ["v3", "v1", "v2"], isVitals);
expect(tiles).toEqual(snapshot);
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* v1.15.11 — insights layout model v2.
*
* The layout blob grew a `sections` array on top of the existing per-
* metric `tiles` list. These tests pin the v2 contract that the
* `/insights` page (web) and the iOS client share: a v1 blob resolves
* forward untouched, section reorder/hide round-trips, unknown section
* ids auto-merge invisible then drop on serialize, and an omitted
* `sections` / `tiles` field falls back to defaults.
*/
import { describe, it, expect } from "vitest";
import {
INSIGHTS_SECTION_IDS,
DEFAULT_INSIGHTS_LAYOUT,
resolveInsightsLayout,
serializeInsightsLayout,
orderedVisibleSectionIds,
resolveTileLayout,
} from "@/lib/insights-layout";
describe("insights-layout v2 — section id universe", () => {
it("carries exactly the eight default sections in render order", () => {
expect([...INSIGHTS_SECTION_IDS]).toEqual([
"wellness-scores",
"daily-briefing",
"vitals",
"trends",
"period-review",
"cycle-summary",
"signals",
"rhythm-events",
]);
});
it("defaults every section visible with a dense 0-based order", () => {
expect(DEFAULT_INSIGHTS_LAYOUT.version).toBe(2);
expect(DEFAULT_INSIGHTS_LAYOUT.sections.length).toBe(
INSIGHTS_SECTION_IDS.length,
);
DEFAULT_INSIGHTS_LAYOUT.sections.forEach((s, i) => {
expect(s.visible).toBe(true);
expect(s.order).toBe(i);
expect(s.id).toBe(INSIGHTS_SECTION_IDS[i]);
});
});
});
describe("resolveInsightsLayout — v1 → v2 forward upgrade", () => {
it("fills all sections default-visible and leaves tiles untouched for a v1 blob", () => {
const v1blob = {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blood-pressure", visible: false, order: 1 },
],
};
const resolved = resolveInsightsLayout(v1blob);
// Upgraded to v2 with the full default section set, all visible.
expect(resolved.version).toBe(2);
expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
for (const s of resolved.sections) expect(s.visible).toBe(true);
expect(resolved.sections.map((s) => s.id)).toEqual([
...INSIGHTS_SECTION_IDS,
]);
// The two saved tiles survive verbatim at the head; the rest merge.
expect(resolved.tiles[0]).toEqual({
id: "overview",
visible: true,
order: 0,
});
expect(resolved.tiles[1]).toEqual({
id: "blood-pressure",
visible: false,
order: 1,
});
});
it("returns the full default layout for garbage input", () => {
expect(resolveInsightsLayout(null).sections.length).toBe(
INSIGHTS_SECTION_IDS.length,
);
expect(resolveInsightsLayout({ version: 2 }).sections.length).toBe(
INSIGHTS_SECTION_IDS.length,
);
});
});
describe("resolveInsightsLayout — section semantics", () => {
it("auto-merges a missing default section default-invisible", () => {
const resolved = resolveInsightsLayout({
version: 2,
sections: [{ id: "vitals", visible: true, order: 0 }],
tiles: [{ id: "overview", visible: true, order: 0 }],
});
const vitals = resolved.sections.find((s) => s.id === "vitals");
const trends = resolved.sections.find((s) => s.id === "trends");
expect(vitals?.visible).toBe(true);
// Every section the blob omitted merges in default-invisible.
expect(trends?.visible).toBe(false);
expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
});
it("drops an unknown section id (auto-merge then filter)", () => {
// `resolveInsightsLayout` takes `unknown` (a raw JSON blob from the
// column), so a garbage section id models a malformed/forward blob.
const blob: unknown = {
version: 2,
sections: [
{ id: "vitals", visible: true, order: 0 },
{ id: "not-a-section", visible: true, order: 1 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
};
const resolved = resolveInsightsLayout(blob);
expect(
resolved.sections.some((s) => (s.id as string) === "not-a-section"),
).toBe(false);
expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
});
it("dedupes a repeated section id, keeping the first occurrence", () => {
const resolved = resolveInsightsLayout({
version: 2,
sections: [
{ id: "vitals", visible: true, order: 0 },
{ id: "vitals", visible: false, order: 1 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
});
const vitalsRows = resolved.sections.filter((s) => s.id === "vitals");
expect(vitalsRows.length).toBe(1);
expect(vitalsRows[0]?.visible).toBe(true);
});
});
describe("section reorder + hide round-trips through serialize → resolve", () => {
it("preserves a custom order + hidden flag", () => {
const reordered = {
version: 2,
sections: [
{ id: "trends", visible: true, order: 0 },
{ id: "wellness-scores", visible: false, order: 1 },
{ id: "vitals", visible: true, order: 2 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
};
const serialized = serializeInsightsLayout(reordered);
const resolved = resolveInsightsLayout(serialized);
// The three explicitly-ordered sections lead in their chosen order.
expect(resolved.sections[0]?.id).toBe("trends");
expect(resolved.sections[1]?.id).toBe("wellness-scores");
expect(resolved.sections[1]?.visible).toBe(false);
expect(resolved.sections[2]?.id).toBe("vitals");
// Orders stay dense 0-based.
resolved.sections.forEach((s, i) => expect(s.order).toBe(i));
});
});
describe("serializeInsightsLayout — defaults + dense order", () => {
it("fills section defaults when the input omits sections", () => {
const serialized = serializeInsightsLayout({
version: 2,
tiles: [{ id: "overview", visible: true, order: 0 }],
});
expect(serialized.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
for (const s of serialized.sections) expect(s.visible).toBe(true);
});
it("fills tile defaults when the input omits tiles", () => {
const serialized = serializeInsightsLayout({
version: 2,
sections: [{ id: "vitals", visible: true, order: 0 }],
});
expect(serialized.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
});
it("normalises sparse section orders to a dense 0-based sequence", () => {
const serialized = serializeInsightsLayout({
version: 2,
sections: [
{ id: "vitals", visible: true, order: 40 },
{ id: "trends", visible: true, order: 10 },
{ id: "signals", visible: false, order: 99 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
});
// Sorted by input order, then re-numbered 0,1,2.
expect(serialized.sections.map((s) => ({ id: s.id, order: s.order }))).toEqual([
{ id: "trends", order: 0 },
{ id: "vitals", order: 1 },
{ id: "signals", order: 2 },
]);
});
it("drops unknown section ids on serialize", () => {
const serialized = serializeInsightsLayout({
version: 2,
sections: [
{ id: "vitals", visible: true, order: 0 },
{ id: "bogus", visible: true, order: 1 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
});
expect(serialized.sections.map((s) => s.id)).toEqual(["vitals"]);
});
});
describe("orderedVisibleSectionIds — W2 render-order helper", () => {
it("returns every section in default order for the default layout", () => {
expect(orderedVisibleSectionIds(DEFAULT_INSIGHTS_LAYOUT)).toEqual([
...INSIGHTS_SECTION_IDS,
]);
});
it("drops hidden sections and keeps the visible ones in order", () => {
const layout = resolveInsightsLayout({
version: 2,
sections: [
{ id: "wellness-scores", visible: true, order: 0 },
{ id: "daily-briefing", visible: false, order: 1 },
{ id: "vitals", visible: true, order: 2 },
{ id: "trends", visible: false, order: 3 },
{ id: "period-review", visible: true, order: 4 },
{ id: "cycle-summary", visible: false, order: 5 },
{ id: "signals", visible: true, order: 6 },
{ id: "rhythm-events", visible: true, order: 7 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
});
expect(orderedVisibleSectionIds(layout)).toEqual([
"wellness-scores",
"vitals",
"period-review",
"signals",
"rhythm-events",
]);
});
it("honours a reordered section list", () => {
const layout = resolveInsightsLayout({
version: 2,
sections: [
{ id: "trends", visible: true, order: 0 },
{ id: "vitals", visible: true, order: 1 },
{ id: "wellness-scores", visible: true, order: 2 },
],
tiles: [{ id: "overview", visible: true, order: 0 }],
});
const visible = orderedVisibleSectionIds(layout);
// The three explicit sections lead in their saved order; the rest
// auto-merge invisible (new-id semantics), so they do NOT appear.
expect(visible.slice(0, 3)).toEqual(["trends", "vitals", "wellness-scores"]);
});
});
describe("resolveTileLayout — W2c per-tile decision", () => {
it("returns the saved visible + order for a known tile", () => {
const layout = resolveInsightsLayout({
version: 2,
sections: [{ id: "vitals", visible: true, order: 0 }],
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "hrv", visible: false, order: 1 },
],
});
expect(resolveTileLayout(layout, "hrv")).toEqual({
visible: false,
order: 1,
});
});
it("treats a tile the layout does not enumerate as always-on, ordered last", () => {
const res = resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, "not-a-tile");
expect(res.visible).toBe(true);
expect(res.order).toBe(Number.MAX_SAFE_INTEGER);
});
it("keeps the grid's default vitals tiles visible by default (no regression)", () => {
for (const id of [
"weight",
"bmi",
"cardio-fitness",
"vascular-age",
"hrv",
"resting-pulse",
"respiratory-rate",
"oxygen",
"body-temperature",
"blood-glucose",
"six-minute-walk",
"stair-ascent-speed",
"stair-descent-speed",
"wrist-temperature",
]) {
expect(resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, id).visible).toBe(true);
}
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* v1.15.11 W3 — pure reorder helper for the inline Insights edit mode.
*
* Mirrors the `reorderWidgets` contract in
* `src/components/settings/dashboard-layout-section.tsx` (move an item from
* `fromId` next to `toId`, renumber the whole list to a dense 0-based
* `order`) but is generic over any `{ id, order }` row so the section list
* AND the Vitals tile sub-list can both reorder through one tested helper.
*
* Kept as a standalone module (not imported from the dashboard component) so
* the inline-edit surface does not depend on a Settings component, and so the
* helper is unit-testable without spinning up a `DndContext`. The dashboard's
* own `reorderWidgets` stays untouched — extracting it there would have
* touched the stable dashboard surface for no behavioural gain.
*/
export interface OrderableRow {
id: string;
order: number;
}
/**
* Move the row identified by `fromId` to the position of `toId`, then
* renumber every row to a dense 0-based `order`. Returns a NEW array of NEW
* row objects (every input field is preserved via the spread); the input is
* never mutated. A no-op move (same id, or either id missing) still returns a
* densely-renumbered copy so the caller can treat the output as canonical.
*/
export function reorderById<T extends OrderableRow>(
rows: readonly T[],
fromId: string,
toId: string,
): T[] {
const sorted = [...rows].sort((a, b) => a.order - b.order);
const fromIdx = sorted.findIndex((r) => r.id === fromId);
const toIdx = sorted.findIndex((r) => r.id === toId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
return sorted.map((r, i) => ({ ...r, order: i }));
}
const next = [...sorted];
const [removed] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, removed);
return next.map((r, i) => ({ ...r, order: i }));
}
/**
* v1.15.11 QA M2 — rebuild a full tile list after reordering ONLY the Vitals
* subset, without the non-transitive mixed-key comparator the inline edit mode
* used to run. Walks the ORIGINAL `order` of every tile: a non-Vitals tile
* (the routed sub-page strip) keeps its slot verbatim; each Vitals slot, in
* original-order position, is filled by the next id from `reorderedVitalsIds`.
* The result is densely renumbered 0-based so the persisted blob stays
* canonical. Pure + total-order by construction — no `sort` with a key that
* flips per pair.
*
* `tiles` is the full draft list; `isVitals` decides membership; the returned
* array preserves every non-`order` field on each row.
*/
export function rebuildTilesWithReorderedVitals<T extends OrderableRow>(
tiles: readonly T[],
reorderedVitalsIds: readonly string[],
isVitals: (id: string) => boolean,
): T[] {
const original = [...tiles].sort((a, b) => a.order - b.order);
const byId = new Map(tiles.map((t) => [t.id, t]));
let vIdx = 0;
return original
.map((t) => {
if (!isVitals(t.id)) return t;
const replacementId = reorderedVitalsIds[vIdx++] ?? t.id;
return byId.get(replacementId) ?? t;
})
.map((t, i) => ({ ...t, order: i }));
}
+200 -2
View File
@@ -90,6 +90,39 @@ export function normalizeInsightsTileId(id: string): string {
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
}
/**
* v1.15.11 — the overview's big semantic blocks, customizable at the
* section level on top of the per-metric `tiles` list. Order here is the
* default render order (Hero is NOT a section — Hero + Score + Coach
* stays an anchored, non-customizable top block above the customizable
* region). English from birth — unlike the tile ids these never had a
* German phase, so no alias map is needed.
*
* Maps to the `/insights` page blocks: WellnessScores → `wellness-scores`,
* DailyBriefing → `daily-briefing`, VitalsDashboard → `vitals`, TrendsRow
* → `trends`, PeriodNarrativeCard → `period-review`, CycleInsightSummary
* → `cycle-summary`, CoincidentDeviationCard → `signals`, RhythmEvents →
* `rhythm-events`.
*/
export const INSIGHTS_SECTION_IDS = [
"wellness-scores",
"daily-briefing",
"vitals",
"trends",
"period-review",
"cycle-summary",
"signals",
"rhythm-events",
] as const;
export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number];
export interface InsightsSectionConfig {
id: InsightsSectionId;
visible: boolean;
order: number;
}
export interface InsightsTileConfig {
id: InsightsTileId;
visible: boolean;
@@ -98,10 +131,11 @@ export interface InsightsTileConfig {
export interface InsightsLayout {
version: number;
sections: InsightsSectionConfig[];
tiles: InsightsTileConfig[];
}
const INSIGHTS_LAYOUT_VERSION = 1;
const INSIGHTS_LAYOUT_VERSION = 2;
/**
* Default layout mirrors the routed tab-strip order — vitals first,
@@ -128,10 +162,43 @@ const DEFAULT_VISIBLE_TILE_IDS = new Set<InsightsTileId>([
"workouts",
"mood",
"medications",
// v1.15.11 W2c — the Vitals + Mobility grid on the overview surfaces every
// one of these tiles today, gated only on data availability. Now that the
// grid honours this layout's tile visibility, they must ship default-visible
// or a fresh account (no saved layout) would lose the tiles it shows today —
// a regression. They stay data-gated on top of visibility, so a tile with no
// readings still renders nothing exactly as before; widening the default set
// only ensures the layout never *suppresses* a tile the user already sees.
// The ids map to the grid's tile concepts:
// cardio-fitness → FitnessAge (VO₂max re-frame); vascular-age →
// VascularAge; hrv → HRV balance; resting-pulse / respiratory-rate /
// oxygen / body-temperature / blood-glucose → the per-vital baseline
// tiles; six-minute-walk / stair-ascent-speed / stair-descent-speed /
// wrist-temperature → the Mobility sub-grid.
"cardio-fitness",
"vascular-age",
"hrv",
"resting-pulse",
"respiratory-rate",
"oxygen",
"body-temperature",
"blood-glucose",
"six-minute-walk",
"stair-ascent-speed",
"stair-descent-speed",
"wrist-temperature",
]);
export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
version: INSIGHTS_LAYOUT_VERSION,
// Every section ships default-visible; feature/data gates still apply
// on top at render time. The dense 0-based `order` follows
// `INSIGHTS_SECTION_IDS`, which mirrors the current page render order.
sections: INSIGHTS_SECTION_IDS.map((id, order) => ({
id,
visible: true,
order,
})),
// Order follows `INSIGHTS_TILE_IDS` (overview first, then the
// `SUB_PAGE_SLUGS` order, which itself tracks the MeasurementCategory
// overlay: vitals → body → activity → sleep → cardiovascular → mood →
@@ -185,10 +252,62 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
return {
version: INSIGHTS_LAYOUT_VERSION,
sections: resolveInsightsSections(candidate.sections),
tiles: [...filtered, ...appended].sort((a, b) => a.order - b.order),
};
}
/**
* v1.15.11 — section-level resolution, mirroring the tile semantics:
* filter unknown ids, dedupe (first occurrence wins), merge missing
* default sections, sort by order. A v1 blob (no `sections` key) — or
* any garbage `sections` value — yields the full default section set, all
* default-visible, so existing iOS-written layouts resolve forward to a
* valid v2 layout untouched. New section ids introduced in a later
* release auto-merge default-INVISIBLE onto a saved layout (same as
* tiles); the current 8 sections come from the defaults default-visible
* whenever the blob omits them.
*/
function resolveInsightsSections(raw: unknown): InsightsSectionConfig[] {
if (!Array.isArray(raw)) {
// Missing / garbage → full default set, all visible.
return DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s }));
}
const knownIds = new Set<string>(INSIGHTS_SECTION_IDS);
const seen = new Set<string>();
const filtered: InsightsSectionConfig[] = (
raw as Array<Partial<InsightsSectionConfig>>
)
.filter((s): s is InsightsSectionConfig => {
if (
!s ||
typeof s.id !== "string" ||
typeof s.visible !== "boolean" ||
typeof s.order !== "number"
) {
return false;
}
if (!knownIds.has(s.id) || seen.has(s.id)) return false;
seen.add(s.id);
return true;
})
.map((s) => ({ id: s.id, visible: s.visible, order: s.order }));
const savedIds = new Set(filtered.map((s) => s.id));
const missing = DEFAULT_INSIGHTS_LAYOUT.sections.filter(
(s) => !savedIds.has(s.id),
);
const maxOrder = Math.max(0, ...filtered.map((s) => s.order));
const appended = missing.map((s, i) => ({
...s,
visible: false, // default-invisible on auto-upgrade of a NEW section id
order: maxOrder + 1 + i,
}));
return [...filtered, ...appended].sort((a, b) => a.order - b.order);
}
/**
* Input shape for {@link serializeInsightsLayout}. Looser than
* {@link InsightsLayout}: the tile `id` is a bare `string` so the PUT
@@ -198,7 +317,8 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
*/
export interface InsightsLayoutInput {
version: number;
tiles: Array<{ id: string; visible: boolean; order: number }>;
sections?: Array<{ id: string; visible: boolean; order: number }>;
tiles?: Array<{ id: string; visible: boolean; order: number }>;
}
export function serializeInsightsLayout(
@@ -211,8 +331,18 @@ export function serializeInsightsLayout(
// legacy and canonical id for one tile collapses to a single entry,
// keeping the dense 0-based order contiguous.
const seen = new Set<string>();
// An omitted `tiles` (a section-only PUT) falls back to the canonical
// default tile set so the persisted blob is always complete.
if (!layout.tiles) {
return {
version: INSIGHTS_LAYOUT_VERSION,
sections: serializeInsightsSections(layout.sections),
tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((t) => ({ ...t })),
};
}
return {
version: INSIGHTS_LAYOUT_VERSION,
sections: serializeInsightsSections(layout.sections),
tiles: layout.tiles
.slice()
.sort((a, b) => a.order - b.order)
@@ -229,3 +359,71 @@ export function serializeInsightsLayout(
})),
};
}
/**
* v1.15.11 — normalise an optional `sections` input: drop unknown ids,
* dedupe (first occurrence by order wins), re-number to a dense 0-based
* order. If the input omits `sections` entirely, fill from defaults so a
* current iOS client PUTting only `tiles` still persists a valid v2 blob.
*/
function serializeInsightsSections(
sections: InsightsLayoutInput["sections"],
): InsightsSectionConfig[] {
if (!sections) {
return DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s }));
}
const knownIds = new Set<string>(INSIGHTS_SECTION_IDS);
const seen = new Set<string>();
return sections
.slice()
.sort((a, b) => a.order - b.order)
.filter((s) => {
if (!knownIds.has(s.id) || seen.has(s.id)) return false;
seen.add(s.id);
return true;
})
.map((s, i) => ({
id: s.id as InsightsSectionId,
visible: s.visible,
order: i, // normalize to 0-based dense order
}));
}
/**
* v1.15.11 W2 — the visible section ids in render order, derived from a
* resolved layout. Pure helper so the page can map each id onto its JSX in
* `SECTION_REGISTRY` without re-implementing the sort/filter inline (and so
* the ordering is unit-testable without rendering React). Sections marked
* `visible: false` are dropped; the rest sort by `order`. Feature/data gates
* still apply on top at render time — a gated-off section that is
* layout-visible still renders nothing (its component self-gates / returns
* null), so this list is the *candidate* order, not a render guarantee.
*/
export function orderedVisibleSectionIds(
layout: InsightsLayout,
): InsightsSectionId[] {
return layout.sections
.filter((s) => s.visible)
.slice()
.sort((a, b) => a.order - b.order)
.map((s) => s.id);
}
/**
* v1.15.11 W2c — resolve a single tile's layout decision against a resolved
* layout. Returns `{ visible, order }` for a known tile id; for a tile id the
* layout does not enumerate (a dashboard tile with no corresponding layout
* entry — e.g. a derived re-frame like FITNESS_AGE) the tile is treated as
* always-on and sorted last so it never disappears on the user. Data-gating
* still applies on top — a `visible: true` tile with no data renders nothing.
*/
export function resolveTileLayout(
layout: InsightsLayout,
tileId: InsightsTileId | string,
): { visible: boolean; order: number } {
const entry = layout.tiles.find((t) => t.id === tileId);
if (entry) return { visible: entry.visible, order: entry.order };
// Unknown-to-layout tile → always render, ordered after every known tile.
return { visible: true, order: Number.MAX_SAFE_INTEGER };
}
+30 -4
View File
@@ -44,7 +44,10 @@ import {
MEDICATION_TREATMENT_CLASS_VALUES,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import {
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
} from "@/lib/insights-layout";
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
@@ -2028,9 +2031,31 @@ medicationExtractionSchema.meta({
// English id before persisting, and GET always returns canonical ids.
// The legacy ids are accepted-but-deprecated and will be dropped from
// the accepted set in a future major.
// v1.15.11 — layout v2 adds an optional `sections` array on top of the
// per-metric `tiles` list so the overview's big semantic blocks can be
// reordered/hidden in their own right. `sections` is additive and
// optional: a client PUTting only `tiles` (the pre-v2 iOS contract) still
// validates, and a v1 blob resolves forward to a valid v2 layout with all
// sections default-visible. Section ids are English from birth — no
// legacy-alias widening. `tiles` is likewise optional now (a section-only
// PUT fills the default tile set), but must carry at least one entry when
// present.
const insightsLayoutSchema = z
.object({
version: z.literal(1),
// v1.15.11 QA C1 — both v1 and v2 are accepted on input. The live iOS
// client still PUTs `version: 1`; the server normalises to the canonical
// v2 blob on persist, so a v1 body never 422s on the layout-schema bump.
version: z.union([z.literal(1), z.literal(2)]),
sections: z
.array(
z.object({
id: z.enum(INSIGHTS_SECTION_IDS),
visible: z.boolean(),
order: z.number().int().min(0).max(99),
}),
)
.max(50)
.optional(),
tiles: z
.array(
z.object({
@@ -2040,12 +2065,13 @@ const insightsLayoutSchema = z
}),
)
.min(1)
.max(50),
.max(50)
.optional(),
})
.meta({
id: "InsightsLayoutBody",
description:
"Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. The legacy ids are deprecated and will be removed in a future major version.",
"Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`); the server always normalises to the canonical v2 blob before persisting, and GET responses always carry `version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.",
});
// v1.7.0 — health-record export selection. Strict shape: unknown keys