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/e2e/v142-qol.spec.ts b/e2e/v142-qol.spec.ts new file mode 100644 index 000000000..a252891b7 --- /dev/null +++ b/e2e/v142-qol.spec.ts @@ -0,0 +1,384 @@ +/** + * 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-body"] svg[role="img"]', + ) + ) + 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-body"] svg[role="img"]'), + ).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 b9b9399de..a65aa5e32 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", @@ -4282,7 +4281,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 +4306,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 +4331,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 +4376,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", @@ -5352,6 +5348,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 +11061,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 7efb7a78b..941d5f2b7 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", @@ -4282,7 +4281,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 +4306,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 +4331,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 +4376,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", @@ -5352,6 +5348,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 +11061,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 e38b9e1a8..1178466e9 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", @@ -4282,7 +4281,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 +4306,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 +4331,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 +4376,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", @@ -5352,6 +5348,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 +11061,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 9070b5fdc..ee8fa291a 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", @@ -4282,7 +4281,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 +4306,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 +4331,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 +4376,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", @@ -5352,6 +5348,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 +11061,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 7da2126a2..6f6c924f0 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", @@ -4282,7 +4281,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 +4306,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 +4331,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 +4376,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", @@ -5352,6 +5348,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 +11061,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 ccd0eb935..381dd916d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "보행 속도", "mood": { "logLink": "기분 기록하기", - "mentalWellbeingLink": "정신 건강 체크인 하기", "heatmapTitle": "기분 캘린더", "heatmapNoEntry": "기록 없음", "legendGreat": "아주 좋음", @@ -4282,7 +4281,6 @@ "contributorsLabel": "구성 요소", "contributorValue": "{value} · 가중치 {weight}%", "contributorMissing": "데이터 없음", - "disclaimer": "참고용이에요 — 임상 평가가 아니에요.", "insufficient": "아직 점수를 낼 데이터가 부족해요. 계속 기록하면 여기에 표시돼요." }, "composite": { @@ -4308,8 +4306,7 @@ "sleep": "수면", "respiratory": "호흡수", "mood": "기분 안정성" - }, - "caveat": "회복도나 임상 점수가 아니라 컨디션을 가늠하는 지표예요." + } }, "VITALS_BASELINE": { "method": "개인 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 인구 평균이 아니라 내 측정값을 따라가요 — 그래서 이 범위 안이면 나에게는 평소 수준이에요." @@ -4334,19 +4331,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 +4376,9 @@ "recovery": "회복 점수", "recoveryCrossLink": "웨어러블의 회복·부하 데이터 보기", "stress": "스트레스", - "strain": "부하" + "strain": "부하", + "historyTitle": "추이", + "historyWindow": "최근 {days}일" }, "coincident": { "label": "여러 지표가 기준값 밖", @@ -5352,6 +5348,10 @@ "mood": { "title": "기분 태그", "description": "기분 선택기의 그룹, 태그, 보관된 태그." + }, + "documents": { + "title": "문서", + "description": "보기 방식과 월 배치." } }, "dashboard": { @@ -11061,6 +11061,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 032c99b02..dc1e0bfcd 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", @@ -4282,7 +4281,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 +4306,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 +4331,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 +4376,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ą", @@ -5352,6 +5348,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 +11061,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__/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/__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/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 f6af7cf94..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 de1c082a2..ccd5a77d9 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChartDayCaption } from "@/components/day/chart-day"; +import { ChartDayCaption, useCoarsePointer } from "@/components/day/chart-day"; import { dayAnchor } from "@/components/day/chart-day-links"; import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day"; import { useCalendarDate } from "@/hooks/use-calendar-date"; @@ -48,6 +48,85 @@ 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, at exactly the height the + * painted heatmap takes: the reserved grid box, the legend line, the day + * caption and the data-table toggle. `days` is the window when it is already + * known; without it the shape assumes a window short enough to reach the cell + * ceiling on every width down to 360 px (eight weeks), the height most + * calendars settle at. + */ +export function MoodHeatmapSkeleton({ days = 56 }: { days?: number }) { + const timeZone = useDisplayTimezone(); + const coarse = useCoarsePointer(); + return ( +