From 53d0d8cc33eadba4e77ca6c458f4ad5da24d712e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:26:06 +0200 Subject: [PATCH] feat(documents): virtualized month timeline and document cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline windows a flat item list (month labels + chunked card rows) with @tanstack/react-virtual over the shell's scroll container — single scroll owner, bounded DOM at four-digit corpus sizes. Columns are measured by a ResizeObserver so the row model and the painted grid never disagree. Cards are compact-density: kind glyph, foreground title, one muted meta line, condition pills, a download-only badge for attachment- class formats, and a hover/focus selection checkbox above the whole-card click target. In-flight and failed uploads share the same footprint with a live progress ring and translated failure copy. Plan-Task: W2-T2, W2-T4 (upload cards) --- package.json | 1 + pnpm-lock.yaml | 20 ++ .../__tests__/document-card.test.tsx | 155 +++++++++++ src/components/documents/document-card.tsx | 249 ++++++++++++++++++ .../documents/document-timeline.tsx | 223 ++++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 src/components/documents/__tests__/document-card.test.tsx create mode 100644 src/components/documents/document-card.tsx create mode 100644 src/components/documents/document-timeline.tsx diff --git a/package.json b/package.json index 4b0145bdc..6d0726baf 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "@tanstack/react-query": "5.101.2", + "@tanstack/react-virtual": "^3.14.5", "@types/sax": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a23c7c62a..4772a0b7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -2868,6 +2871,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@testcontainers/postgresql@12.0.3': resolution: {integrity: sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q==} @@ -9450,6 +9462,14 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.3': {} + '@testcontainers/postgresql@12.0.3': dependencies: testcontainers: 12.0.3 diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx new file mode 100644 index 000000000..df7c5d249 --- /dev/null +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -0,0 +1,155 @@ +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 { DocumentCard, UploadStateCard } from "../document-card"; +import type { UploadQueueItem } from "../use-document-upload"; + +/** + * The vault card's render contract: title falls back filename → "untitled", + * the meta line stays muted, the attachment-class badge appears ONLY for + * download-only formats, condition links paint as pills, and the two + * transient upload cards (in-flight ring / translated failure) render from + * the same footprint. + */ + +function doc(overrides: Partial = {}): InboundDocumentDto { + return { + id: "doc-1", + kind: "IMAGING", + title: "MRT Knie", + filename: "mrt-knie.pdf", + mimeType: "application/pdf", + byteSize: 2_500_000, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "2025-10-04", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }], + servingClass: "inline", + createdAt: "2025-10-05T08:00:00.000Z", + updatedAt: "2025-10-05T08:00:00.000Z", + ...overrides, + }; +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const noop = () => {}; + +describe("", () => { + it("renders title, condition pill, and NO attachment badge for inline docs", () => { + const html = render( + , + ); + expect(html).toContain("MRT Knie"); + expect(html).toContain("Knie"); + expect(html).toContain('data-document-id="doc-1"'); + expect(html).not.toContain("Download only"); + }); + + it("shows the download-only badge for attachment-class documents", () => { + const html = render( + , + ); + expect(html).toContain("Download only"); + // Title falls back to the filename. + expect(html).toContain("befund.docx"); + }); + + it("falls back to the untitled label and rings when highlighted", () => { + const html = render( + , + ); + expect(html).toContain("Untitled document"); + expect(html).toContain("ring-2"); + }); +}); + +describe("", () => { + const base: UploadQueueItem = { + localId: "u1", + fileName: "scan.jpg", + byteSize: 4_000_000, + status: "uploading", + progress: 0.4, + }; + + it("paints the progress ring while uploading", () => { + const html = render(); + expect(html).toContain('role="progressbar"'); + expect(html).toContain('aria-valuenow="40"'); + expect(html).toContain("scan.jpg"); + }); + + it("renders the translated over-limit reason with the configured cap", () => { + const html = render( + , + ); + expect(html).toContain('role="alert"'); + expect(html).toContain("25 MB"); + expect(html).not.toContain('role="progressbar"'); + }); + + it("renders the quota reason with used/quota figures", () => { + const html = render( + , + ); + expect(html).toContain("Storage is full"); + expect(html).toContain("1 GB"); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx new file mode 100644 index 000000000..f54f9c571 --- /dev/null +++ b/src/components/documents/document-card.tsx @@ -0,0 +1,249 @@ +"use client"; + +/** + * One document in the vault timeline (compact-density Card) plus the two + * transient upload-state cards (in-flight with a live progress ring, failed + * with the translated §3.2 reason). All three share the card footprint so an + * optimistic entry morphs into the stored row without the grid jumping. + * + * Anatomy per the design standards: kind icon `text-foreground size-5`, + * title in foreground (`text-sm font-medium`, truncated), one muted meta + * line (date · size · filename), condition-tag pills, an attachment-class + * badge for download-only formats, and a selection checkbox that appears on + * hover / focus / while selected. The whole card is clickable through an + * invisible overlay button; the checkbox floats above it. + */ +import { Download, X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; +import type { UploadQueueItem } from "./use-document-upload"; +import { documentDateKey, formatBytes } from "./vault-utils"; + +export function DocumentCard({ + document, + selected, + onToggleSelected, + onOpen, + highlighted, + onPrefetch, +}: { + document: InboundDocumentDto; + selected: boolean; + onToggleSelected: (id: string) => void; + onOpen: (id: string) => void; + /** Brief ring after a duplicate upload resolved to this existing row. */ + highlighted: boolean; + /** Hover/focus intent — prefetches the detail metadata (never the blob). */ + onPrefetch?: (id: string) => void; +}) { + const { t, locale } = useTranslations(); + const format = useFormatters(); + + const title = + document.title ?? document.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[document.kind]; + const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`); + const size = formatBytes(document.byteSize, locale); + const showFilename = + document.filename !== null && document.filename !== title; + + return ( + + +
+ +

{title}

+ onToggleSelected(document.id)} + aria-label={t("documents.card.selectLabel", { title })} + className={cn( + "relative z-10 shrink-0 transition-opacity", + "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100", + selected && "opacity-100", + )} + /> +
+

+ {date} · {size} + {showFilename ? ` · ${document.filename}` : ""} +

+ {document.conditionLinks.length > 0 || + document.servingClass === "attachment" ? ( +
+ {document.conditionLinks.map((link) => ( + + {link.name} + + ))} + {document.servingClass === "attachment" ? ( + + + {t("documents.card.attachmentBadge")} + + ) : null} +
+ ) : null} +
+ {/* Whole-card click target. Painted last so it sits above the content + (the checkbox stays reachable via its own z-10). */} + + ) : null} + + {failureCopy ? ( +

+ {failureCopy} +

+ ) : null} + +
+ ); +} diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx new file mode 100644 index 000000000..3cd3aed8c --- /dev/null +++ b/src/components/documents/document-timeline.tsx @@ -0,0 +1,223 @@ +"use client"; + +/** + * The vault timeline: a virtualized, month-sectioned card grid windowed + * with `@tanstack/react-virtual` over the shell's scroll container + * (`#main-content` — single scroll owner per the design standards; the + * timeline never brings its own scrollport). The flat item list (month + * labels + chunked card rows) comes from `buildTimelineItems`, so the + * mounted DOM stays bounded (< ~400 nodes) regardless of corpus size. + * + * Columns are measured, not breakpoint-classed: a ResizeObserver on the + * grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps + * the virtualizer's row model and the painted grid in lockstep. + * + * In-flight / failed uploads render as a small non-virtualized grid above + * the timeline — they are few (client concurrency 3) and must appear + * < 100 ms after file selection, before any query settles. + */ +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DocumentCard, UploadStateCard } from "./document-card"; +import type { UploadQueueItem } from "./use-document-upload"; +import { buildTimelineItems, formatMonthLabel } from "./vault-utils"; + +const SCROLL_CONTAINER_ID = "main-content"; + +/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */ +function columnsForWidth(width: number): number { + if (width >= 1200) return 4; + if (width >= 900) return 3; + if (width >= 600) return 2; + return 1; +} + +export function DocumentTimeline({ + documents, + uploadItems, + onDismissUpload, + hasNextPage, + isFetchingNextPage, + onLoadMore, + selectedIds, + onToggleSelected, + onOpen, + highlightId, + onPrefetch, +}: { + documents: InboundDocumentDto[]; + uploadItems: UploadQueueItem[]; + onDismissUpload: (localId: string) => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + selectedIds: ReadonlySet; + onToggleSelected: (id: string) => void; + onOpen: (id: string) => void; + highlightId: string | null; + onPrefetch?: (id: string) => void; +}) { + const { t, locale } = useTranslations(); + const listRef = useRef(null); + const [columns, setColumns] = useState(1); + const [scrollMargin, setScrollMargin] = useState(0); + + // Measured columns — the ResizeObserver drives the row chunking. + useEffect(() => { + const el = listRef.current; + if (!el) return; + const update = () => setColumns(columnsForWidth(el.clientWidth)); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const items = useMemo( + () => buildTimelineItems(documents, columns), + [documents, columns], + ); + + // 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 + // virtualizer so window positions line up. Re-measured when the content + // above changes height (upload cards appearing/leaving) and on resize. + useEffect(() => { + const scrollEl = document.getElementById(SCROLL_CONTAINER_ID); + const listEl = listRef.current; + if (!scrollEl || !listEl) return; + const measure = () => { + const margin = + listEl.getBoundingClientRect().top - + scrollEl.getBoundingClientRect().top + + scrollEl.scrollTop; + setScrollMargin(margin); + }; + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [uploadItems.length, columns]); + + // The React Compiler cannot memoize across TanStack Virtual's instance + // API (library-level opt-out, not a fixable call-site problem) — the + // windowing still works, the compiler just skips this component. + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID), + estimateSize: (index) => (items[index].type === "month" ? 40 : 140), + getItemKey: (index) => items[index].key, + overscan: 6, + scrollMargin, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + // Keyset infinite feed: pull the next page when the window nears the end. + const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; + useEffect(() => { + if ( + hasNextPage && + !isFetchingNextPage && + lastIndex >= items.length - columns * 3 - 1 + ) { + onLoadMore(); + } + }, [ + lastIndex, + items.length, + columns, + hasNextPage, + isFetchingNextPage, + onLoadMore, + ]); + + return ( +
+ {uploadItems.length > 0 ? ( +
+ {uploadItems.map((item) => ( + + ))} +
+ ) : null} + +
+
+ {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index]; + return ( +
+ {item.type === "month" ? ( +

+ {formatMonthLabel(item.key, locale)} +

+ ) : ( +
+ {item.documents.map((doc) => ( + + ))} +
+ )} +
+ ); + })} +
+
+ + {isFetchingNextPage ? ( +
+ + {t("documents.timeline.loadingMore")} +
+ ) : null} +
+ ); +}