diff --git a/CHANGELOG.md b/CHANGELOG.md index 052491547..b0afeca35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 197c9f6b1..7c1d1ded1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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/` 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/` 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/` 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/` 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: diff --git a/messages/de.json b/messages/de.json index 431fa1863..6e28dbb65 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index b83e09a1d..350e1e68a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 13e215365..33284f26e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 4481bde38..b34577c5b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index dc804f209..cbb8ccfb5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 36aab26fc..00c462378 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index f3113ca91..9e220d718 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index 42ac6ef5d..e0086226e 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -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 }; @@ -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); diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 699e353c6..551c8aed0 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -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 { diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 70487d82b..544370864 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 ). 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(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 = { + "wellness-scores": ( + : 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 ? ( + + ) : null, + vitals: , + trends: ( + + ), + "period-review": flags.briefing ? ( + + ) : 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 ? ( + + ) : null, + signals: , + "rhythm-events": , + }; + + // 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(); + 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} /> - : 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 && ( - + {/* 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 && ( +
+ +
)} - - - - - {flags.briefing && } - - {/* 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 ? : null} - - - - + {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. */ + { + 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. */ + } + title={t("insights.editMode.emptyTitle")} + description={t("insights.editMode.emptyDescription")} + action={ + + } + /> + ) : ( + /* 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) => ( + {SECTION_REGISTRY[id]} + )) + )} ); } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 37501ab1b..1710af3df 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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 = { + 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 = { + 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({ 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({ metric: "FITNESS_AGE" }); + if (fitness?.status === "ok" && fitness.value) return true; + } - const vascular = read({ - metric: "VASCULAR_AGE_DELTA", - }); - if (vascular?.status === "ok" && vascular.value) return true; + if (tileVisible(layout, "vascular-age")) { + const vascular = read({ + metric: "VASCULAR_AGE_DELTA", + }); + if (vascular?.status === "ok" && vascular.value) return true; + } - const bmi = read({ metric: "BMI" }); - if (bmi?.status === "ok" && bmi.value) return true; + if (tileVisible(layout, "bmi")) { + const bmi = read({ metric: "BMI" }); + if (bmi?.status === "ok" && bmi.value) return true; + } - const hrv = read({ metric: "HRV_BALANCE" }); - if ( - hrv && - !(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window") - ) { - return true; + if (tileVisible(layout, "hrv")) { + const hrv = read({ 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({ 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({ 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({ 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({ 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: , + }, + { + id: "vascular-age", + order: tileOrder(layout, "vascular-age"), + node: , + }, + { + id: "hrv", + order: tileOrder(layout, "hrv"), + node: , + }, + { + id: "bmi", + order: tileOrder(layout, "bmi"), + node: , + }, + ...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: ( + + ), + }; + }), + ]; + 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: , + }, + ...SECTION_MOBILITY.map(({ metric, type }) => { + const id = MOBILITY_TILE_LAYOUT_ID[type] ?? type; + return { + id, + order: tileOrder(layout, id), + node: ( + + ), + }; + }), + ]; + 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 (
- {isLoading ? ( - Array.from({ length: 6 }).map((_, i) => ( - - )) - ) : ( - <> - - - - - {vitals - .filter((type) => type !== "HEART_RATE_VARIABILITY") - .map((type) => ( - - ))} - - )} + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + + )) + : vitalTiles.map((tile) => tile.node)}
)} @@ -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" > - - {SECTION_MOBILITY.map(({ metric, type }) => ( - - ))} + {mobilityTiles.map((tile) => tile.node)} )} diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx new file mode 100644 index 000000000..aaac26f46 --- /dev/null +++ b/src/components/insights/insights-edit-mode.tsx @@ -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 = { + "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; + /** 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(() => ({ + 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(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( + 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( + 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 ( +
+
+
+

+ {t("insights.editMode.title")} +

+

+ {t("insights.editMode.description")} +

+
+
+ + +
+
+ + {allHidden && ( +

+ {t("insights.editMode.allHiddenHint")} +

+ )} + + + +
+ {sections.map((section) => { + const gatedOff = gatedOffSectionIds.has(section.id); + const isVitals = section.id === "vitals"; + return ( + + {isVitals && ( +
+ + {tilesOpen && ( + + + {/* 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. */} +
+ {vitalsTiles.map((tile) => ( + + ))} +
+
+
+ )} +
+ )} +
+ ); + })} +
+
+
+
+ ); +} + +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 ( + + ); +} + +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 ( +
+
+ +
+ + {title} + + {/* 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 && ( + + {labels.gatedHint} + + )} +
+ {/* 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. */} + onToggle(section.id, !section.visible)} + slot="insights-edit-section-eye" + /> +
+ {children} +
+ ); +} + +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 ( +
+ + + {title} + + onToggle(id, !visible)} + slot="insights-edit-tile-eye" + /> +
+ ); +} diff --git a/src/hooks/use-insights-layout.ts b/src/hooks/use-insights-layout.ts new file mode 100644 index 000000000..707092c7f --- /dev/null +++ b/src/hooks/use-insights-layout.ts @@ -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, + }; +} diff --git a/src/lib/__tests__/insights-layout-reorder.test.ts b/src/lib/__tests__/insights-layout-reorder.test.ts new file mode 100644 index 000000000..c36d6013c --- /dev/null +++ b/src/lib/__tests__/insights-layout-reorder.test.ts @@ -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); + }); +}); diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts new file mode 100644 index 000000000..12689687f --- /dev/null +++ b/src/lib/__tests__/insights-layout.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/insights-layout-reorder.ts b/src/lib/insights-layout-reorder.ts new file mode 100644 index 000000000..8a21af264 --- /dev/null +++ b/src/lib/insights-layout-reorder.ts @@ -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( + 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( + 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 })); +} diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index fa7ca7971..3a8eb7bc6 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -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([ "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(INSIGHTS_SECTION_IDS); + const seen = new Set(); + const filtered: InsightsSectionConfig[] = ( + raw as Array> + ) + .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(); + // 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(INSIGHTS_SECTION_IDS); + const seen = new Set(); + 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 }; +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 328e2630c..2acc5601c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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/` 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/` 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