Merge pull request #495 from MBombeck/release/v1.28.38

release/v1.28.38 — documents polish, waist reminders, bulk sharing
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-16 01:05:35 +02:00
commit ccc5c9ada3
27 files changed
+621 -96

No files matched your search

+17
View File
@@ -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
+2 -1
View File
@@ -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
+11 -19
View File
@@ -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 ──────────────
+11 -4
View File
@@ -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}"
}
},
+11 -4
View File
@@ -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}"
}
},
+11 -4
View File
@@ -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}"
}
},
+11 -4
View File
@@ -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}"
}
},
+11 -4
View File
@@ -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}"
}
},
+11 -4
View File
@@ -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}"
}
},
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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("<DocumentBulkBar>", () => {
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");
});
@@ -115,6 +115,53 @@ describe("<DocumentCard>", () => {
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(
<DocumentCard
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
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(
<DocumentCard
document={doc({
title: "MRT Knie",
filename: "mrt-knie-2025-10-04.pdf",
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
// 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(
<DocumentCard
document={doc({ title: "report.pdf", filename: "report.pdf" })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-card-filename"');
});
it("falls back to the untitled label and rings when highlighted", () => {
const html = render(
<DocumentCard
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The document-launched share sheet now accepts an ARRAY of documents so the
* bulk multi-select can fold the whole selection into ONE documents-only link.
* The single-doc (detail sheet) caller passes a one-element array.
*
* ResponsiveSheet wraps its body in a Radix portal that renderToStaticMarkup
* won't materialise, so both it and the create form are mocked down to plain
* wrappers that surface the title + the seeded form props.
*/
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
title,
children,
}: {
title: React.ReactNode;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<div data-slot="mock-responsive-sheet">
<div data-slot="sheet-title">{title}</div>
{children}
</div>
),
}));
vi.mock("@/components/settings/share-link-create-form", () => ({
ShareLinkCreateForm: (props: {
documentOnly?: boolean;
initialDocuments?: { id: string; title: string }[];
initialLabel?: string;
}) => (
<div
data-slot="mock-share-form"
data-document-only={String(props.documentOnly ?? false)}
data-initial-label={props.initialLabel ?? ""}
data-doc-ids={(props.initialDocuments ?? []).map((d) => 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(
<I18nProvider initialLocale="en">
<DocumentShareSheet open onOpenChange={() => {}} documents={documents} />
</I18nProvider>,
);
}
describe("<DocumentShareSheet> — 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(
<I18nProvider initialLocale="en">
<DocumentShareSheet
open={false}
onOpenChange={() => {}}
documents={[{ id: "d1", title: "A" }]}
/>
</I18nProvider>,
);
expect(html).not.toContain('data-slot="mock-share-form"');
});
});
@@ -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);
});
});
+25 -6
View File
@@ -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({
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
>
<Share2 className="size-4" aria-hidden />
{t("documents.bulk.share")}
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
+17 -23
View File
@@ -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 (
<Card
@@ -133,22 +129,20 @@ export function DocumentCard({
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
<span className="truncate">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</span>
{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).
<Sparkles
data-slot="document-card-ai-read"
role="img"
aria-label={t("documents.ai.statusAiRead")}
className="text-primary size-3 shrink-0"
/>
) : null}
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
</p>
{showFilename ? (
// Filename on its OWN muted line so it never clips the date/size
// meta; the full name stays reachable on hover via `title`.
<p
data-slot="document-card-filename"
className="text-muted-foreground truncate text-xs"
title={document.filename ?? undefined}
>
{document.filename}
</p>
) : null}
</div>
<Checkbox
checked={selected}
@@ -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}` : ""}
</p>
{aiRead ? (
<p
data-slot="document-detail-ai-read"
className="text-muted-foreground text-xs"
>
{t("documents.ai.statusAiRead")}
</p>
) : null}
</div>
</div>
) : null}
@@ -761,8 +777,7 @@ export function DocumentDetailSheet({
<DocumentShareSheet
open={shareOpen}
onOpenChange={setShareOpen}
documentId={doc.id}
documentTitle={title}
documents={[{ id: doc.id, title }]}
/>
) : null}
@@ -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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.share.title")}
title={
multiple ? t("documents.share.multiTitle") : t("documents.share.title")
}
description={t("documents.share.description")}
contentWidth="2xl"
>
{open ? (
<ShareLinkCreateForm
documentOnly
initialDocuments={[{ id: documentId, title: documentTitle }]}
initialLabel={documentTitle}
initialDocuments={documents}
initialLabel={label}
/>
) : null}
</ResponsiveSheet>
@@ -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<string | null>(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}
<DocumentShareSheet
open={bulkShareOpen}
onOpenChange={setBulkShareOpen}
documents={bulkShareDocs}
/>
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
itself announces through the live region below. */}
{dropActive ? (
+36
View File
@@ -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<string>,
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) ───────────────────────────────────────
/**
@@ -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);
});
}
}
});
@@ -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
@@ -193,6 +193,69 @@ describe("<ResponsiveSheet>", () => {
expect(html).toContain("Edit medication");
});
it("reserves the close-button gutter on the Sheet header by default", () => {
mobile = true;
const html = renderToStaticMarkup(
<ResponsiveSheet open onOpenChange={() => {}} title="Detail">
<p>body</p>
</ResponsiveSheet>,
);
// 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(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Detail"
showCloseButton={false}
headerAction={<button type="button">X</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
// 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(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Detail"
headerAction={<button type="button">X</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain("pr-9");
});
it("drops the Dialog header gutter when showCloseButton is false", () => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Detail"
showCloseButton={false}
headerAction={<button type="button">X</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).not.toContain("pr-9");
});
it("does not throw when onOpenChange is invoked (controlled-state plumbing)", () => {
mobile = false;
const onOpenChange = vi.fn();
+13 -2
View File
@@ -167,7 +167,13 @@ export function ResponsiveSheet({
<SheetHeader
data-slot="responsive-sheet-header"
className={cn(
"border-border/70 gap-1.5 border-b p-4 pr-12",
"border-border/70 gap-1.5 border-b p-4",
// Reserve the right gutter ONLY when the primitive paints its
// absolute close button. With `showCloseButton={false}` the
// consumer's own trailing control (headerAction) sits flush to
// the `p-4` right edge instead of being pushed in by an empty
// reserved gutter.
showCloseButton && "pr-12",
headerAction && "flex-row items-start justify-between gap-3",
)}
>
@@ -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 ? (
@@ -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");
@@ -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