From 4852421f054e6c0d38a252a0b8d4c4ff62439172 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 16 Jul 2026 00:33:20 +0200
Subject: [PATCH 1/2] =?UTF-8?q?chore(release):=20v1.28.38=20=E2=80=94=20do?=
=?UTF-8?q?cuments=20polish,=20waist=20reminders,=20bulk=20sharing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Document vault: card list drops a stray glyph and gives the real filename
its own line (was truncating on the meta row); the detail sheet's close
button aligns correctly — ResponsiveSheet reserved the right gutter for a
close button it wasn't rendering, now conditional on showCloseButton. The
open-in-coach action reads 'Coach fragen'.
Bulk share: a multi-selection in the overview shares several documents in
ONE link (documents-only privacy preserved, owner-scoped, up to 50 per
link; >50 refuses with a toast rather than silently truncating).
Reminders can link to WAIST_CIRCUMFERENCE (additive MeasurementReminderType
enum value, OpenAPI regenerated). The WHO-5 and SCI well-being reminder
labels were absent in all six locales and rendered their raw dotted keys
(the shared 'measurementReminders.types' prefix is what showed as the stray
'Measurement Reminders Types'); added in every language. A new
type-labels-i18n test asserts every dynamic reminder-type key resolves
non-empty in all six bundles — the static coverage guard cannot see the
dynamic key that let this through.
---
CHANGELOG.md | 16 ++++
docs/api/openapi.yaml | 3 +-
messages/de.json | 16 +++-
messages/en.json | 16 +++-
messages/es.json | 16 +++-
messages/fr.json | 16 +++-
messages/it.json | 16 +++-
messages/pl.json | 16 +++-
package.json | 2 +-
public/sw.js | 2 +-
.../__tests__/document-bulk-bar.test.tsx | 10 +-
.../__tests__/document-card.test.tsx | 47 ++++++++++
.../__tests__/document-share-sheet.test.tsx | 92 +++++++++++++++++++
.../documents/__tests__/vault-utils.test.ts | 59 ++++++++++++
.../documents/document-bulk-bar.tsx | 31 +++++--
src/components/documents/document-card.tsx | 40 ++++----
.../documents/document-detail-sheet.tsx | 3 +-
.../documents/document-share-sheet.tsx | 44 ++++++---
src/components/documents/documents-view.tsx | 36 ++++++++
src/components/documents/vault-utils.ts | 36 ++++++++
.../__tests__/type-labels-i18n.test.ts | 64 +++++++++++++
.../vorsorge-section.tsx | 1 +
.../ui/__tests__/responsive-sheet.test.tsx | 63 +++++++++++++
src/components/ui/responsive-sheet.tsx | 15 ++-
.../__tests__/measurement-reminders.test.ts | 15 +++
src/lib/validations/measurement-reminders.ts | 1 +
26 files changed, 593 insertions(+), 83 deletions(-)
create mode 100644 src/components/documents/__tests__/document-share-sheet.test.tsx
create mode 100644 src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56417186e..a09d74550 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
## [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 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/messages/de.json b/messages/de.json
index 4c8c5b8fa..88df9f595 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",
@@ -8350,7 +8355,6 @@
"close": "Ausblenden"
},
"ai": {
- "statusAiRead": "Von KI gelesen",
"readDone": "Die KI hat dein Dokument gelesen.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
@@ -8366,6 +8370,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 +8391,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..59a6a1d01 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",
@@ -8350,7 +8355,6 @@
"close": "Hide"
},
"ai": {
- "statusAiRead": "Read by AI",
"readDone": "The AI read your document.",
"read": "Read with AI",
"reread": "Read again",
@@ -8366,6 +8370,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 +8391,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..e7f7fd84e 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",
@@ -8350,7 +8355,6 @@
"close": "Ocultar"
},
"ai": {
- "statusAiRead": "Leído por IA",
"readDone": "La IA leyó tu documento.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
@@ -8366,6 +8370,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 +8391,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..b1f04b4e3 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",
@@ -8350,7 +8355,6 @@
"close": "Masquer"
},
"ai": {
- "statusAiRead": "Lu par l’IA",
"readDone": "L’IA a lu votre document.",
"read": "Lire avec l’IA",
"reread": "Relire",
@@ -8366,6 +8370,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 +8391,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..9c62d4510 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",
@@ -8350,7 +8355,6 @@
"close": "Nascondi"
},
"ai": {
- "statusAiRead": "Letto dall’IA",
"readDone": "L’IA ha letto il tuo documento.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
@@ -8366,6 +8370,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 +8391,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..392590bd1 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",
@@ -8350,7 +8355,6 @@
"close": "Ukryj"
},
"ai": {
- "statusAiRead": "Odczytane przez AI",
"readDone": "AI odczytało Twój dokument.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
@@ -8366,6 +8370,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 +8391,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;
+ }) => (
+
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}
) : 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
From 3e917969c70b264eb765ac9ed53d241b2d1e0ce5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 16 Jul 2026 00:54:01 +0200
Subject: [PATCH 2/2] fix(documents): surface the read-by-AI provenance in the
detail view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
v1.28.38 removed the purple AI-read glyph from the vault card (it looked
wrong there), but that glyph had been the only visible surface for the
'read by AI' provenance since v1.28.22 — the detail sheet's AI section
shows no status and collapses entirely under auto-read. Add a discreet
muted 'Read by AI' line under the document's uploaded-metadata in the
detail view (data-slot document-detail-ai-read), independent of the AI
section's collapse logic; reinstate documents.ai.statusAiRead in all six
locales. Card glyph stays removed. The e2e that asserted the card marker
now deep-links the document and asserts the detail-view line.
---
CHANGELOG.md | 3 +-
e2e/documents-ai.spec.ts | 30 +++++++------------
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
.../documents/document-detail-sheet.tsx | 16 ++++++++++
9 files changed, 35 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a09d74550..3f7f113bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,8 @@
## [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 and give the real filename its own line so it stops being cut
+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".
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 88df9f595..b44c2efcb 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -8355,6 +8355,7 @@
"close": "Ausblenden"
},
"ai": {
+ "statusAiRead": "Von KI gelesen",
"readDone": "Die KI hat dein Dokument gelesen.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
diff --git a/messages/en.json b/messages/en.json
index 59a6a1d01..312ee4ee7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -8355,6 +8355,7 @@
"close": "Hide"
},
"ai": {
+ "statusAiRead": "Read by AI",
"readDone": "The AI read your document.",
"read": "Read with AI",
"reread": "Read again",
diff --git a/messages/es.json b/messages/es.json
index e7f7fd84e..d0a4e9700 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -8355,6 +8355,7 @@
"close": "Ocultar"
},
"ai": {
+ "statusAiRead": "Leído por IA",
"readDone": "La IA leyó tu documento.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
diff --git a/messages/fr.json b/messages/fr.json
index b1f04b4e3..4e444d565 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -8355,6 +8355,7 @@
"close": "Masquer"
},
"ai": {
+ "statusAiRead": "Lu par l’IA",
"readDone": "L’IA a lu votre document.",
"read": "Lire avec l’IA",
"reread": "Relire",
diff --git a/messages/it.json b/messages/it.json
index 9c62d4510..42b207b9c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -8355,6 +8355,7 @@
"close": "Nascondi"
},
"ai": {
+ "statusAiRead": "Letto dall’IA",
"readDone": "L’IA ha letto il tuo documento.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
diff --git a/messages/pl.json b/messages/pl.json
index 392590bd1..8122f13f0 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -8355,6 +8355,7 @@
"close": "Ukryj"
},
"ai": {
+ "statusAiRead": "Odczytane przez AI",
"readDone": "AI odczytało Twój dokument.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx
index aa34664d4..48c3409b6 100644
--- a/src/components/documents/document-detail-sheet.tsx
+++ b/src/components/documents/document-detail-sheet.tsx
@@ -62,6 +62,7 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
+ isAiReadSource,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
@@ -420,6 +421,12 @@ export function DocumentDetailSheet({
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
+ // AI-read provenance — surfaced here (the detail view) as calm muted meta
+ // since v1.28.38 dropped the vault-card glyph. Same condition the AI section
+ // uses: a content index exists AND its source is a provider vision/text read.
+ const aiRead = doc
+ ? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource)
+ : false;
return (
<>
@@ -752,6 +759,15 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}