From 4273af231e073f72c080ae2a59a4c72732d1b0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:45:06 +0200 Subject: [PATCH 1/4] feat(documents): choose how the vault is laid out, and confirm every delete The wrench beside Upload opens Settings, Appearance, Documents, the way the illness journal offers its display options: preview cards or a compact list, and months stacked as blocks or flowing across the full width with the month name inline above the document that opens it. The choice is stored per account in `users.documents_layout_json` behind GET/PUT /api/documents/inbound/layout (preserve-when-absent, module gated, the read delegable like the rest of the vault), carried in the account-settings backup and cleared by the data wipe. Deleting from the vault now always asks first. The bulk bar's Delete and a focused card's Delete key both go through one confirmation on the page, and the detail sheet's Delete opens its own; before, all three deleted on the spot with only an undo toast behind them. The selection bar is two tight lines: the count with an X at its right edge, then one action row with Delete alone at the right in the destructive style. Below 1024 px the verbs are icon-only at the 44 px tap floor, so the row stays one line at 360 px. The bar carries the selection-bar slot the Coach launcher already steps aside for. --- docs/api/openapi.yaml | 120 +++++++ e2e/documents.spec.ts | 2 + messages/de.json | 19 + messages/en.json | 19 + messages/es.json | 19 + messages/fr.json | 19 + messages/it.json | 19 + messages/ko.json | 19 + messages/pl.json | 19 + .../0388_documents_layout/migration.sql | 3 + prisma/schema.prisma | 6 + src/__tests__/sharing-surface-guard.test.ts | 9 +- .../inbound/layout/__tests__/route.test.ts | 167 +++++++++ src/app/api/documents/inbound/layout/route.ts | 99 ++++++ .../__tests__/document-bulk-bar.test.tsx | 47 ++- .../__tests__/document-month-grid.test.tsx | 75 ++++ .../documents-delete-confirm-guard.test.ts | 55 +++ .../__tests__/documents-view-states.test.tsx | 32 +- .../documents/__tests__/vault-utils.test.ts | 23 ++ .../documents/document-bulk-bar.tsx | 324 ++++++++++-------- .../documents/document-detail-sheet.tsx | 19 +- .../documents/document-month-grid.tsx | 99 +++++- .../documents/document-timeline.tsx | 56 ++- src/components/documents/documents-view.tsx | 135 ++++++-- src/components/documents/vault-utils.ts | 46 ++- .../settings/__tests__/layout-groups.test.ts | 4 +- src/components/settings/documents-section.tsx | 65 ++++ src/components/settings/layout-groups.ts | 7 + src/components/ui/confirm-button.tsx | 7 + src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/documents/documents-layout.ts | 70 ++++ src/lib/export/account-settings-backup.ts | 1 + src/lib/export/backup-plan.ts | 1 + src/lib/modules/surface.ts | 1 + src/lib/openapi/routes/documents.ts | 86 +++++ .../__tests__/use-documents-layout.test.ts | 85 +++++ src/lib/queries/use-documents-layout.ts | 85 +++++ src/lib/query-keys/documents.ts | 6 + src/lib/validations/backup.ts | 1 + 39 files changed, 1661 insertions(+), 209 deletions(-) create mode 100644 prisma/migrations/0388_documents_layout/migration.sql create mode 100644 src/app/api/documents/inbound/layout/__tests__/route.test.ts create mode 100644 src/app/api/documents/inbound/layout/route.ts create mode 100644 src/components/documents/__tests__/documents-delete-confirm-guard.test.ts create mode 100644 src/components/settings/documents-section.tsx create mode 100644 src/lib/documents/documents-layout.ts create mode 100644 src/lib/queries/__tests__/use-documents-layout.test.ts create mode 100644 src/lib/queries/use-documents-layout.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9447ab1d..b66a81c6c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -17386,6 +17386,46 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/documents/inbound/layout: + get: + tags: + - Documents + summary: Get the vault presentation + description: The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has + been chosen. Inside a shared record this answers the owner's choice. + responses: + "200": + description: The resolved presentation. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + put: + tags: + - Documents + summary: Change the vault presentation + description: "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the + full resolved presentation. Refused while acting inside somebody else's record." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutPutBody" + responses: + "200": + description: The presentation as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/documents/inbound/bulk: post: tags: @@ -31275,6 +31315,26 @@ components: additionalProperties: false description: Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys. + DocumentsLayoutPutBody: + type: object + properties: + version: + type: number + const: 1 + view: + type: string + enum: + - cards + - list + arrangement: + type: string + enum: + - stacked + - flow + required: + - version + additionalProperties: false + description: Either field may be omitted; the stored value is kept for the one left out. DocumentBulkRequest: type: object properties: @@ -36661,6 +36721,7 @@ components: insightsLayoutJson: {} medicationListLayoutJson: {} moodTagLayoutJson: {} + documentsLayoutJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -72136,6 +72197,65 @@ components: - assistAvailable - contentIndex additionalProperties: false + DocumentsLayoutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentsLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentsLayout: + type: object + properties: + version: + type: number + const: 1 + view: + type: string + enum: + - cards + - list + description: '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".' + arrangement: + type: string + enum: + - stacked + - flow + description: '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with + month names inline. Default "stacked".' + required: + - version + - view + - arrangement + additionalProperties: false + description: Per-user document vault presentation. Display-only; a client that does not render a vault ignores it. + DocumentsLayoutSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentsLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DocumentBulkEnvelope: type: object properties: diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 4dc51327d..4cd77f256 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -464,6 +464,8 @@ test.describe("document vault", () => { const sheet = page.getByRole("dialog", { name: `${title}.pdf` }); await expect(sheet).toBeVisible(); await sheet.getByRole("button", { name: "Delete" }).click(); + // v1.42 — the sheet's Delete asks first, like every vault delete. + await page.locator('[data-slot="document-detail-delete-confirm"]').click(); // Undo toast → restore → the card returns. // diff --git a/messages/de.json b/messages/de.json index 625afb001..7ed78a685 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Stimmungs-Tags", "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl." + }, + "documents": { + "title": "Dokumente", + "description": "Ansicht und Anordnung der Monate." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.", "maxReached": "Du kannst höchstens {max} Dokumente auf einmal importieren.", "noMatchHint": "Versuche einen Teil des Namens oder setze Tag und Datum zurück." + }, + "customize": "Darstellung anpassen", + "layout": { + "viewDescription": "Vorschaukarten mit Miniaturansicht oder eine kompakte Liste.", + "arrangementHeading": "Anordnung", + "arrangementDescription": "Jeder Monat als eigener Block oder alles fortlaufend über die ganze Breite.", + "stacked": "Monate untereinander", + "flow": "Fortlaufend", + "saveFailed": "Die Darstellung konnte nicht gespeichert werden." + }, + "deleteConfirm": { + "titleOne": "Dieses Dokument löschen?", + "titleMany": "{count} Dokumente löschen?", + "body": "Direkt danach lässt sich das noch rückgängig machen. Nach 30 Tagen wird es endgültig entfernt.", + "action": "Löschen" } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index c8a52c09a..62f080bed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Mood tags", "description": "Groups, tags, and archived tags for the mood picker." + }, + "documents": { + "title": "Documents", + "description": "View and how the months are arranged." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Search again or change a filter to pick more documents.", "maxReached": "You can import up to {max} documents at a time.", "noMatchHint": "Try part of the name, or clear the tag and dates." + }, + "customize": "Customize view", + "layout": { + "viewDescription": "Preview cards with thumbnails, or a compact list.", + "arrangementHeading": "Arrangement", + "arrangementDescription": "Each month as its own block, or everything flowing across the full width.", + "stacked": "Months stacked", + "flow": "Continuous", + "saveFailed": "Couldn't save the view." + }, + "deleteConfirm": { + "titleOne": "Delete this document?", + "titleMany": "Delete {count} documents?", + "body": "You can undo this right afterwards. After 30 days it is removed for good.", + "action": "Delete" } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 238bb72f4..b0ba7065f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Etiquetas de ánimo", "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo." + }, + "documents": { + "title": "Documentos", + "description": "Vista y disposición de los meses." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.", "maxReached": "Puedes importar como máximo {max} documentos a la vez.", "noMatchHint": "Prueba con parte del nombre o quita la etiqueta y las fechas." + }, + "customize": "Personalizar vista", + "layout": { + "viewDescription": "Tarjetas con miniatura o una lista compacta.", + "arrangementHeading": "Disposición", + "arrangementDescription": "Cada mes en su propio bloque o todo seguido a lo ancho.", + "stacked": "Meses apilados", + "flow": "Continuo", + "saveFailed": "No se pudo guardar la vista." + }, + "deleteConfirm": { + "titleOne": "¿Eliminar este documento?", + "titleMany": "¿Eliminar {count} documentos?", + "body": "Puedes deshacerlo justo después. A los 30 días se elimina definitivamente.", + "action": "Eliminar" } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 0fd018599..6f8adf4a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Étiquettes d’humeur", "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur." + }, + "documents": { + "title": "Documents", + "description": "Affichage et disposition des mois." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.", "maxReached": "Vous pouvez importer au plus {max} documents à la fois.", "noMatchHint": "Essayez une partie du nom, ou effacez l'étiquette et les dates." + }, + "customize": "Personnaliser l'affichage", + "layout": { + "viewDescription": "Des cartes avec aperçu ou une liste compacte.", + "arrangementHeading": "Disposition", + "arrangementDescription": "Chaque mois dans son propre bloc, ou tout à la suite sur toute la largeur.", + "stacked": "Mois empilés", + "flow": "En continu", + "saveFailed": "Impossible d'enregistrer l'affichage." + }, + "deleteConfirm": { + "titleOne": "Supprimer ce document ?", + "titleMany": "Supprimer {count} documents ?", + "body": "Vous pouvez annuler juste après. Au bout de 30 jours, la suppression est définitive.", + "action": "Supprimer" } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 582cd2e10..a3fa178ae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Tag dell’umore", "description": "Gruppi, tag e tag archiviati del selettore dell’umore." + }, + "documents": { + "title": "Documenti", + "description": "Visualizzazione e disposizione dei mesi." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.", "maxReached": "Puoi importare al massimo {max} documenti alla volta.", "noMatchHint": "Prova con parte del nome, oppure azzera etichetta e date." + }, + "customize": "Personalizza visualizzazione", + "layout": { + "viewDescription": "Schede con anteprima oppure un elenco compatto.", + "arrangementHeading": "Disposizione", + "arrangementDescription": "Ogni mese in un blocco a sé oppure tutto di seguito su tutta la larghezza.", + "stacked": "Mesi impilati", + "flow": "Continuo", + "saveFailed": "Impossibile salvare la visualizzazione." + }, + "deleteConfirm": { + "titleOne": "Eliminare questo documento?", + "titleMany": "Eliminare {count} documenti?", + "body": "Puoi annullare subito dopo. Dopo 30 giorni viene rimosso definitivamente.", + "action": "Elimina" } }, "selfDescription": { diff --git a/messages/ko.json b/messages/ko.json index 3fc16b737..fa7c8f328 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5352,6 +5352,10 @@ "mood": { "title": "기분 태그", "description": "기분 선택기의 그룹, 태그, 보관된 태그." + }, + "documents": { + "title": "문서", + "description": "보기 방식과 월 배치." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.", "maxReached": "한 번에 최대 {max}개 문서를 가져올 수 있습니다.", "noMatchHint": "이름의 일부로 검색하거나 태그와 날짜를 지워 보세요." + }, + "customize": "보기 사용자 지정", + "layout": { + "viewDescription": "미리보기가 있는 카드 또는 간단한 목록.", + "arrangementHeading": "배치", + "arrangementDescription": "월별로 따로 묶거나 전체 너비에 이어서 표시합니다.", + "stacked": "월별로 나누기", + "flow": "이어서 보기", + "saveFailed": "보기 설정을 저장하지 못했습니다." + }, + "deleteConfirm": { + "titleOne": "이 문서를 삭제할까요?", + "titleMany": "문서 {count}개를 삭제할까요?", + "body": "삭제 직후에는 되돌릴 수 있습니다. 30일이 지나면 완전히 삭제됩니다.", + "action": "삭제" } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index f9487fb8f..8fc97f260 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Tagi nastroju", "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju." + }, + "documents": { + "title": "Dokumenty", + "description": "Widok i układ miesięcy." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.", "maxReached": "Możesz zaimportować naraz najwyżej {max} dokumentów.", "noMatchHint": "Spróbuj części nazwy albo wyczyść tag i daty." + }, + "customize": "Dostosuj widok", + "layout": { + "viewDescription": "Karty z miniaturą albo zwarta lista.", + "arrangementHeading": "Układ", + "arrangementDescription": "Każdy miesiąc osobno albo wszystko po kolei na całą szerokość.", + "stacked": "Miesiące jeden pod drugim", + "flow": "Ciągły", + "saveFailed": "Nie udało się zapisać widoku." + }, + "deleteConfirm": { + "titleOne": "Usunąć ten dokument?", + "titleMany": "Usunąć dokumenty ({count})?", + "body": "Zaraz potem możesz to cofnąć. Po 30 dniach zostanie usunięte na stałe.", + "action": "Usuń" } }, "selfDescription": { diff --git a/prisma/migrations/0388_documents_layout/migration.sql b/prisma/migrations/0388_documents_layout/migration.sql new file mode 100644 index 000000000..b7b5122c7 --- /dev/null +++ b/prisma/migrations/0388_documents_layout/migration.sql @@ -0,0 +1,3 @@ +-- Per-user document vault presentation (view + month arrangement). +-- Nullable, display-only; null reads as the defaults. +ALTER TABLE "users" ADD COLUMN "documents_layout_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f24308c7b..72acf6c71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -310,6 +310,12 @@ model User { // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } medicationListLayoutJson Json? @map("medication_list_layout_json") + // v1.42 — per-user document vault presentation: the card/list view and + // the stacked/flowing month arrangement. Display-only, preserve-when-absent + // PUT on `/api/documents/inbound/layout`, resolved field by field on read + // (`src/lib/documents/documents-layout.ts`). Null = defaults (cards, stacked). + // Shape: { "version": 1, "view": "list", "arrangement": "flow" } + documentsLayoutJson Json? @map("documents_layout_json") // v1.17.0 — per-user mood-tag presentation: group order + per-group tag // placement (catalogue tags "moved" into a group live here only — their // `categoryId` never changes). Display-only, preserve-when-absent PUT, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 1324fca12..46ec7e4db 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1190,6 +1190,10 @@ const DELEGABLE_ROUTES: Record = { domain: "documents", why: "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.", }, + "app/api/documents/inbound/layout/route.ts": { + domain: "documents", + why: "The record's vault presentation, read only: two closed enums (card or list view, stacked or flowing months) and nothing else. The admitted list renders through it, so a refused read would leave a delegate's vault waiting on a shape it can never learn. The PUT stays bare — how the owner's vault is laid out is the owner's choice, the same split `/api/medications/layout` keeps.", + }, /* ------------------------------------------------------------------ */ /* v1.37.0 — the fifty-one modules that became delegable at MANAGE. */ @@ -2086,8 +2090,11 @@ const ACTOR_ROUTES: Record = { * the life-event writes join it with their audit rows: the edit/delete module * on the record list, the create and the edit/delete on the manage literal. * 251 -> 255. + * + * v1.42 -- the document vault's presentation read joins the record list + * beside the rest of the vault; its PUT stays bare. 255 -> 256. */ -const FROZEN_ENTRY_COUNT = 255; +const FROZEN_ENTRY_COUNT = 256; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/documents/inbound/layout/__tests__/route.test.ts b/src/app/api/documents/inbound/layout/__tests__/route.test.ts new file mode 100644 index 000000000..8c267af61 --- /dev/null +++ b/src/app/api/documents/inbound/layout/__tests__/route.test.ts @@ -0,0 +1,167 @@ +/** + * `/api/documents/inbound/layout` — the vault presentation. + * + * GET answers the defaults for an account that never chose, without writing + * a row; PUT is preserve-when-absent per field, refuses unknown values and + * unknown keys with the multi-issue 422, and both halves stop at the + * documents module gate. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { DEFAULT_DOCUMENTS_LAYOUT } from "@/lib/documents/documents-layout"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPut = PUT as unknown as (req: NextRequest) => Promise; + +function put(body: unknown): Promise { + return callPut( + new NextRequest("http://localhost/api/documents/inbound/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +describe("GET /api/documents/inbound/layout", () => { + it("answers the defaults and writes nothing for an account that never chose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: null, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual(DEFAULT_DOCUMENTS_LAYOUT); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("serves the stored choice, repairing an unknown value field by field", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "list", arrangement: "spiral" }, + } as never); + + const res = await callGet(); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "stacked", + }); + }); + + it("stops at the module gate", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + + const res = await callGet(); + expect(res.status).toBe(403); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/documents/inbound/layout", () => { + it("keeps the stored arrangement when only the view changes", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "cards", arrangement: "flow" }, + } as never); + + const res = await put({ version: 1, view: "list" }); + expect(res.status).toBe(200); + const expected = { version: 1, view: "list", arrangement: "flow" }; + expect((await res.json()).data).toEqual(expected); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { documentsLayoutJson: expected }, + }); + }); + + it("keeps the stored view when only the arrangement changes", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "list", arrangement: "stacked" }, + } as never); + + const res = await put({ version: 1, arrangement: "flow" }); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "flow", + }); + }); + + it("refuses an unknown value and an unknown key with 422 and writes nothing", async () => { + const bad = await put({ version: 1, view: "carousel" }); + expect(bad.status).toBe(422); + const extra = await put({ version: 1, view: "list", userId: "user-2" }); + expect(extra.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("stops at the module gate before reading the body", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + + const res = await put({ version: 1, view: "list" }); + expect(res.status).toBe(403); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/layout/route.ts b/src/app/api/documents/inbound/layout/route.ts new file mode 100644 index 000000000..45d3e2b94 --- /dev/null +++ b/src/app/api/documents/inbound/layout/route.ts @@ -0,0 +1,99 @@ +/** + * GET / PUT the document vault presentation (view + month arrangement). + * + * GET returns the resolved presentation, defaults merged in when the user has + * not chosen yet. PUT changes it with preserve-when-absent semantics: a body + * carrying only `view` keeps the stored arrangement and vice versa, the same + * contract `/api/medications/layout` keeps for its view and order. The blob + * lives on its own `User` column (`documents_layout_json`) per the + * per-surface-column convention. + * + * Both halves sit behind the documents module gate like every other route + * under `/api/documents/inbound`. + */ +import { z } from "zod/v4"; + +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { + DOCUMENTS_LAYOUT_ARRANGEMENTS, + DOCUMENTS_LAYOUT_VIEWS, + resolveDocumentsLayout, + type DocumentsLayout, +} from "@/lib/documents/documents-layout"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +const layoutPutSchema = z + .object({ + version: z.literal(1), + view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(), + arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(), + }) + .strict(); + +async function readLayout(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { documentsLayoutJson: true }, + }); + return resolveDocumentsLayout(row?.documentsLayoutJson); +} + +export const GET = apiHandler(async () => { + // Read: the RECORD's presentation, so a delegate browsing somebody's vault + // sees it the way its owner arranged it. The write below stays the + // caller's own and refuses under a switch. + const { user } = await requireRecordAuth("read", "documents"); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const layout = await readLayout(user.id); + annotate({ + action: { name: "documents.layout.read" }, + meta: { view: layout.view, arrangement: layout.arrangement }, + }); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: Request) => { + // Bare on purpose: how the owner's vault is laid out is the owner's choice, + // not something a helper rearranges on their behalf. + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = layoutPutSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "documents.layout.validation-failed" } }); + return returnAllZodIssues(parsed.error, 422); + } + + // Preserve-when-absent: one stored read fills whichever field the body + // left out. + const existing = await readLayout(user.id); + const next: DocumentsLayout = { + version: 1, + view: parsed.data.view ?? existing.view, + arrangement: parsed.data.arrangement ?? existing.arrangement, + }; + + await prisma.user.update({ + where: { id: user.id }, + data: { documentsLayoutJson: toJson(next) }, + }); + + annotate({ + action: { name: "documents.layout.update" }, + meta: { view: next.view, arrangement: next.arrangement }, + }); + return apiSuccess(next); +}); diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index 2770facfe..3ddea1b6c 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -29,7 +29,7 @@ function render( onLinkEpisode={() => {}} onLinkEncounter={() => {}} onShare={() => {}} - onDelete={() => {}} + onRequestDelete={() => {}} onClear={() => {}} /> , @@ -51,6 +51,51 @@ describe("", () => { expect(html).toContain("Clear selection"); }); + it("clears with an X icon at the right of the count line, not a text button", () => { + const html = render([{ id: "ep1", label: "Knee" }]); + const countLine = html.slice( + html.indexOf('data-slot="document-bulk-count"'), + html.indexOf('data-slot="document-bulk-actions"'), + ); + // The clear control lives on the count line, before the action row… + expect(countLine).toContain('data-slot="document-bulk-clear"'); + expect(countLine).toContain('aria-label="Clear selection"'); + // …and carries no visible label: the name rides on aria-label and title. + expect(countLine).not.toContain(">Clear selection<"); + }); + + it("keeps every verb in ONE action row with Delete alone at its right edge", () => { + const html = render([{ id: "ep1", label: "Knee" }]); + const actions = html.slice( + html.indexOf('data-slot="document-bulk-actions"'), + ); + const order = [ + "document-bulk-set-kind", + "document-bulk-link-condition", + "document-bulk-link-visit", + "document-bulk-share", + "document-bulk-delete", + ].map((slot) => actions.indexOf(`data-slot="${slot}"`)); + expect(order.every((index) => index >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + // The row never wraps into a second line, and Delete is pushed right. + const rowTag = /]*data-slot="document-bulk-actions"[^>]*>/.exec( + html, + ); + expect(rowTag?.[0]).not.toContain("flex-wrap"); + const deleteTag = /]*data-slot="document-bulk-delete"[^>]*>/.exec( + html, + ); + expect(deleteTag?.[0]).toContain("ml-auto"); + }); + + it("carries the selection-bar slot the Coach launcher steps aside for", () => { + const html = render([{ id: "ep1", label: "Knee" }]); + expect(html).toMatch( + /^
/, + ); + }); + it("omits the link-condition verb when the account has no episodes", () => { const html = render([]); expect(html).not.toContain("Link condition"); diff --git a/src/components/documents/__tests__/document-month-grid.test.tsx b/src/components/documents/__tests__/document-month-grid.test.tsx index c0b2a8a1b..360c2bb48 100644 --- a/src/components/documents/__tests__/document-month-grid.test.tsx +++ b/src/components/documents/__tests__/document-month-grid.test.tsx @@ -7,12 +7,50 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { columnsForWidth, DocumentMonthHeading, + DocumentMonthRow, estimatedRowHeight, + listColumnsForWidth, } from "../document-month-grid"; +function doc(id: string): InboundDocumentDto { + return { + id, + kind: "DOCTOR_REPORT", + title: `Letter ${id}`, + filename: null, + mimeType: "application/pdf", + byteSize: 1024, + status: "STORED", + documentDate: "2026-03-10", + createdAt: "2026-03-10T10:00:00Z", + conditionLinks: [], + servingClass: "inline", + hasThumbnail: false, + hasContentIndex: true, + } as unknown as InboundDocumentDto; +} + +function renderRow(props: Partial[0]>) { + return renderToStaticMarkup( + + {}} + highlightId={null} + rovingId="a" + onCardFocus={() => {}} + {...props} + /> + , + ); +} + describe("columnsForWidth", () => { it("lays a 390 px phone out in two columns, the narrowest screens in one", () => { // 390 viewport minus the shell's 16 px gutters. @@ -50,3 +88,40 @@ describe("", () => { expect(html).toMatch(/^

]*uppercase[^>]*>March 2026<\/h2>$/); }); }); + +describe("listColumnsForWidth", () => { + it("keeps compact rows single-file on a phone and side by side on a desktop", () => { + expect(listColumnsForWidth(358)).toBe(1); + expect(listColumnsForWidth(1024 - 48)).toBe(2); + expect(listColumnsForWidth(1280 - 48)).toBe(3); + }); +}); + +describe("", () => { + it("renders preview tiles by default and compact rows in the list view", () => { + expect(renderRow({})).toContain('data-variant="tile"'); + const list = renderRow({ view: "list" }); + expect(list).toContain('data-variant="row"'); + expect(list).not.toContain('data-variant="tile"'); + }); + + it("flowing: the month name rides inline above the document that opens it", () => { + const html = renderRow({ + monthStarts: { b: "2026-02" }, + formatMonth: () => "February 2026", + }); + expect(html.match(/data-slot="document-flow-month"/g)).toHaveLength(1); + expect(html).toContain("February 2026"); + // The marker belongs to b's cell, not a's. + expect(html.indexOf("February 2026")).toBeGreaterThan( + html.indexOf('data-document-id="a"'), + ); + expect(html.indexOf("February 2026")).toBeLessThan( + html.indexOf('data-document-id="b"'), + ); + }); + + it("stacked rows carry no inline month marker", () => { + expect(renderRow({})).not.toContain('data-slot="document-flow-month"'); + }); +}); diff --git a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts new file mode 100644 index 000000000..abfde0ac0 --- /dev/null +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -0,0 +1,55 @@ +/** + * Every way of deleting from the vault asks first. + * + * Until v1.42 the bulk bar's Delete and a focused card's Delete key both + * called the bulk delete straight away; only the transient undo toast stood + * between a stray press and a medical document leaving the vault. The page now + * routes both through one confirmation (`requestDelete` → `ConfirmDialog` → + * `confirmDelete`), and the detail sheet's Delete opens its own. + * + * Structural rather than behavioural because the unit layer renders statically; + * the e2e journey (`documents-qol.spec.ts`) clicks through the real dialog. The + * matchers assert a non-zero count, so a rename that leaves them matching + * nothing fails instead of passing quietly. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const dir = join(process.cwd(), "src/components/documents"); +const view = readFileSync(join(dir, "documents-view.tsx"), "utf8"); +const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8"); +const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8"); + +describe("vault delete paths confirm before deleting", () => { + it("the page calls the bulk delete only from the confirmation", () => { + const calls = [...view.matchAll(/deleteBulk\(\s*([^)]*)\)/g)].map((m) => + m[1].trim(), + ); + // One call site, and it is the confirmed id list. + expect(calls).toEqual(["pendingDeleteIds"]); + expect(view).toMatch( + /const confirmDelete = useCallback\(\(\) => \{\s*if \(pendingDeleteIds\) deleteBulk\(pendingDeleteIds\);/, + ); + expect(view).toContain(" { + expect(view).toMatch( + /onDelete=\{\s*canManageDocuments \? \(id\) => requestDelete\(\[id\]\)/, + ); + expect(view).toMatch( + /onRequestDelete=\{\(\) => requestDelete\(\[\.\.\.selectedIds\]\)\}/, + ); + // The bar has no delete of its own to call. + expect(bar).not.toMatch(/\bonDelete\b/); + expect(bar).toMatch(/onClick=\{onRequestDelete\}/); + }); + + it("the detail sheet's Delete opens a confirmation instead of deleting", () => { + const mutateCalls = [...sheet.matchAll(/remove\.mutate\(/g)]; + expect(mutateCalls.length).toBe(1); + expect(sheet).toMatch(/onConfirm=\{\(\) => remove\.mutate\(doc\.id\)\}/); + expect(sheet).toMatch(/onClick=\{\(\) => setConfirmDeleteOpen\(true\)\}/); + }); +}); diff --git a/src/components/documents/__tests__/documents-view-states.test.tsx b/src/components/documents/__tests__/documents-view-states.test.tsx index 56c270cf1..324b287a6 100644 --- a/src/components/documents/__tests__/documents-view-states.test.tsx +++ b/src/components/documents/__tests__/documents-view-states.test.tsx @@ -49,11 +49,18 @@ import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { DocumentsView } from "../documents-view"; -function render(search: string, seedList?: { kinds?: never } | object) { +function render( + search: string, + seedList?: { kinds?: never } | object, + layout: object | null = { version: 1, view: "cards", arrangement: "stacked" }, +) { mockSearch = search; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + if (layout !== null) { + queryClient.setQueryData(queryKeys.documentsLayout(), layout); + } if (seedList !== undefined) { queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), { pages: [{ documents: [], nextCursor: null }], @@ -80,6 +87,29 @@ describe(" states", () => { expect(html).not.toContain("Your document vault is empty"); }); + it("header: a wrench opens the vault's display settings, left of Upload", () => { + const html = render("", {}); + const wrench = html.indexOf('data-slot="documents-customize"'); + expect(wrench).toBeGreaterThan(-1); + expect(html).toContain('href="/settings/layout/documents"'); + expect(html.indexOf("Upload", wrench)).toBeGreaterThan(wrench); + }); + + it("first load waits for the presentation, then paints the list skeleton for a list reader", () => { + // The presentation read is still out: the vault does not guess a shape. + const pending = render("", {}, null); + expect(pending).toContain('data-slot="documents-loading"'); + expect(pending).not.toContain("Your document vault is empty"); + + const listReader = render("", undefined, { + version: 1, + view: "list", + arrangement: "flow", + }); + expect(listReader).toContain('data-slot="documents-loading"'); + expect(listReader).not.toContain("aspect-[4/3]"); + }); + it("filter bar: type facet is a compact dropdown, not an inline chip row", () => { const html = render(""); // The type facet renders as a single dropdown trigger labelled by the diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index ad9ddb2e9..cc515e682 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { buildProcedureChoices, + buildFlowTimelineItems, buildTimelineItems, buildVaultListApiSearch, classifyUploadFailure, @@ -127,6 +128,28 @@ describe("API list search", () => { }); describe("timeline items", () => { + it("flowing: rows run across month boundaries and mark where each month opens", () => { + const docs = [ + doc({ id: "a", documentDate: "2026-03-20" }), + doc({ id: "b", documentDate: "2026-03-15" }), + doc({ id: "c", documentDate: "2026-02-28" }), + doc({ id: "d", documentDate: "2026-02-10" }), + doc({ id: "e", documentDate: "2026-01-05" }), + ]; + const items = buildFlowTimelineItems(docs, 3, "UTC"); + // No row is spent on a month: five documents in three columns are two rows. + expect(items.map((i) => i.type)).toEqual(["row", "row"]); + expect(items[0]).toMatchObject({ + documents: [docs[0], docs[1], docs[2]], + monthStarts: { a: "2026-03", c: "2026-02" }, + }); + // February continues into the second row without a second marker. + expect(items[1]).toMatchObject({ + documents: [docs[3], docs[4]], + monthStarts: { e: "2026-01" }, + }); + }); + it("buckets by month and chunks rows to the column count", () => { const docs = [ doc({ id: "a", documentDate: "2026-03-20" }), diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 45d3a2069..ece68fb42 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -3,20 +3,24 @@ /** * Floating bulk-action bar for the vault's multi-select mode. Appears once * at least one document is selected, pinned above the bottom nav on phones - * and near the bottom edge on desktop. Carries the selected count and the - * bulk verbs — set type, link condition, file against a visit, share (one - * link for the whole selection), delete (undo-able), clear — all driving the - * page's handlers. + * and near the bottom edge on desktop. + * + * Two tight lines. The first carries the selected count and, at its right + * edge, the X that clears the selection. The second is the one action row: + * set type, link condition, file against a visit and share lead from the + * left, and Delete sits alone at the right edge in the destructive style. + * Delete never deletes from here — it asks the page, which confirms first + * (`onRequestDelete`), the same path the card's Delete key takes. * * The two link verbs are the same shape and stay that way: each renders only * when the account HAS something to link to, so an empty menu is never * offered. * - * A `role="toolbar"` with proper labels; the destructive verb sits last - * before Clear so a stray tap sequence never ends on Delete. Share sits just - * before Delete (the safe actions lead). The bar is a deliberate hand-rolled - * shell (a floating toolbar is not a Card surface): dense-tile padding `p-3` - * per the standards. + * Below `lg` every verb is icon-only with its name in `aria-label` and + * `title`, at the 44 px tap floor, so the row stays one line at 360 px; the + * labels return where the bar is wide enough to hold them on one line. A + * `role="toolbar"`; the bar is a deliberate hand-rolled shell (a floating + * toolbar is not a Card surface): dense-tile padding `p-3` per the standards. */ import { CalendarClock, @@ -50,6 +54,13 @@ export interface BulkEncounterOption { label: string; } +/** Shared shape of the leading verbs: icon-only below `lg`, labelled from it. */ +const VERB_CLASS = "min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"; + +function VerbLabel({ children }: { children: string }) { + return {children}; +} + export function DocumentBulkBar({ selectedCount, episodes, @@ -59,7 +70,7 @@ export function DocumentBulkBar({ onLinkEpisode, onLinkEncounter, onShare, - onDelete, + onRequestDelete, onClear, }: { selectedCount: number; @@ -78,153 +89,170 @@ export function DocumentBulkBar({ * this handler just opens the share sheet seeded with the selection. */ onShare: () => void; - onDelete: () => void; + /** Ask to delete the selection. The page confirms before anything goes. */ + onRequestDelete: () => void; onClear: () => void; }) { const { t } = useTranslations(); return ( -
-

- {t("documents.selection.count", { count: selectedCount })} -

+ // The outer slot is the data-list selection bar's: the Coach launcher + // watches for it and steps aside while a selection is open, so it never + // sits over Delete at the bar's right edge. `contents` keeps the wrapper + // out of the layout. +
+
+
+

+ {t("documents.selection.count", { count: selectedCount })} +

+ +
-
- {/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a - 2–3-row floating slab at ~360 px. Below `sm` every verb collapses - to icon-only (`aria-label` carries the accessible name; the - visible label returns at `sm+`) — the `document-detail-sheet` - footer's established icon-collapse pattern. */} - - - - - - {DOCUMENT_KIND_ORDER.map((kind) => { - const Icon = DOCUMENT_KIND_ICONS[kind]; - return ( - onSetKind(kind)}> - - {t(`documents.kind.${kind}`)} - - ); - })} - - - - {episodes.length > 0 ? ( - - - - - - {episodes.map((episode) => ( - onLinkEpisode(episode.id)} - > - {episode.label} - - ))} - - - ) : null} - - {encounters.length > 0 ? ( - - - - - - {encounters.map((encounter) => ( - onLinkEncounter(encounter.id)} - > - {encounter.label} - - ))} - - - ) : null} - - + + + + + + {DOCUMENT_KIND_ORDER.map((kind) => { + const Icon = DOCUMENT_KIND_ICONS[kind]; + return ( + onSetKind(kind)}> + + {t(`documents.kind.${kind}`)} + + ); + })} + + - {/* Solid destructive (matching the detail sheet's Delete) — the + {episodes.length > 0 ? ( + + + + + + {episodes.map((episode) => ( + onLinkEpisode(episode.id)} + > + {episode.label} + + ))} + + + ) : null} + + {encounters.length > 0 ? ( + + + + + + {encounters.map((encounter) => ( + onLinkEncounter(encounter.id)} + > + {encounter.label} + + ))} + + + ) : null} + + + + {/* Solid destructive (matching the detail sheet's Delete) — the outline variant's destructive text on the card surface fails the - WCAG contrast gate. */} - - - + WCAG contrast gate. Alone at the right edge, away from the safe + verbs. */} + +
); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 396e1db28..740670268 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -38,6 +38,7 @@ import { toast } from "sonner"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-button"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -442,6 +443,8 @@ export function DocumentDetailSheet({ /** Bumped on a failed write; remounts the link block onto server state. */ const [linkResets, setLinkResets] = useState(0); const [shareOpen, setShareOpen] = useState(false); + // Delete asks first, like every other way of deleting from the vault. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [editingTitle, setEditingTitle] = useState(false); /** * The visit this SESSION filed the document against, if any. @@ -769,8 +772,9 @@ export function DocumentDetailSheet({ + {/* The wrench left of the primary action, as on the illness + journal: it opens this vault's display settings. `/settings` + is the caller's own, so it never shows inside somebody + else's record. */} + {!inSharedRecord ? ( + + ) : null} + {canManageDocuments ? ( + <> + {/* Renders only with a connected Paperless-ngx or Papra. */} + + + + ) : null}
) : null } @@ -990,22 +1038,30 @@ export function DocumentsView() { onIndexAll={handleIndexAll} /> - {list.isPending ? ( -
- {Array.from({ length: 8 }, (_, i) => ( -
- - - -
- ))} -
+ {list.isPending || isLayoutLoading ? ( + layout.view === "list" ? ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+ ) : ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + + +
+ ))} +
+ ) ) : list.isError ? ( ) : ( deleteBulk([id]) : undefined} + onDelete={ + canManageDocuments ? (id) => requestDelete([id]) : undefined + } highlightId={upload.highlightId} onPrefetch={prefetchDetail} /> @@ -1081,11 +1141,30 @@ export function DocumentsView() { runBulk("linkEncounter", { encounterId }) } onShare={openBulkShare} - onDelete={() => deleteBulk([...selectedIds])} + onRequestDelete={() => requestDelete([...selectedIds])} onClear={clearSelection} /> ) : null} + { + if (!open) setPendingDeleteIds(null); + }} + title={ + pendingDeleteIds !== null && pendingDeleteIds.length > 1 + ? t("documents.deleteConfirm.titleMany", { + count: pendingDeleteIds.length, + }) + : t("documents.deleteConfirm.titleOne") + } + body={t("documents.deleteConfirm.body")} + confirmLabel={t("documents.deleteConfirm.action")} + onConfirm={confirmDelete} + slot="documents-delete" + destructive + /> + ; + }; /** * Bucket an already-sorted document list into consecutive YYYY-MM sections @@ -224,6 +234,40 @@ export function buildTimelineItems( return items; } +/** + * The flowing arrangement: documents run left to right across every row and + * wrap, with no row reserved for a month. The first document of each month + * carries that month in `monthStarts`, so its name can sit inline above it. + * Order is preserved exactly as received, like `buildTimelineItems`. + */ +export function buildFlowTimelineItems( + documents: InboundDocumentDto[], + columns: number, + timezone: string, +): TimelineItem[] { + const cols = Math.max(1, columns); + const items: TimelineItem[] = []; + let month: string | null = null; + for (let i = 0; i < documents.length; i += cols) { + const row = documents.slice(i, i + cols); + const monthStarts: Record = {}; + for (const doc of row) { + const key = documentMonthKey(doc, timezone); + if (key !== month) { + monthStarts[doc.id] = key; + month = key; + } + } + items.push({ + type: "row", + key: `flow:${row[0].id}`, + documents: row, + monthStarts, + }); + } + return items; +} + /** * Format a YYYY-MM month key as a locale month label ("März 2026"). Handed * over as a noon-UTC instant so the month never shifts under the display diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index a4a666ca4..b028df7dc 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -29,7 +29,7 @@ import { } from "../layout-groups"; describe("layout-groups registry", () => { - it("exposes the seven Appearance modules as statically-generated subpage params", () => { + it("exposes the eight Appearance modules as statically-generated subpage params", () => { expect(LAYOUT_GROUP_IDS).toEqual([ "dashboard", "insights", @@ -37,6 +37,7 @@ describe("layout-groups registry", () => { "mood", "labs", "illness", + "documents", "vorsorge", ]); // generateStaticParams() shape. @@ -47,6 +48,7 @@ describe("layout-groups registry", () => { { module: "mood" }, { module: "labs" }, { module: "illness" }, + { module: "documents" }, { module: "vorsorge" }, ]); }); diff --git a/src/components/settings/documents-section.tsx b/src/components/settings/documents-section.tsx new file mode 100644 index 000000000..f1e9b4f12 --- /dev/null +++ b/src/components/settings/documents-section.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * Settings → Appearance → Documents. + * + * Reached from the wrench beside the vault's Upload button, the way the + * illness journal's wrench reaches its own page. Two choices, each in its own + * card with the toggle in the header's status slot (the illness page's + * shape): the card/list view and the month arrangement. Both persist per user + * through `/api/documents/inbound/layout`. + */ +import { Eye, Rows3, WrapText } from "lucide-react"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; +import { ViewToggle } from "@/components/ui/view-toggle"; +import { useTranslations } from "@/lib/i18n/context"; +import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; + +export function DocumentsSection() { + const { t } = useTranslations(); + const { layout, setView, setArrangement } = useDocumentsLayout(); + + return ( +
+ + } + /> + + + + + } + /> + +
+ ); +} diff --git a/src/components/settings/layout-groups.ts b/src/components/settings/layout-groups.ts index 42f2edb77..5186a54bb 100644 --- a/src/components/settings/layout-groups.ts +++ b/src/components/settings/layout-groups.ts @@ -8,6 +8,7 @@ import { MedicationsSection } from "./medications-section"; import { MoodSection } from "./mood-section"; import { LabsSection } from "./labs-section"; import { IllnessSection } from "./illness-section"; +import { DocumentsSection } from "./documents-section"; import { VorsorgeSection } from "./vorsorge-section"; /** @@ -93,6 +94,12 @@ export const LAYOUT_GROUPS: ReadonlyArray = [ descriptionKey: "settings.sections.layout.illness.description", Body: IllnessSection, }, + { + id: "documents", + titleKey: "settings.sections.layout.documents.title", + descriptionKey: "settings.sections.layout.documents.description", + Body: DocumentsSection, + }, { id: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title", diff --git a/src/components/ui/confirm-button.tsx b/src/components/ui/confirm-button.tsx index 108f36f49..3f7022c9c 100644 --- a/src/components/ui/confirm-button.tsx +++ b/src/components/ui/confirm-button.tsx @@ -54,6 +54,11 @@ interface ConfirmCopy { slot?: string; /** A control that belongs with the question, such as an opt-out checkbox. */ extra?: ReactNode; + /** + * The action deletes something: it wears the destructive style, as the + * data-list selection bar's confirm already does. + */ + destructive?: boolean; } function ConfirmBody({ @@ -64,6 +69,7 @@ function ConfirmBody({ pending = false, slot, extra, + destructive = false, }: ConfirmCopy) { const { t } = useTranslations(); @@ -77,6 +83,7 @@ function ConfirmBody({ {t("common.cancel")} `) are mood's too; see diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 1c585fa2d..ce08c12f3 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -30,6 +30,10 @@ import { INBOUND_DOCUMENT_KINDS, INBOUND_DOCUMENT_STATUSES, } from "@/lib/validations/inbound-documents"; +import { + DOCUMENTS_LAYOUT_ARRANGEMENTS, + DOCUMENTS_LAYOUT_VIEWS, +} from "@/lib/documents/documents-layout"; import { aiExtractionRefusals } from "./ai-extraction-refusals"; import { aiRunAccepted, preferRespondAsyncParameter } from "./ai-run-accepted"; @@ -44,6 +48,41 @@ import { } from "./shared"; const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS); + +// ─── Vault presentation (v1.42) ──────────────────────────────────────────── + +const documentsLayoutSchema = z + .object({ + version: z.literal(1), + view: z + .enum(DOCUMENTS_LAYOUT_VIEWS) + .describe( + '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".', + ), + arrangement: z + .enum(DOCUMENTS_LAYOUT_ARRANGEMENTS) + .describe( + '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with month names inline. Default "stacked".', + ), + }) + .meta({ + id: "DocumentsLayout", + description: + "Per-user document vault presentation. Display-only; a client that does not render a vault ignores it.", + }); + +const documentsLayoutPutBody = z + .object({ + version: z.literal(1), + view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(), + arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(), + }) + .strict() + .meta({ + id: "DocumentsLayoutPutBody", + description: + "Either field may be omitted; the stored value is kept for the one left out.", + }); const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES); // ─── Chat about a document (P4) — response shapes ────────────────────────── @@ -719,6 +758,53 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/layout": { + get: { + tags: ["Documents"], + summary: "Get the vault presentation", + description: + "The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has been chosen. Inside a shared record this answers the owner's choice.", + responses: { + ...recordRefusal(), + "200": { + description: "The resolved presentation.", + content: { + "application/json": { + schema: dataEnvelope( + documentsLayoutSchema, + "DocumentsLayoutEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Documents"], + summary: "Change the vault presentation", + description: + "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the full resolved presentation. Refused while acting inside somebody else's record.", + requestBody: { + required: true, + content: { "application/json": { schema: documentsLayoutPutBody } }, + }, + responses: { + "200": { + description: "The presentation as stored.", + content: { + "application/json": { + schema: dataEnvelope( + documentsLayoutSchema, + "DocumentsLayoutSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/bulk": { post: { tags: ["Documents"], diff --git a/src/lib/queries/__tests__/use-documents-layout.test.ts b/src/lib/queries/__tests__/use-documents-layout.test.ts new file mode 100644 index 000000000..b7c3d01e5 --- /dev/null +++ b/src/lib/queries/__tests__/use-documents-layout.test.ts @@ -0,0 +1,85 @@ +/** + * The vault presentation switch: field-scoped PUT, optimistic cache flip, + * rollback and a toast when the save fails. Exercised through the + * dependency-injected `runSetDocumentsLayout`, without a React render. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() }, +})); + +import { apiPut } from "@/lib/api/api-fetch"; +import { toast } from "sonner"; +import { runSetDocumentsLayout } from "@/lib/queries/use-documents-layout"; +import { queryKeys } from "@/lib/query-keys"; +import type { DocumentsLayout } from "@/lib/documents/documents-layout"; + +const t = (key: string) => key; +const key = queryKeys.documentsLayout(); +const STORED: DocumentsLayout = { + version: 1, + view: "cards", + arrangement: "stacked", +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("runSetDocumentsLayout", () => { + it("sends only the field that changed and keeps the server's answer", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, STORED); + const saved: DocumentsLayout = { ...STORED, arrangement: "flow" }; + vi.mocked(apiPut).mockResolvedValue(saved); + + await runSetDocumentsLayout({ + patch: { arrangement: "flow" }, + queryClient, + t, + }); + + expect(apiPut).toHaveBeenCalledWith("/api/documents/inbound/layout", { + version: 1, + arrangement: "flow", + }); + expect(queryClient.getQueryData(key)).toEqual(saved); + }); + + it("flips the cache before the save lands", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, STORED); + let settle: (value: DocumentsLayout) => void = () => {}; + vi.mocked(apiPut).mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }), + ); + + const run = runSetDocumentsLayout({ + patch: { view: "list" }, + queryClient, + t, + }); + expect(queryClient.getQueryData(key)?.view).toBe("list"); + settle({ ...STORED, view: "list" }); + await run; + }); + + it("rolls back and says so when the save fails", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, STORED); + vi.mocked(apiPut).mockRejectedValue(new Error("offline")); + + await runSetDocumentsLayout({ patch: { view: "list" }, queryClient, t }); + + expect(queryClient.getQueryData(key)).toEqual(STORED); + expect(toast.error).toHaveBeenCalledWith("documents.layout.saveFailed"); + }); +}); diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts new file mode 100644 index 000000000..e9602a5d7 --- /dev/null +++ b/src/lib/queries/use-documents-layout.ts @@ -0,0 +1,85 @@ +"use client"; + +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { + DEFAULT_DOCUMENTS_LAYOUT, + type DocumentsLayout, + type DocumentsLayoutArrangement, + type DocumentsLayoutView, +} from "@/lib/documents/documents-layout"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +type LayoutPatch = { + view?: DocumentsLayoutView; + arrangement?: DocumentsLayoutArrangement; +}; + +/** + * Apply one field optimistically (the switch has to feel instant), then + * persist it. The PUT is field-scoped, so a view change never resets the + * arrangement and vice versa. On failure the cache rolls back and a calm + * toast says so. + */ +export async function runSetDocumentsLayout(deps: { + patch: LayoutPatch; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { patch, queryClient, t } = deps; + const key = queryKeys.documentsLayout(); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_DOCUMENTS_LAYOUT), + ...patch, + }); + try { + const saved = await apiPut( + "/api/documents/inbound/layout", + { version: 1, ...patch }, + ); + if (saved) queryClient.setQueryData(key, saved); + } catch { + queryClient.setQueryData(key, previous); + toast.error(t("documents.layout.saveFailed")); + } +} + +/** The vault presentation, persisted per user. Defaults until the read lands. */ +export function useDocumentsLayout(enabled: boolean = true): { + layout: DocumentsLayout; + isLayoutLoading: boolean; + setView: (view: DocumentsLayoutView) => Promise; + setArrangement: (arrangement: DocumentsLayoutArrangement) => Promise; +} { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.documentsLayout(), + queryFn: () => apiGet("/api/documents/inbound/layout"), + staleTime: 5 * 60 * 1000, + enabled, + }); + + return { + layout: data ?? DEFAULT_DOCUMENTS_LAYOUT, + isLayoutLoading: enabled && isLoading, + setView: (view) => + runSetDocumentsLayout({ patch: { view }, queryClient, t }), + setArrangement: (arrangement) => + runSetDocumentsLayout({ patch: { arrangement }, queryClient, t }), + }; +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index c5c8e8dc9..a562672b7 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -30,6 +30,12 @@ export interface DocumentVaultFilters { export const documentKeys = { documents: () => ["documents"] as const, + /** + * The vault presentation (view + month arrangement). Deliberately outside + * the `["documents"]` prefix: a document write changes nothing about how + * the vault is laid out, so it must not refetch this. + */ + documentsLayout: () => ["documents-layout"] as const, inboundDocuments: () => ["documents", "inbound"] as const, /** * The parameterised vault list (keyset-paginated via `useInfiniteQuery`). diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 50c666df3..23083ea68 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2008,6 +2008,7 @@ const accountSettingsBackupSchema = z insightsLayoutJson: z.unknown().optional(), medicationListLayoutJson: z.unknown().optional(), moodTagLayoutJson: z.unknown().optional(), + documentsLayoutJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), globalExcludedInjectionSites: z.array(z.string()).optional(), healthKitConfigJson: z.unknown().optional(), From 1a5eab9f7cd94b90a8ec891654537ada06998156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:45:24 +0200 Subject: [PATCH 2/4] feat(insights): show every wellness score's course, drop the caveat line Each score tile in "Your health scores" draws the score over its window under the band word (the persisted window for recovery, stress and strain; thirty days for the sleep score and readiness), on a fixed 0-100 scale so a few points do not read as a cliff. A score's detail page carries its history directly under the score card: the stored nightly scores get the full chart with the period switch, tooltip and day access every measured page has; the two composites draw the course their value carries. The data was already there for three of the five. The sleep score now returns the score of every scorable night in its window, judged by the same blend and the same window baseline as the headline, so the last point is the headline. Readiness reads its course from what the nightly job persisted, the COMPUTED recovery row that is this exact blend, without recomputing a single day. Both fields are additive. The warning-coloured "descriptive proxy, not clinical" line above the method is gone from all four score pages that carried it, with its keys in all seven locales and the unused anatomy disclaimer key. --- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/pl.json | 11 +- .../composite-score-anatomy-coach.test.tsx | 7 + .../__tests__/score-history-wiring.test.tsx | 145 ++++++++++++++++++ .../__tests__/wellness-scores.test.tsx | 33 ++++ .../derived/composite-score-anatomy.tsx | 45 ++++-- .../insights/derived/delta-sparkline.tsx | 9 +- .../insights/derived/score-history.tsx | 91 +++++++++++ src/components/insights/derived/standards.ts | 4 - .../insights/derived/wellness-scores.tsx | 81 +++++++++- .../__tests__/derived-assessment.test.ts | 1 + .../__tests__/readiness-history.test.ts | 46 ++++++ .../derived/__tests__/sleep-score.test.ts | 32 ++++ src/lib/insights/derived/dispatch.ts | 19 ++- src/lib/insights/derived/readiness.ts | 45 +++++- src/lib/insights/derived/sleep-score.ts | 31 +++- 21 files changed, 591 insertions(+), 75 deletions(-) create mode 100644 src/components/insights/derived/__tests__/score-history-wiring.test.tsx create mode 100644 src/components/insights/derived/score-history.tsx create mode 100644 src/lib/insights/derived/__tests__/readiness-history.test.ts diff --git a/messages/de.json b/messages/de.json index 7ed78a685..dccc6e5f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "Faktoren", "contributorValue": "{value} · {weight}% Gewicht", "contributorMissing": "keine Daten", - "disclaimer": "Richtwert — keine klinische Bewertung.", "insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "Schlaf", "respiratory": "Atemfrequenz", "mood": "Stimmungsstabilität" - }, - "caveat": "Ein Wellness-Proxy, kein Erholungs- oder klinischer Wert." + } }, "VITALS_BASELINE": { "method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm — ein Wert im Band ist also typisch für dich." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen — nie spontan neu berechnet.", - "caveat": "Ein beschreibender Wellness-Proxy, kein trainings- oder klinikgerechtes Erholungsmaß.", "description": "Ein nächtlicher Erholungs-Proxy aus HRV, Ruhepuls und Schlaf." }, "STRESS_SCORE": { "method": "Ein nächtlicher 0–100-Stress-Proxy aus dem Verlauf deiner HRV über den Tag gegenüber deiner Baseline. Höher heißt mehr physiologische Belastung.", - "caveat": "Ein aus der HRV abgeleiteter Proxy, KEINE EDA-/Hautleitwert-Stressmessung — deine Uhr hat keinen EDA-Sensor. Beschreibend, nicht klinisch.", "description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV." }, "STRAIN_SCORE": { "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.", "anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.", "anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.", - "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "Erholungs-Score", "recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen", "stress": "Stress", - "strain": "Belastung" + "strain": "Belastung", + "historyTitle": "Verlauf", + "historyWindow": "Letzte {days} Tage" }, "coincident": { "label": "Mehrere Vitalwerte außerhalb der Baseline", diff --git a/messages/en.json b/messages/en.json index 62f080bed..154461567 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "Contributors", "contributorValue": "{value} · {weight}% weight", "contributorMissing": "no data", - "disclaimer": "Indicative — not a clinical assessment.", "insufficient": "Not enough data yet to score this. Keep tracking and it will appear here." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "Sleep", "respiratory": "Respiratory rate", "mood": "Mood stability" - }, - "caveat": "A wellness proxy, not a recovery or clinical score." + } }, "VITALS_BASELINE": { "method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm — so a value inside the band is typical for you." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here — never recomputed on the fly.", - "caveat": "A descriptive wellness proxy, not a training-grade or clinical recovery measure.", "description": "A nightly recovery proxy from your HRV, resting heart rate and sleep." }, "STRESS_SCORE": { "method": "A nightly 0–100 stress proxy from the shape of your intra-day HRV against your baseline. Higher means more physiological load.", - "caveat": "An HRV-derived proxy, NOT an EDA / galvanic-skin stress measurement — your watch has no EDA sensor. Descriptive, not clinical.", "description": "A nightly stress proxy from the shape of your intra-day HRV." }, "STRAIN_SCORE": { "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.", "anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.", "anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.", - "caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.", "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "Recovery score", "recoveryCrossLink": "See your wearable's recovery & strain data", "stress": "Stress", - "strain": "Strain" + "strain": "Strain", + "historyTitle": "History", + "historyWindow": "Last {days} days" }, "coincident": { "label": "Several vitals off baseline", diff --git a/messages/es.json b/messages/es.json index b0ba7065f..ce8949c7b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "Factores", "contributorValue": "{value} · {weight}% de peso", "contributorMissing": "sin datos", - "disclaimer": "Indicativo — no es una evaluación clínica.", "insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "Sueño", "respiratory": "Frecuencia respiratoria", "mood": "Estabilidad del ánimo" - }, - "caveat": "Un indicador de bienestar, no una puntuación de recuperación ni clínica." + } }, "VITALS_BASELINE": { "method": "Tu rango típico personal es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta mediana, escalada). Sigue TUS lecturas, no una norma poblacional, así que un valor dentro de la banda es típico para ti." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "Un indicador nocturno de recuperación de 0 a 100 que combina tu VFC, frecuencia cardíaca en reposo y sueño frente a tu línea base. Más alto significa mejor recuperado. Se calcula una vez por noche y aquí solo se lee, nunca se recalcula al vuelo.", - "caveat": "Un indicador de bienestar descriptivo, no una medida de recuperación clínica ni de nivel de entrenamiento.", "description": "Un indicador nocturno de recuperación a partir de tu VFC, frecuencia en reposo y sueño." }, "STRESS_SCORE": { "method": "Un indicador nocturno de estrés de 0 a 100 a partir de la forma de tu VFC a lo largo del día frente a tu línea base. Más alto significa más carga fisiológica.", - "caveat": "Un indicador derivado de la VFC, NO una medición de estrés por EDA / actividad electrodérmica: tu reloj no tiene sensor EDA. Descriptivo, no clínico.", "description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día." }, "STRAIN_SCORE": { "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.", "anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.", "anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.", - "caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.", "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "Puntuación de recuperación", "recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable", "stress": "Estrés", - "strain": "Esfuerzo" + "strain": "Esfuerzo", + "historyTitle": "Evolución", + "historyWindow": "Últimos {days} días" }, "coincident": { "label": "Varias constantes fuera de la base", diff --git a/messages/fr.json b/messages/fr.json index 6f8adf4a5..2d7beeb73 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "Facteurs", "contributorValue": "{value} · {weight}% du poids", "contributorMissing": "aucune donnée", - "disclaimer": "Indicatif — pas une évaluation clinique.", "insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "Sommeil", "respiratory": "Fréquence respiratoire", "mood": "Stabilité de l’humeur" - }, - "caveat": "Un indicateur de bien-être, pas un score de récupération ni clinique." + } }, "VITALS_BASELINE": { "method": "Votre plage typique personnelle est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS mesures, pas une norme de population : une valeur dans la bande est donc typique pour vous." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "Un indice de récupération nocturne de 0 à 100 combinant votre VFC, votre fréquence cardiaque au repos et votre sommeil par rapport à votre référence. Plus élevé signifie mieux récupéré. Calculé une fois par nuit et seulement relu ici, jamais recalculé à la volée.", - "caveat": "Un indicateur de bien-être descriptif, pas une mesure de récupération clinique ou de niveau d’entraînement.", "description": "Un indice de récupération nocturne à partir de votre VFC, fréquence au repos et sommeil." }, "STRESS_SCORE": { "method": "Un indice de stress nocturne de 0 à 100 à partir de la forme de votre VFC au cours de la journée par rapport à votre référence. Plus élevé signifie une charge physiologique plus forte.", - "caveat": "Un indicateur dérivé de la VFC, PAS une mesure de stress par EDA / activité électrodermale : votre montre n’a pas de capteur EDA. Descriptif, non clinique.", "description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée." }, "STRAIN_SCORE": { "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.", "anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.", "anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.", - "caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.", "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "Score de récupération", "recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable", "stress": "Stress", - "strain": "Effort" + "strain": "Effort", + "historyTitle": "Évolution", + "historyWindow": "{days} derniers jours" }, "coincident": { "label": "Plusieurs constantes hors référence", diff --git a/messages/it.json b/messages/it.json index a3fa178ae..824a5b12a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "Fattori", "contributorValue": "{value} · {weight}% di peso", "contributorMissing": "nessun dato", - "disclaimer": "Indicativo — non è una valutazione clinica.", "insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "Sonno", "respiratory": "Frequenza respiratoria", "mood": "Stabilità dell’umore" - }, - "caveat": "Un indicatore di benessere, non un punteggio di recupero né clinico." + } }, "VITALS_BASELINE": { "method": "Il tuo intervallo tipico personale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture, non una norma di popolazione: un valore dentro la banda è quindi tipico per te." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "Un indicatore notturno di recupero da 0 a 100 che combina VFC, frequenza cardiaca a riposo e sonno rispetto alla tua linea di base. Più alto significa più recuperato. Calcolato una volta a notte e qui solo riletto, mai ricalcolato al volo.", - "caveat": "Un indicatore di benessere descrittivo, non una misura di recupero clinica o da allenamento.", "description": "Un indicatore notturno di recupero da VFC, frequenza a riposo e sonno." }, "STRESS_SCORE": { "method": "Un indicatore notturno di stress da 0 a 100 dalla forma della tua VFC nel corso della giornata rispetto alla tua linea di base. Più alto significa maggiore carico fisiologico.", - "caveat": "Un indicatore derivato dalla VFC, NON una misura di stress da EDA / attività elettrodermica: il tuo orologio non ha un sensore EDA. Descrittivo, non clinico.", "description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata." }, "STRAIN_SCORE": { "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.", "anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.", "anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.", - "caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.", "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "Punteggio di recupero", "recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable", "stress": "Stress", - "strain": "Sforzo" + "strain": "Sforzo", + "historyTitle": "Andamento", + "historyWindow": "Ultimi {days} giorni" }, "coincident": { "label": "Più parametri fuori dalla base", diff --git a/messages/ko.json b/messages/ko.json index fa7c8f328..a8fb4aa52 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "구성 요소", "contributorValue": "{value} · 가중치 {weight}%", "contributorMissing": "데이터 없음", - "disclaimer": "참고용이에요 — 임상 평가가 아니에요.", "insufficient": "아직 점수를 낼 데이터가 부족해요. 계속 기록하면 여기에 표시돼요." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "수면", "respiratory": "호흡수", "mood": "기분 안정성" - }, - "caveat": "회복도나 임상 점수가 아니라 컨디션을 가늠하는 지표예요." + } }, "VITALS_BASELINE": { "method": "개인 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 인구 평균이 아니라 내 측정값을 따라가요 — 그래서 이 범위 안이면 나에게는 평소 수준이에요." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "심박변이도, 안정 시 심박수, 수면을 내 기준값과 비교해 섞은 밤마다의 0–100 회복 지표예요. 높을수록 더 잘 회복된 거예요. 매일 밤 한 번 계산해 두고 여기서 그대로 읽어와요.", - "caveat": "설명용 컨디션 지표예요. 훈련용이나 임상용 회복 측정값이 아니에요.", "description": "심박변이도, 안정 시 심박수, 수면으로 매일 밤 계산하는 회복 지표예요." }, "STRESS_SCORE": { "method": "하루 중 심박변이도의 흐름을 기준값과 비교해 매일 밤 내는 0–100 스트레스 지표예요. 높을수록 생리적 부담이 큰 거예요.", - "caveat": "심박변이도에서 뽑은 지표이고 EDA / 피부 전기 반응 측정이 아니에요 — 시계에 EDA 센서가 없어요. 설명용이고 임상용이 아니에요.", "description": "하루 중 심박변이도의 흐름으로 매일 밤 계산하는 스트레스 지표예요." }, "STRAIN_SCORE": { "method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.", "anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.", "anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.", - "caveat": "설명용 활동 부하 지표예요. 임상용이나 훈련용 부하 측정값이 아니에요.", "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "회복 점수", "recoveryCrossLink": "웨어러블의 회복·부하 데이터 보기", "stress": "스트레스", - "strain": "부하" + "strain": "부하", + "historyTitle": "추이", + "historyWindow": "최근 {days}일" }, "coincident": { "label": "여러 지표가 기준값 밖", diff --git a/messages/pl.json b/messages/pl.json index 8fc97f260..a4642ee23 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4282,7 +4282,6 @@ "contributorsLabel": "Czynniki", "contributorValue": "{value} · {weight}% wagi", "contributorMissing": "brak danych", - "disclaimer": "Orientacyjne — to nie ocena kliniczna.", "insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj." }, "composite": { @@ -4308,8 +4307,7 @@ "sleep": "Sen", "respiratory": "Częstość oddechu", "mood": "Stabilność nastroju" - }, - "caveat": "Wskaźnik dobrostanu, nie ocena regeneracji ani wynik kliniczny." + } }, "VITALS_BASELINE": { "method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną — wartość w obrębie pasma jest więc typowa dla Ciebie." @@ -4334,19 +4332,16 @@ }, "RECOVERY_SCORE": { "method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany — nigdy nieprzeliczany na bieżąco.", - "caveat": "Opisowy wskaźnik dobrostanu, nie kliniczna ani treningowa miara regeneracji.", "description": "Nocny wskaźnik regeneracji z Twojego HRV, tętna spoczynkowego i snu." }, "STRESS_SCORE": { "method": "Nocny wskaźnik stresu 0–100 na podstawie kształtu Twojego HRV w ciągu dnia względem linii bazowej. Wyższy oznacza większe obciążenie fizjologiczne.", - "caveat": "Wskaźnik pochodzący z HRV, a NIE pomiar stresu metodą EDA / reakcji skórno-galwanicznej — Twój zegarek nie ma czujnika EDA. Opisowy, nie kliniczny.", "description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia." }, "STRAIN_SCORE": { "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.", "anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.", "anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.", - "caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.", "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku." }, "WRIST_TEMPERATURE_BASELINE": { @@ -4382,7 +4377,9 @@ "recovery": "Wynik regeneracji", "recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia", "stress": "Stres", - "strain": "Obciążenie" + "strain": "Obciążenie", + "historyTitle": "Przebieg", + "historyWindow": "Ostatnie {days} dni" }, "coincident": { "label": "Kilka parametrów poza bazą", diff --git a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx index 910255c6a..928d734ab 100644 --- a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx +++ b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx @@ -42,6 +42,13 @@ vi.mock("../score-anatomy-view", () => ({ ScoreAnatomyView: () =>
, })); +// The score's course renders a live chart for the stored scores; this deck is +// about the assessment's hand-off, so the course is a probe here. +vi.mock("../score-history", () => ({ + ScoreHistoryChart: () =>
, + ScoreHistoryCard: () =>
, +})); + import { CompositeScoreAnatomy } from "../composite-score-anatomy"; import type { AnatomyMetricId } from "../composite-score-anatomy"; diff --git a/src/components/insights/derived/__tests__/score-history-wiring.test.tsx b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx new file mode 100644 index 000000000..6ccf806fe --- /dev/null +++ b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.42 — every score's detail page shows its course right under the score + * card, and the warning-coloured "descriptive proxy, not clinical" line no + * longer rides above the method. + * + * The stored nightly scores (recovery, stress, strain) get the full metric + * chart over their measurement type; the two composites draw the course their + * value carries, and nothing when it carries fewer than two points. The chart + * and the card are stubbed to probes that state what they were given. + */ + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +const derived = { current: {} as Record }; +vi.mock("../use-derived-metric", () => ({ + useDerivedMetric: () => derived.current, +})); + +vi.mock("@/components/insights/insight-status-card", () => ({ + InsightStatusCard: () =>
, +})); + +vi.mock("../score-anatomy-view", () => ({ + ScoreAnatomyView: ({ method }: { method: React.ReactNode }) => ( +
{method}
+ ), +})); + +vi.mock("../score-history", () => ({ + ScoreHistoryChart: ({ type }: { type: string }) => ( +
+ ), + ScoreHistoryCard: ({ + series, + windowDays, + }: { + series: number[]; + windowDays: number; + }) => ( +
+ ), +})); + +import { + CompositeScoreAnatomy, + type AnatomyMetricId, +} from "../composite-score-anatomy"; + +function render(metric: AnatomyMetricId, series?: number[]): string { + derived.current = { + isLoading: false, + isError: false, + data: { + status: "ok", + value: { + score: 70, + band: "yellow", + subScores: [], + components: [], + series, + }, + coverage: {}, + confidence: null, + provenance: { windowDays: 30 }, + assessment: null, + }, + }; + return renderToStaticMarkup(); +} + +describe("score detail history", () => { + it.each(["RECOVERY_SCORE", "STRESS_SCORE", "STRAIN_SCORE"] as const)( + "%s gets the full chart over its stored type", + (metric) => { + const html = render(metric, [60, 70]); + expect(html).toContain( + `data-slot="history-chart-probe" data-type="${metric}"`, + ); + expect(html).not.toContain("history-card-probe"); + // Under the score card, not somewhere else on the page. + expect(html.indexOf("history-chart-probe")).toBeGreaterThan( + html.indexOf("anatomy-probe"), + ); + }, + ); + + it.each(["SLEEP_SCORE", "READINESS"] as const)( + "%s draws the course its value carries, over its window", + (metric) => { + const html = render(metric, [55, 61, 70]); + expect(html).toContain('data-points="3"'); + expect(html).toContain('data-window="30"'); + expect(html).not.toContain("history-chart-probe"); + }, + ); + + it("an account without a score gets no empty chart under the insufficient card", () => { + derived.current = { + isLoading: false, + isError: false, + data: { + status: "insufficient", + value: null, + coverage: {}, + confidence: null, + provenance: { windowDays: 14 }, + assessment: null, + }, + }; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("anatomy-probe"); + expect(html).not.toContain("history-chart-probe"); + }); + + it("a composite with fewer than two points draws no course", () => { + expect(render("READINESS", [70])).not.toContain("history-card-probe"); + expect(render("READINESS")).not.toContain("history-card-probe"); + }); + + it("no score page carries the caveat line above its method", () => { + for (const metric of [ + "SLEEP_SCORE", + "READINESS", + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", + ] as const) { + const html = render(metric, [60, 70]); + expect(html).toContain(`insights.derived.composite.${metric}.method`); + expect(html).not.toContain(".caveat"); + expect(html).not.toContain("text-warning"); + } + }); +}); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx index e96cae1f5..1b55ca633 100644 --- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -135,4 +135,37 @@ describe("", () => { expect(hidden).not.toContain('data-metric="STRAIN_SCORE"'); expect(hidden).toContain('data-metric="READINESS"'); }); + + it("draws each tile's course under its band word, naming the window", () => { + const html = render( + , + ); + const recovery = html.slice( + html.indexOf('data-metric="RECOVERY_SCORE"'), + html.indexOf('data-metric="STRESS_SCORE"'), + ); + expect(recovery).toContain('data-slot="wellness-score-history"'); + expect(recovery).toContain("Last 30 days"); + // A score without a course draws no empty line. + const stress = html.slice(html.indexOf('data-metric="STRESS_SCORE"')); + expect(stress).not.toContain('data-slot="wellness-score-history"'); + }); + + it("reserves the history band in the loading skeleton", () => { + const html = render(); + const tile = html.slice( + html.indexOf('data-slot="wellness-score-tile-skeleton"'), + ); + expect(tile).toContain("h-10 w-full"); + }); }); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 43f35a545..7f6bee80d 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -19,12 +19,13 @@ import { import type { RingHue } from "./ring-hues"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { METRIC_PROVENANCE } from "./standards"; +import { ScoreHistoryCard, ScoreHistoryChart } from "./score-history"; /** * v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived * score and renders the reusable `ScoreAnatomyView`. It owns the per-metric * presentation map (title, contributor labels, the cited standard, the - * plain-language method + honesty caveat) so the anatomy view itself stays + * plain-language method) so the anatomy view itself stays * metric-agnostic. * * Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` — @@ -32,12 +33,10 @@ import { METRIC_PROVENANCE } from "./standards"; * three persisted nightly scores (`RECOVERY_SCORE`, `STRESS_SCORE`, * `STRAIN_SCORE`). The persisted scores carry no sub-decomposition, so they * render the ring + coverage + the provenance surface (method + cited - * standard + caveat) with no contributor rows — the acceptance-criterion fix - * that gives every wellness ring a provenance surface instead of a read-only - * dead-end. STRESS surfaces its "HRV-derived proxy, not an EDA/galvanic - * measurement" caveat via the standards map. + * standard) with no contributor rows — the acceptance-criterion fix that gives + * every wellness ring a provenance surface instead of a read-only dead-end. * - * The standard + method/caveat keys come from the single `METRIC_PROVENANCE` + * The standard + method keys come from the single `METRIC_PROVENANCE` * source map so the citation a metric exposes never drifts across surfaces. * * Client-only: `import type` for the value shapes (no server graph leaks); @@ -147,16 +146,12 @@ export function CompositeScoreAnatomy({ } } - // Method copy carries an optional honesty caveat above it (STRESS proxy, - // descriptive-not-clinical, …) so the caveat reaches the user, not just - // the engine header. + // v1.42 — the per-score "descriptive proxy, not clinical" line that sat + // above the method in warning colour is gone: it repeated on every score + // page what the method and the page explainer already say, and read as an + // alarm. The method copy closes the card on its own. const method = ( <> - {meta.caveatKey ? ( - - {t(meta.caveatKey)} - - ) : null} {t(meta.methodKey)} {anchorLine ? {anchorLine} : null} @@ -268,6 +263,27 @@ export function CompositeScoreAnatomy({ // the card always renders its prose rather than the no-provider fallback. const assessment = data.assessment; + // v1.42 — the score's course, right under its card. A stored nightly score + // gets the full chart; a composite draws the course its value carries. + const series = + data.status === "ok" && data.value + ? (data.value as { series?: number[] }).series + : undefined; + // Only beside a score: an account without one gets the honest + // insufficient card, not an empty chart under it. + const history = + data.status !== "ok" ? null : metric === "RECOVERY_SCORE" || + metric === "STRESS_SCORE" || + metric === "STRAIN_SCORE" ? ( + + ) : series && series.length >= 2 ? ( + + ) : null; + return (
+ {history} {assessment ? ( - + {/* The comparison curve sits UNDER the live one so today's line is never occluded by the reference it is being judged against. */} {hasComparison ? ( diff --git a/src/components/insights/derived/score-history.tsx b/src/components/insights/derived/score-history.tsx new file mode 100644 index 000000000..59744484d --- /dev/null +++ b/src/components/insights/derived/score-history.tsx @@ -0,0 +1,91 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { History } from "lucide-react"; + +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { RING_GRADIENT, type RingHue } from "./ring-hues"; + +/** + * v1.42 — a score's course over time on its detail page, directly under the + * score card. + * + * The three nightly scores (recovery, stress, strain) are stored readings, so + * they get the full metric chart every measured page carries: the 7 / 30 / 90 + * / All switch, the tooltip, and each day opening its day view. The two + * composites (sleep score, readiness) are computed rather than stored, so the + * course they carry on their value is drawn as a compact line over its window. + */ + +const DeltaSparkline = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.DeltaSparkline, + })), + { ssr: false, loading: () => null }, +); + +export function ScoreHistoryChart({ + type, + hue, +}: { + /** The stored measurement type the score is written as. */ + type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE"; + hue: RingHue; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + return ( +
+ +
+ ); +} + +export function ScoreHistoryCard({ + series, + windowDays, + hue, +}: { + series: number[]; + windowDays: number; + hue: RingHue; +}) { + const { t } = useTranslations(); + return ( + + + + {t("insights.derived.scores.historyWindow", { days: windowDays })} + + } + /> + + + + + + ); +} diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index 39d1e3c12..a41a04394 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -111,7 +111,6 @@ export const METRIC_PROVENANCE: Record = name: "Plews et al. 2013, Sports Medicine", url: "https://doi.org/10.1007/s40279-013-0071-8", }, - caveatKey: "insights.derived.composite.READINESS.caveat", }, RECOVERY_SCORE: { methodKey: "insights.derived.composite.RECOVERY_SCORE.method", @@ -120,7 +119,6 @@ export const METRIC_PROVENANCE: Record = name: "Buchheit 2014, Front. Physiol.", url: "https://doi.org/10.3389/fphys.2014.00073", }, - caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat", }, STRESS_SCORE: { methodKey: "insights.derived.composite.STRESS_SCORE.method", @@ -129,7 +127,6 @@ export const METRIC_PROVENANCE: Record = name: "Kim et al. 2018, Psychiatry Investig.", url: "https://doi.org/10.30773/pi.2017.08.17", }, - caveatKey: "insights.derived.composite.STRESS_SCORE.caveat", }, STRAIN_SCORE: { methodKey: "insights.derived.composite.STRAIN_SCORE.method", @@ -138,7 +135,6 @@ export const METRIC_PROVENANCE: Record = name: "Banister 1991, TRIMP model", url: "https://en.wikipedia.org/wiki/Training_Impulse", }, - caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat", }, WRIST_TEMPERATURE_BASELINE: { methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method", diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 29759f462..2ed39c544 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, type ComponentType } from "react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { Activity, @@ -16,7 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { SectionHeading } from "@/components/ui/section-heading"; import { ScoreRing } from "./score-ring"; -import { TILE_HUE, type RingHue } from "./ring-hues"; +import { RING_GRADIENT, TILE_HUE, type RingHue } from "./ring-hues"; import type { DerivedBatchRead } from "./use-derived-metric"; import type { ScoreBand } from "./band-tokens"; // Type-only — the compute payloads never drag the server graph into the bundle. @@ -42,6 +43,17 @@ import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores" * rollup/persisted values; nothing warms on visit. */ +// The tile's history line loads through the shared chart-runtime boundary, so +// the strip carries no recharts in its own chunk. The tile owns the fixed +// 40 px band, so the async gap paints an empty band of the same size. +const DeltaSparkline = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.DeltaSparkline, + })), + { ssr: false, loading: () => null }, +); + interface ScoreStripProps { /** The batched-read selector from the parent dashboard's one query. */ read: DerivedBatchRead; @@ -97,7 +109,14 @@ function RingTile({ index, animate, baseline, + history, }: { + /** + * v1.42 — the score's course (oldest → newest) and the window it covers, + * drawn under the band word so each tile shows where the number came from. + * Fewer than two points draws nothing. + */ + history?: { series: number[]; windowDays: number } | null; score: number; band: ScoreBand; label: string; @@ -165,10 +184,60 @@ function RingTile({ {bandWord}
+ {history && history.series.length >= 2 ? ( + + ) : null} ); } +/** + * The tile's history: a fixed 40 px line of the score over its window, with + * the window named beneath it. Decorative to assistive tech — the tile's link + * leads to the detail page, where the full course is readable. + */ +function ScoreHistoryLine({ + series, + windowDays, + strokeVar, +}: { + series: number[]; + windowDays: number; + strokeVar: string; +}) { + const { t } = useTranslations(); + return ( +
+ +

+ {t("insights.derived.scores.historyWindow", { days: windowDays })} +

+
+ ); +} + +/** The course a score value carries, if any, paired with its window. */ +function historyOf( + read: { + value: { series?: number[] } | null; + provenance: { windowDays: number }; + } | null, +): { series: number[]; windowDays: number } | null { + const series = read?.value?.series; + if (!read || !series || series.length < 2) return null; + return { series, windowDays: read.provenance.windowDays }; +} + /** * Loading placeholder mirroring `RingTile`'s footprint (icon+label row, the * `size="sm"` 120 px ring, band word) so the strip reserves its final height @@ -191,6 +260,11 @@ function WellnessScoreTileSkeleton() {
+ {/* The history line's band, so a tile that gains one does not grow. */} +
+ + +
); } @@ -322,6 +396,7 @@ export function WellnessScores({ metricSlot="READINESS" hue="readiness" icon={Gauge} + history={historyOf(readiness)} index={tiles.length} animate={play} baseline={null} @@ -340,6 +415,7 @@ export function WellnessScores({ metricSlot="RECOVERY_SCORE" hue="recovery" icon={HeartPulse} + history={historyOf(recovery)} index={tiles.length} animate={play} baseline={baselineOf(recovery.value.series)} @@ -358,6 +434,7 @@ export function WellnessScores({ metricSlot="SLEEP_SCORE" hue="sleep" icon={Moon} + history={historyOf(sleep)} index={tiles.length} animate={play} baseline={null} @@ -376,6 +453,7 @@ export function WellnessScores({ metricSlot="STRESS_SCORE" hue="stress" icon={Activity} + history={historyOf(stress)} index={tiles.length} animate={play} baseline={baselineOf(stress.value.series)} @@ -397,6 +475,7 @@ export function WellnessScores({ metricSlot="STRAIN_SCORE" hue="strain" icon={Flame} + history={historyOf(strain)} index={tiles.length} animate={play} baseline={baselineOf(strain.value.series)} diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts index e1df67c5a..2aba3a4aa 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts @@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = { { key: "composition", value: 88, weight: 0.15 }, ], windowNights: 20, + series: [78, 82], }; const recovery: WellnessScoreValue = { diff --git a/src/lib/insights/derived/__tests__/readiness-history.test.ts b/src/lib/insights/derived/__tests__/readiness-history.test.ts new file mode 100644 index 000000000..d8f6cc27f --- /dev/null +++ b/src/lib/insights/derived/__tests__/readiness-history.test.ts @@ -0,0 +1,46 @@ +/** + * The readiness course is read, never recomputed: the nightly job persists + * this blend as the COMPUTED recovery row, and a device's own recovery number + * (WHOOP, Oura, Polar) is not this blend, so it never enters the series. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany } }, +})); + +import { readReadinessHistory } from "../readiness"; + +const NOW = new Date("2026-06-02T07:00:00Z"); + +beforeEach(() => findMany.mockReset()); + +describe("readReadinessHistory", () => { + it("reads only the persisted blend inside the window, oldest first", async () => { + // Newest first, as the query orders them. + findMany.mockResolvedValue([ + { value: 71.6 }, + { value: 64.2 }, + { value: 80 }, + ]); + + const series = await readReadinessHistory("u1", NOW, 30); + + expect(series).toEqual([80, 64, 72]); + const where = findMany.mock.calls[0][0].where; + expect(where).toMatchObject({ + userId: "u1", + type: "RECOVERY_SCORE", + source: "COMPUTED", + deletedAt: null, + }); + expect(where.measuredAt.lte).toEqual(NOW); + expect(where.measuredAt.gte).toEqual(new Date("2026-05-03T07:00:00Z")); + }); + + it("answers an empty course when the job never ran", async () => { + findMany.mockResolvedValue([]); + expect(await readReadinessHistory("u1", NOW, 30)).toEqual([]); + }); +}); diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index 9fb3da3aa..c464868a3 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -326,6 +326,38 @@ describe("computeSleepScore", () => { } }); + it("carries the score of every night in the window, ending on the headline", async () => { + const rows = [ + ...night("2026-05-31", [ + ["IN_BED", 480, "06:00"], + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ]), + // A short night, so the course is not flat. + ...night("2026-06-01", [ + ["IN_BED", 300, "06:10"], + ["REM", 40, "02:10"], + ["CORE", 200, "03:10"], + ["DEEP", 20, "04:10"], + ]), + ...night("2026-06-02", [ + ["IN_BED", 480, "06:05"], + ["REM", 95, "02:05"], + ["CORE", 235, "03:05"], + ["DEEP", 65, "04:05"], + ]), + ]; + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.series).toHaveLength(3); + expect(result.value.series.at(-1)).toBe(result.value.score); + expect(result.value.series[1]).toBeLessThan(result.value.series[0]); + } + }); + it("reweights around composition on a legacy ASLEEP-only night", async () => { const rows = [ ...night("2026-05-31", [["ASLEEP", 420, "06:00"]]), diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index 7487914f6..f91de1a29 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -27,7 +27,7 @@ import { computeVascularAgeDelta } from "./vascular-age"; import { computeHrvBalance } from "./hrv-balance"; import { computeBmi } from "./bmi"; import { computeSleepScore } from "./sleep-score"; -import { computeReadiness } from "./readiness"; +import { computeReadiness, readReadinessHistory } from "./readiness"; import { computeCoincidentDeviation } from "./coincident-deviation"; import { computeTrajectory } from "./trajectory"; import { computeSameTimeBaseline } from "./same-time-baseline"; @@ -141,11 +141,22 @@ export async function computeDerivedMetric( windowDays: args.windowDays, now, }) as Promise>; - case "READINESS": - return computeReadiness(args.userId, args.profile, { + case "READINESS": { + const readiness = await computeReadiness(args.userId, args.profile, { windowDays: args.windowDays, now, - }) as Promise>; + }); + // The blend's course rides beside today's score for the surfaces that + // draw it; the nightly job's own compute never pays for the read. + if (readiness.status === "ok" && readiness.value) { + readiness.value.series = await readReadinessHistory( + args.userId, + now, + readiness.provenance.windowDays, + ); + } + return readiness as Derived; + } case "COINCIDENT_DEVIATION": return computeCoincidentDeviation(args.userId, args.profile, { windowDays: args.windowDays, diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts index 87e1c5603..37003c5d4 100644 --- a/src/lib/insights/derived/readiness.ts +++ b/src/lib/insights/derived/readiness.ts @@ -50,7 +50,11 @@ import { } from "./coverage"; import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; import { computeSleepScore } from "./sleep-score"; -import type { Derived, DerivedProvenanceSource } from "./types"; +import { + SPARKLINE_MAX_POINTS, + type Derived, + type DerivedProvenanceSource, +} from "./types"; const DEFAULT_WINDOW_DAYS = 30; /** @@ -89,6 +93,45 @@ export interface ReadinessValue { score: number; band: "green" | "yellow" | "red"; components: ReadinessComponent[]; + /** + * v1.42 — the blend's own course over the window (oldest → newest, capped + * to `SPARKLINE_MAX_POINTS`), read from what the nightly job persisted; see + * `readReadinessHistory`. Attached by the derived dispatch for the surfaces + * that show it, absent on the job's own compute. Additive. + */ + series?: number[]; +} + +/** + * The readiness blend's history, without recomputing a single day. + * + * The nightly recovery job persists exactly this blend — `computeRecoveryScore` + * delegates to `computeReadiness` verbatim — as the `COMPUTED`-source + * `RECOVERY_SCORE` row. Those rows ARE the readiness score of each past night. + * A device-native recovery row (WHOOP, Oura, Polar) shares the type but is the + * vendor's number, not this blend, so the read is held to `COMPUTED`. + */ +export async function readReadinessHistory( + userId: string, + now: Date, + windowDays: number, +): Promise { + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "RECOVERY_SCORE" satisfies MeasurementType, + source: "COMPUTED", + deletedAt: null, + measuredAt: { + gte: new Date(now.getTime() - windowDays * MS_PER_DAY), + lte: now, + }, + }, + select: { value: true }, + orderBy: { measuredAt: "desc" }, + take: SPARKLINE_MAX_POINTS, + }); + return rows.map((row) => Math.round(row.value)).reverse(); } // ── pure deviation scorers (exported for tests) ──────────────────────── diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index cc34c711c..78a5d3799 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -52,7 +52,7 @@ import { nowProvenanceTimestamp, } from "./coverage"; import type { BaselineProfile } from "./baseline"; -import type { Derived } from "./types"; +import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Default trailing window for the consistency/timing baseline (days). */ @@ -93,6 +93,14 @@ export interface SleepScoreValue { subScores: SleepSubScore[]; /** Nights in the window that backed the consistency/timing baseline. */ windowNights: number; + /** + * v1.42 — the score of every scorable night in the window (oldest → newest, + * capped to `SPARKLINE_MAX_POINTS`), so a surface can show the score's + * course rather than one night. Each night is scored by the same blend as + * the headline, against the same window's habitual midpoint, so the last + * point IS `score`. Additive; older clients ignore it. + */ + series: number[]; } // ── pure scorers (exported for tests) ───────────────────────────────── @@ -530,6 +538,26 @@ export async function computeSleepScore( }; const { score, subScores } = blendSleepSubScores(raw); + + // The same blend over every scorable night in the window. Consistency is a + // window-level figure and timing reads against the window's habitual + // midpoint, so every night is judged by the yardstick the headline uses. + const consistency = scoreConsistency(midpoints); + const series = scorableNights.slice(-SPARKLINE_MAX_POINTS).map( + (night) => + blendSleepSubScores({ + sufficiency: scoreSufficiency(night.asleepMinutes, needMinutes), + efficiency: scoreEfficiency(night.asleepMinutes, night.inBedMinutes), + consistency, + timing: scoreTiming(night.midpoint, habitualMidpoint, midpoints.length), + composition: scoreComposition( + night.remMinutes, + night.deepMinutes, + night.asleepMinutes, + night.hasStageBreakdown, + ), + }).score, + ); const presentCount = subScores.filter((s) => s.value !== null).length; const missing = subScores.filter((s) => s.value === null).map((s) => s.key); @@ -557,6 +585,7 @@ export async function computeSleepScore( latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes), subScores, windowNights: scorableNights.length, + series, }, coverage, confidence, From 41ed8eb6a302707a2a32bf161ed0b64b5eceef07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:45:49 +0200 Subject: [PATCH 3/4] feat(insights): mood calendar and chart arrive together; add via the header plus On /insights/mood the line chart used to paint first and the calendar, whose aggregate is the slower read, pushed it down when it landed (CLS 0.16 at 390 px on a cold load). The page now holds both on final-height skeletons until the chart's read has settled and the calendar's has landed, then shows them in the same frame. The calendar's height is written in container-query units, so its box is the right size from the first paint, in the skeleton and the painted grid alike. A server wrapper prefetches both reads in parallel (time-boxed, skipped under a record switch, off when SSR prefetch is off), so on a warm cache they are in the first HTML. The route and the prefetch share `moodInsightsWire`, so the seeded cell is the route's shape. The "Log a mood entry" and "Take a mental-wellbeing check-in" text links under the heading are gone. Logging is the header plus, which opens the mood form in place; the check-in keeps its own place in the navigation. The plus is one shared component, `SubPageAddButton`, now also behind the measurement pages' capture link and the workouts page, which drew its own. An audit of every Insights page found no other text add-button of this kind; the remaining buttons are empty-state calls to action. A sweep keeps it that way. `e2e/v142-qol.spec.ts` covers all three surfaces of this change set. --- e2e/v142-qol.spec.ts | 382 ++++++++++++++++++ messages/de.json | 1 - messages/en.json | 1 - messages/es.json | 1 - messages/fr.json | 1 - messages/it.json | 1 - messages/ko.json | 1 - messages/pl.json | 1 - .../prefetch-page-caching-guard.test.ts | 1 + src/__tests__/session-surface-guard.test.ts | 2 + src/app/api/mood/insights/route.ts | 15 +- .../__tests__/assessment-spine-order.test.ts | 10 +- .../__tests__/header-add-pattern.test.ts | 60 +++ src/app/insights/mood/page-client.tsx | 232 +++++++++++ src/app/insights/mood/page.tsx | 279 ++++++------- src/app/insights/workouts/page-client.tsx | 28 +- src/components/charts/mood-heatmap.tsx | 303 +++++++++----- .../mood-insights-sections-reveal.test.tsx | 81 ++++ .../insights/mood/mood-insights-sections.tsx | 57 ++- src/components/insights/sub-page-shell.tsx | 78 +++- src/lib/insights/mood-aggregates.ts | 27 ++ 21 files changed, 1223 insertions(+), 339 deletions(-) create mode 100644 e2e/v142-qol.spec.ts create mode 100644 src/app/insights/__tests__/header-add-pattern.test.ts create mode 100644 src/app/insights/mood/page-client.tsx create mode 100644 src/components/insights/mood/__tests__/mood-insights-sections-reveal.test.tsx diff --git a/e2e/v142-qol.spec.ts b/e2e/v142-qol.spec.ts new file mode 100644 index 000000000..7e1fadd82 --- /dev/null +++ b/e2e/v142-qol.spec.ts @@ -0,0 +1,382 @@ +/** + * v1.42 — three surfaces made easier to live with. + * + * - Documents: the display choice behind the wrench (cards or list, months + * stacked or flowing) persists per account; every delete asks first; the + * selection is cleared with an X on the count line. + * - Wellness scores: every score tile shows its course; a score's detail + * page carries its history right under the score card and no longer the + * warning-coloured "not clinical" line. + * - Mood insights: the calendar and the line chart appear together without + * moving the page, and logging is the header plus every Insights page + * uses. + * + * Everything is addressed through stable data attributes, never copy. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; + +test.use({ storageState: STORAGE_STATE_PATH }); + +async function resetDocumentsLayout(page: Page): Promise { + const res = await page.request.put("/api/documents/inbound/layout", { + data: { version: 1, view: "cards", arrangement: "stacked" }, + }); + expect(res.status()).toBe(200); +} + +test.describe("documents", () => { + test.beforeAll(async () => { + await ensureVaultFixture(); + }); + + test("the display choice behind the wrench persists across visits", async ({ + page, + isMobile, + }) => { + // Both projects share one account; the layout is account state, so one + // project owns this journey. + test.skip(isMobile, "account-level preference; one project owns it"); + await resetDocumentsLayout(page); + try { + await page.goto("/documents"); + const timeline = page.locator('[data-slot="document-timeline"]'); + await expect(timeline).toHaveAttribute("data-view", "cards"); + await expect(timeline).toHaveAttribute("data-arrangement", "stacked"); + + await page.locator('[data-slot="documents-customize"]').click(); + await expect(page).toHaveURL(/\/settings\/layout\/documents$/); + const settings = page.locator('[data-slot="documents-layout-settings"]'); + await settings.locator('[data-slot="module-view-list"]').click(); + await expect( + settings.locator('[data-slot="module-view-list"]'), + ).toHaveAttribute("aria-pressed", "true"); + const flow = settings.locator('[data-slot="documents-arrangement-flow"]'); + // Wait for the write to land, so the reload below reads it back. + const saved = page.waitForResponse( + (r) => + r.url().includes("/api/documents/inbound/layout") && + r.request().method() === "PUT", + ); + await flow.click(); + expect((await saved).status()).toBe(200); + + await page.goto("/documents"); + await expect(timeline).toHaveAttribute("data-view", "list"); + await expect(timeline).toHaveAttribute("data-arrangement", "flow"); + await expect( + timeline + .locator('[data-slot="document-card"][data-variant="row"]') + .first(), + ).toBeVisible(); + // Flowing: no month gets a row of its own, the name rides inline. + await expect( + timeline.locator('[data-slot="document-flow-month"]').first(), + ).toBeVisible(); + + // A fresh visit in a fresh page — the choice is the account's, not + // this tab's. + const second = await page.context().newPage(); + await second.goto("/documents"); + await expect( + second.locator('[data-slot="document-timeline"]'), + ).toHaveAttribute("data-arrangement", "flow"); + await second.close(); + } finally { + await resetDocumentsLayout(page); + } + }); + + test("bulk delete asks first, and cancelling deletes nothing", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "selection by checkbox is a pointer flow"); + await seedNamespaceDocs("qoldel", 2); + await page.goto("/documents?q=qoldel"); + const box = page.getByRole("checkbox", { name: "Select qoldel 002" }); + await expect(box).toBeVisible(); + await box.click(); + + let deletes = 0; + page.on("request", (req) => { + if ( + req.method() === "POST" && + req.url().includes("/api/documents/inbound/bulk") && + (req.postData() ?? "").includes('"delete"') + ) { + deletes += 1; + } + }); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await bar.locator('[data-slot="document-bulk-delete"]').click(); + const confirm = page.locator('[data-slot="documents-delete-confirm"]'); + await expect(confirm).toBeVisible(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Cancel" }) + .click(); + await expect(confirm).toBeHidden(); + expect(deletes).toBe(0); + await expect( + page.getByRole("button", { name: "Open qoldel 002" }), + ).toBeVisible(); + + await bar.locator('[data-slot="document-bulk-delete"]').click(); + await confirm.click(); + await expect( + page.getByRole("button", { name: "Open qoldel 002" }), + ).toBeHidden(); + expect(deletes).toBe(1); + }); + + test("the Delete key on a focused card asks first too", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "keyboard flow"); + await seedNamespaceDocs("qolkey", 1); + await page.goto("/documents?q=qolkey"); + const open = page.getByRole("button", { name: "Open qolkey 001" }); + await expect(open).toBeVisible(); + await open.focus(); + await page.keyboard.press("Delete"); + await expect( + page.locator('[data-slot="documents-delete-confirm"]'), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(open).toBeVisible(); + }); + + test("the selection clears with the X on the count line", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "selection by checkbox is a pointer flow"); + await seedNamespaceDocs("qolclear", 2); + await page.goto("/documents?q=qolclear"); + await page.getByRole("checkbox", { name: "Select qolclear 001" }).click(); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await expect(bar).toBeVisible(); + const count = bar.locator('[data-slot="document-bulk-count"]'); + const clear = bar.locator('[data-slot="document-bulk-clear"]'); + const actions = bar.locator('[data-slot="document-bulk-actions"]'); + + // The X sits on the count line, at its right edge, above the action row, + // and the action row follows the count line without a gap of its own. + const [countBox, clearBox, actionsBox, barBox] = await Promise.all([ + count.boundingBox(), + clear.boundingBox(), + actions.boundingBox(), + bar.boundingBox(), + ]); + expect(countBox && clearBox && actionsBox && barBox).toBeTruthy(); + const mid = (b: { y: number; height: number }) => b.y + b.height / 2; + expect(Math.abs(mid(clearBox!) - mid(countBox!))).toBeLessThan(6); + expect( + barBox!.x + barBox!.width - (clearBox!.x + clearBox!.width), + ).toBeLessThan(20); + expect( + actionsBox!.y - (clearBox!.y + clearBox!.height), + ).toBeLessThanOrEqual(10); + // One action row: every verb shares a top edge. + const tops = await actions + .locator("button") + .evaluateAll((els) => + els.map((el) => Math.round(el.getBoundingClientRect().top)), + ); + expect(new Set(tops).size).toBe(1); + + await clear.click(); + await expect(bar).toBeHidden(); + }); +}); + +test.describe("wellness scores", () => { + test("every score tile shows its course", async ({ page }) => { + await mockPopulatedInsights(page); + await page.goto("/insights"); + for (const metric of ["RECOVERY_SCORE", "STRESS_SCORE"]) { + const tile = page.locator( + `[data-slot="wellness-score-tile"][data-metric="${metric}"]`, + ); + await tile.scrollIntoViewIfNeeded(); + await expect( + tile.locator('[data-slot="wellness-score-history"]'), + ).toBeVisible(); + } + }); + + test("a score's detail page carries its history under the card, without the caveat line", async ({ + page, + }) => { + // The e2e account has no nightly score; serve one so the page has a + // score to draw the history under. + await page.route( + /\/api\/insights\/derived\?metric=RECOVERY_SCORE/, + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + metric: "RECOVERY_SCORE", + status: "ok", + value: { + score: 66, + band: "yellow", + trendDelta: 2, + daysInWindow: 5, + asOf: new Date().toISOString(), + series: [58, 61, 64, 60, 66], + anchor: null, + components: null, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 5, + missing: [], + }, + confidence: { score: 80, band: "high" }, + provenance: { + inputs: ["RECOVERY_SCORE"], + source: "live", + windowDays: 14, + computedAt: new Date().toISOString(), + }, + reason: null, + assessment: null, + }, + error: null, + }), + }), + ); + await page.goto("/insights/scores/recovery"); + const anatomy = page.locator('[data-slot="score-anatomy-view"]'); + await expect(anatomy).toBeVisible(); + const history = page.locator('[data-slot="score-history-chart"]'); + await expect(history).toBeAttached(); + const [cardBox, historyBox] = await Promise.all([ + anatomy.boundingBox(), + history.boundingBox(), + ]); + expect(historyBox!.y).toBeGreaterThan(cardBox!.y + cardBox!.height - 1); + await expect(anatomy.locator(".text-warning")).toHaveCount(0); + }); +}); + +test.describe("mood insights", () => { + test.beforeAll(async ({ browser }) => { + // A short, real history, so the calendar and the chart both have days. + const context = await browser.newContext({ + storageState: STORAGE_STATE_PATH, + }); + const now = Date.now(); + for (let day = 1; day <= 12; day++) { + const res = await context.request.post("/api/mood-entries", { + data: { + mood: day % 3 === 0 ? "OKAY" : "GUT", + moodLoggedAt: new Date(now - day * 86_400_000).toISOString(), + source: "MANUAL", + externalId: `e2e-qol-mood-${day}`, + }, + }); + expect([200, 201]).toContain(res.status()); + } + await context.close(); + }); + + test("the calendar and the chart appear together without moving the page", async ({ + page, + }) => { + // The calendar's read is the slower one in production; slow it down here + // so the old order (chart first, calendar pushing it down) would show. + await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_200)); + await route.continue(); + }); + await page.addInitScript(() => { + const w = window as unknown as { + __cls: number; + __seen: Record; + }; + w.__cls = 0; + w.__seen = {}; + new PerformanceObserver((list) => { + for (const entry of list.getEntries() as Array< + PerformanceEntry & { value: number; hadRecentInput: boolean } + >) { + if (!entry.hadRecentInput) w.__cls += entry.value; + } + }).observe({ type: "layout-shift", buffered: true }); + const watch = () => { + if ( + !w.__seen.heatmap && + document.querySelector('[data-slot="mood-heatmap-day-list"]') + ) + w.__seen.heatmap = performance.now(); + if ( + !w.__seen.chart && + document.querySelector( + '[data-slot="insights-mood-chart"] .recharts-surface', + ) && + !document.querySelector( + '[data-slot="insights-mood-chart"] .invisible', + ) + ) + w.__seen.chart = performance.now(); + requestAnimationFrame(watch); + }; + requestAnimationFrame(watch); + }); + + await page.goto("/insights/mood"); + await expect( + page.locator('[data-slot="mood-heatmap-day-list"]'), + ).toBeAttached({ + timeout: 15_000, + }); + await expect( + page + .locator('[data-slot="insights-mood-chart"] .recharts-surface') + .first(), + ).toBeVisible(); + await page.waitForTimeout(1_500); + + const { cls, seen } = await page.evaluate(() => { + const w = window as unknown as { + __cls: number; + __seen: Record; + }; + return { cls: w.__cls, seen: w.__seen }; + }); + expect(cls).toBeLessThan(0.02); + expect(seen.heatmap).toBeDefined(); + expect(seen.chart).toBeDefined(); + // Together: within a couple of frames of each other. + expect(Math.abs(seen.heatmap - seen.chart)).toBeLessThan(100); + }); + + test("logging is the header plus, not a text link", async ({ page }) => { + await page.goto("/insights/mood"); + const add = page.locator( + '[data-slot="insights-mood-add"][data-header-add]', + ); + await expect(add).toBeVisible(); + await expect( + page.locator('[data-slot="insights-mood-log-link"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="insights-mood-mental-wellbeing-link"]'), + ).toHaveCount(0); + await add.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + }); +}); diff --git a/messages/de.json b/messages/de.json index dccc6e5f9..7774c2681 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Gehgeschwindigkeit", "mood": { "logLink": "Stimmung eintragen", - "mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen", "heatmapTitle": "Stimmungskalender", "heatmapNoEntry": "Kein Eintrag", "legendGreat": "Super", diff --git a/messages/en.json b/messages/en.json index 154461567..5c2bb9d38 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Walking Speed", "mood": { "logLink": "Log a mood entry", - "mentalWellbeingLink": "Take a mental-wellbeing check-in", "heatmapTitle": "Mood calendar", "heatmapNoEntry": "No entry", "legendGreat": "Great", diff --git a/messages/es.json b/messages/es.json index ce8949c7b..71dbfbd84 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Velocidad al andar", "mood": { "logLink": "Registrar un estado de ánimo", - "mentalWellbeingLink": "Hacer un chequeo de bienestar mental", "heatmapTitle": "Calendario de ánimo", "heatmapNoEntry": "Sin registro", "legendGreat": "Genial", diff --git a/messages/fr.json b/messages/fr.json index 2d7beeb73..ebef8a983 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Vitesse de marche", "mood": { "logLink": "Enregistrer une humeur", - "mentalWellbeingLink": "Faire un bilan de bien-être mental", "heatmapTitle": "Calendrier d'humeur", "heatmapNoEntry": "Aucune entrée", "legendGreat": "Excellent", diff --git a/messages/it.json b/messages/it.json index 824a5b12a..c68fa9b1d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Velocità di cammino", "mood": { "logLink": "Registra un umore", - "mentalWellbeingLink": "Fai un check-in sul benessere mentale", "heatmapTitle": "Calendario dell'umore", "heatmapNoEntry": "Nessun dato", "legendGreat": "Ottimo", diff --git a/messages/ko.json b/messages/ko.json index a8fb4aa52..01c4bc613 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "보행 속도", "mood": { "logLink": "기분 기록하기", - "mentalWellbeingLink": "정신 건강 체크인 하기", "heatmapTitle": "기분 캘린더", "heatmapNoEntry": "기록 없음", "legendGreat": "아주 좋음", diff --git a/messages/pl.json b/messages/pl.json index a4642ee23..10f0c1b44 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Prędkość chodu", "mood": { "logLink": "Zapisz nastrój", - "mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego", "heatmapTitle": "Kalendarz nastroju", "heatmapNoEntry": "Brak wpisu", "legendGreat": "Świetnie", diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts index 883bb2c9d..609b32256 100644 --- a/src/__tests__/prefetch-page-caching-guard.test.ts +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -175,6 +175,7 @@ const PREFETCHING_ROUTES = [ "/checkups", "/coach", "/insights", + "/insights/mood", "/insights/workouts", "/medications", "/mood", diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 9cecc9448..ff563f791 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -138,6 +138,8 @@ describe("S1 — the direct session-resolution set is frozen", () => { "RSC prefetch of the session user's coach state, skipped under a switch", "app/insights/page.tsx": "RSC prefetch of the session user's insights, skipped under a switch", + "app/insights/mood/page.tsx": + "RSC prefetch of the session user's mood calendar and line, skipped under a switch", "app/insights/workouts/page.tsx": "RSC prefetch of the session user's workouts, skipped under a switch", "app/medications/page.tsx": diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index 354d5895d..d856af2fd 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -7,6 +7,7 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { fetchMoodAggregates, MOOD_STABILITY_WINDOWS, + moodInsightsWire, type MoodAggregates, type MoodStabilityWindow, } from "@/lib/insights/mood-aggregates"; @@ -74,17 +75,5 @@ export const GET = apiHandler(async (request: Request) => { }, }); - // The per-window map is the cache's, not the wire's: the chosen window is - // served as `stability`. An aggregate cached before the map existed has - // only the year, so a shorter window reads as not yet computed (`null`) - // until the next rebuild rather than as the year's score under a new name. - const { stabilityByWindow, ...wire } = result; - return apiSuccess({ - ...wire, - stability: - stabilityWindowDays === 365 - ? result.stability - : (stabilityByWindow?.[stabilityWindowDays] ?? null), - stabilityWindowDays, - }); + return apiSuccess(moodInsightsWire(result, stabilityWindowDays)); }); diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index 66fb9f720..49f601778 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; /** @@ -61,7 +61,13 @@ const BESPOKE_PAGES: ReadonlyArray<{ describe("bespoke metric-detail spine — assessment is the last block", () => { for (const { slug, assessmentTag, assessmentMayTrail } of BESPOKE_PAGES) { it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => { - const source = readFileSync(join(PAGES_DIR, slug, "page.tsx"), "utf8"); + // A page split into an RSC prefetch wrapper and its client body keeps + // the spine in the client file. + const client = join(PAGES_DIR, slug, "page-client.tsx"); + const source = readFileSync( + existsSync(client) ? client : join(PAGES_DIR, slug, "page.tsx"), + "utf8", + ); // The render branch that carries the assessment is the final // `…` in the module (the empty-state diff --git a/src/app/insights/__tests__/header-add-pattern.test.ts b/src/app/insights/__tests__/header-add-pattern.test.ts new file mode 100644 index 000000000..72f9616a8 --- /dev/null +++ b/src/app/insights/__tests__/header-add-pattern.test.ts @@ -0,0 +1,60 @@ +/** + * v1.42 — adding from an Insights page is the header plus, one component. + * + * The mood page carried two text links under its heading ("Log a mood entry", + * "Take a mental-wellbeing check-in") where every other Insights page offers + * a plus in the header, and the workouts page drew its plus by hand. Both now + * go through `SubPageAddButton`. This sweep keeps it that way: no page under + * `src/app/insights` draws its own plus glyph, and the mood page carries no + * text link into a capture surface. + * + * The matchers assert a non-zero population, so a move that leaves the sweep + * reading nothing fails instead of passing quietly. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const INSIGHTS = join(ROOT, "src/app/insights"); + +function pages(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (name === "__tests__") return []; + if (statSync(full).isDirectory()) return pages(full); + return /^page(-client)?\.tsx$/.test(name) ? [full] : []; + }); +} + +describe("Insights add actions", () => { + const files = pages(INSIGHTS); + + it("sweeps the Insights pages", () => { + expect(files.length).toBeGreaterThan(40); + }); + + it("no page draws its own plus; the header plus is the shared component", () => { + const own = files.filter((file) => + /import\s*\{[^}]*\bPlus\b[^}]*\}\s*from\s*"lucide-react"/.test( + readFileSync(file, "utf8"), + ), + ); + expect(own.map((file) => relative(ROOT, file))).toEqual([]); + }); + + it("the mood and workouts pages add through SubPageAddButton", () => { + for (const rel of ["mood/page-client.tsx", "workouts/page-client.tsx"]) { + const source = readFileSync(join(INSIGHTS, rel), "utf8"); + expect(source, rel).toContain(" { + const source = readFileSync(join(INSIGHTS, "mood/page-client.tsx"), "utf8"); + expect(source).not.toMatch(/ + import("@/components/mood/mood-form").then((mod) => ({ + default: mod.MoodForm, + })), + { ssr: false, loading: () => null }, +); + +/** + * v1.4.25 W4 — `/insights/mood`. + * + * Routed Mood sub-page. Unlike the mother page (which hid the mood + * section entirely when no mood data existed), a dedicated sub-page + * cannot just blank itself — we'd land on an empty white page. Instead, + * the sub-page surfaces a clear empty-state CTA into `/mood` so the + * user can log their first entry, matching the Apple Health "No data" + * convention (research §1.1). + */ +const MoodChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodChart, + })), + { ssr: false, loading: () => }, +); + +/** + * The day's two readings. Deferred like every other block on this route that + * is not the first paint: it reads its own endpoint and renders below the + * calendar, so nothing about it belongs in the eager graph of a page whose + * first paint is a heatmap. This route sits close to its bundle ceiling and + * every addition to it goes through the same treatment. + */ +const MoodPrognosisCard = dynamic( + () => + import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ + default: mod.MoodPrognosisCard, + })), + { ssr: false, loading: () => null }, +); + +interface ComprehensiveMoodData { + moodSummary: { count: number } | null; +} + +export default function InsightsMoodPageClient() { + const { user, isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + // A mood entry is a MANAGE create under the mind section, as on `/mood`. + const { canManageDomain } = useRecordCapabilities(); + const canAddMood = canManageDomain("mind") && user?.modules?.mood !== false; + const [addOpen, setAddOpen] = useState(false); + const [footerEl, setFooterEl] = useState(null); + + // v1.42 — the calendar and the line chart appear together. The chart says + // when its read has settled (`onDataReady`, the dashboard's reveal gate); + // the calendar's read is the shared mood-insights cell. Until both are in, + // each holds its final-height skeleton, so the reveal moves nothing. + const [chartSettled, setChartSettled] = useState(false); + const markChartSettled = useCallback(() => setChartSettled(true), []); + const insights = useMoodInsights(); + const topRevealed = chartSettled && !insights.isPending; + + // Reuse the mother-page comprehensive query — TanStack Query + // dedups so this is a free cache read for the common case. + const { data: comprehensive } = useQuery({ + queryKey: queryKeys.insightsComprehensive(), + queryFn: async () => { + return apiGet("/api/insights/comprehensive"); + }, + enabled: isAuthenticated, + }); + + const moodCount = comprehensive?.moodSummary?.count ?? 0; + + // v1.4.27 F17 — Mood is event-driven so the gate reads + // `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated + // mood-logging surface) — short-circuits the user to the quickest + // path to log their first entry. + // + // v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared + // `` primitive. The mood data path stays on + // `/api/insights/comprehensive` because the `moodSummary.count` + // signal is event-driven, not sensor-aggregated. + if (isAuthenticated && comprehensive && moodCount === 0) { + return ( + + } + title={t("insights.emptyState.mood.title")} + description={t("insights.emptyState.mood.description")} + cta={ + + } + coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?" + /> + + ); + } + + return ( + setAddOpen(true)} + slot="insights-mood-add" + /> + ) : null + } + > + {canAddMood ? ( + } + > + {addOpen ? ( + setAddOpen(false)} + onCancel={() => setAddOpen(false)} + footerSlot={footerEl} + /> + ) : null} + + ) : null} + + {/* v1.12.7 — operator spine: heading + summary (the shell above), then + the Stimmungskalender, then the line chart, then the Ziel card, then + the better-days Einschätzung, then the classification + breakdowns. + The heatmap and the assessment are lifted out of `MoodInsightsSections` + into their own regions so they land in this exact order; all three + regions share one `moodInsights` query (TanStack dedups the fetch). */} + + {/* v1.42 — the "log a mood entry" and "take a check-in" text links that + sat here are gone: logging is the header plus, the same control every + other Insights page carries, and the check-in has its own entry in + the navigation. */} + + + {/* No `` here: mood is event-driven, not a + MeasurementType series, so the period-over-period range read + (`/api/analytics/range`, keyed on a MeasurementType enum) has + nothing to aggregate. */} + {/* Held invisible under its own skeleton until the calendar above is + ready too; it keeps its place in the flow the whole time, so the + page below never moves. */} +
+ {!topRevealed ? ( + + ) : null} +
+ +
+
+ + + + {/* The day's own rating beside what the account's past days imply. It + sits high because the self-assessment is the leading value on this + page, and directly under the line chart because that is where a + reader has just seen the rating it leads with. */} + + + {/* The better-days Einschätzung sits directly under the Ziel card, + ahead of the classification tiles and breakdowns. */} + + + + + {/* v1.12.2 — the assessment is the LAST block on every bespoke + metric-detail page, matching the canonical spine the generic + scaffold (weight / bmi / pulse / blood-pressure) renders. The + reader sees the trend and the breakdown sections first, then the + narration of them at the foot. */} + } /> +
+ ); +} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 50886b099..e52e82600 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -1,175 +1,130 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { ArrowRight, Smile } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; +import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { + cached, + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; +import { + fetchMoodAggregates, + moodInsightsWire, + type MoodAggregates, +} from "@/lib/insights/mood-aggregates"; +import type { MoodDailySeries } from "@/lib/analytics/mood-series"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; -import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; -import { Button } from "@/components/ui/button"; -import { ChartSkeleton } from "@/components/charts/chart-skeleton"; -import { MetricEmptyState } from "@/components/insights/metric-empty-state"; -import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; -import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections"; -import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; -import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { apiGet } from "@/lib/api/api-fetch"; + +import InsightsMoodPageClient from "./page-client"; /** - * v1.4.25 W4 — `/insights/mood`. - * - * Routed Mood sub-page. Unlike the mother page (which hid the mood - * section entirely when no mood data existed), a dedicated sub-page - * cannot just blank itself — we'd land on an empty white page. Instead, - * the sub-page surfaces a clear empty-state CTA into `/mood` so the - * user can log their first entry, matching the Apple Health "No data" - * convention (research §1.1). + * How long the first HTML waits for the two reads. Both are cached per user + * (the aggregate stale-while-revalidate), so a warm visit answers in a few + * milliseconds; a cold aggregate can take longer than a reader should wait + * for a blank page, and then the client's own fetch takes over. */ -const MoodChart = dynamic( - () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.MoodChart, - })), - { ssr: false, loading: () => }, -); +const PREFETCH_BUDGET_MS = 1_500; -/** - * The day's two readings. Deferred like every other block on this route that - * is not the first paint: it reads its own endpoint and renders below the - * calendar, so nothing about it belongs in the eager graph of a page whose - * first paint is a heatmap. This route sits close to its bundle ceiling and - * every addition to it goes through the same treatment. - */ -const MoodPrognosisCard = dynamic( - () => - import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ - default: mod.MoodPrognosisCard, - })), - { ssr: false, loading: () => null }, -); - -interface ComprehensiveMoodData { - moodSummary: { count: number } | null; +function withinBudget(work: Promise): Promise { + return Promise.race([ + work, + new Promise((resolve) => + setTimeout(() => resolve(null), PREFETCH_BUDGET_MS), + ), + ]).catch(() => null); } -export default function InsightsStimmungPage() { - const { user, isAuthenticated } = useAuth(); - const { t } = useTranslations(); - const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - - // Reuse the mother-page comprehensive query — TanStack Query - // dedups so this is a free cache read for the common case. - const { data: comprehensive } = useQuery({ - queryKey: queryKeys.insightsComprehensive(), - queryFn: async () => { - return apiGet("/api/insights/comprehensive"); - }, - enabled: isAuthenticated, - }); - - const moodCount = comprehensive?.moodSummary?.count ?? 0; - - // v1.4.27 F17 — Mood is event-driven so the gate reads - // `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated - // mood-logging surface) — short-circuits the user to the quickest - // path to log their first entry. - // - // v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared - // `` primitive. The mood data path stays on - // `/api/insights/comprehensive` because the `moodSummary.count` - // signal is event-driven, not sensor-aggregated. - if (isAuthenticated && comprehensive && moodCount === 0) { - return ( - - } - title={t("insights.emptyState.mood.title")} - description={t("insights.emptyState.mood.description")} - cta={ - - } - coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?" - /> - - ); +/** + * Thin RSC wrapper around the (client) mood insights page. + * + * The page's first paint is the mood calendar over the line chart. Both read + * their own endpoint after hydrate, and the calendar's aggregate is the + * slower of the two, so the chart used to paint first and the calendar pushed + * it down when it landed. This wrapper runs the SAME two reads the routes run + * (`moodInsightsWire` over the cached aggregate, `buildMoodDailySeries` over + * its cache) in parallel during SSR and hands both to TanStack, so the two + * appear together in the first client frame. The client page still gates the + * pair on each other, which is what keeps them together when this wrapper + * steps aside. + * + * Contract notes (the `/mood` wrapper's, unchanged): + * - the seeded keys are exactly the client's (`moodInsights()`, + * `moodAnalytics()`), and each value is JSON-round-tripped into the wire + * shape the client `queryFn` reads — never a Date-carrying sibling; + * - module-gate parity: nothing is prefetched when `modules.mood` is off; + * - record identity: `getUnswitchedSession()` answers null while the browser + * acts on somebody else's record, so a delegate's own mood never seeds a + * page opened on the owner's record; + * - fail-soft and time-boxed: no session, a lookup hiccup or a slow cold + * aggregate renders the page exactly as the client path would. + */ +export default async function InsightsMoodPage() { + // The e2e server turns every SSR prefetch off so its route mocks, which + // only see CLIENT fetches, keep governing what the page paints. + if (process.env.DASHBOARD_SSR_PREFETCH === "false") { + return ; } + let dehydratedState = null; + try { + const session = await getUnswitchedSession(); + if (session) { + const { user } = session; + const modules = await resolveModuleMap(user.id); + if (modules.mood !== false) { + const [aggregates, series] = await Promise.all([ + withinBudget( + cachedSwr( + caches.moodInsights as ServerCache, + user.id, + () => fetchMoodAggregates(user.id), + ), + ), + withinBudget( + cached( + caches.moodAnalytics as ServerCache, + user.id, + () => buildMoodDailySeries(user.id), + ), + ), + ]); + const queryClient = new QueryClient(); + if (aggregates) { + queryClient.setQueryData( + queryKeys.moodInsights(), + JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))), + ); + } + if (series) { + queryClient.setQueryData( + queryKeys.moodAnalytics(), + JSON.parse( + JSON.stringify({ + entries: series.entries, + summary: series.summary, + }), + ), + ); + } + if (aggregates || series) dehydratedState = dehydrate(queryClient); + } + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. + } + + if (dehydratedState === null) { + return ; + } return ( - - {/* v1.12.7 — operator spine: heading + summary (the shell above), then - the Stimmungskalender, then the line chart, then the Ziel card, then - the better-days Einschätzung, then the classification + breakdowns. - The heatmap and the assessment are lifted out of `MoodInsightsSections` - into their own regions so they land in this exact order; all three - regions share one `moodInsights` query (TanStack dedups the fetch). */} - - {/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and - this insights page form one mental-health domain; this cross-links - into the other two so the trend a user reads here can lead - somewhere. */} -
- - {t("insights.mood.logLink")} -
- - - - {/* No `` here: mood is event-driven, not a - MeasurementType series, so the period-over-period range read - (`/api/analytics/range`, keyed on a MeasurementType enum) has - nothing to aggregate. */} - - - - - {/* The day's own rating beside what the account's past days imply. It - sits high because the self-assessment is the leading value on this - page, and directly under the line chart because that is where a - reader has just seen the rating it leads with. */} - - - {/* The better-days Einschätzung sits directly under the Ziel card, - ahead of the classification tiles and breakdowns. */} - - - - - {/* v1.12.2 — the assessment is the LAST block on every bespoke - metric-detail page, matching the canonical spine the generic - scaffold (weight / bmi / pulse / blood-pressure) renders. The - reader sees the trend and the breakdown sections first, then the - narration of them at the foot. */} - } /> -
+ + + ); } diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index b1c3238fc..8dcd5efa2 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -1,13 +1,16 @@ "use client"; import { useMemo, useState } from "react"; -import { Activity, Plus } from "lucide-react"; +import { Activity } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useInfiniteWorkouts } from "@/hooks/use-workouts"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; -import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { + SubPageAddButton, + SubPageShell, +} from "@/components/insights/sub-page-shell"; import { WorkoutList } from "@/components/insights/workout-list"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; @@ -91,23 +94,12 @@ export default function InsightsWorkoutsPageClient() { coachLaunch headerAction={ showLog ? ( - // Same 40 px ghost icon and widened hit area as the header - // cluster's other actions; it opens the form in place. - + slot="workout-log-manual" + /> ) : null } > diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 39841e476..7b60bf68e 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -46,6 +46,79 @@ const CELL_FLOOR_PX = 14; // neighbouring tiles. Capping the cell keeps the grid dense + left-aligned so // the calendar holds the tile-height rhythm. Mirrors `compliance-heatmap.tsx`. const CELL_CEIL_PX = 22; +const HEADER_PX = 18; + +/** + * The grid's height in the stretch layout, as CSS, so it is known before the + * grid has measured anything — on the server, in the loading skeleton, and in + * the first client frame alike. + * + * The stretch cell is the container width shared out across the weeks, + * clamped to the floor and the ceiling; the grid is the month header plus + * seven cells and six gaps. The same rule `MoodHeatmap` applies in JavaScript + * once it has measured, written here in container-query units so the box is + * the right height from the first paint and nothing below it moves when the + * measurement lands. Read against the nearest `container-type: inline-size` + * ancestor, which `MoodHeatmapBody` provides. + */ +export function moodHeatmapStretchHeight(weeks: number): string { + const w = Math.max(1, weeks); + const cell = `clamp(${CELL_FLOOR_PX}px, calc((100cqw - ${(w - 1) * GAP}px) / ${w}), ${CELL_CEIL_PX}px)`; + return `calc(${HEADER_PX + 6 * GAP}px + 7 * ${cell})`; +} + +/** Monday-aligned columns a window of `days` spans, as the grid lays it out. */ +export function moodHeatmapWeeks(days: number, timeZone: string): number { + const dates = heatmapDays(new Date(), timeZone, days); + const firstDow = dates[0]?.dow ?? 0; + return Math.floor((dates.length - 1 + firstDow) / 7) + 1; +} + +/** + * The size container + the reserved box the grid paints into. Shared by the + * heatmap and its skeleton so the two can never disagree about the height. + */ +export function MoodHeatmapBody({ + weeks, + children, +}: { + weeks: number; + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} + +/** + * The loading shape of the stretched heatmap: the reserved grid box and the + * legend line, at exactly the height the painted heatmap will take. + * `days` is the window when it is already known; without it the shape + * assumes a window short enough to reach the cell ceiling, which every + * window under a quarter year does on any width. + */ +export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) { + const timeZone = useDisplayTimezone(); + return ( +