diff --git a/CHANGELOG.md b/CHANGELOG.md index 56417186e..3f7f113bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [1.28.38] — 2026-07-16 — Documents polish, waist reminders, bulk sharing + +The document vault gets a round of overdue polish. The list cards drop a +stray glyph — the "read by AI" mark it carried moves into the document's +detail view — and give the real filename its own line so it stops being cut +off; the detail view's close button is aligned correctly (a shared sheet +primitive was reserving space for a button it wasn't showing). The action +that opens a document in the coach now reads "Coach fragen". + +From a multi-selection in the overview you can now share several documents +at once — one link holding the selection, with the same documents-only +privacy as a single share (up to 50 per link). + +Reminders can now be linked to waist circumference. And the well-being +reminder types (WHO-5, sleep-condition) that were showing their internal +names instead of a label are now translated in every language. + ## [1.28.37] — 2026-07-15 — Provider step totals survive a restart A boot-time maintenance pass that folds genuinely old raw step samples into diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 10aac6148..4651e5ab9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.37 + version: 1.28.38 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -12092,6 +12092,7 @@ components: - TOTAL_BODY_WATER - VISCERAL_FAT - BODY_MASS_INDEX + - WAIST_CIRCUMFERENCE - PHQ9_SCORE - GAD7_SCORE - WHO5_SCORE diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index ad33f54b7..1bb3b71d0 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -357,26 +357,18 @@ test.describe("document vault — AI assist + content search", () => { page, }) => { // CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it). - // No mocks: the real list GET threads the provenance through. v1.28.22 — - // the marker lives on the vault CARD's meta row (an inline glyph beside - // date · size), not in the sheet. Surface the card through search (the - // grid is virtualized, so a targeted card must be brought into view the - // way a user would). - await page.goto("/documents"); - await expect( - page.getByRole("heading", { name: "Documents" }), - ).toBeVisible(); - await page - .getByRole("searchbox", { name: "Search documents" }) - .fill(CONTENT_BODY_WORD); - await expect( - page.getByRole("button", { name: "Open Radiology note" }), - ).toBeVisible({ timeout: 10_000 }); + // No mocks: the real detail GET threads the provenance through. v1.28.38 — + // the vault card no longer carries the AI-read glyph (it looked wrong on + // the card face); the provenance now lives as a calm muted meta line in the + // document DETAIL view. Deep-link straight to the document so the sheet + // opens deterministically (the vault grid is virtualized). + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); - const marker = page.locator( - `[data-document-id="${CONTENT_DOC_ID}"] [data-slot="document-card-ai-read"]`, - ); - await expect(marker.first()).toBeVisible(); + const marker = sheet.locator('[data-slot="document-detail-ai-read"]'); + await expect(marker).toBeVisible(); + await expect(marker).toHaveText("Read by AI"); }); // ── (f) Text-mode refuses a non-image before any OCR/upload ────────────── diff --git a/messages/de.json b/messages/de.json index 4c8c5b8fa..b44c2efcb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7178,8 +7178,11 @@ "TOTAL_BODY_WATER": "Körperwasser", "VISCERAL_FAT": "Viszeralfett", "BODY_MASS_INDEX": "Body-Mass-Index (BMI)", + "WAIST_CIRCUMFERENCE": "Taillenumfang", "PHQ9_SCORE": "PHQ-9 · Stimmung", - "GAD7_SCORE": "GAD-7 · Sorgen" + "GAD7_SCORE": "GAD-7 · Sorgen", + "WHO5_SCORE": "WHO-5 · Wohlbefinden", + "SCI_SCORE": "SCI · Schlaf" }, "typeGroups": { "vitals": "Vitalwerte", @@ -8302,7 +8305,9 @@ "deleted": "{count} Dokument(e) gelöscht", "restored": "{count} Dokument(e) wiederhergestellt", "partialFailure": "{failed} von {total} konnten nicht aktualisiert werden", - "failed": "Die Aktion konnte nicht ausgeführt werden" + "failed": "Die Aktion konnte nicht ausgeführt werden", + "share": "Teilen", + "shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut." }, "episodeCard": { "title": "Dokumente", @@ -8366,6 +8371,8 @@ }, "share": { "title": "Dieses Dokument teilen", + "multiTitle": "Diese Dokumente teilen", + "multiLabel": "{count} Dokumente", "description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal." }, "chat": { @@ -8385,8 +8392,8 @@ "errorCredential": "Der KI-Anbieter muss in den Einstellungen neu verbunden werden.", "errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.", "errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.", - "openAria": "Über dieses Dokument chatten", - "openButton": "Zu diesem Dokument fragen", + "openAria": "Coach über dieses Dokument fragen", + "openButton": "Coach fragen", "scopeBadge": "Chat über: {title}" } }, diff --git a/messages/en.json b/messages/en.json index 63b715da9..312ee4ee7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7178,8 +7178,11 @@ "TOTAL_BODY_WATER": "Body water", "VISCERAL_FAT": "Visceral fat", "BODY_MASS_INDEX": "Body mass index (BMI)", + "WAIST_CIRCUMFERENCE": "Waist circumference", "PHQ9_SCORE": "PHQ-9 · mood", - "GAD7_SCORE": "GAD-7 · worry" + "GAD7_SCORE": "GAD-7 · worry", + "WHO5_SCORE": "WHO-5 · wellbeing", + "SCI_SCORE": "SCI · sleep" }, "typeGroups": { "vitals": "Vitals", @@ -8302,7 +8305,9 @@ "deleted": "{count} document(s) deleted", "restored": "{count} document(s) restored", "partialFailure": "{failed} of {total} could not be updated", - "failed": "The action could not be completed" + "failed": "The action could not be completed", + "share": "Share", + "shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again." }, "episodeCard": { "title": "Documents", @@ -8366,6 +8371,8 @@ }, "share": { "title": "Share this document", + "multiTitle": "Share these documents", + "multiLabel": "{count} documents", "description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once." }, "chat": { @@ -8385,8 +8392,8 @@ "errorCredential": "The AI provider needs reconnecting in settings.", "errorConsent": "Turn on AI consent in settings to chat about this document.", "errorNetwork": "You appear to be offline. Check your connection and try again.", - "openAria": "Chat about this document", - "openButton": "Ask about this document", + "openAria": "Ask the Coach about this document", + "openButton": "Ask the Coach", "scopeBadge": "Chatting about: {title}" } }, diff --git a/messages/es.json b/messages/es.json index 7819d6c05..d0a4e9700 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7178,8 +7178,11 @@ "TOTAL_BODY_WATER": "Agua corporal", "VISCERAL_FAT": "Grasa visceral", "BODY_MASS_INDEX": "Índice de masa corporal (IMC)", + "WAIST_CIRCUMFERENCE": "Perímetro de cintura", "PHQ9_SCORE": "PHQ-9 · ánimo", - "GAD7_SCORE": "GAD-7 · preocupación" + "GAD7_SCORE": "GAD-7 · preocupación", + "WHO5_SCORE": "WHO-5 · bienestar", + "SCI_SCORE": "SCI · sueño" }, "typeGroups": { "vitals": "Constantes vitales", @@ -8302,7 +8305,9 @@ "deleted": "{count} documento(s) eliminados", "restored": "{count} documento(s) restaurados", "partialFailure": "No se pudieron actualizar {failed} de {total}", - "failed": "No se pudo completar la acción" + "failed": "No se pudo completar la acción", + "share": "Compartir", + "shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo." }, "episodeCard": { "title": "Documentos", @@ -8366,6 +8371,8 @@ }, "share": { "title": "Compartir este documento", + "multiTitle": "Compartir estos documentos", + "multiLabel": "{count} documentos", "description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez." }, "chat": { @@ -8385,8 +8392,8 @@ "errorCredential": "Hay que volver a conectar el proveedor de IA en los ajustes.", "errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.", "errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.", - "openAria": "Chatea sobre este documento", - "openButton": "Pregunta sobre este documento", + "openAria": "Pregunta al Coach sobre este documento", + "openButton": "Preguntar al Coach", "scopeBadge": "Chateando sobre: {title}" } }, diff --git a/messages/fr.json b/messages/fr.json index 0de7ed629..4e444d565 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7178,8 +7178,11 @@ "TOTAL_BODY_WATER": "Eau corporelle", "VISCERAL_FAT": "Graisse viscérale", "BODY_MASS_INDEX": "Indice de masse corporelle (IMC)", + "WAIST_CIRCUMFERENCE": "Tour de taille", "PHQ9_SCORE": "PHQ-9 · humeur", - "GAD7_SCORE": "GAD-7 · inquiétude" + "GAD7_SCORE": "GAD-7 · inquiétude", + "WHO5_SCORE": "WHO-5 · bien-être", + "SCI_SCORE": "SCI · sommeil" }, "typeGroups": { "vitals": "Constantes vitales", @@ -8302,7 +8305,9 @@ "deleted": "{count} document(s) supprimés", "restored": "{count} document(s) restaurés", "partialFailure": "Impossible de mettre à jour {failed} sur {total}", - "failed": "L'action n'a pas pu être effectuée" + "failed": "L'action n'a pas pu être effectuée", + "share": "Partager", + "shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez." }, "episodeCard": { "title": "Documents", @@ -8366,6 +8371,8 @@ }, "share": { "title": "Partager ce document", + "multiTitle": "Partager ces documents", + "multiLabel": "{count} documents", "description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois." }, "chat": { @@ -8385,8 +8392,8 @@ "errorCredential": "Le fournisseur d'IA doit être reconnecté dans les paramètres.", "errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.", "errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.", - "openAria": "Discuter de ce document", - "openButton": "Poser une question sur ce document", + "openAria": "Interroger le Coach sur ce document", + "openButton": "Demander au Coach", "scopeBadge": "Discussion sur : {title}" } }, diff --git a/messages/it.json b/messages/it.json index d96a65b25..42b207b9c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7178,8 +7178,11 @@ "TOTAL_BODY_WATER": "Acqua corporea", "VISCERAL_FAT": "Grasso viscerale", "BODY_MASS_INDEX": "Indice di massa corporea (IMC)", + "WAIST_CIRCUMFERENCE": "Circonferenza vita", "PHQ9_SCORE": "PHQ-9 · umore", - "GAD7_SCORE": "GAD-7 · preoccupazione" + "GAD7_SCORE": "GAD-7 · preoccupazione", + "WHO5_SCORE": "WHO-5 · benessere", + "SCI_SCORE": "SCI · sonno" }, "typeGroups": { "vitals": "Parametri vitali", @@ -8302,7 +8305,9 @@ "deleted": "{count} documenti eliminati", "restored": "{count} documenti ripristinati", "partialFailure": "Impossibile aggiornare {failed} su {total}", - "failed": "Impossibile completare l'azione" + "failed": "Impossibile completare l'azione", + "share": "Condividi", + "shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova." }, "episodeCard": { "title": "Documenti", @@ -8366,6 +8371,8 @@ }, "share": { "title": "Condividi questo documento", + "multiTitle": "Condividi questi documenti", + "multiLabel": "{count} documenti", "description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta." }, "chat": { @@ -8385,8 +8392,8 @@ "errorCredential": "Il provider IA va ricollegato nelle impostazioni.", "errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.", "errorNetwork": "Sembri offline. Controlla la connessione e riprova.", - "openAria": "Chatta su questo documento", - "openButton": "Fai una domanda su questo documento", + "openAria": "Chiedi al Coach su questo documento", + "openButton": "Chiedi al Coach", "scopeBadge": "Stai chattando su: {title}" } }, diff --git a/messages/pl.json b/messages/pl.json index f12ab2f65..8122f13f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7178,8 +7178,11 @@ "TOTAL_BODY_WATER": "Woda w organizmie", "VISCERAL_FAT": "Tłuszcz trzewny", "BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)", + "WAIST_CIRCUMFERENCE": "Obwód talii", "PHQ9_SCORE": "PHQ-9 · nastrój", - "GAD7_SCORE": "GAD-7 · lęk" + "GAD7_SCORE": "GAD-7 · lęk", + "WHO5_SCORE": "WHO-5 · dobrostan", + "SCI_SCORE": "SCI · sen" }, "typeGroups": { "vitals": "Parametry życiowe", @@ -8302,7 +8305,9 @@ "deleted": "Usunięto dokumenty: {count}", "restored": "Przywrócono dokumenty: {count}", "partialFailure": "Nie udało się zaktualizować {failed} z {total}", - "failed": "Nie udało się wykonać akcji" + "failed": "Nie udało się wykonać akcji", + "share": "Udostępnij", + "shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie." }, "episodeCard": { "title": "Dokumenty", @@ -8366,6 +8371,8 @@ }, "share": { "title": "Udostępnij ten dokument", + "multiTitle": "Udostępnij te dokumenty", + "multiLabel": "{count} dokumentów", "description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz." }, "chat": { @@ -8385,8 +8392,8 @@ "errorCredential": "Dostawcę AI trzeba ponownie połączyć w ustawieniach.", "errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.", "errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.", - "openAria": "Porozmawiaj o tym dokumencie", - "openButton": "Zapytaj o ten dokument", + "openAria": "Zapytaj Coacha o ten dokument", + "openButton": "Zapytaj Coacha", "scopeBadge": "Rozmowa o: {title}" } }, diff --git a/package.json b/package.json index c93f29e06..930216689 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.37", + "version": "1.28.38", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 223305f07..29bd81ba1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.37"; + /* @sw-version-fallback */ "v1.28.38"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index df8a52613..2222b1800 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -3,9 +3,10 @@ import { renderToStaticMarkup } from "react-dom/server"; /** * The floating bulk bar, pinned via single-pass static renders: a labelled - * toolbar carrying the selected count and the four bulk verbs. The - * link-condition menu only renders when the account actually has episodes - * to link — no dead affordance on an episode-free account. + * toolbar carrying the selected count and the bulk verbs (set type, link + * condition, share, delete, clear). The link-condition menu only renders when + * the account actually has episodes to link — no dead affordance on an + * episode-free account. */ import { I18nProvider } from "@/lib/i18n/context"; import { DocumentBulkBar } from "../document-bulk-bar"; @@ -19,6 +20,7 @@ function render(episodes: { id: string; label: string }[]) { busy={false} onSetKind={() => {}} onLinkEpisode={() => {}} + onShare={() => {}} onDelete={() => {}} onClear={() => {}} /> @@ -34,6 +36,8 @@ describe("", () => { expect(html).toContain("3 selected"); expect(html).toContain("Change type"); expect(html).toContain("Link condition"); + expect(html).toContain('data-slot="document-bulk-share"'); + expect(html).toContain("Share"); expect(html).toContain("Delete"); expect(html).toContain("Clear selection"); }); diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 29ab71f7d..8734d2ab0 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -115,6 +115,53 @@ describe("", () => { expect(html).not.toContain("/thumbnail"); }); + it("does NOT render the AI-read glyph on the card face, even when AI-read", () => { + // The AI-read state stays surfaced authoritatively in the detail sheet's + // DocumentAiSection; the card face no longer duplicates it (v1.28.38). + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-card-ai-read"'); + }); + + it("renders the filename on its own line with the full name in title", () => { + const html = render( + , + ); + // Dedicated filename line, muted text-xs, full name reachable on hover. + expect(html).toContain('data-slot="document-card-filename"'); + expect(html).toContain('title="mrt-knie-2025-10-04.pdf"'); + expect(html).toContain("mrt-knie-2025-10-04.pdf"); + }); + + it("omits the filename line when the filename equals the title", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-card-filename"'); + }); + it("falls back to the untitled label and rings when highlighted", () => { const html = render( ({ + ResponsiveSheet: ({ + title, + children, + }: { + title: React.ReactNode; + children: React.ReactNode; + [key: string]: unknown; + }) => ( +
+
{title}
+ {children} +
+ ), +})); + +vi.mock("@/components/settings/share-link-create-form", () => ({ + ShareLinkCreateForm: (props: { + documentOnly?: boolean; + initialDocuments?: { id: string; title: string }[]; + initialLabel?: string; + }) => ( +
d.id).join(",")} + data-doc-count={String((props.initialDocuments ?? []).length)} + /> + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { DocumentShareSheet } from "../document-share-sheet"; + +function render(documents: { id: string; title: string }[]) { + return renderToStaticMarkup( + + {}} documents={documents} /> + , + ); +} + +describe(" — one link for the selection", () => { + it("seeds a single-doc share with the document title as the label", () => { + const html = render([{ id: "d1", title: "Blood panel" }]); + expect(html).toContain("Share this document"); + expect(html).toContain('data-document-only="true"'); + expect(html).toContain('data-doc-ids="d1"'); + expect(html).toContain('data-initial-label="Blood panel"'); + }); + + it("folds N selected docs into ONE documents-only link with a count label", () => { + const html = render([ + { id: "d1", title: "A" }, + { id: "d2", title: "B" }, + { id: "d3", title: "C" }, + ]); + // Multi title, a "{count} documents" summary label, all ids in one link. + expect(html).toContain("Share these documents"); + expect(html).toContain('data-doc-ids="d1,d2,d3"'); + expect(html).toContain('data-doc-count="3"'); + expect(html).toContain('data-initial-label="3 documents"'); + // Privacy: documents-only stays frozen on for the launched share. + expect(html).toContain('data-document-only="true"'); + }); + + it("does not mount the form when closed", () => { + const html = renderToStaticMarkup( + + {}} + documents={[{ id: "d1", title: "A" }]} + /> + , + ); + expect(html).not.toContain('data-slot="mock-share-form"'); + }); +}); diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index c23b70225..47f72c1ea 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -12,6 +12,8 @@ import { formatMonthLabel, parseUploadResponse, parseVaultSearchParams, + resolveBulkShareDocuments, + SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, } from "../vault-utils"; @@ -284,3 +286,60 @@ describe("shift-click range selection", () => { ]).toEqual([]); }); }); + +describe("resolveBulkShareDocuments", () => { + const corpus = [ + doc({ id: "d1", title: "Blood panel", filename: "bp.pdf" }), + doc({ id: "d2", title: null, filename: "scan.jpg" }), + doc({ id: "d3", title: null, filename: null }), + doc({ id: "d4", title: "Referral" }), + ]; + + it("maps the selection to {id,title}, falling back title → filename → untitled", () => { + const result = resolveBulkShareDocuments( + corpus, + new Set(["d1", "d2", "d3"]), + "Untitled", + ); + expect(result.overCap).toBe(false); + if (result.overCap) throw new Error("unexpected over-cap"); + expect(result.documents).toEqual([ + { id: "d1", title: "Blood panel" }, + { id: "d2", title: "scan.jpg" }, + { id: "d3", title: "Untitled" }, + ]); + }); + + it("preserves corpus order and ignores ids not in the corpus", () => { + const result = resolveBulkShareDocuments( + corpus, + new Set(["d4", "d1", "ghost"]), + "Untitled", + ); + if (result.overCap) throw new Error("unexpected over-cap"); + expect(result.documents.map((d) => d.id)).toEqual(["d1", "d4"]); + }); + + it("caps at SHARE_LINK_MAX_DOCUMENTS (50) — over the cap refuses rather than truncates", () => { + const many = Array.from({ length: 60 }, (_, i) => doc({ id: `x${i}` })); + const selected = new Set(many.map((d) => d.id)); + expect(selected.size).toBeGreaterThan(SHARE_LINK_MAX_DOCUMENTS); + expect(resolveBulkShareDocuments(many, selected, "Untitled")).toEqual({ + overCap: true, + }); + }); + + it("allows exactly 50 selected documents", () => { + const fifty = Array.from({ length: SHARE_LINK_MAX_DOCUMENTS }, (_, i) => + doc({ id: `y${i}` }), + ); + const result = resolveBulkShareDocuments( + fifty, + new Set(fifty.map((d) => d.id)), + "Untitled", + ); + expect(result.overCap).toBe(false); + if (result.overCap) throw new Error("unexpected over-cap"); + expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS); + }); +}); diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 48b794247..1d57acbfe 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -4,15 +4,16 @@ * 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 - * four bulk verbs — set type, link condition, delete (undo-able), clear — - * all driving `POST /api/documents/inbound/bulk` from the page. + * bulk verbs — set type, link condition, share (one link for the whole + * selection), delete (undo-able), clear — all driving the page's handlers. * * A `role="toolbar"` with proper labels; the destructive verb sits last - * before Clear so a stray tap sequence never ends on Delete. The bar is a - * deliberate hand-rolled shell (a floating toolbar is not a Card surface): - * dense-tile padding `p-3` per the standards. + * 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. */ -import { FolderPlus, Tag, Trash2, X } from "lucide-react"; +import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -37,6 +38,7 @@ export function DocumentBulkBar({ busy, onSetKind, onLinkEpisode, + onShare, onDelete, onClear, }: { @@ -47,6 +49,12 @@ export function DocumentBulkBar({ busy: boolean; onSetKind: (kind: InboundDocumentKindValue) => void; onLinkEpisode: (episodeId: string) => void; + /** + * Share the whole selection as ONE documents-only link. The page caps the + * selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint — + * this handler just opens the share sheet seeded with the selection. + */ + onShare: () => void; onDelete: () => void; onClear: () => void; }) { @@ -108,6 +116,17 @@ export function DocumentBulkBar({ ) : null} + + {/* Solid destructive (matching the detail sheet's Delete) — the outline variant's destructive text on the card surface fails the WCAG contrast gate. */} diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index b4c37bf91..462d7816e 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -9,13 +9,14 @@ * Anatomy per the design standards: a leading edge that shows a small preview * thumbnail tile when one has been rendered (`hasThumbnail`) and otherwise the * kind icon `text-foreground size-5`, title in foreground (`text-sm - * font-medium`, truncated), one muted meta line (date · size · filename), + * font-medium`, truncated), a muted meta line (date · size) with the real + * filename on its own muted line below (full name on hover via `title`), * 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, Sparkles, X } from "lucide-react"; +import { Download, X } from "lucide-react"; import { useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; @@ -24,10 +25,7 @@ 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 { - isAiReadSource, - type InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; +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"; @@ -87,8 +85,6 @@ export function DocumentCard({ const size = formatBytes(document.byteSize, locale); const showFilename = document.filename !== null && document.filename !== title; - const aiRead = - document.hasContentIndex && isAiReadSource(document.contentIndexSource); return (

{title}

-

- - {date} · {size} - {showFilename ? ` · ${document.filename}` : ""} - - {aiRead ? ( - // AI-read marker rides INLINE on the meta row — a bare glyph, - // no pill line (the tag ate a whole row in the dense grid). - - ) : null} +

+ {date} · {size}

+ {showFilename ? ( + // Filename on its OWN muted line so it never clips the date/size + // meta; the full name stays reachable on hover via `title`. +

+ {document.filename} +

+ ) : null}
@@ -752,6 +759,15 @@ export function DocumentDetailSheet({ })} {doc.filename ? ` · ${doc.filename}` : ""}

+ + {aiRead ? ( +

+ {t("documents.ai.statusAiRead")} +

+ ) : null} ) : null} @@ -761,8 +777,7 @@ export function DocumentDetailSheet({ ) : null} diff --git a/src/components/documents/document-share-sheet.tsx b/src/components/documents/document-share-sheet.tsx index 255a4b465..79bab9f4d 100644 --- a/src/components/documents/document-share-sheet.tsx +++ b/src/components/documents/document-share-sheet.tsx @@ -3,46 +3,62 @@ /** * The document-launched entry point into the clinician share-link create flow. * Mounts the shared `ShareLinkCreateForm` inside a `ResponsiveSheet` (bottom - * sheet on phones, dialog on desktop) with the current document pre-attached - * and its title pre-filled as the link label — the owner shares the document - * they were looking at without leaving for Settings, and the one-time QR / - * passphrase reveal lands in the same surface. + * sheet on phones, dialog on desktop) with the picked document(s) pre-attached + * and a label pre-filled — the owner shares the document(s) they were looking + * at (detail sheet) or selected (bulk multi-select) without leaving for + * Settings, and the one-time QR / passphrase reveal lands in the same surface. + * + * Accepts an ARRAY: a single-document share (detail sheet) passes one entry; a + * bulk share passes the whole selection, all folded into ONE `documentOnly` + * link (the share model carries up to `SHARE_LINK_MAX_DOCUMENTS` docs per + * link). The label defaults to the single title, or a `{count} documents` + * summary for a multi-doc share. * * The frozen-write-once + ≤50 rules, the EXIF note, and expiry all come from - * the shared form as-is; the same `POST /api/share-links` handles the create. - * The form is mounted only while the sheet is open so each open starts a fresh - * link (the one-time secret is never re-shown for a stale create). + * the shared form as-is; the same `POST /api/share-links` handles the create + * and re-verifies every id as the owner's own live document. The form is + * mounted only while the sheet is open so each open starts a fresh link (the + * one-time secret is never re-shown for a stale create). */ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import type { PickedDocument } from "@/components/settings/share-document-picker"; import { ShareLinkCreateForm } from "@/components/settings/share-link-create-form"; import { useTranslations } from "@/lib/i18n/context"; export function DocumentShareSheet({ open, onOpenChange, - documentId, - documentTitle, + documents, }: { open: boolean; onOpenChange: (open: boolean) => void; - documentId: string; - documentTitle: string; + /** The document(s) to pre-attach — one entry (detail) or many (bulk). */ + documents: PickedDocument[]; }) { const { t } = useTranslations(); + const multiple = documents.length > 1; + // Single doc → its title; multiple → a "{count} documents" summary. The + // owner can still edit the label in the form before minting the link. + const label = multiple + ? t("documents.share.multiLabel", { count: documents.length }) + : (documents[0]?.title ?? ""); + return ( {open ? ( ) : null} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 7fa6fa114..717ad242e 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -53,6 +53,7 @@ import { } from "@/lib/validations/inbound-documents"; import { DocumentBulkBar } from "./document-bulk-bar"; import { DocumentDetailSheet } from "./document-detail-sheet"; +import { DocumentShareSheet } from "./document-share-sheet"; import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar"; import { DocumentTimeline } from "./document-timeline"; import { UploadZone } from "./upload-zone"; @@ -65,6 +66,8 @@ import { documentDateKey, expandRangeSelection, parseVaultSearchParams, + resolveBulkShareDocuments, + SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, } from "./vault-utils"; @@ -481,6 +484,32 @@ export function DocumentsView() { [bulk, clearSelection, restoreBulk, t], ); + // ── 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 + // corpus — a selected id is always a rendered row. Over the cap we surface a + // hint and refuse rather than silently dropping docs from the link. + const [bulkShareOpen, setBulkShareOpen] = useState(false); + const [bulkShareDocs, setBulkShareDocs] = useState< + { id: string; title: string }[] + >([]); + const openBulkShare = useCallback(() => { + const resolved = resolveBulkShareDocuments( + documents, + selectedIds, + t("documents.card.untitled"), + ); + if (resolved.overCap) { + toast.error( + t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), + ); + return; + } + if (resolved.documents.length === 0) return; + setBulkShareDocs(resolved.documents); + setBulkShareOpen(true); + }, [documents, selectedIds, t]); + // ── Detail sheet ────────────────────────────────────────────────────── const [detailId, setDetailId] = useState(null); const [detailOpen, setDetailOpen] = useState(false); @@ -662,11 +691,18 @@ export function DocumentsView() { busy={bulk.isPending} onSetKind={(kind) => runBulk("setKind", { kind })} onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })} + onShare={openBulkShare} onDelete={() => deleteBulk([...selectedIds])} onClear={clearSelection} /> ) : null} + + {/* Page-wide drop overlay — pure decoration (aria-hidden); the intake itself announces through the live region below. */} {dropActive ? ( diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 92eba8f8e..bb15c532d 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -223,6 +223,42 @@ export function expandRangeSelection( return next; } +// ─── Bulk share selection ────────────────────────────────────────────────── + +/** + * Client mirror of the server's `SHARE_LINK_MAX_DOCUMENTS` — one share link + * carries at most 50 documents. Kept as a local literal (NOT imported from the + * clinician-share validations module, which pulls the Prisma client into scope + * and would drag the DB into the client bundle); the server re-enforces the cap + * on create regardless. The bulk-SELECT cap (`DOCUMENT_BULK_MAX_IDS`) is higher + * (100), so a large selection is capped for the share path with an explicit hint. + */ +export const SHARE_LINK_MAX_DOCUMENTS = 50; + +/** + * Map a selection over the loaded corpus onto the `{ id, title }` list a share + * link seeds from. Returns `{ overCap: true }` when the selection exceeds + * `SHARE_LINK_MAX_DOCUMENTS` — the caller surfaces the hint and refuses rather + * than silently dropping documents from the link. `untitledLabel` is injected + * so the helper stays pure / i18n-free. + */ +export function resolveBulkShareDocuments( + documents: readonly InboundDocumentDto[], + selected: ReadonlySet, + untitledLabel: string, +): + | { overCap: true } + | { overCap: false; documents: { id: string; title: string }[] } { + if (selected.size > SHARE_LINK_MAX_DOCUMENTS) return { overCap: true }; + const picked = documents + .filter((d) => selected.has(d.id)) + .map((d) => ({ + id: d.id, + title: d.title ?? d.filename ?? untitledLabel, + })); + return { overCap: false, documents: picked }; +} + // ─── Upload response contract (§3.2) ─────────────────────────────────────── /** diff --git a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts new file mode 100644 index 000000000..5f5317c8b --- /dev/null +++ b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { resolveKey } from "@/lib/i18n/resolve-key"; + +/** + * The Vorsorge reminder type labels + the document bulk-share copy resolve + * through DYNAMIC keys (`t(\`measurementReminders.types.${type}\`)`), which the + * static i18n call-site coverage guard cannot see. This test asserts the + * dynamic keys exist and resolve to a non-empty string in EVERY locale bundle — + * the guard that would have caught the missing WHO5_SCORE / SCI_SCORE labels + * (which rendered as the raw key prefix "measurementReminders.types.…"). + */ +const MESSAGES_DIR = join(__dirname, "../../../../messages"); + +const LOCALES = readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ + locale: f.replace(/\.json$/, ""), + messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record< + string, + unknown + >, + })); + +// The keys that MUST resolve in every locale (dynamic-key floor). +const REQUIRED_KEYS = [ + // Newly added Vorsorge reminder type labels. + "measurementReminders.types.WAIST_CIRCUMFERENCE", + "measurementReminders.types.WHO5_SCORE", + "measurementReminders.types.SCI_SCORE", + // Pre-existing screening labels (regression floor). + "measurementReminders.types.PHQ9_SCORE", + "measurementReminders.types.GAD7_SCORE", + // Bulk-share copy. + "documents.bulk.share", + "documents.bulk.shareTooMany", + "documents.share.multiTitle", + "documents.share.multiLabel", +]; + +describe("measurement-reminder + bulk-share dynamic i18n keys", () => { + it("discovers all six shipped locales", () => { + expect(LOCALES.map((l) => l.locale).sort()).toEqual([ + "de", + "en", + "es", + "fr", + "it", + "pl", + ]); + }); + + for (const { locale, messages } of LOCALES) { + for (const key of REQUIRED_KEYS) { + it(`resolves ${key} in ${locale}`, () => { + const value = resolveKey(messages, key); + expect(value, `${key} missing in ${locale}.json`).toBeTypeOf("string"); + expect((value ?? "").trim().length).toBeGreaterThan(0); + }); + } + } +}); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 44df73e39..c66f23a12 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -117,6 +117,7 @@ const TYPE_GROUPS = [ "TOTAL_BODY_WATER", "VISCERAL_FAT", "BODY_MASS_INDEX", + "WAIST_CIRCUMFERENCE", ], }, // v1.27.6 — the mental-wellbeing screenings become plannable like any diff --git a/src/components/ui/__tests__/responsive-sheet.test.tsx b/src/components/ui/__tests__/responsive-sheet.test.tsx index 026a3826f..76dc1d77a 100644 --- a/src/components/ui/__tests__/responsive-sheet.test.tsx +++ b/src/components/ui/__tests__/responsive-sheet.test.tsx @@ -193,6 +193,69 @@ describe("", () => { expect(html).toContain("Edit medication"); }); + it("reserves the close-button gutter on the Sheet header by default", () => { + mobile = true; + const html = renderToStaticMarkup( + {}} title="Detail"> +

body

+
, + ); + // Default showCloseButton=true → the primitive paints its absolute X, so + // the header reserves the right gutter for it. + expect(html).toContain("pr-12"); + mobile = false; + }); + + it("drops the Sheet header gutter when showCloseButton is false", () => { + mobile = true; + const html = renderToStaticMarkup( + {}} + title="Detail" + showCloseButton={false} + headerAction={} + > +

body

+
, + ); + // No primitive close button → no reserved gutter; the headerAction sits + // flush to the header's p-4 right edge. + expect(html).not.toContain("pr-12"); + mobile = false; + }); + + it("reserves the Dialog header gutter for headerAction by default", () => { + mobile = false; + const html = renderToStaticMarkup( + {}} + title="Detail" + headerAction={} + > +

body

+
, + ); + expect(html).toContain("pr-9"); + }); + + it("drops the Dialog header gutter when showCloseButton is false", () => { + mobile = false; + const html = renderToStaticMarkup( + {}} + title="Detail" + showCloseButton={false} + headerAction={} + > +

body

+
, + ); + expect(html).not.toContain("pr-9"); + }); + it("does not throw when onOpenChange is invoked (controlled-state plumbing)", () => { mobile = false; const onOpenChange = vi.fn(); diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 19967b9de..39d0c4733 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -167,7 +167,13 @@ export function ResponsiveSheet({ @@ -255,7 +261,12 @@ export function ResponsiveSheet({ data-slot="responsive-sheet-header" className={cn( "shrink-0", - headerAction && "flex-row items-start justify-between gap-3 pr-9", + headerAction && "flex-row items-start justify-between gap-3", + // Reserve the trailing gutter for the primitive's absolute close + // button ONLY when it is actually painted. With + // `showCloseButton={false}` the headerAction sits flush to the + // header's right edge instead of being pushed in. + headerAction && showCloseButton && "pr-9", )} > {headerAction ? ( diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts index 325e21019..aa013d421 100644 --- a/src/lib/validations/__tests__/measurement-reminders.test.ts +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -34,6 +34,7 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => { "TOTAL_BODY_WATER", "VISCERAL_FAT", "BODY_MASS_INDEX", + "WAIST_CIRCUMFERENCE", // v1.27.6 — plannable mental-wellbeing screenings "PHQ9_SCORE", "GAD7_SCORE", @@ -75,6 +76,20 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => { expect(parsed.success).toBe(true); }); + it("accepts WAIST_CIRCUMFERENCE (the added body-composition target)", () => { + expect(allowed).toContain("WAIST_CIRCUMFERENCE"); + const parsed = createMeasurementReminderSchema.safeParse({ + label: "Taillenumfang messen", + measurementType: "WAIST_CIRCUMFERENCE", + intervalDays: 30, + }); + expect(parsed.success).toBe(true); + }); + + it("does NOT add WAIST_TO_HEIGHT (only waist circumference was requested)", () => { + expect(allowed).not.toContain("WAIST_TO_HEIGHT"); + }); + it("matches BP on the SYS sentinel only (DIA would double-count)", () => { expect(allowed).toContain("BLOOD_PRESSURE_SYS"); expect(allowed).not.toContain("BLOOD_PRESSURE_DIA"); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 0ee684b90..22dad2305 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -56,6 +56,7 @@ export const measurementReminderTypeEnum = z "TOTAL_BODY_WATER", "VISCERAL_FAT", "BODY_MASS_INDEX", + "WAIST_CIRCUMFERENCE", // v1.27.6 — the mental-wellbeing screeners become plannable Vorsorge // items. Not a passive wearable score: a screening is an active "go and // do it" action exactly like stepping on the scale. Auto-resolves from