mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/release/v1.42.0' into v142/pwa
This commit is contained in:
commit
8a7eddaf26
67 files changed
+3473
-606
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user