mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge documents, wellness and mood polish into release/v1.42.0
This commit is contained in:
commit
9f17960e7c
67 files changed
+3473
-606
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user