mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #290 from MBombeck/release/v1.15.11
v1.15.11 — a customizable Insights overview
This commit is contained in:
20 files changed
+2195
-138
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user