Merge remote-tracking branch 'origin/release/v1.42.0' into v142/pwa

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:24:38 +02:00
commit 8a7eddaf26
67 files changed
+3473 -606

No files matched your search

+181 -86
View File
@@ -1,6 +1,6 @@
"use client";
import { ChartDayCaption } from "@/components/day/chart-day";
import { ChartDayCaption, useCoarsePointer } from "@/components/day/chart-day";
import { dayAnchor } from "@/components/day/chart-day-links";
import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day";
import { useCalendarDate } from "@/hooks/use-calendar-date";
@@ -48,6 +48,85 @@ const CELL_FLOOR_PX = 14;
// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned so
// the calendar holds the tile-height rhythm. Mirrors `compliance-heatmap.tsx`.
const CELL_CEIL_PX = 22;
const HEADER_PX = 18;
/**
* The grid's height in the stretch layout, as CSS, so it is known before the
* grid has measured anything — on the server, in the loading skeleton, and in
* the first client frame alike.
*
* The stretch cell is the container width shared out across the weeks,
* clamped to the floor and the ceiling; the grid is the month header plus
* seven cells and six gaps. The same rule `MoodHeatmap` applies in JavaScript
* once it has measured, written here in container-query units so the box is
* the right height from the first paint and nothing below it moves when the
* measurement lands. Read against the nearest `container-type: inline-size`
* ancestor, which `MoodHeatmapBody` provides.
*/
export function moodHeatmapStretchHeight(weeks: number): string {
const w = Math.max(1, weeks);
const cell = `clamp(${CELL_FLOOR_PX}px, calc((100cqw - ${(w - 1) * GAP}px) / ${w}), ${CELL_CEIL_PX}px)`;
return `calc(${HEADER_PX + 6 * GAP}px + 7 * ${cell})`;
}
/** Monday-aligned columns a window of `days` spans, as the grid lays it out. */
export function moodHeatmapWeeks(days: number, timeZone: string): number {
const dates = heatmapDays(new Date(), timeZone, days);
const firstDow = dates[0]?.dow ?? 0;
return Math.floor((dates.length - 1 + firstDow) / 7) + 1;
}
/**
* The size container + the reserved box the grid paints into. Shared by the
* heatmap and its skeleton so the two can never disagree about the height.
*/
export function MoodHeatmapBody({
weeks,
children,
}: {
weeks: number;
children: React.ReactNode;
}) {
return (
<div style={{ containerType: "inline-size" }} className="w-full">
<div
data-slot="mood-heatmap-body"
style={{ height: moodHeatmapStretchHeight(weeks) }}
>
{children}
</div>
</div>
);
}
/**
* The loading shape of the stretched heatmap, at exactly the height the
* painted heatmap takes: the reserved grid box, the legend line, the day
* caption and the data-table toggle. `days` is the window when it is already
* known; without it the shape assumes a window short enough to reach the cell
* ceiling on every width down to 360 px (eight weeks), the height most
* calendars settle at.
*/
export function MoodHeatmapSkeleton({ days = 56 }: { days?: number }) {
const timeZone = useDisplayTimezone();
const coarse = useCoarsePointer();
return (
<div
data-slot="mood-heatmap-skeleton"
aria-hidden="true"
className="w-full"
>
<MoodHeatmapBody weeks={moodHeatmapWeeks(days, timeZone)}>
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
</MoodHeatmapBody>
<div className="mt-2 h-4" />
<ChartDayCaption coarse={coarse} mark="cell" rug={false} />
<div className="border-border mt-3 border-t pt-3">
<div className="min-h-11" />
</div>
</div>
);
}
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
@@ -174,7 +253,7 @@ export function MoodHeatmap({
});
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
const headerHeight = HEADER_PX;
const cellSize =
stretch && containerWidth > 0
? Math.min(
@@ -207,96 +286,98 @@ export function MoodHeatmap({
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
{...heatmapDay.svgProps}
<StretchFrame stretch={stretch} weeks={weeks}>
<div
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
{...heatmapDay.svgProps}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{cells.map((cell) => (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// v1.19.1 — populated days render at full saturation so the
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the data table below the grid, not
// through the rects: a cell subtree nested under the SVG's
// `role="img"` is pruned from the a11y tree, and per-cell tab
// stops would flood the keyboard order on a year-long grid.
// The rects stay a pure pointer affordance.
{...heatmapDay.cellProps({
dateKey: cell.dateKey,
hasEntry: cell.cell !== null,
describe: () => describeCell(cell),
})}
/>
))}
{cells
.filter((cell) => cell.dateKey === heatmapDay.openKey)
.map((cell) => (
{cells.map((cell) => (
<rect
key={`open-${cell.dateKey}`}
data-slot="heatmap-open-day"
x={labelWidth + cell.col * step - 1.5}
y={headerHeight + cell.row * step - 1.5}
width={cellSize + 3}
height={cellSize + 3}
rx={3}
pointerEvents="none"
{...OPEN_DAY_CELL}
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// v1.19.1 — populated days render at full saturation so the
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the data table below the grid, not
// through the rects: a cell subtree nested under the SVG's
// `role="img"` is pruned from the a11y tree, and per-cell tab
// stops would flood the keyboard order on a year-long grid.
// The rects stay a pure pointer affordance.
{...heatmapDay.cellProps({
dateKey: cell.dateKey,
hasEntry: cell.cell !== null,
describe: () => describeCell(cell),
})}
/>
))}
</svg>
</div>
{cells
.filter((cell) => cell.dateKey === heatmapDay.openKey)
.map((cell) => (
<rect
key={`open-${cell.dateKey}`}
data-slot="heatmap-open-day"
x={labelWidth + cell.col * step - 1.5}
y={headerHeight + cell.row * step - 1.5}
width={cellSize + 3}
height={cellSize + 3}
rx={3}
pointerEvents="none"
{...OPEN_DAY_CELL}
/>
))}
</svg>
</div>
</StretchFrame>
{heatmapDay.tooltipNode}
@@ -356,3 +437,17 @@ export function MoodHeatmap({
</div>
);
}
/** The stretch layout paints into the reserved box; the fixed one sizes itself. */
function StretchFrame({
stretch,
weeks,
children,
}: {
stretch: boolean;
weeks: number;
children: React.ReactNode;
}) {
if (!stretch) return <>{children}</>;
return <MoodHeatmapBody weeks={weeks}>{children}</MoodHeatmapBody>;
}
@@ -29,7 +29,7 @@ function render(
onLinkEpisode={() => {}}
onLinkEncounter={() => {}}
onShare={() => {}}
onDelete={() => {}}
onRequestDelete={() => {}}
onClear={() => {}}
/>
</I18nProvider>,
@@ -51,6 +51,51 @@ describe("<DocumentBulkBar>", () => {
expect(html).toContain("Clear selection");
});
it("clears with an X icon at the right of the count line, not a text button", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
const countLine = html.slice(
html.indexOf('data-slot="document-bulk-count"'),
html.indexOf('data-slot="document-bulk-actions"'),
);
// The clear control lives on the count line, before the action row…
expect(countLine).toContain('data-slot="document-bulk-clear"');
expect(countLine).toContain('aria-label="Clear selection"');
// …and carries no visible label: the name rides on aria-label and title.
expect(countLine).not.toContain(">Clear selection<");
});
it("keeps every verb in ONE action row with Delete alone at its right edge", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
const actions = html.slice(
html.indexOf('data-slot="document-bulk-actions"'),
);
const order = [
"document-bulk-set-kind",
"document-bulk-link-condition",
"document-bulk-link-visit",
"document-bulk-share",
"document-bulk-delete",
].map((slot) => actions.indexOf(`data-slot="${slot}"`));
expect(order.every((index) => index >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
// The row never wraps into a second line, and Delete is pushed right.
const rowTag = /<div[^>]*data-slot="document-bulk-actions"[^>]*>/.exec(
html,
);
expect(rowTag?.[0]).not.toContain("flex-wrap");
const deleteTag = /<button[^>]*data-slot="document-bulk-delete"[^>]*>/.exec(
html,
);
expect(deleteTag?.[0]).toContain("ml-auto");
});
it("carries the selection-bar slot the Coach launcher steps aside for", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
expect(html).toMatch(
/^<div data-slot="selection-action-bar" class="contents">/,
);
});
it("omits the link-condition verb when the account has no episodes", () => {
const html = render([]);
expect(html).not.toContain("Link condition");
@@ -7,12 +7,50 @@ import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
listColumnsForWidth,
} from "../document-month-grid";
function doc(id: string): InboundDocumentDto {
return {
id,
kind: "DOCTOR_REPORT",
title: `Letter ${id}`,
filename: null,
mimeType: "application/pdf",
byteSize: 1024,
status: "STORED",
documentDate: "2026-03-10",
createdAt: "2026-03-10T10:00:00Z",
conditionLinks: [],
servingClass: "inline",
hasThumbnail: false,
hasContentIndex: true,
} as unknown as InboundDocumentDto;
}
function renderRow(props: Partial<Parameters<typeof DocumentMonthRow>[0]>) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentMonthRow
documents={[doc("a"), doc("b")]}
columns={2}
selectedIds={new Set()}
onOpen={() => {}}
highlightId={null}
rovingId="a"
onCardFocus={() => {}}
{...props}
/>
</I18nProvider>,
);
}
describe("columnsForWidth", () => {
it("lays a 390 px phone out in two columns, the narrowest screens in one", () => {
// 390 viewport minus the shell's 16 px gutters.
@@ -50,3 +88,40 @@ describe("<DocumentMonthHeading>", () => {
expect(html).toMatch(/^<h2 [^>]*uppercase[^>]*>March 2026<\/h2>$/);
});
});
describe("listColumnsForWidth", () => {
it("keeps compact rows single-file on a phone and side by side on a desktop", () => {
expect(listColumnsForWidth(358)).toBe(1);
expect(listColumnsForWidth(1024 - 48)).toBe(2);
expect(listColumnsForWidth(1280 - 48)).toBe(3);
});
});
describe("<DocumentMonthRow>", () => {
it("renders preview tiles by default and compact rows in the list view", () => {
expect(renderRow({})).toContain('data-variant="tile"');
const list = renderRow({ view: "list" });
expect(list).toContain('data-variant="row"');
expect(list).not.toContain('data-variant="tile"');
});
it("flowing: the month name rides inline above the document that opens it", () => {
const html = renderRow({
monthStarts: { b: "2026-02" },
formatMonth: () => "February 2026",
});
expect(html.match(/data-slot="document-flow-month"/g)).toHaveLength(1);
expect(html).toContain("February 2026");
// The marker belongs to b's cell, not a's.
expect(html.indexOf("February 2026")).toBeGreaterThan(
html.indexOf('data-document-id="a"'),
);
expect(html.indexOf("February 2026")).toBeLessThan(
html.indexOf('data-document-id="b"'),
);
});
it("stacked rows carry no inline month marker", () => {
expect(renderRow({})).not.toContain('data-slot="document-flow-month"');
});
});
@@ -0,0 +1,55 @@
/**
* Every way of deleting from the vault asks first.
*
* Until v1.42 the bulk bar's Delete and a focused card's Delete key both
* called the bulk delete straight away; only the transient undo toast stood
* between a stray press and a medical document leaving the vault. The page now
* routes both through one confirmation (`requestDelete` → `ConfirmDialog` →
* `confirmDelete`), and the detail sheet's Delete opens its own.
*
* Structural rather than behavioural because the unit layer renders statically;
* the e2e journey (`documents-qol.spec.ts`) clicks through the real dialog. The
* matchers assert a non-zero count, so a rename that leaves them matching
* nothing fails instead of passing quietly.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const dir = join(process.cwd(), "src/components/documents");
const view = readFileSync(join(dir, "documents-view.tsx"), "utf8");
const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8");
const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8");
describe("vault delete paths confirm before deleting", () => {
it("the page calls the bulk delete only from the confirmation", () => {
const calls = [...view.matchAll(/deleteBulk\(\s*([^)]*)\)/g)].map((m) =>
m[1].trim(),
);
// One call site, and it is the confirmed id list.
expect(calls).toEqual(["pendingDeleteIds"]);
expect(view).toMatch(
/const confirmDelete = useCallback\(\(\) => \{\s*if \(pendingDeleteIds\) deleteBulk\(pendingDeleteIds\);/,
);
expect(view).toContain("<ConfirmDialog");
});
it("the bulk bar and the card key both go through requestDelete", () => {
expect(view).toMatch(
/onDelete=\{\s*canManageDocuments \? \(id\) => requestDelete\(\[id\]\)/,
);
expect(view).toMatch(
/onRequestDelete=\{\(\) => requestDelete\(\[\.\.\.selectedIds\]\)\}/,
);
// The bar has no delete of its own to call.
expect(bar).not.toMatch(/\bonDelete\b/);
expect(bar).toMatch(/onClick=\{onRequestDelete\}/);
});
it("the detail sheet's Delete opens a confirmation instead of deleting", () => {
const mutateCalls = [...sheet.matchAll(/remove\.mutate\(/g)];
expect(mutateCalls.length).toBe(1);
expect(sheet).toMatch(/onConfirm=\{\(\) => remove\.mutate\(doc\.id\)\}/);
expect(sheet).toMatch(/onClick=\{\(\) => setConfirmDeleteOpen\(true\)\}/);
});
});
@@ -49,11 +49,18 @@ import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DocumentsView } from "../documents-view";
function render(search: string, seedList?: { kinds?: never } | object) {
function render(
search: string,
seedList?: { kinds?: never } | object,
layout: object | null = { version: 1, view: "cards", arrangement: "stacked" },
) {
mockSearch = search;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (layout !== null) {
queryClient.setQueryData(queryKeys.documentsLayout(), layout);
}
if (seedList !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
pages: [{ documents: [], nextCursor: null }],
@@ -80,6 +87,29 @@ describe("<DocumentsView> states", () => {
expect(html).not.toContain("Your document vault is empty");
});
it("header: a wrench opens the vault's display settings, left of Upload", () => {
const html = render("", {});
const wrench = html.indexOf('data-slot="documents-customize"');
expect(wrench).toBeGreaterThan(-1);
expect(html).toContain('href="/settings/layout/documents"');
expect(html.indexOf("Upload", wrench)).toBeGreaterThan(wrench);
});
it("first load waits for the presentation, then paints the list skeleton for a list reader", () => {
// The presentation read is still out: the vault does not guess a shape.
const pending = render("", {}, null);
expect(pending).toContain('data-slot="documents-loading"');
expect(pending).not.toContain("Your document vault is empty");
const listReader = render("", undefined, {
version: 1,
view: "list",
arrangement: "flow",
});
expect(listReader).toContain('data-slot="documents-loading"');
expect(listReader).not.toContain("aspect-[4/3]");
});
it("filter bar: type facet is a compact dropdown, not an inline chip row", () => {
const html = render("");
// The type facet renders as a single dropdown trigger labelled by the
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildProcedureChoices,
buildFlowTimelineItems,
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
@@ -127,6 +128,28 @@ describe("API list search", () => {
});
describe("timeline items", () => {
it("flowing: rows run across month boundaries and mark where each month opens", () => {
const docs = [
doc({ id: "a", documentDate: "2026-03-20" }),
doc({ id: "b", documentDate: "2026-03-15" }),
doc({ id: "c", documentDate: "2026-02-28" }),
doc({ id: "d", documentDate: "2026-02-10" }),
doc({ id: "e", documentDate: "2026-01-05" }),
];
const items = buildFlowTimelineItems(docs, 3, "UTC");
// No row is spent on a month: five documents in three columns are two rows.
expect(items.map((i) => i.type)).toEqual(["row", "row"]);
expect(items[0]).toMatchObject({
documents: [docs[0], docs[1], docs[2]],
monthStarts: { a: "2026-03", c: "2026-02" },
});
// February continues into the second row without a second marker.
expect(items[1]).toMatchObject({
documents: [docs[3], docs[4]],
monthStarts: { e: "2026-01" },
});
});
it("buckets by month and chunks rows to the column count", () => {
const docs = [
doc({ id: "a", documentDate: "2026-03-20" }),
+186 -152
View File
@@ -3,20 +3,24 @@
/**
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* bulk verbs — set type, link condition, file against a visit, share (one
* link for the whole selection), delete (undo-able), clear — all driving the
* page's handlers.
* and near the bottom edge on desktop.
*
* Two tight lines. The first carries the selected count and, at its right
* edge, the X that clears the selection. The second is the one action row:
* set type, link condition, file against a visit and share lead from the
* left, and Delete sits alone at the right edge in the destructive style.
* Delete never deletes from here — it asks the page, which confirms first
* (`onRequestDelete`), the same path the card's Delete key takes.
*
* The two link verbs are the same shape and stay that way: each renders only
* when the account HAS something to link to, so an empty menu is never
* offered.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. Share sits just
* before Delete (the safe actions lead). The bar is a deliberate hand-rolled
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
* per the standards.
* Below `lg` every verb is icon-only with its name in `aria-label` and
* `title`, at the 44 px tap floor, so the row stays one line at 360 px; the
* labels return where the bar is wide enough to hold them on one line. A
* `role="toolbar"`; the bar is a deliberate hand-rolled shell (a floating
* toolbar is not a Card surface): dense-tile padding `p-3` per the standards.
*/
import {
CalendarClock,
@@ -50,6 +54,19 @@ export interface BulkEncounterOption {
label: string;
}
/** Shared shape of the leading verbs: icon-only below `lg`, labelled from it. */
const VERB_CLASS = "min-h-11 min-w-11 sm:min-h-9 sm:min-w-9";
function VerbLabel({ children }: { children: string }) {
// Icon-only again while a day is docked: the bar then shares the width
// with the day panel and the labelled row would no longer fit one line.
return (
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
{children}
</span>
);
}
export function DocumentBulkBar({
selectedCount,
episodes,
@@ -59,7 +76,7 @@ export function DocumentBulkBar({
onLinkEpisode,
onLinkEncounter,
onShare,
onDelete,
onRequestDelete,
onClear,
}: {
selectedCount: number;
@@ -78,157 +95,174 @@ export function DocumentBulkBar({
* this handler just opens the share sheet seeded with the selection.
*/
onShare: () => void;
onDelete: () => void;
/** Ask to delete the selection. The page confirms before anything goes. */
onRequestDelete: () => void;
onClear: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
{t("documents.selection.count", { count: selectedCount })}
</p>
// The outer slot is the data-list selection bar's: the Coach launcher
// watches for it and steps aside while a selection is open, so it never
// sits over Delete at the bar's right edge. `contents` keeps the wrapper
// out of the layout.
<div data-slot="selection-action-bar" className="contents">
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<div className="flex items-center justify-between gap-2">
<p
className="px-1 text-sm font-medium whitespace-nowrap"
role="status"
data-slot="document-bulk-count"
>
{t("documents.selection.count", { count: selectedCount })}
</p>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 sm:size-9"
onClick={onClear}
data-slot="document-bulk-clear"
aria-label={t("documents.selection.clear")}
title={t("documents.selection.clear")}
>
<X className="size-4" aria-hidden />
</Button>
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
{/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a
2–3-row floating slab at ~360 px. Below `sm` every verb collapses
to icon-only (`aria-label` carries the accessible name; the
visible label returns at `sm+`) — the `document-detail-sheet`
footer's established icon-collapse pattern. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.setKind")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkCondition")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkVisit")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
<div
data-slot="document-bulk-actions"
className="flex items-center gap-1.5"
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">{t("documents.bulk.share")}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-set-kind"
aria-label={t("documents.bulk.setKind")}
title={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.setKind")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Solid destructive (matching the detail sheet's Delete) — the
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-link-condition"
aria-label={t("documents.bulk.linkCondition")}
title={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.linkCondition")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
title={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.linkVisit")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
className={VERB_CLASS}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
title={t("documents.bulk.share")}
>
<Share2 className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.share")}</VerbLabel>
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onDelete}
aria-label={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
<span className="hidden sm:inline">{t("documents.bulk.delete")}</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClear}
aria-label={t("documents.selection.clear")}
>
<X className="size-3.5" aria-hidden />
<span className="hidden sm:inline">
{t("documents.selection.clear")}
</span>
</Button>
WCAG contrast gate. Alone at the right edge, away from the safe
verbs. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onRequestDelete}
className={cn("ml-auto", VERB_CLASS)}
data-slot="document-bulk-delete"
aria-label={t("documents.bulk.delete")}
title={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.delete")}</VerbLabel>
</Button>
</div>
</div>
</div>
);
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -442,6 +443,8 @@ export function DocumentDetailSheet({
/** Bumped on a failed write; remounts the link block onto server state. */
const [linkResets, setLinkResets] = useState(0);
const [shareOpen, setShareOpen] = useState(false);
// Delete asks first, like every other way of deleting from the vault.
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
* The visit this SESSION filed the document against, if any.
@@ -769,8 +772,9 @@ export function DocumentDetailSheet({
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
onClick={() => setConfirmDeleteOpen(true)}
disabled={remove.isPending}
data-slot="document-detail-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
@@ -1214,6 +1218,19 @@ export function DocumentDetailSheet({
documents={[{ id: doc.id, title }]}
/>
) : null}
{doc ? (
<ConfirmDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title={t("documents.deleteConfirm.titleOne")}
body={t("documents.deleteConfirm.body")}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={() => remove.mutate(doc.id)}
pending={remove.isPending}
slot="document-detail-delete"
destructive
/>
) : null}
</>
);
}
@@ -22,6 +22,20 @@ export function columnsForWidth(width: number): number {
return 1;
}
/**
* Measured width → compact rows side by side in the list view. Stacked, the
* list is one column; flowing, it fills the width the way the tiles do, with
* wider cells because a row carries its title beside the preview.
*/
export function listColumnsForWidth(width: number): number {
if (width >= 1100) return 3;
if (width >= 700) return 2;
return 1;
}
/** Height the flowing arrangement reserves above each row for month names. */
export const FLOW_MONTH_LABEL_PX = 24;
/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */
const GRID_GAP_PX = 16;
@@ -29,10 +43,17 @@ const GRID_GAP_PX = 16;
* First guess at a row's height before the virtualizer measures it: a 4:3
* preview across the tile's width plus the title and meta lines under it.
*/
export function estimatedRowHeight(width: number, columns: number): number {
export function estimatedRowHeight(
width: number,
columns: number,
view: "cards" | "list" = "cards",
flow = false,
): number {
const extra = flow ? FLOW_MONTH_LABEL_PX - GRID_GAP_PX : 0;
if (view === "list") return 84 + GRID_GAP_PX + extra;
const cols = Math.max(1, columns);
const tileWidth = Math.max(0, (width - GRID_GAP_PX * (cols - 1)) / cols);
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX;
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX + extra;
}
export function DocumentMonthHeading({ label }: { label: string }) {
@@ -43,9 +64,32 @@ export function DocumentMonthHeading({ label }: { label: string }) {
);
}
/**
* The flowing arrangement's month marker: the month's name and a hairline
* that runs to the edge of its cell, sitting in the space above the first
* document of that month. It marks where a month begins without taking a row
* of its own, so the documents keep running across the width.
*/
function FlowMonthMarker({ label }: { label: string }) {
return (
<div
data-slot="document-flow-month"
className="absolute inset-x-0 -top-6 flex h-6 items-center gap-2"
>
<span className="text-muted-foreground shrink-0 truncate text-xs font-medium tracking-wide uppercase">
{label}
</span>
<span aria-hidden className="bg-border h-px flex-1" />
</div>
);
}
export function DocumentMonthRow({
documents,
columns,
view = "cards",
monthStarts,
formatMonth,
selectedIds,
onToggleSelected,
onOpen,
@@ -57,6 +101,12 @@ export function DocumentMonthRow({
}: {
documents: InboundDocumentDto[];
columns: number;
/** `cards` = preview tiles, `list` = compact rows. */
view?: "cards" | "list";
/** Flowing arrangement: documents that open a month → that month's key. */
monthStarts?: Record<string, string>;
/** YYYY-MM → the reader's month label; needed with `monthStarts`. */
formatMonth?: (key: string) => string;
selectedIds: ReadonlySet<string>;
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
@@ -66,27 +116,40 @@ export function DocumentMonthRow({
onCardFocus: (id: string) => void;
onPrefetch?: (id: string) => void;
}) {
const flow = monthStarts !== undefined;
return (
<div
data-slot="document-month-row"
className="grid gap-4 pb-4"
className={flow ? "grid gap-4 pt-6" : "grid gap-4 pb-4"}
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{documents.map((doc) => (
<DocumentCard
key={doc.id}
variant="tile"
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={onCardFocus}
onPrefetch={onPrefetch}
/>
))}
{documents.map((doc) => {
const monthKey = monthStarts?.[doc.id];
const card = (
<DocumentCard
key={doc.id}
variant={view === "list" ? "row" : "tile"}
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={onCardFocus}
onPrefetch={onPrefetch}
/>
);
if (!flow) return card;
return (
<div key={doc.id} className="relative min-w-0">
{monthKey ? (
<FlowMonthMarker label={formatMonth?.(monthKey) ?? monthKey} />
) : null}
{card}
</div>
);
})}
</div>
);
}
+47 -9
View File
@@ -31,14 +31,23 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { UploadStateCard } from "./document-card";
import type {
DocumentsLayoutArrangement,
DocumentsLayoutView,
} from "@/lib/documents/documents-layout";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
listColumnsForWidth,
} from "./document-month-grid";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
import {
buildFlowTimelineItems,
buildTimelineItems,
formatMonthLabel,
} from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
@@ -56,7 +65,13 @@ export function DocumentTimeline({
highlightId,
onPrefetch,
timezone,
view = "cards",
arrangement = "stacked",
}: {
/** Preview tiles or compact rows (the reader's vault presentation). */
view?: DocumentsLayoutView;
/** Months as their own blocks, or one continuous run across the width. */
arrangement?: DocumentsLayoutArrangement;
/** The reader's profile zone; an undated document files under its upload day there. */
timezone: string;
documents: InboundDocumentDto[];
@@ -85,24 +100,39 @@ export function DocumentTimeline({
// the remembered card left the corpus (filter change, deletion).
const [activeId, setActiveId] = useState<string | null>(null);
// Measured columns — the ResizeObserver drives the row chunking.
const flow = arrangement === "flow";
// Measured columns — the ResizeObserver drives the row chunking. Tiles
// share a row on any width; compact rows only in the flowing arrangement,
// a stacked list stays one column.
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => {
setColumns(columnsForWidth(el.clientWidth));
setGridWidth(el.clientWidth);
const width = el.clientWidth;
setColumns(
view === "cards"
? columnsForWidth(width)
: flow
? listColumnsForWidth(width)
: 1,
);
setGridWidth(width);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [view, flow]);
const items = useMemo(
() => buildTimelineItems(documents, columns, timezone),
[documents, columns, timezone],
() =>
flow
? buildFlowTimelineItems(documents, columns, timezone)
: buildTimelineItems(documents, columns, timezone),
[documents, columns, timezone, flow],
);
const formatMonth = (key: string) => formatMonthLabel(key, locale);
// The timeline does not start at the scrollport's top edge (page header,
// filter bar, upload row sit above it) — feed the offset to the
@@ -134,7 +164,7 @@ export function DocumentTimeline({
estimateSize: (index) =>
items[index].type === "month"
? 40
: estimatedRowHeight(gridWidth, columns),
: estimatedRowHeight(gridWidth, columns, view, flow),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
@@ -237,7 +267,12 @@ export function DocumentTimeline({
]);
return (
<div data-slot="document-timeline" className="space-y-4">
<div
data-slot="document-timeline"
data-view={view}
data-arrangement={arrangement}
className="space-y-4"
>
{uploadItems.length > 0 ? (
<div
data-slot="document-upload-queue"
@@ -296,6 +331,9 @@ export function DocumentTimeline({
<DocumentMonthRow
documents={item.documents}
columns={columns}
view={view}
monthStarts={item.monthStarts}
formatMonth={formatMonth}
selectedIds={selectedIds}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
+107 -28
View File
@@ -27,13 +27,16 @@ import {
SearchX,
Upload,
UploadCloud,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -56,6 +59,7 @@ import {
useCoachLaunch,
} from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
@@ -408,6 +412,10 @@ export function DocumentsView() {
[list.data],
);
// The reader's presentation (cards / list, stacked / flowing months),
// persisted per user and changed behind the wrench.
const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled);
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/
// measurements/medications. The vault is one of the sharpest PWA-resume-
// staleness surfaces named in the audit: a document indexed server-side
@@ -755,6 +763,21 @@ export function DocumentsView() {
[bulk, clearSelection, restoreBulk, t],
);
// ── Delete confirmation ───────────────────────────────────────────────
// Every way of deleting from the vault page — the bulk bar's Delete and a
// card's Delete key — comes through here and asks first. Nothing on this
// page calls `deleteBulk` directly.
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
null,
);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length > 0) setPendingDeleteIds(ids);
}, []);
const confirmDelete = useCallback(() => {
if (pendingDeleteIds) deleteBulk(pendingDeleteIds);
setPendingDeleteIds(null);
}, [pendingDeleteIds, deleteBulk]);
// ── Bulk share ────────────────────────────────────────────────────────
// ONE documents-only link for the whole selection (the share model carries
// up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded
@@ -934,17 +957,42 @@ export function DocumentsView() {
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
canManageDocuments ? (
canManageDocuments || !inSharedRecord ? (
<div className="flex flex-wrap items-center gap-2">
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
{/* The wrench left of the primary action, as on the illness
journal: it opens this vault's display settings. `/settings`
is the caller's own, so it never shows inside somebody
else's record. */}
{!inSharedRecord ? (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/documents"
aria-label={t("documents.customize")}
title={t("documents.customize")}
data-slot="documents-customize"
>
<Wrench className="size-4" aria-hidden />
</Link>
</Button>
) : null}
{canManageDocuments ? (
<>
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
</>
) : null}
</div>
) : null
}
@@ -990,22 +1038,30 @@ export function DocumentsView() {
onIndexAll={handleIndexAll}
/>
{list.isPending ? (
<div
data-slot="documents-loading"
// The archive's tile shape (a 4:3 preview over two text lines), in
// the timeline's column counts, so the grid does not jump when the
// first page lands.
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
{list.isPending || isLayoutLoading ? (
layout.view === "list" ? (
<div data-slot="documents-loading" className="space-y-4">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : (
<div
data-slot="documents-loading"
// The archive's tile shape (a 4:3 preview over two text lines),
// in the timeline's column counts, so the grid does not jump when
// the first page lands.
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
)
) : list.isError ? (
<QueryErrorCard
title={t("documents.list.loadError")}
@@ -1042,6 +1098,8 @@ export function DocumentsView() {
/>
) : (
<DocumentTimeline
view={layout.view}
arrangement={layout.arrangement}
timezone={timezone}
documents={documents}
uploadItems={upload.items}
@@ -1052,7 +1110,9 @@ export function DocumentsView() {
selectedIds={selectedIds}
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
onOpen={openDetail}
onDelete={canManageDocuments ? (id) => deleteBulk([id]) : undefined}
onDelete={
canManageDocuments ? (id) => requestDelete([id]) : undefined
}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
@@ -1081,11 +1141,30 @@ export function DocumentsView() {
runBulk("linkEncounter", { encounterId })
}
onShare={openBulkShare}
onDelete={() => deleteBulk([...selectedIds])}
onRequestDelete={() => requestDelete([...selectedIds])}
onClear={clearSelection}
/>
) : null}
<ConfirmDialog
open={pendingDeleteIds !== null}
onOpenChange={(open) => {
if (!open) setPendingDeleteIds(null);
}}
title={
pendingDeleteIds !== null && pendingDeleteIds.length > 1
? t("documents.deleteConfirm.titleMany", {
count: pendingDeleteIds.length,
})
: t("documents.deleteConfirm.titleOne")
}
body={t("documents.deleteConfirm.body")}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={confirmDelete}
slot="documents-delete"
destructive
/>
<DocumentShareSheet
open={bulkShareOpen}
onOpenChange={setBulkShareOpen}
+45 -1
View File
@@ -185,7 +185,17 @@ export function hasProcessingDocument(
*/
export type TimelineItem =
| { type: "month"; key: string }
| { type: "row"; key: string; documents: InboundDocumentDto[] };
| {
type: "row";
key: string;
documents: InboundDocumentDto[];
/**
* Flowing arrangement only: the documents in this row that open a
* month, mapped to that month's YYYY-MM key. The month name rides
* inline above the document instead of taking a row of its own.
*/
monthStarts?: Record<string, string>;
};
/**
* Bucket an already-sorted document list into consecutive YYYY-MM sections
@@ -224,6 +234,40 @@ export function buildTimelineItems(
return items;
}
/**
* The flowing arrangement: documents run left to right across every row and
* wrap, with no row reserved for a month. The first document of each month
* carries that month in `monthStarts`, so its name can sit inline above it.
* Order is preserved exactly as received, like `buildTimelineItems`.
*/
export function buildFlowTimelineItems(
documents: InboundDocumentDto[],
columns: number,
timezone: string,
): TimelineItem[] {
const cols = Math.max(1, columns);
const items: TimelineItem[] = [];
let month: string | null = null;
for (let i = 0; i < documents.length; i += cols) {
const row = documents.slice(i, i + cols);
const monthStarts: Record<string, string> = {};
for (const doc of row) {
const key = documentMonthKey(doc, timezone);
if (key !== month) {
monthStarts[doc.id] = key;
month = key;
}
}
items.push({
type: "row",
key: `flow:${row[0].id}`,
documents: row,
monthStarts,
});
}
return items;
}
/**
* Format a YYYY-MM month key as a locale month label ("März 2026"). Handed
* over as a noon-UTC instant so the month never shifts under the display
@@ -42,6 +42,13 @@ vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: () => <div data-slot="score-anatomy-view-probe" />,
}));
// The score's course renders a live chart for the stored scores; this deck is
// about the assessment's hand-off, so the course is a probe here.
vi.mock("../score-history", () => ({
ScoreHistoryChart: () => <div data-slot="score-history-probe" />,
ScoreHistoryCard: () => <div data-slot="score-history-probe" />,
}));
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
import type { AnatomyMetricId } from "../composite-score-anatomy";
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.42 — every score's detail page shows its course right under the score
* card, and the warning-coloured "descriptive proxy, not clinical" line no
* longer rides above the method.
*
* The stored nightly scores (recovery, stress, strain) get the full metric
* chart over their measurement type; the two composites draw the course their
* value carries, and nothing when it carries fewer than two points. The chart
* and the card are stubbed to probes that state what they were given.
*/
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
const derived = { current: {} as Record<string, unknown> };
vi.mock("../use-derived-metric", () => ({
useDerivedMetric: () => derived.current,
}));
vi.mock("@/components/insights/insight-status-card", () => ({
InsightStatusCard: () => <div data-slot="assessment-probe" />,
}));
vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: ({ method }: { method: React.ReactNode }) => (
<div data-slot="anatomy-probe">{method}</div>
),
}));
vi.mock("../score-history", () => ({
ScoreHistoryChart: ({ type }: { type: string }) => (
<div data-slot="history-chart-probe" data-type={type} />
),
ScoreHistoryCard: ({
series,
windowDays,
}: {
series: number[];
windowDays: number;
}) => (
<div
data-slot="history-card-probe"
data-points={series.length}
data-window={windowDays}
/>
),
}));
import {
CompositeScoreAnatomy,
type AnatomyMetricId,
} from "../composite-score-anatomy";
function render(metric: AnatomyMetricId, series?: number[]): string {
derived.current = {
isLoading: false,
isError: false,
data: {
status: "ok",
value: {
score: 70,
band: "yellow",
subScores: [],
components: [],
series,
},
coverage: {},
confidence: null,
provenance: { windowDays: 30 },
assessment: null,
},
};
return renderToStaticMarkup(<CompositeScoreAnatomy metric={metric} />);
}
describe("score detail history", () => {
it.each(["RECOVERY_SCORE", "STRESS_SCORE", "STRAIN_SCORE"] as const)(
"%s gets the full chart over its stored type",
(metric) => {
const html = render(metric, [60, 70]);
expect(html).toContain(
`data-slot="history-chart-probe" data-type="${metric}"`,
);
expect(html).not.toContain("history-card-probe");
// Under the score card, not somewhere else on the page.
expect(html.indexOf("history-chart-probe")).toBeGreaterThan(
html.indexOf("anatomy-probe"),
);
},
);
it.each(["SLEEP_SCORE", "READINESS"] as const)(
"%s draws the course its value carries, over its window",
(metric) => {
const html = render(metric, [55, 61, 70]);
expect(html).toContain('data-points="3"');
expect(html).toContain('data-window="30"');
expect(html).not.toContain("history-chart-probe");
},
);
it("an account without a score gets no empty chart under the insufficient card", () => {
derived.current = {
isLoading: false,
isError: false,
data: {
status: "insufficient",
value: null,
coverage: {},
confidence: null,
provenance: { windowDays: 14 },
assessment: null,
},
};
const html = renderToStaticMarkup(
<CompositeScoreAnatomy metric="RECOVERY_SCORE" />,
);
expect(html).toContain("anatomy-probe");
expect(html).not.toContain("history-chart-probe");
});
it("a composite with fewer than two points draws no course", () => {
expect(render("READINESS", [70])).not.toContain("history-card-probe");
expect(render("READINESS")).not.toContain("history-card-probe");
});
it("no score page carries the caveat line above its method", () => {
for (const metric of [
"SLEEP_SCORE",
"READINESS",
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const) {
const html = render(metric, [60, 70]);
expect(html).toContain(`insights.derived.composite.${metric}.method`);
expect(html).not.toContain(".caveat");
expect(html).not.toContain("text-warning");
}
});
});
@@ -135,4 +135,37 @@ describe("<WellnessScores>", () => {
expect(hidden).not.toContain('data-metric="STRAIN_SCORE"');
expect(hidden).toContain('data-metric="READINESS"');
});
it("draws each tile's course under its band word, naming the window", () => {
const html = render(
<WellnessScores
read={readFrom({
RECOVERY_SCORE: ok({
score: 70,
band: "yellow",
series: [60, 64, 70],
}),
STRESS_SCORE: ok({ score: 30, band: "green", series: [] }),
})}
isLoading={false}
/>,
);
const recovery = html.slice(
html.indexOf('data-metric="RECOVERY_SCORE"'),
html.indexOf('data-metric="STRESS_SCORE"'),
);
expect(recovery).toContain('data-slot="wellness-score-history"');
expect(recovery).toContain("Last 30 days");
// A score without a course draws no empty line.
const stress = html.slice(html.indexOf('data-metric="STRESS_SCORE"'));
expect(stress).not.toContain('data-slot="wellness-score-history"');
});
it("reserves the history band in the loading skeleton", () => {
const html = render(<WellnessScores read={readFrom({})} isLoading />);
const tile = html.slice(
html.indexOf('data-slot="wellness-score-tile-skeleton"'),
);
expect(tile).toContain("h-10 w-full");
});
});
@@ -19,12 +19,13 @@ import {
import type { RingHue } from "./ring-hues";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { METRIC_PROVENANCE } from "./standards";
import { ScoreHistoryCard, ScoreHistoryChart } from "./score-history";
/**
* v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived
* score and renders the reusable `ScoreAnatomyView`. It owns the per-metric
* presentation map (title, contributor labels, the cited standard, the
* plain-language method + honesty caveat) so the anatomy view itself stays
* plain-language method) so the anatomy view itself stays
* metric-agnostic.
*
* Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` —
@@ -32,12 +33,10 @@ import { METRIC_PROVENANCE } from "./standards";
* three persisted nightly scores (`RECOVERY_SCORE`, `STRESS_SCORE`,
* `STRAIN_SCORE`). The persisted scores carry no sub-decomposition, so they
* render the ring + coverage + the provenance surface (method + cited
* standard + caveat) with no contributor rows — the acceptance-criterion fix
* that gives every wellness ring a provenance surface instead of a read-only
* dead-end. STRESS surfaces its "HRV-derived proxy, not an EDA/galvanic
* measurement" caveat via the standards map.
* standard) with no contributor rows — the acceptance-criterion fix that gives
* every wellness ring a provenance surface instead of a read-only dead-end.
*
* The standard + method/caveat keys come from the single `METRIC_PROVENANCE`
* The standard + method keys come from the single `METRIC_PROVENANCE`
* source map so the citation a metric exposes never drifts across surfaces.
*
* Client-only: `import type` for the value shapes (no server graph leaks);
@@ -147,16 +146,12 @@ export function CompositeScoreAnatomy({
}
}
// Method copy carries an optional honesty caveat above it (STRESS proxy,
// descriptive-not-clinical, …) so the caveat reaches the user, not just
// the engine header.
// v1.42 — the per-score "descriptive proxy, not clinical" line that sat
// above the method in warning colour is gone: it repeated on every score
// page what the method and the page explainer already say, and read as an
// alarm. The method copy closes the card on its own.
const method = (
<>
{meta.caveatKey ? (
<span className="text-warning block font-medium">
{t(meta.caveatKey)}
</span>
) : null}
{t(meta.methodKey)}
{anchorLine ? <span className="mt-1 block">{anchorLine}</span> : null}
</>
@@ -268,6 +263,27 @@ export function CompositeScoreAnatomy({
// the card always renders its prose rather than the no-provider fallback.
const assessment = data.assessment;
// v1.42 — the score's course, right under its card. A stored nightly score
// gets the full chart; a composite draws the course its value carries.
const series =
data.status === "ok" && data.value
? (data.value as { series?: number[] }).series
: undefined;
// Only beside a score: an account without one gets the honest
// insufficient card, not an empty chart under it.
const history =
data.status !== "ok" ? null : metric === "RECOVERY_SCORE" ||
metric === "STRESS_SCORE" ||
metric === "STRAIN_SCORE" ? (
<ScoreHistoryChart type={metric} hue={METRIC_HUE[metric]} />
) : series && series.length >= 2 ? (
<ScoreHistoryCard
series={series}
windowDays={data.provenance.windowDays}
hue={METRIC_HUE[metric]}
/>
) : null;
return (
<div className="space-y-3">
<ScoreAnatomyView
@@ -284,6 +300,7 @@ export function CompositeScoreAnatomy({
insufficient={insufficient}
className={className}
/>
{history}
{assessment ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
@@ -32,7 +32,14 @@ export function DeltaSparkline({
data,
strokeVar,
comparisonLabel,
domain,
}: {
/**
* A fixed y-domain. Omitted, the line spans its own min..max, which suits a
* vital read against itself; a bounded score passes its scale (`[0, 100]`)
* so a night-to-night wobble of a few points is not drawn as a cliff.
*/
domain?: [number, number];
/**
* `{ i, v }` points, already length-guarded (≥2) by the tile. An optional
* `b` on a point is the comparison series' value at the same index —
@@ -62,7 +69,7 @@ export function DeltaSparkline({
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
<YAxis hide domain={domain ?? ["dataMin", "dataMax"]} />
{/* The comparison curve sits UNDER the live one so today's line is
never occluded by the reference it is being judged against. */}
{hasComparison ? (
@@ -0,0 +1,91 @@
"use client";
import dynamic from "next/dynamic";
import { History } from "lucide-react";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { TileHeader } from "@/components/insights/tile-header";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { RING_GRADIENT, type RingHue } from "./ring-hues";
/**
* v1.42 — a score's course over time on its detail page, directly under the
* score card.
*
* The three nightly scores (recovery, stress, strain) are stored readings, so
* they get the full metric chart every measured page carries: the 7 / 30 / 90
* / All switch, the tooltip, and each day opening its day view. The two
* composites (sleep score, readiness) are computed rather than stored, so the
* course they carry on their value is drawn as a compact line over its window.
*/
const DeltaSparkline = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DeltaSparkline,
})),
{ ssr: false, loading: () => null },
);
export function ScoreHistoryChart({
type,
hue,
}: {
/** The stored measurement type the score is written as. */
type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE";
hue: RingHue;
}) {
const { t } = useTranslations();
const { user } = useAuth();
return (
<div data-slot="score-history-chart" data-type={type}>
<HealthChartDynamic
types={[type]}
title={t("insights.derived.scores.historyTitle")}
titleIcon={History}
colors={[RING_GRADIENT[hue][1]]}
unit={t("insights.deviceScore.unitScore")}
userTimezone={user?.timezone}
dayLinks
/>
</div>
);
}
export function ScoreHistoryCard({
series,
windowDays,
hue,
}: {
series: number[];
windowDays: number;
hue: RingHue;
}) {
const { t } = useTranslations();
return (
<Card data-slot="score-history-card" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={History}
title={t("insights.derived.scores.historyTitle")}
right={
<span className="text-muted-foreground text-xs">
{t("insights.derived.scores.historyWindow", { days: windowDays })}
</span>
}
/>
</CardHeader>
<CardContent>
<div className="h-24 w-full" aria-hidden="true">
<DeltaSparkline
data={series.map((v, i) => ({ i, v }))}
strokeVar={RING_GRADIENT[hue][1]}
domain={[0, 100]}
/>
</div>
</CardContent>
</Card>
);
}
@@ -111,7 +111,6 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
name: "Plews et al. 2013, Sports Medicine",
url: "https://doi.org/10.1007/s40279-013-0071-8",
},
caveatKey: "insights.derived.composite.READINESS.caveat",
},
RECOVERY_SCORE: {
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
@@ -120,7 +119,6 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
name: "Buchheit 2014, Front. Physiol.",
url: "https://doi.org/10.3389/fphys.2014.00073",
},
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
STRESS_SCORE: {
methodKey: "insights.derived.composite.STRESS_SCORE.method",
@@ -129,7 +127,6 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
name: "Kim et al. 2018, Psychiatry Investig.",
url: "https://doi.org/10.30773/pi.2017.08.17",
},
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
STRAIN_SCORE: {
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
@@ -138,7 +135,6 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
name: "Banister 1991, TRIMP model",
url: "https://en.wikipedia.org/wiki/Training_Impulse",
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
@@ -1,6 +1,7 @@
"use client";
import { useState, type ComponentType } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
Activity,
@@ -16,7 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import { RING_GRADIENT, TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
import type { ScoreBand } from "./band-tokens";
// Type-only — the compute payloads never drag the server graph into the bundle.
@@ -42,6 +43,17 @@ import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores"
* rollup/persisted values; nothing warms on visit.
*/
// The tile's history line loads through the shared chart-runtime boundary, so
// the strip carries no recharts in its own chunk. The tile owns the fixed
// 40 px band, so the async gap paints an empty band of the same size.
const DeltaSparkline = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DeltaSparkline,
})),
{ ssr: false, loading: () => null },
);
interface ScoreStripProps {
/** The batched-read selector from the parent dashboard's one query. */
read: DerivedBatchRead;
@@ -97,7 +109,14 @@ function RingTile({
index,
animate,
baseline,
history,
}: {
/**
* v1.42 — the score's course (oldest → newest) and the window it covers,
* drawn under the band word so each tile shows where the number came from.
* Fewer than two points draws nothing.
*/
history?: { series: number[]; windowDays: number } | null;
score: number;
band: ScoreBand;
label: string;
@@ -165,10 +184,60 @@ function RingTile({
{bandWord}
</span>
</div>
{history && history.series.length >= 2 ? (
<ScoreHistoryLine
series={history.series}
windowDays={history.windowDays}
strokeVar={RING_GRADIENT[hue][1]}
/>
) : null}
</Link>
);
}
/**
* The tile's history: a fixed 40 px line of the score over its window, with
* the window named beneath it. Decorative to assistive tech — the tile's link
* leads to the detail page, where the full course is readable.
*/
function ScoreHistoryLine({
series,
windowDays,
strokeVar,
}: {
series: number[];
windowDays: number;
strokeVar: string;
}) {
const { t } = useTranslations();
return (
<div data-slot="wellness-score-history" className="space-y-1">
<div className="h-10 w-full" aria-hidden="true">
<DeltaSparkline
data={series.map((v, i) => ({ i, v }))}
strokeVar={strokeVar}
domain={[0, 100]}
/>
</div>
<p className="text-muted-foreground text-center text-xs">
{t("insights.derived.scores.historyWindow", { days: windowDays })}
</p>
</div>
);
}
/** The course a score value carries, if any, paired with its window. */
function historyOf(
read: {
value: { series?: number[] } | null;
provenance: { windowDays: number };
} | null,
): { series: number[]; windowDays: number } | null {
const series = read?.value?.series;
if (!read || !series || series.length < 2) return null;
return { series, windowDays: read.provenance.windowDays };
}
/**
* Loading placeholder mirroring `RingTile`'s footprint (icon+label row, the
* `size="sm"` 120 px ring, band word) so the strip reserves its final height
@@ -191,6 +260,11 @@ function WellnessScoreTileSkeleton() {
<Skeleton className="size-[120px] rounded-full" />
<Skeleton className="h-3 w-12" />
</div>
{/* The history line's band, so a tile that gains one does not grow. */}
<div className="space-y-1">
<Skeleton className="h-10 w-full" />
<Skeleton className="mx-auto h-3 w-20" />
</div>
</div>
);
}
@@ -322,6 +396,7 @@ export function WellnessScores({
metricSlot="READINESS"
hue="readiness"
icon={Gauge}
history={historyOf(readiness)}
index={tiles.length}
animate={play}
baseline={null}
@@ -340,6 +415,7 @@ export function WellnessScores({
metricSlot="RECOVERY_SCORE"
hue="recovery"
icon={HeartPulse}
history={historyOf(recovery)}
index={tiles.length}
animate={play}
baseline={baselineOf(recovery.value.series)}
@@ -358,6 +434,7 @@ export function WellnessScores({
metricSlot="SLEEP_SCORE"
hue="sleep"
icon={Moon}
history={historyOf(sleep)}
index={tiles.length}
animate={play}
baseline={null}
@@ -376,6 +453,7 @@ export function WellnessScores({
metricSlot="STRESS_SCORE"
hue="stress"
icon={Activity}
history={historyOf(stress)}
index={tiles.length}
animate={play}
baseline={baselineOf(stress.value.series)}
@@ -397,6 +475,7 @@ export function WellnessScores({
metricSlot="STRAIN_SCORE"
hue="strain"
icon={Flame}
history={historyOf(strain)}
index={tiles.length}
animate={play}
baseline={baselineOf(strain.value.series)}
@@ -0,0 +1,81 @@
/**
* v1.42 — the mood calendar no longer pops in after the line chart.
*
* While the shared read is pending, and while the page holds the reveal until
* the chart is ready too, the heatmap region paints the calendar's card with
* a skeleton at the calendar's final height — the same reserved box the
* painted grid uses — instead of nothing. Before this release the region
* rendered nothing while loading, so the calendar arrived later and pushed
* the chart down.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
isLoading: false,
isAuthenticated: true,
}),
}));
const state = { current: {} as Record<string, unknown> };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => state.current,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodInsightsSections } from "../mood-insights-sections";
const LOADED = {
data: {
summary: { totalEntries: 12, inTargetPct: null },
heatmap: { windowDays: 40, cells: [] },
betterDays: [],
},
isLoading: false,
isPending: false,
isError: false,
refetch: vi.fn(),
};
function render(reveal?: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodInsightsSections region="heatmap" reveal={reveal} />
</I18nProvider>,
);
}
describe("<MoodInsightsSections region=heatmap> reveal", () => {
it("paints the calendar card with a reserved-height skeleton while the read is pending", () => {
state.current = {
data: undefined,
isLoading: true,
isPending: true,
isError: false,
refetch: vi.fn(),
};
const html = render();
expect(html).toContain("Mood calendar");
expect(html).toContain('data-slot="mood-heatmap-skeleton"');
expect(html).toMatch(/data-slot="mood-heatmap-body" style="height:calc\(/);
});
it("holds the skeleton while the page has not revealed, then paints the grid", () => {
state.current = LOADED;
const held = render(false);
expect(held).toContain('data-slot="mood-heatmap-skeleton"');
const shown = render(true);
expect(shown).not.toContain('data-slot="mood-heatmap-skeleton"');
expect(shown).toContain('role="img"');
});
it("the skeleton and the grid reserve the same box for the same window", () => {
state.current = LOADED;
const box = (html: string) =>
/data-slot="mood-heatmap-body" style="(height:[^"]+)"/.exec(html)?.[1];
expect(box(render(false))).toBeDefined();
expect(box(render(false))).toBe(box(render(true)));
});
});
@@ -12,7 +12,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
MoodHeatmap,
MoodHeatmapSkeleton,
} from "@/components/charts/mood-heatmap";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
import { apiGet } from "@/lib/api/api-fetch";
@@ -97,15 +100,14 @@ const MoodInsightsBreakdowns = dynamic(
*/
export type MoodInsightsRegion = "heatmap" | "assessment" | "rest";
export function MoodInsightsSections({
region = "rest",
}: {
region?: MoodInsightsRegion;
} = {}) {
/**
* The one read every region shares, exported so the page can wait on the same
* cell (same key, same fetch, same options) when it decides when the calendar
* and the line chart appear.
*/
export function useMoodInsights() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
return useQuery({
queryKey: queryKeys.moodInsights(),
queryFn: async () => {
return apiGet<MoodInsightsResponse>("/api/mood/insights");
@@ -113,11 +115,42 @@ export function MoodInsightsSections({
enabled: isAuthenticated,
staleTime: 60_000,
});
}
export function MoodInsightsSections({
region = "rest",
reveal = true,
}: {
region?: MoodInsightsRegion;
/**
* v1.42 — heatmap region only: hold the calendar on its skeleton until the
* page says the line chart below is ready too, so the two appear in the
* same frame instead of one popping in after the other. The skeleton holds
* the calendar's final height, so the reveal moves nothing.
*/
reveal?: boolean;
} = {}) {
const { t } = useTranslations();
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
// `isPending`, not `isLoading`: before the session resolves the read is
// disabled rather than loading, and the frame must already be there.
if (region === "heatmap" && (isPending || (!reveal && !isError))) {
// The calendar's frame and final height from the first paint; the
// window is known once the read has landed, assumed short until then.
if (data && data.summary.totalEntries === 0) return null;
return (
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmapSkeleton days={data?.heatmap.windowDays} />
</SectionCard>
);
}
if (isLoading) {
// Only the "rest" region carries the page-level loading skeleton; the
// heatmap / assessment regions stay invisible while loading so they don't
// stack three skeletons down the page.
// Only the "rest" region carries the page-level loading skeleton (the
// heatmap's own is above); the assessment region stays invisible while
// loading so the page does not stack skeletons down its length.
return region === "rest" ? (
<Skeleton className="h-48 w-full rounded-lg" />
) : null;
+60 -18
View File
@@ -161,6 +161,65 @@ export interface SubPageShellProps {
children: ReactNode;
}
/**
* The header's add action — one plus, one shape, on every Insights page that
* offers one. A 40 px ghost icon with the extended hit area its cluster
* siblings carry; the accessible name rides on `aria-label` and `title`.
*
* Two forms: `href` links to a capture form elsewhere (the measurement pages'
* `captureType`), `onClick` opens something in place (the workouts and mood
* pages). A page never draws its own "log an entry" text link instead.
*/
export function SubPageAddButton({
label,
href,
onClick,
slot,
}: {
label: string;
href?: string;
onClick?: () => void;
/** `data-slot` for tests; defaults to the shared one. */
slot?: string;
}) {
const className = cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
);
const icon = <Plus className="size-4" aria-hidden="true" />;
if (href) {
return (
<Button
asChild
variant="ghost"
size="icon"
data-slot={slot ?? "metric-add-reading"}
data-header-add=""
className={className}
>
<Link href={href} aria-label={label} title={label}>
{icon}
</Link>
</Button>
);
}
return (
<Button
type="button"
variant="ghost"
size="icon"
data-slot={slot ?? "metric-add-reading"}
data-header-add=""
aria-label={label}
title={label}
onClick={onClick}
className={className}
>
{icon}
</Button>
);
}
export function SubPageShell({
title,
backLink,
@@ -319,24 +378,7 @@ export function SubPageShell({
<div className="flex shrink-0 items-center gap-3">
{headerAction}
{captureHref ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="metric-add-reading"
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href={captureHref}
aria-label={captureLabel}
title={captureLabel}
>
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>
<SubPageAddButton label={captureLabel} href={captureHref} />
) : null}
{/* v1.16.8 — "show all readings" rides the header cluster
as an icon button, LEFT of the target-adjust gear (it
@@ -29,7 +29,7 @@ import {
} from "../layout-groups";
describe("layout-groups registry", () => {
it("exposes the seven Appearance modules as statically-generated subpage params", () => {
it("exposes the eight Appearance modules as statically-generated subpage params", () => {
expect(LAYOUT_GROUP_IDS).toEqual([
"dashboard",
"insights",
@@ -37,6 +37,7 @@ describe("layout-groups registry", () => {
"mood",
"labs",
"illness",
"documents",
"vorsorge",
]);
// generateStaticParams() shape.
@@ -47,6 +48,7 @@ describe("layout-groups registry", () => {
{ module: "mood" },
{ module: "labs" },
{ module: "illness" },
{ module: "documents" },
{ module: "vorsorge" },
]);
});
@@ -0,0 +1,65 @@
"use client";
/**
* Settings → Appearance → Documents.
*
* Reached from the wrench beside the vault's Upload button, the way the
* illness journal's wrench reaches its own page. Two choices, each in its own
* card with the toggle in the header's status slot (the illness page's
* shape): the card/list view and the month arrangement. Both persist per user
* through `/api/documents/inbound/layout`.
*/
import { Eye, Rows3, WrapText } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
import { ViewToggle } from "@/components/ui/view-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
export function DocumentsSection() {
const { t } = useTranslations();
const { layout, setView, setArrangement } = useDocumentsLayout();
return (
<div className="space-y-6" data-slot="documents-layout-settings">
<SettingsCard id="documents-view" className="scroll-mt-28">
<SettingsCardHeader
icon={Eye}
title={t("moduleList.viewHeading")}
description={t("documents.layout.viewDescription")}
status={<ModuleViewToggle view={layout.view} onChange={setView} />}
/>
</SettingsCard>
<SettingsCard id="documents-arrangement" className="scroll-mt-28">
<SettingsCardHeader
icon={Rows3}
title={t("documents.layout.arrangementHeading")}
description={t("documents.layout.arrangementDescription")}
status={
<ViewToggle
view={layout.arrangement}
onChange={setArrangement}
groupLabel={t("documents.layout.arrangementHeading")}
dataSlotPrefix="documents-arrangement"
segments={[
{
value: "stacked",
label: t("documents.layout.stacked"),
icon: Rows3,
},
{
value: "flow",
label: t("documents.layout.flow"),
icon: WrapText,
},
]}
/>
}
/>
</SettingsCard>
</div>
);
}
+7
View File
@@ -8,6 +8,7 @@ import { MedicationsSection } from "./medications-section";
import { MoodSection } from "./mood-section";
import { LabsSection } from "./labs-section";
import { IllnessSection } from "./illness-section";
import { DocumentsSection } from "./documents-section";
import { VorsorgeSection } from "./vorsorge-section";
/**
@@ -93,6 +94,12 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
descriptionKey: "settings.sections.layout.illness.description",
Body: IllnessSection,
},
{
id: "documents",
titleKey: "settings.sections.layout.documents.title",
descriptionKey: "settings.sections.layout.documents.description",
Body: DocumentsSection,
},
{
id: "vorsorge",
titleKey: "settings.sections.layout.vorsorge.title",
+7
View File
@@ -54,6 +54,11 @@ interface ConfirmCopy {
slot?: string;
/** A control that belongs with the question, such as an opt-out checkbox. */
extra?: ReactNode;
/**
* The action deletes something: it wears the destructive style, as the
* data-list selection bar's confirm already does.
*/
destructive?: boolean;
}
function ConfirmBody({
@@ -64,6 +69,7 @@ function ConfirmBody({
pending = false,
slot,
extra,
destructive = false,
}: ConfirmCopy) {
const { t } = useTranslations();
@@ -77,6 +83,7 @@ function ConfirmBody({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
aria-busy={pending || undefined}