Merge documents, wellness and mood polish into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:13:47 +02:00
commit 9f17960e7c
67 files changed
+3473 -606

No files matched your search

+120
View File
@@ -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:
+2
View File
@@ -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.
//
+384
View File
@@ -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<void> {
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<string, number>;
};
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<string, number>;
};
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();
});
});
+23 -8
View File
@@ -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": {
+23 -8
View File
@@ -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": {
+23 -8
View File
@@ -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": {
+23 -8
View File
@@ -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": {
+23 -8
View File
@@ -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": {
+23 -8
View File
@@ -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": {
+23 -8
View File
@@ -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": {
@@ -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;
+6
View File
@@ -310,6 +310,12 @@ model User {
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
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,
@@ -175,6 +175,7 @@ const PREFETCHING_ROUTES = [
"/checkups",
"/coach",
"/insights",
"/insights/mood",
"/insights/workouts",
"/medications",
"/mood",
@@ -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":
+8 -1
View File
@@ -1190,6 +1190,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, string> = {
* 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` —
@@ -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<Response>;
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
function put(body: unknown): Promise<Response> {
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();
});
});
@@ -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<DocumentsLayout> {
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);
});
+2 -13
View File
@@ -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));
});
@@ -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
// `<SubPageShell>…</SubPageShell>` in the module (the empty-state
@@ -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("<SubPageAddButton");
expect(source, rel).toContain("headerAction=");
}
});
it("the mood page has no text links into logging or the check-in", () => {
const source = readFileSync(join(INSIGHTS, "mood/page-client.tsx"), "utf8");
expect(source).not.toMatch(/<Link\s+href="\/mood"\s+data-slot/);
expect(source).not.toContain('href="/mental-wellbeing"');
expect(source).not.toContain("insights.mood.mentalWellbeingLink");
});
});
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useCallback, useState } from "react";
import { Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
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,
useMoodInsights,
} from "@/components/insights/mood/mood-insights-sections";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import {
SubPageAddButton,
SubPageShell,
} from "@/components/insights/sub-page-shell";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { apiGet } from "@/lib/api/api-fetch";
/**
* The mood form, loaded only when the header plus opens it: the route's first
* paint is the calendar and the chart, and the form's tag catalogue is heavy.
*/
const MoodForm = dynamic(
() =>
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: () => <ChartSkeleton /> },
);
/**
* 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<HTMLDivElement | null>(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<ComprehensiveMoodData>("/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
// `<MetricEmptyState>` 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 (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
title={t("insights.emptyState.mood.title")}
description={t("insights.emptyState.mood.description")}
cta={
<Button size="sm" asChild>
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
coachLaunch
headerAction={
canAddMood ? (
<SubPageAddButton
label={t("insights.mood.logLink")}
onClick={() => setAddOpen(true)}
slot="insights-mood-add"
/>
) : null
}
>
{canAddMood ? (
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("mood.addEntry")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{addOpen ? (
<MoodForm
onSuccess={() => setAddOpen(false)}
onCancel={() => setAddOpen(false)}
footerSlot={footerEl}
/>
) : null}
</ResponsiveSheet>
) : 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. */}
<MoodInsightsSections region="heatmap" reveal={topRevealed} />
{/* No `<MetricRangeControls>` 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. */}
<div data-slot="insights-mood-chart" className="relative">
{!topRevealed ? (
<ChartSkeleton className="absolute inset-0 z-10" />
) : null}
<div
className={topRevealed ? undefined : "invisible"}
aria-hidden={topRevealed ? undefined : true}
>
<MoodChart
chartKey="mood"
compareBaseline={compareBaseline}
onDataReady={markChartSettled}
dayLinks
/>
</div>
</div>
<MetricTargetSummary slug="mood" />
{/* 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. */}
<MoodPrognosisCard />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />
<MoodInsightsSections region="rest" />
{/* 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. */}
<SlugInsightStatusCard slug="mood" icon={<Smile className="h-5 w-5" />} />
</SubPageShell>
);
}
+117 -162
View File
@@ -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: () => <ChartSkeleton /> },
);
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<T>(work: Promise<T>): Promise<T | null> {
return Promise.race([
work,
new Promise<null>((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<ComprehensiveMoodData>("/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
// `<MetricEmptyState>` 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 (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
title={t("insights.emptyState.mood.title")}
description={t("insights.emptyState.mood.description")}
cta={
<Button size="sm" asChild>
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
/**
* 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 <InsightsMoodPageClient />;
}
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<MoodAggregates>,
user.id,
() => fetchMoodAggregates(user.id),
),
),
withinBudget(
cached(
caches.moodAnalytics as ServerCache<MoodDailySeries>,
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 <InsightsMoodPageClient />;
}
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
coachLaunch
>
{/* 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. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<Link
href="/mood"
data-slot="insights-mood-log-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.logLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="insights-mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
<MoodInsightsSections region="heatmap" />
{/* No `<MetricRangeControls>` 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. */}
<MoodChart chartKey="mood" compareBaseline={compareBaseline} dayLinks />
<MetricTargetSummary slug="mood" />
{/* 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. */}
<MoodPrognosisCard />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />
<MoodInsightsSections region="rest" />
{/* 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. */}
<SlugInsightStatusCard slug="mood" icon={<Smile className="h-5 w-5" />} />
</SubPageShell>
<HydrationBoundary state={dehydratedState}>
<InsightsMoodPageClient />
</HydrationBoundary>
);
}
+10 -18
View File
@@ -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.
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-log-manual"
aria-label={t("insights.workouts.manual.logWorkout")}
title={t("insights.workouts.manual.logWorkout")}
// The shared header plus; it opens the form in place.
<SubPageAddButton
label={t("insights.workouts.manual.logWorkout")}
onClick={() => setLogOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
slot="workout-log-manual"
/>
) : null
}
>
+181 -86
View File
@@ -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 (
<div style={{ containerType: "inline-size" }} className="w-full">
<div
data-slot="mood-heatmap-body"
style={{ height: moodHeatmapStretchHeight(weeks) }}
>
{children}
</div>
</div>
);
}
/**
* 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 (
<div
data-slot="mood-heatmap-skeleton"
aria-hidden="true"
className="w-full"
>
<MoodHeatmapBody weeks={moodHeatmapWeeks(days, timeZone)}>
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
</MoodHeatmapBody>
<div className="mt-2 h-4" />
<ChartDayCaption coarse={coarse} mark="cell" rug={false} />
<div className="border-border mt-3 border-t pt-3">
<div className="min-h-11" />
</div>
</div>
);
}
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
@@ -174,7 +253,7 @@ export function MoodHeatmap({
});
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
const headerHeight = HEADER_PX;
const cellSize =
stretch && containerWidth > 0
? Math.min(
@@ -207,96 +286,98 @@ export function MoodHeatmap({
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
{...heatmapDay.svgProps}
<StretchFrame stretch={stretch} weeks={weeks}>
<div
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
{...heatmapDay.svgProps}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{cells.map((cell) => (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// v1.19.1 — populated days render at full saturation so the
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the data table below the grid, not
// through the rects: a cell subtree nested under the SVG's
// `role="img"` is pruned from the a11y tree, and per-cell tab
// stops would flood the keyboard order on a year-long grid.
// The rects stay a pure pointer affordance.
{...heatmapDay.cellProps({
dateKey: cell.dateKey,
hasEntry: cell.cell !== null,
describe: () => describeCell(cell),
})}
/>
))}
{cells
.filter((cell) => cell.dateKey === heatmapDay.openKey)
.map((cell) => (
{cells.map((cell) => (
<rect
key={`open-${cell.dateKey}`}
data-slot="heatmap-open-day"
x={labelWidth + cell.col * step - 1.5}
y={headerHeight + cell.row * step - 1.5}
width={cellSize + 3}
height={cellSize + 3}
rx={3}
pointerEvents="none"
{...OPEN_DAY_CELL}
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// v1.19.1 — populated days render at full saturation so the
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the data table below the grid, not
// through the rects: a cell subtree nested under the SVG's
// `role="img"` is pruned from the a11y tree, and per-cell tab
// stops would flood the keyboard order on a year-long grid.
// The rects stay a pure pointer affordance.
{...heatmapDay.cellProps({
dateKey: cell.dateKey,
hasEntry: cell.cell !== null,
describe: () => describeCell(cell),
})}
/>
))}
</svg>
</div>
{cells
.filter((cell) => cell.dateKey === heatmapDay.openKey)
.map((cell) => (
<rect
key={`open-${cell.dateKey}`}
data-slot="heatmap-open-day"
x={labelWidth + cell.col * step - 1.5}
y={headerHeight + cell.row * step - 1.5}
width={cellSize + 3}
height={cellSize + 3}
rx={3}
pointerEvents="none"
{...OPEN_DAY_CELL}
/>
))}
</svg>
</div>
</StretchFrame>
{heatmapDay.tooltipNode}
@@ -356,3 +437,17 @@ export function MoodHeatmap({
</div>
);
}
/** The stretch layout paints into the reserved box; the fixed one sizes itself. */
function StretchFrame({
stretch,
weeks,
children,
}: {
stretch: boolean;
weeks: number;
children: React.ReactNode;
}) {
if (!stretch) return <>{children}</>;
return <MoodHeatmapBody weeks={weeks}>{children}</MoodHeatmapBody>;
}
@@ -29,7 +29,7 @@ function render(
onLinkEpisode={() => {}}
onLinkEncounter={() => {}}
onShare={() => {}}
onDelete={() => {}}
onRequestDelete={() => {}}
onClear={() => {}}
/>
</I18nProvider>,
@@ -51,6 +51,51 @@ describe("<DocumentBulkBar>", () => {
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 = /<div[^>]*data-slot="document-bulk-actions"[^>]*>/.exec(
html,
);
expect(rowTag?.[0]).not.toContain("flex-wrap");
const deleteTag = /<button[^>]*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(
/^<div data-slot="selection-action-bar" class="contents">/,
);
});
it("omits the link-condition verb when the account has no episodes", () => {
const html = render([]);
expect(html).not.toContain("Link condition");
@@ -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<Parameters<typeof DocumentMonthRow>[0]>) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentMonthRow
documents={[doc("a"), doc("b")]}
columns={2}
selectedIds={new Set()}
onOpen={() => {}}
highlightId={null}
rovingId="a"
onCardFocus={() => {}}
{...props}
/>
</I18nProvider>,
);
}
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("<DocumentMonthHeading>", () => {
expect(html).toMatch(/^<h2 [^>]*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("<DocumentMonthRow>", () => {
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"');
});
});
@@ -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("<ConfirmDialog");
});
it("the bulk bar and the card key both go through requestDelete", () => {
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\)\}/);
});
});
@@ -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("<DocumentsView> 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
@@ -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" }),
+186 -152
View File
@@ -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,19 @@ 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 }) {
// Icon-only again while a day is docked: the bar then shares the width
// with the day panel and the labelled row would no longer fit one line.
return (
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
{children}
</span>
);
}
export function DocumentBulkBar({
selectedCount,
episodes,
@@ -59,7 +76,7 @@ export function DocumentBulkBar({
onLinkEpisode,
onLinkEncounter,
onShare,
onDelete,
onRequestDelete,
onClear,
}: {
selectedCount: number;
@@ -78,157 +95,174 @@ 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 (
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
{t("documents.selection.count", { count: selectedCount })}
</p>
// 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.
<div data-slot="selection-action-bar" className="contents">
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<div className="flex items-center justify-between gap-2">
<p
className="px-1 text-sm font-medium whitespace-nowrap"
role="status"
data-slot="document-bulk-count"
>
{t("documents.selection.count", { count: selectedCount })}
</p>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 sm:size-9"
onClick={onClear}
data-slot="document-bulk-clear"
aria-label={t("documents.selection.clear")}
title={t("documents.selection.clear")}
>
<X className="size-4" aria-hidden />
</Button>
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
{/* 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. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.setKind")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkCondition")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkVisit")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
<div
data-slot="document-bulk-actions"
className="flex items-center gap-1.5"
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">{t("documents.bulk.share")}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-set-kind"
aria-label={t("documents.bulk.setKind")}
title={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.setKind")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Solid destructive (matching the detail sheet's Delete) — the
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-link-condition"
aria-label={t("documents.bulk.linkCondition")}
title={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.linkCondition")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
title={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.linkVisit")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
className={VERB_CLASS}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
title={t("documents.bulk.share")}
>
<Share2 className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.share")}</VerbLabel>
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onDelete}
aria-label={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
<span className="hidden sm:inline">{t("documents.bulk.delete")}</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClear}
aria-label={t("documents.selection.clear")}
>
<X className="size-3.5" aria-hidden />
<span className="hidden sm:inline">
{t("documents.selection.clear")}
</span>
</Button>
WCAG contrast gate. Alone at the right edge, away from the safe
verbs. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onRequestDelete}
className={cn("ml-auto", VERB_CLASS)}
data-slot="document-bulk-delete"
aria-label={t("documents.bulk.delete")}
title={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.delete")}</VerbLabel>
</Button>
</div>
</div>
</div>
);
@@ -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({
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
onClick={() => setConfirmDeleteOpen(true)}
disabled={remove.isPending}
data-slot="document-detail-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
@@ -1214,6 +1218,19 @@ export function DocumentDetailSheet({
documents={[{ id: doc.id, title }]}
/>
) : null}
{doc ? (
<ConfirmDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title={t("documents.deleteConfirm.titleOne")}
body={t("documents.deleteConfirm.body")}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={() => remove.mutate(doc.id)}
pending={remove.isPending}
slot="document-detail-delete"
destructive
/>
) : null}
</>
);
}
@@ -22,6 +22,20 @@ export function columnsForWidth(width: number): number {
return 1;
}
/**
* Measured width → compact rows side by side in the list view. Stacked, the
* list is one column; flowing, it fills the width the way the tiles do, with
* wider cells because a row carries its title beside the preview.
*/
export function listColumnsForWidth(width: number): number {
if (width >= 1100) return 3;
if (width >= 700) return 2;
return 1;
}
/** Height the flowing arrangement reserves above each row for month names. */
export const FLOW_MONTH_LABEL_PX = 24;
/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */
const GRID_GAP_PX = 16;
@@ -29,10 +43,17 @@ const GRID_GAP_PX = 16;
* First guess at a row's height before the virtualizer measures it: a 4:3
* preview across the tile's width plus the title and meta lines under it.
*/
export function estimatedRowHeight(width: number, columns: number): number {
export function estimatedRowHeight(
width: number,
columns: number,
view: "cards" | "list" = "cards",
flow = false,
): number {
const extra = flow ? FLOW_MONTH_LABEL_PX - GRID_GAP_PX : 0;
if (view === "list") return 84 + GRID_GAP_PX + extra;
const cols = Math.max(1, columns);
const tileWidth = Math.max(0, (width - GRID_GAP_PX * (cols - 1)) / cols);
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX;
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX + extra;
}
export function DocumentMonthHeading({ label }: { label: string }) {
@@ -43,9 +64,32 @@ export function DocumentMonthHeading({ label }: { label: string }) {
);
}
/**
* The flowing arrangement's month marker: the month's name and a hairline
* that runs to the edge of its cell, sitting in the space above the first
* document of that month. It marks where a month begins without taking a row
* of its own, so the documents keep running across the width.
*/
function FlowMonthMarker({ label }: { label: string }) {
return (
<div
data-slot="document-flow-month"
className="absolute inset-x-0 -top-6 flex h-6 items-center gap-2"
>
<span className="text-muted-foreground shrink-0 truncate text-xs font-medium tracking-wide uppercase">
{label}
</span>
<span aria-hidden className="bg-border h-px flex-1" />
</div>
);
}
export function DocumentMonthRow({
documents,
columns,
view = "cards",
monthStarts,
formatMonth,
selectedIds,
onToggleSelected,
onOpen,
@@ -57,6 +101,12 @@ export function DocumentMonthRow({
}: {
documents: InboundDocumentDto[];
columns: number;
/** `cards` = preview tiles, `list` = compact rows. */
view?: "cards" | "list";
/** Flowing arrangement: documents that open a month → that month's key. */
monthStarts?: Record<string, string>;
/** YYYY-MM → the reader's month label; needed with `monthStarts`. */
formatMonth?: (key: string) => string;
selectedIds: ReadonlySet<string>;
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
@@ -66,27 +116,40 @@ export function DocumentMonthRow({
onCardFocus: (id: string) => void;
onPrefetch?: (id: string) => void;
}) {
const flow = monthStarts !== undefined;
return (
<div
data-slot="document-month-row"
className="grid gap-4 pb-4"
className={flow ? "grid gap-4 pt-6" : "grid gap-4 pb-4"}
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{documents.map((doc) => (
<DocumentCard
key={doc.id}
variant="tile"
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={onCardFocus}
onPrefetch={onPrefetch}
/>
))}
{documents.map((doc) => {
const monthKey = monthStarts?.[doc.id];
const card = (
<DocumentCard
key={doc.id}
variant={view === "list" ? "row" : "tile"}
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={onCardFocus}
onPrefetch={onPrefetch}
/>
);
if (!flow) return card;
return (
<div key={doc.id} className="relative min-w-0">
{monthKey ? (
<FlowMonthMarker label={formatMonth?.(monthKey) ?? monthKey} />
) : null}
{card}
</div>
);
})}
</div>
);
}
+47 -9
View File
@@ -31,14 +31,23 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { UploadStateCard } from "./document-card";
import type {
DocumentsLayoutArrangement,
DocumentsLayoutView,
} from "@/lib/documents/documents-layout";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
listColumnsForWidth,
} from "./document-month-grid";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
import {
buildFlowTimelineItems,
buildTimelineItems,
formatMonthLabel,
} from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
@@ -56,7 +65,13 @@ export function DocumentTimeline({
highlightId,
onPrefetch,
timezone,
view = "cards",
arrangement = "stacked",
}: {
/** Preview tiles or compact rows (the reader's vault presentation). */
view?: DocumentsLayoutView;
/** Months as their own blocks, or one continuous run across the width. */
arrangement?: DocumentsLayoutArrangement;
/** The reader's profile zone; an undated document files under its upload day there. */
timezone: string;
documents: InboundDocumentDto[];
@@ -85,24 +100,39 @@ export function DocumentTimeline({
// the remembered card left the corpus (filter change, deletion).
const [activeId, setActiveId] = useState<string | null>(null);
// Measured columns — the ResizeObserver drives the row chunking.
const flow = arrangement === "flow";
// Measured columns — the ResizeObserver drives the row chunking. Tiles
// share a row on any width; compact rows only in the flowing arrangement,
// a stacked list stays one column.
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => {
setColumns(columnsForWidth(el.clientWidth));
setGridWidth(el.clientWidth);
const width = el.clientWidth;
setColumns(
view === "cards"
? columnsForWidth(width)
: flow
? listColumnsForWidth(width)
: 1,
);
setGridWidth(width);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [view, flow]);
const items = useMemo(
() => buildTimelineItems(documents, columns, timezone),
[documents, columns, timezone],
() =>
flow
? buildFlowTimelineItems(documents, columns, timezone)
: buildTimelineItems(documents, columns, timezone),
[documents, columns, timezone, flow],
);
const formatMonth = (key: string) => formatMonthLabel(key, locale);
// The timeline does not start at the scrollport's top edge (page header,
// filter bar, upload row sit above it) — feed the offset to the
@@ -134,7 +164,7 @@ export function DocumentTimeline({
estimateSize: (index) =>
items[index].type === "month"
? 40
: estimatedRowHeight(gridWidth, columns),
: estimatedRowHeight(gridWidth, columns, view, flow),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
@@ -237,7 +267,12 @@ export function DocumentTimeline({
]);
return (
<div data-slot="document-timeline" className="space-y-4">
<div
data-slot="document-timeline"
data-view={view}
data-arrangement={arrangement}
className="space-y-4"
>
{uploadItems.length > 0 ? (
<div
data-slot="document-upload-queue"
@@ -296,6 +331,9 @@ export function DocumentTimeline({
<DocumentMonthRow
documents={item.documents}
columns={columns}
view={view}
monthStarts={item.monthStarts}
formatMonth={formatMonth}
selectedIds={selectedIds}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
+107 -28
View File
@@ -27,13 +27,16 @@ import {
SearchX,
Upload,
UploadCloud,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -56,6 +59,7 @@ import {
useCoachLaunch,
} from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
@@ -408,6 +412,10 @@ export function DocumentsView() {
[list.data],
);
// The reader's presentation (cards / list, stacked / flowing months),
// persisted per user and changed behind the wrench.
const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled);
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/
// measurements/medications. The vault is one of the sharpest PWA-resume-
// staleness surfaces named in the audit: a document indexed server-side
@@ -755,6 +763,21 @@ export function DocumentsView() {
[bulk, clearSelection, restoreBulk, t],
);
// ── Delete confirmation ───────────────────────────────────────────────
// Every way of deleting from the vault page — the bulk bar's Delete and a
// card's Delete key — comes through here and asks first. Nothing on this
// page calls `deleteBulk` directly.
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
null,
);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length > 0) setPendingDeleteIds(ids);
}, []);
const confirmDelete = useCallback(() => {
if (pendingDeleteIds) deleteBulk(pendingDeleteIds);
setPendingDeleteIds(null);
}, [pendingDeleteIds, deleteBulk]);
// ── Bulk share ────────────────────────────────────────────────────────
// ONE documents-only link for the whole selection (the share model carries
// up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded
@@ -934,17 +957,42 @@ export function DocumentsView() {
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
canManageDocuments ? (
canManageDocuments || !inSharedRecord ? (
<div className="flex flex-wrap items-center gap-2">
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
{/* 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 ? (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/documents"
aria-label={t("documents.customize")}
title={t("documents.customize")}
data-slot="documents-customize"
>
<Wrench className="size-4" aria-hidden />
</Link>
</Button>
) : null}
{canManageDocuments ? (
<>
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
</>
) : null}
</div>
) : null
}
@@ -990,22 +1038,30 @@ export function DocumentsView() {
onIndexAll={handleIndexAll}
/>
{list.isPending ? (
<div
data-slot="documents-loading"
// The archive's tile shape (a 4:3 preview over two text lines), in
// the timeline's column counts, so the grid does not jump when the
// first page lands.
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
{list.isPending || isLayoutLoading ? (
layout.view === "list" ? (
<div data-slot="documents-loading" className="space-y-4">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : (
<div
data-slot="documents-loading"
// The archive's tile shape (a 4:3 preview over two text lines),
// in the timeline's column counts, so the grid does not jump when
// the first page lands.
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
)
) : list.isError ? (
<QueryErrorCard
title={t("documents.list.loadError")}
@@ -1042,6 +1098,8 @@ export function DocumentsView() {
/>
) : (
<DocumentTimeline
view={layout.view}
arrangement={layout.arrangement}
timezone={timezone}
documents={documents}
uploadItems={upload.items}
@@ -1052,7 +1110,9 @@ export function DocumentsView() {
selectedIds={selectedIds}
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
onOpen={openDetail}
onDelete={canManageDocuments ? (id) => 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}
<ConfirmDialog
open={pendingDeleteIds !== null}
onOpenChange={(open) => {
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
/>
<DocumentShareSheet
open={bulkShareOpen}
onOpenChange={setBulkShareOpen}
+45 -1
View File
@@ -185,7 +185,17 @@ export function hasProcessingDocument(
*/
export type TimelineItem =
| { type: "month"; key: string }
| { type: "row"; key: string; documents: InboundDocumentDto[] };
| {
type: "row";
key: string;
documents: InboundDocumentDto[];
/**
* Flowing arrangement only: the documents in this row that open a
* month, mapped to that month's YYYY-MM key. The month name rides
* inline above the document instead of taking a row of its own.
*/
monthStarts?: Record<string, string>;
};
/**
* 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<string, string> = {};
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
@@ -42,6 +42,13 @@ vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: () => <div data-slot="score-anatomy-view-probe" />,
}));
// 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: () => <div data-slot="score-history-probe" />,
ScoreHistoryCard: () => <div data-slot="score-history-probe" />,
}));
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
import type { AnatomyMetricId } from "../composite-score-anatomy";
@@ -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<string, unknown> };
vi.mock("../use-derived-metric", () => ({
useDerivedMetric: () => derived.current,
}));
vi.mock("@/components/insights/insight-status-card", () => ({
InsightStatusCard: () => <div data-slot="assessment-probe" />,
}));
vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: ({ method }: { method: React.ReactNode }) => (
<div data-slot="anatomy-probe">{method}</div>
),
}));
vi.mock("../score-history", () => ({
ScoreHistoryChart: ({ type }: { type: string }) => (
<div data-slot="history-chart-probe" data-type={type} />
),
ScoreHistoryCard: ({
series,
windowDays,
}: {
series: number[];
windowDays: number;
}) => (
<div
data-slot="history-card-probe"
data-points={series.length}
data-window={windowDays}
/>
),
}));
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(<CompositeScoreAnatomy metric={metric} />);
}
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(
<CompositeScoreAnatomy metric="RECOVERY_SCORE" />,
);
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");
}
});
});
@@ -135,4 +135,37 @@ describe("<WellnessScores>", () => {
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(
<WellnessScores
read={readFrom({
RECOVERY_SCORE: ok({
score: 70,
band: "yellow",
series: [60, 64, 70],
}),
STRESS_SCORE: ok({ score: 30, band: "green", series: [] }),
})}
isLoading={false}
/>,
);
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(<WellnessScores read={readFrom({})} isLoading />);
const tile = html.slice(
html.indexOf('data-slot="wellness-score-tile-skeleton"'),
);
expect(tile).toContain("h-10 w-full");
});
});
@@ -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 ? (
<span className="text-warning block font-medium">
{t(meta.caveatKey)}
</span>
) : null}
{t(meta.methodKey)}
{anchorLine ? <span className="mt-1 block">{anchorLine}</span> : 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" ? (
<ScoreHistoryChart type={metric} hue={METRIC_HUE[metric]} />
) : series && series.length >= 2 ? (
<ScoreHistoryCard
series={series}
windowDays={data.provenance.windowDays}
hue={METRIC_HUE[metric]}
/>
) : null;
return (
<div className="space-y-3">
<ScoreAnatomyView
@@ -284,6 +300,7 @@ export function CompositeScoreAnatomy({
insufficient={insufficient}
className={className}
/>
{history}
{assessment ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
@@ -32,7 +32,14 @@ export function DeltaSparkline({
data,
strokeVar,
comparisonLabel,
domain,
}: {
/**
* A fixed y-domain. Omitted, the line spans its own min..max, which suits a
* vital read against itself; a bounded score passes its scale (`[0, 100]`)
* so a night-to-night wobble of a few points is not drawn as a cliff.
*/
domain?: [number, number];
/**
* `{ i, v }` points, already length-guarded (≥2) by the tile. An optional
* `b` on a point is the comparison series' value at the same index —
@@ -62,7 +69,7 @@ export function DeltaSparkline({
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
<YAxis hide domain={domain ?? ["dataMin", "dataMax"]} />
{/* The comparison curve sits UNDER the live one so today's line is
never occluded by the reference it is being judged against. */}
{hasComparison ? (
@@ -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 (
<div data-slot="score-history-chart" data-type={type}>
<HealthChartDynamic
types={[type]}
title={t("insights.derived.scores.historyTitle")}
titleIcon={History}
colors={[RING_GRADIENT[hue][1]]}
unit={t("insights.deviceScore.unitScore")}
userTimezone={user?.timezone}
dayLinks
/>
</div>
);
}
export function ScoreHistoryCard({
series,
windowDays,
hue,
}: {
series: number[];
windowDays: number;
hue: RingHue;
}) {
const { t } = useTranslations();
return (
<Card data-slot="score-history-card" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={History}
title={t("insights.derived.scores.historyTitle")}
right={
<span className="text-muted-foreground text-xs">
{t("insights.derived.scores.historyWindow", { days: windowDays })}
</span>
}
/>
</CardHeader>
<CardContent>
<div className="h-24 w-full" aria-hidden="true">
<DeltaSparkline
data={series.map((v, i) => ({ i, v }))}
strokeVar={RING_GRADIENT[hue][1]}
domain={[0, 100]}
/>
</div>
</CardContent>
</Card>
);
}
@@ -111,7 +111,6 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
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<DerivedMetricId, MetricProvenanceMeta> =
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<DerivedMetricId, MetricProvenanceMeta> =
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<DerivedMetricId, MetricProvenanceMeta> =
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",
@@ -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}
</span>
</div>
{history && history.series.length >= 2 ? (
<ScoreHistoryLine
series={history.series}
windowDays={history.windowDays}
strokeVar={RING_GRADIENT[hue][1]}
/>
) : null}
</Link>
);
}
/**
* 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 (
<div data-slot="wellness-score-history" className="space-y-1">
<div className="h-10 w-full" aria-hidden="true">
<DeltaSparkline
data={series.map((v, i) => ({ i, v }))}
strokeVar={strokeVar}
domain={[0, 100]}
/>
</div>
<p className="text-muted-foreground text-center text-xs">
{t("insights.derived.scores.historyWindow", { days: windowDays })}
</p>
</div>
);
}
/** 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() {
<Skeleton className="size-[120px] rounded-full" />
<Skeleton className="h-3 w-12" />
</div>
{/* The history line's band, so a tile that gains one does not grow. */}
<div className="space-y-1">
<Skeleton className="h-10 w-full" />
<Skeleton className="mx-auto h-3 w-20" />
</div>
</div>
);
}
@@ -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)}
@@ -0,0 +1,81 @@
/**
* v1.42 — the mood calendar no longer pops in after the line chart.
*
* While the shared read is pending, and while the page holds the reveal until
* the chart is ready too, the heatmap region paints the calendar's card with
* a skeleton at the calendar's final height — the same reserved box the
* painted grid uses — instead of nothing. Before this release the region
* rendered nothing while loading, so the calendar arrived later and pushed
* the chart down.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
isLoading: false,
isAuthenticated: true,
}),
}));
const state = { current: {} as Record<string, unknown> };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => state.current,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodInsightsSections } from "../mood-insights-sections";
const LOADED = {
data: {
summary: { totalEntries: 12, inTargetPct: null },
heatmap: { windowDays: 40, cells: [] },
betterDays: [],
},
isLoading: false,
isPending: false,
isError: false,
refetch: vi.fn(),
};
function render(reveal?: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodInsightsSections region="heatmap" reveal={reveal} />
</I18nProvider>,
);
}
describe("<MoodInsightsSections region=heatmap> reveal", () => {
it("paints the calendar card with a reserved-height skeleton while the read is pending", () => {
state.current = {
data: undefined,
isLoading: true,
isPending: true,
isError: false,
refetch: vi.fn(),
};
const html = render();
expect(html).toContain("Mood calendar");
expect(html).toContain('data-slot="mood-heatmap-skeleton"');
expect(html).toMatch(/data-slot="mood-heatmap-body" style="height:calc\(/);
});
it("holds the skeleton while the page has not revealed, then paints the grid", () => {
state.current = LOADED;
const held = render(false);
expect(held).toContain('data-slot="mood-heatmap-skeleton"');
const shown = render(true);
expect(shown).not.toContain('data-slot="mood-heatmap-skeleton"');
expect(shown).toContain('role="img"');
});
it("the skeleton and the grid reserve the same box for the same window", () => {
state.current = LOADED;
const box = (html: string) =>
/data-slot="mood-heatmap-body" style="(height:[^"]+)"/.exec(html)?.[1];
expect(box(render(false))).toBeDefined();
expect(box(render(false))).toBe(box(render(true)));
});
});
@@ -12,7 +12,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
MoodHeatmap,
MoodHeatmapSkeleton,
} from "@/components/charts/mood-heatmap";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
import { apiGet } from "@/lib/api/api-fetch";
@@ -97,15 +100,14 @@ const MoodInsightsBreakdowns = dynamic(
*/
export type MoodInsightsRegion = "heatmap" | "assessment" | "rest";
export function MoodInsightsSections({
region = "rest",
}: {
region?: MoodInsightsRegion;
} = {}) {
/**
* The one read every region shares, exported so the page can wait on the same
* cell (same key, same fetch, same options) when it decides when the calendar
* and the line chart appear.
*/
export function useMoodInsights() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
return useQuery({
queryKey: queryKeys.moodInsights(),
queryFn: async () => {
return apiGet<MoodInsightsResponse>("/api/mood/insights");
@@ -113,11 +115,42 @@ export function MoodInsightsSections({
enabled: isAuthenticated,
staleTime: 60_000,
});
}
export function MoodInsightsSections({
region = "rest",
reveal = true,
}: {
region?: MoodInsightsRegion;
/**
* v1.42 — heatmap region only: hold the calendar on its skeleton until the
* page says the line chart below is ready too, so the two appear in the
* same frame instead of one popping in after the other. The skeleton holds
* the calendar's final height, so the reveal moves nothing.
*/
reveal?: boolean;
} = {}) {
const { t } = useTranslations();
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
// `isPending`, not `isLoading`: before the session resolves the read is
// disabled rather than loading, and the frame must already be there.
if (region === "heatmap" && (isPending || (!reveal && !isError))) {
// The calendar's frame and final height from the first paint; the
// window is known once the read has landed, assumed short until then.
if (data && data.summary.totalEntries === 0) return null;
return (
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmapSkeleton days={data?.heatmap.windowDays} />
</SectionCard>
);
}
if (isLoading) {
// Only the "rest" region carries the page-level loading skeleton; the
// heatmap / assessment regions stay invisible while loading so they don't
// stack three skeletons down the page.
// Only the "rest" region carries the page-level loading skeleton (the
// heatmap's own is above); the assessment region stays invisible while
// loading so the page does not stack skeletons down its length.
return region === "rest" ? (
<Skeleton className="h-48 w-full rounded-lg" />
) : null;
+60 -18
View File
@@ -161,6 +161,65 @@ export interface SubPageShellProps {
children: ReactNode;
}
/**
* The header's add action — one plus, one shape, on every Insights page that
* offers one. A 40 px ghost icon with the extended hit area its cluster
* siblings carry; the accessible name rides on `aria-label` and `title`.
*
* Two forms: `href` links to a capture form elsewhere (the measurement pages'
* `captureType`), `onClick` opens something in place (the workouts and mood
* pages). A page never draws its own "log an entry" text link instead.
*/
export function SubPageAddButton({
label,
href,
onClick,
slot,
}: {
label: string;
href?: string;
onClick?: () => void;
/** `data-slot` for tests; defaults to the shared one. */
slot?: string;
}) {
const className = cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
);
const icon = <Plus className="size-4" aria-hidden="true" />;
if (href) {
return (
<Button
asChild
variant="ghost"
size="icon"
data-slot={slot ?? "metric-add-reading"}
data-header-add=""
className={className}
>
<Link href={href} aria-label={label} title={label}>
{icon}
</Link>
</Button>
);
}
return (
<Button
type="button"
variant="ghost"
size="icon"
data-slot={slot ?? "metric-add-reading"}
data-header-add=""
aria-label={label}
title={label}
onClick={onClick}
className={className}
>
{icon}
</Button>
);
}
export function SubPageShell({
title,
backLink,
@@ -319,24 +378,7 @@ export function SubPageShell({
<div className="flex shrink-0 items-center gap-3">
{headerAction}
{captureHref ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="metric-add-reading"
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href={captureHref}
aria-label={captureLabel}
title={captureLabel}
>
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>
<SubPageAddButton label={captureLabel} href={captureHref} />
) : null}
{/* v1.16.8 — "show all readings" rides the header cluster
as an icon button, LEFT of the target-adjust gear (it
@@ -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" },
]);
});
@@ -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 (
<div className="space-y-6" data-slot="documents-layout-settings">
<SettingsCard id="documents-view" className="scroll-mt-28">
<SettingsCardHeader
icon={Eye}
title={t("moduleList.viewHeading")}
description={t("documents.layout.viewDescription")}
status={<ModuleViewToggle view={layout.view} onChange={setView} />}
/>
</SettingsCard>
<SettingsCard id="documents-arrangement" className="scroll-mt-28">
<SettingsCardHeader
icon={Rows3}
title={t("documents.layout.arrangementHeading")}
description={t("documents.layout.arrangementDescription")}
status={
<ViewToggle
view={layout.arrangement}
onChange={setArrangement}
groupLabel={t("documents.layout.arrangementHeading")}
dataSlotPrefix="documents-arrangement"
segments={[
{
value: "stacked",
label: t("documents.layout.stacked"),
icon: Rows3,
},
{
value: "flow",
label: t("documents.layout.flow"),
icon: WrapText,
},
]}
/>
}
/>
</SettingsCard>
</div>
);
}
+7
View File
@@ -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<LayoutGroup> = [
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",
+7
View File
@@ -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({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
aria-busy={pending || undefined}
+1
View File
@@ -454,6 +454,7 @@ export const USER_RESET = {
dashboardWidgetsJson: Prisma.DbNull,
medicationListLayoutJson: Prisma.DbNull,
moodTagLayoutJson: Prisma.DbNull,
documentsLayoutJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
+70
View File
@@ -0,0 +1,70 @@
/**
* Document vault presentation — persisted in `User.documentsLayoutJson`.
*
* Two independent choices, both display-only:
*
* - `view`: `cards` is the monthly grid of preview tiles; `list` is one
* compact row per document.
* - `arrangement`: `stacked` gives every month its own block, one under the
* other; `flow` lets the documents run left to right across the full width
* and wrap, with each month's name riding inline as a small marker that does
* not break the run.
*
* Null / missing column = the defaults below (cards, stacked — the vault as it
* has always looked). The GET endpoint never lazy-writes, so the column only
* carries data once the user has changed one of the two. Mirrors
* `medication-list-layout.ts`: tolerant resolver on read, normaliser on write,
* preserve-when-absent PUT (see the route).
*/
export const DOCUMENTS_LAYOUT_VIEWS = ["cards", "list"] as const;
export type DocumentsLayoutView = (typeof DOCUMENTS_LAYOUT_VIEWS)[number];
export const DOCUMENTS_LAYOUT_ARRANGEMENTS = ["stacked", "flow"] as const;
export type DocumentsLayoutArrangement =
(typeof DOCUMENTS_LAYOUT_ARRANGEMENTS)[number];
export interface DocumentsLayout {
version: 1;
view: DocumentsLayoutView;
arrangement: DocumentsLayoutArrangement;
}
export const DEFAULT_DOCUMENTS_LAYOUT: DocumentsLayout = {
version: 1,
view: "cards",
arrangement: "stacked",
};
function isView(value: unknown): value is DocumentsLayoutView {
return (
typeof value === "string" &&
(DOCUMENTS_LAYOUT_VIEWS as readonly string[]).includes(value)
);
}
function isArrangement(value: unknown): value is DocumentsLayoutArrangement {
return (
typeof value === "string" &&
(DOCUMENTS_LAYOUT_ARRANGEMENTS as readonly string[]).includes(value)
);
}
/**
* Any malformed, partial or legacy blob collapses onto the defaults field by
* field, so a GET never fails on a stored row and a field added later
* defaults cleanly for blobs written before it existed.
*/
export function resolveDocumentsLayout(raw: unknown): DocumentsLayout {
if (raw === null || raw === undefined || typeof raw !== "object") {
return { ...DEFAULT_DOCUMENTS_LAYOUT };
}
const blob = raw as { view?: unknown; arrangement?: unknown };
return {
version: 1,
view: isView(blob.view) ? blob.view : DEFAULT_DOCUMENTS_LAYOUT.view,
arrangement: isArrangement(blob.arrangement)
? blob.arrangement
: DEFAULT_DOCUMENTS_LAYOUT.arrangement,
};
}
@@ -524,6 +524,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
insightsLayoutJson: json("insightsLayoutJson"),
medicationListLayoutJson: json("medicationListLayoutJson"),
moodTagLayoutJson: json("moodTagLayoutJson"),
documentsLayoutJson: json("documentsLayoutJson"),
reportSelectionJson: json("reportSelectionJson"),
globalExcludedInjectionSites: excludedSites,
healthKitConfigJson: json("healthKitConfigJson"),
+1
View File
@@ -950,6 +950,7 @@ export const USER_COLUMN_BACKUP_CLASS = {
insightsLayoutJson: "SETTING",
medicationListLayoutJson: "SETTING",
moodTagLayoutJson: "SETTING",
documentsLayoutJson: "SETTING",
reportSelectionJson: "SETTING",
globalExcludedInjectionSites: "SETTING",
healthKitConfigJson: "SETTING",
@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
{ key: "composition", value: 88, weight: 0.15 },
],
windowNights: 20,
series: [78, 82],
};
const recovery: WellnessScoreValue = {
@@ -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([]);
});
});
@@ -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"]]),
+15 -4
View File
@@ -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<Derived<unknown>>;
case "READINESS":
return computeReadiness(args.userId, args.profile, {
case "READINESS": {
const readiness = await computeReadiness(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
});
// 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<unknown>;
}
case "COINCIDENT_DEVIATION":
return computeCoincidentDeviation(args.userId, args.profile, {
windowDays: args.windowDays,
+44 -1
View File
@@ -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<number[]> {
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) ────────────────────────
+30 -1
View File
@@ -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,
+27
View File
@@ -979,3 +979,30 @@ export async function fetchMoodAggregates(
})),
};
}
/**
* The `/api/mood/insights` wire for one stability window.
*
* 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.
*
* Shared by the route and by the `/insights/mood` server prefetch, so the
* cell the prefetch seeds is byte-for-byte what the client's own fetch would
* have put there — the same key with a different shape is silent cache poison.
*/
export function moodInsightsWire(
result: MoodAggregates,
stabilityWindowDays: MoodStabilityWindow,
) {
const { stabilityByWindow, ...wire } = result;
return {
...wire,
stability:
stabilityWindowDays === 365
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
};
}
+1
View File
@@ -200,6 +200,7 @@ const STATIC_SURFACE_MODULE = {
"settings-layout:mood": "mood",
"settings-layout:labs": "labs",
"settings-layout:illness": "illness",
"settings-layout:documents": "inboundDocuments",
// ── Correlation channels ──
// Rated mood factors (`FACTOR:<key>`) are mood's too; see
+86
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
@@ -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<DocumentsLayout>((resolve) => {
settle = resolve;
}),
);
const run = runSetDocumentsLayout({
patch: { view: "list" },
queryClient,
t,
});
expect(queryClient.getQueryData<DocumentsLayout>(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");
});
});
+85
View File
@@ -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, string | number>,
) => 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<void> {
const { patch, queryClient, t } = deps;
const key = queryKeys.documentsLayout();
const previous = queryClient.getQueryData<DocumentsLayout>(key);
queryClient.setQueryData<DocumentsLayout>(key, {
...(previous ?? DEFAULT_DOCUMENTS_LAYOUT),
...patch,
});
try {
const saved = await apiPut<DocumentsLayout>(
"/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<void>;
setArrangement: (arrangement: DocumentsLayoutArrangement) => Promise<void>;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.documentsLayout(),
queryFn: () => apiGet<DocumentsLayout>("/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 }),
};
}
+6
View File
@@ -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`).
+1
View File
@@ -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(),