mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
27 files changed
+621
-96
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user