mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge pull request #754 from MBombeck/feat/arztbesuch-water-polish
Doctor-visit, link-picker, address-book and water-entry polish
This commit is contained in:
34 files changed
+1290
-969
No files matched your search
@@ -8,8 +8,15 @@
|
||||
- A failed load of your share links now says so, with a way to retry, instead of quietly showing "no active share links" as if you had never shared anything. The same held for the API and connector token lists on the security screens.
|
||||
- A read that fails now shows a clear, recoverable error across the app instead of an empty list or a silent gap. The sharing and token screens, the ECG detail and list, the cycle and mood insights, the dashboard preventive-care tile, the checkups list, and the admin console all say when a read failed and offer a retry, rather than reading as "nothing here".
|
||||
- The AI consent and connector notes in Settings render as normal text now, not faint fine print, so the copy you are meant to read before turning a feature on is legible.
|
||||
- The record-a-visit action on the checkups list now appears only where there is a real practice visit behind the reminder. A reminder you satisfy yourself in the app, like weighing in or a mood check-in, no longer offers to file a visit it never involved.
|
||||
- The practices and doctors address book searches by specialty now, so typing "Zahnmedizin" finds the dentist even when the name and practice hold nothing of the sort. The list groups its entries under their specialty, with the ones you left unspecified gathered at the end, and the visit form's picker shows each contact's field beside the practice.
|
||||
- Linking documents, lab results and illnesses to a visit is searchable now instead of a flat list you had to scroll. Each link block shows what you have picked as removable chips over an add button that opens a searchable sheet; lab results gather under their panel and sample date so you can attach a whole day's panel in one tap, and documents gather by month. The vaccination form uses the same picker for its document link.
|
||||
- The respiratory-rate tile on the dashboard shows its unit in your own language instead of a fixed English label, and the personalized greeting carries its comma.
|
||||
|
||||
### Removed
|
||||
|
||||
- Logging water by hand inside the app is gone. The water entry on the capture menu, the dashboard quick-add and the hydration card have all been removed. Water that syncs in from another app through Apple Health still arrives and still shows on the hydration card with its daily total, history and reference line.
|
||||
|
||||
## [1.37.3] — 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -210,7 +210,7 @@ test.describe("mobile-viewport smoke", () => {
|
||||
// 1) The center capture action opens the capture picker.
|
||||
await page.getByTestId("bottom-nav-capture").click();
|
||||
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
|
||||
for (const kind of ["measurement", "medication", "mood", "water"]) {
|
||||
for (const kind of ["measurement", "medication", "mood"]) {
|
||||
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
|
||||
}
|
||||
// Dismiss the picker before opening the hub.
|
||||
|
||||
@@ -310,7 +310,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// out (50+ failed CI runs since v1.4.13). Specs that need the
|
||||
// tour can opt back in by mocking `/api/auth/me`.
|
||||
// Nutrients is intentionally opt-in in production. The shared fixture
|
||||
// enables it because the water-capture specs exercise that real gate.
|
||||
// enables it so the nutrients/hydration display specs render the card.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role,
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { openMenu } from "./open-menu";
|
||||
import {
|
||||
mockDashboardSnapshot,
|
||||
WEIGHT_ONLY_SUMMARIES,
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
|
||||
test.describe("water capture", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("opens globally, waits for success, and retains failed input", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let writeCount = 0;
|
||||
let confirmSuccess: (() => void) | undefined;
|
||||
const successConfirmed = new Promise<void>((resolve) => {
|
||||
confirmSuccess = resolve;
|
||||
});
|
||||
await page.route("**/api/nutrients/water", async (route) => {
|
||||
writeCount += 1;
|
||||
if (writeCount === 1) {
|
||||
await successConfirmed;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { amountMl: 250 }, error: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: null, error: "write failed" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const openWater = async (toastMayOverlap = false) => {
|
||||
if (testInfo.project.name === "chromium-mobile") {
|
||||
const capture = page.getByTestId("bottom-nav-capture");
|
||||
if (toastMayOverlap) {
|
||||
await capture.dispatchEvent("click");
|
||||
} else {
|
||||
await openMenu(page, capture);
|
||||
}
|
||||
const option = page.getByTestId("capture-picker-water");
|
||||
await expect(option).toBeVisible();
|
||||
const box = await option.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
if (toastMayOverlap) {
|
||||
await option.dispatchEvent("click");
|
||||
} else {
|
||||
await option.click();
|
||||
}
|
||||
} else {
|
||||
await openMenu(
|
||||
page,
|
||||
page.locator('[data-tour-id="dashboard-quick-add"]'),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Log water" }).click();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Add water" }),
|
||||
).toBeVisible();
|
||||
if (testInfo.project.name === "chromium-mobile") {
|
||||
const actions = page
|
||||
.getByRole("dialog")
|
||||
.locator(
|
||||
'[data-slot="water-quick-add-chips"] button, form button[type="submit"]',
|
||||
);
|
||||
await expect(actions).toHaveCount(4);
|
||||
for (const action of await actions.all()) {
|
||||
await expect(action).toHaveAccessibleName(/.+/);
|
||||
const actionBox = await action.boundingBox();
|
||||
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await openWater();
|
||||
const amount = page.getByRole("spinbutton", {
|
||||
name: "Custom amount (mL)",
|
||||
});
|
||||
await amount.fill("250");
|
||||
const submit = page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Add", exact: true });
|
||||
await amount.press("Enter");
|
||||
|
||||
await expect(submit).toBeDisabled();
|
||||
await expect(amount).toHaveValue("250");
|
||||
await expect(page.getByText("250 mL water added.")).toHaveCount(0);
|
||||
|
||||
confirmSuccess?.();
|
||||
await expect(page.getByText("250 mL water added.")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Add water" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
await openWater(true);
|
||||
const failedAmount = page.getByRole("spinbutton", {
|
||||
name: "Custom amount (mL)",
|
||||
});
|
||||
await expect(failedAmount).toHaveValue("");
|
||||
await failedAmount.fill("375");
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Couldn't save that — try again."),
|
||||
).toBeVisible();
|
||||
await expect(failedAmount).toHaveValue("375");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Add water" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("375 mL water added.")).toHaveCount(0);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
+17
-15
@@ -349,9 +349,7 @@
|
||||
"medication": "Medikament",
|
||||
"medicationDescription": "Eine Dosis als genommen markieren",
|
||||
"mood": "Stimmung",
|
||||
"moodDescription": "Stimmung und Tags erfassen",
|
||||
"water": "Wasser",
|
||||
"waterDescription": "Wasseraufnahme schnell hinzufügen"
|
||||
"moodDescription": "Stimmung und Tags erfassen"
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
@@ -455,7 +453,6 @@
|
||||
"quickAddMeasurement": "Messung erfassen",
|
||||
"quickAddMood": "Stimmung erfassen",
|
||||
"quickAddMedicationIntake": "Einnahme erfassen",
|
||||
"quickAddWater": "Wasser erfassen",
|
||||
"customizeDashboard": "Dashboard anpassen",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Medikamenteneinnahme erfassen",
|
||||
@@ -9654,17 +9651,11 @@
|
||||
"dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.",
|
||||
"upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.",
|
||||
"unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"moduleEnableError": "Das Nährstoffmodul konnte nicht aktiviert werden. Versuch es noch mal."
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA",
|
||||
"quickAddTitle": "Wasser hinzufügen",
|
||||
"quickAddCustomLabel": "Eigene Menge (ml)",
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Hinzufügen",
|
||||
"quickAddError": "Konnte nicht gespeichert werden — versuch's noch mal.",
|
||||
"quickAddSuccess": "{amount} ml Wasser hinzugefügt.",
|
||||
"editTotal": "Heutige Summe bearbeiten"
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag · EFSA"
|
||||
@@ -10285,7 +10276,7 @@
|
||||
"description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.",
|
||||
"linkLabel": "Adressbuch",
|
||||
"add": "Eintrag",
|
||||
"searchPlaceholder": "Name oder Praxis suchen",
|
||||
"searchPlaceholder": "Name, Praxis oder Fachrichtung suchen",
|
||||
"loadError": "Adressbuch konnte nicht geladen werden.",
|
||||
"emptyTitle": "Noch kein Eintrag",
|
||||
"emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.",
|
||||
@@ -10304,6 +10295,17 @@
|
||||
"saveFailed": "Eintrag konnte nicht gespeichert werden.",
|
||||
"deleteTitle": "Eintrag löschen?",
|
||||
"deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.",
|
||||
"deleteFailed": "Eintrag konnte nicht gelöscht werden."
|
||||
"deleteFailed": "Eintrag konnte nicht gelöscht werden.",
|
||||
"noSpecialtyGroup": "Ohne Fachrichtung"
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
"add": "Hinzufügen",
|
||||
"done": "Fertig",
|
||||
"selectAll": "Alle auswählen",
|
||||
"searchPlaceholder": "Suchen",
|
||||
"searchNoMatch": "Kein Treffer",
|
||||
"remove": "Entfernen"
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -349,9 +349,7 @@
|
||||
"medication": "Medication",
|
||||
"medicationDescription": "Mark a dose as taken",
|
||||
"mood": "Mood",
|
||||
"moodDescription": "Log your mood and tags",
|
||||
"water": "Water",
|
||||
"waterDescription": "Quickly add your water intake"
|
||||
"moodDescription": "Log your mood and tags"
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
@@ -455,7 +453,6 @@
|
||||
"quickAddMeasurement": "Log measurement",
|
||||
"quickAddMood": "Log mood",
|
||||
"quickAddMedicationIntake": "Log medication intake",
|
||||
"quickAddWater": "Log water",
|
||||
"customizeDashboard": "Customize dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Log medication intake",
|
||||
@@ -9654,17 +9651,11 @@
|
||||
"dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.",
|
||||
"upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.",
|
||||
"unknown": "These entries arrived but couldn't be stored."
|
||||
}
|
||||
},
|
||||
"moduleEnableError": "The nutrients module couldn't be turned on. Please try again."
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Reference intake {value} {unit}/day · EFSA",
|
||||
"quickAddTitle": "Add water",
|
||||
"quickAddCustomLabel": "Custom amount (mL)",
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Add",
|
||||
"quickAddError": "Couldn't save that — try again.",
|
||||
"quickAddSuccess": "{amount} mL water added.",
|
||||
"editTotal": "Edit today's total"
|
||||
"referenceMeta": "Reference intake {value} {unit}/day · EFSA"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Safe-level ceiling {value} {unit}/day · EFSA"
|
||||
@@ -10285,7 +10276,7 @@
|
||||
"description": "Your own address book — type a practice once, pick it everywhere.",
|
||||
"linkLabel": "Address book",
|
||||
"add": "Entry",
|
||||
"searchPlaceholder": "Search name or practice",
|
||||
"searchPlaceholder": "Search name, practice or specialty",
|
||||
"loadError": "The address book could not be loaded.",
|
||||
"emptyTitle": "No entry yet",
|
||||
"emptyDescription": "Add a practice so you can pick it when you record a visit.",
|
||||
@@ -10304,6 +10295,17 @@
|
||||
"saveFailed": "The entry could not be saved.",
|
||||
"deleteTitle": "Delete this entry?",
|
||||
"deleteDescription": "Your existing visits stay, and they keep this name.",
|
||||
"deleteFailed": "The entry could not be deleted."
|
||||
"deleteFailed": "The entry could not be deleted.",
|
||||
"noSpecialtyGroup": "No specialty"
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
"add": "Add",
|
||||
"done": "Done",
|
||||
"selectAll": "Select all",
|
||||
"searchPlaceholder": "Search",
|
||||
"searchNoMatch": "No match",
|
||||
"remove": "Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -349,9 +349,7 @@
|
||||
"medication": "Medicamento",
|
||||
"medicationDescription": "Marcar una dosis como tomada",
|
||||
"mood": "Estado de ánimo",
|
||||
"moodDescription": "Registra tu estado de ánimo y etiquetas",
|
||||
"water": "Agua",
|
||||
"waterDescription": "Añade rápidamente tu consumo de agua"
|
||||
"moodDescription": "Registra tu estado de ánimo y etiquetas"
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
@@ -455,7 +453,6 @@
|
||||
"quickAddMeasurement": "Registrar medición",
|
||||
"quickAddMood": "Registrar estado de ánimo",
|
||||
"quickAddMedicationIntake": "Registrar toma",
|
||||
"quickAddWater": "Registrar agua",
|
||||
"customizeDashboard": "Personalizar panel",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registrar toma de medicación",
|
||||
@@ -9654,17 +9651,11 @@
|
||||
"dayInvalid": "Las entradas llevaban una fecha que el servidor no pudo aceptar. Esto puede ocurrir cuando el reloj o la zona horaria del lado que envía los datos están mal configurados; merece la pena revisarlo.",
|
||||
"upsertFailed": "Estas entradas llegaron al servidor, pero un problema de nuestro lado impidió guardarlas. No tienes que hacer nada.",
|
||||
"unknown": "Estas entradas llegaron, pero no se pudieron guardar."
|
||||
}
|
||||
},
|
||||
"moduleEnableError": "No se pudo activar el módulo de nutrientes. Inténtalo de nuevo."
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA",
|
||||
"quickAddTitle": "Añadir agua",
|
||||
"quickAddCustomLabel": "Cantidad personalizada (mL)",
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Añadir",
|
||||
"quickAddError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"quickAddSuccess": "Se añadieron {amount} mL de agua.",
|
||||
"editTotal": "Editar el total de hoy"
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Límite de seguridad {value} {unit}/día · EFSA"
|
||||
@@ -10285,7 +10276,7 @@
|
||||
"description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.",
|
||||
"linkLabel": "Agenda",
|
||||
"add": "Entrada",
|
||||
"searchPlaceholder": "Buscar nombre o consulta",
|
||||
"searchPlaceholder": "Buscar nombre, consulta o especialidad",
|
||||
"loadError": "No se pudo cargar la agenda.",
|
||||
"emptyTitle": "Todavía no hay entradas",
|
||||
"emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.",
|
||||
@@ -10304,6 +10295,17 @@
|
||||
"saveFailed": "No se pudo guardar la entrada.",
|
||||
"deleteTitle": "¿Eliminar esta entrada?",
|
||||
"deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.",
|
||||
"deleteFailed": "No se pudo eliminar la entrada."
|
||||
"deleteFailed": "No se pudo eliminar la entrada.",
|
||||
"noSpecialtyGroup": "Sin especialidad"
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
"add": "Añadir",
|
||||
"done": "Listo",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"searchNoMatch": "Sin coincidencias",
|
||||
"remove": "Quitar"
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -349,9 +349,7 @@
|
||||
"medication": "Médicament",
|
||||
"medicationDescription": "Marquer une dose comme prise",
|
||||
"mood": "Humeur",
|
||||
"moodDescription": "Enregistrez votre humeur et des étiquettes",
|
||||
"water": "Eau",
|
||||
"waterDescription": "Ajoutez rapidement votre consommation d’eau"
|
||||
"moodDescription": "Enregistrez votre humeur et des étiquettes"
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
@@ -455,7 +453,6 @@
|
||||
"quickAddMeasurement": "Saisir une mesure",
|
||||
"quickAddMood": "Saisir une humeur",
|
||||
"quickAddMedicationIntake": "Saisir une prise",
|
||||
"quickAddWater": "Saisir de l’eau",
|
||||
"customizeDashboard": "Personnaliser le tableau de bord",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Saisir une prise de médicament",
|
||||
@@ -9654,17 +9651,11 @@
|
||||
"dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.",
|
||||
"upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.",
|
||||
"unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées."
|
||||
}
|
||||
},
|
||||
"moduleEnableError": "Le module nutriments n'a pas pu être activé. Réessaie."
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA",
|
||||
"quickAddTitle": "Ajouter de l'eau",
|
||||
"quickAddCustomLabel": "Quantité personnalisée (mL)",
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Ajouter",
|
||||
"quickAddError": "Échec de l'enregistrement — réessayez.",
|
||||
"quickAddSuccess": "{amount} mL d’eau ajoutés.",
|
||||
"editTotal": "Modifier le total du jour"
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour · EFSA"
|
||||
@@ -10285,7 +10276,7 @@
|
||||
"description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.",
|
||||
"linkLabel": "Carnet d'adresses",
|
||||
"add": "Entrée",
|
||||
"searchPlaceholder": "Rechercher un nom ou un cabinet",
|
||||
"searchPlaceholder": "Rechercher un nom, un cabinet ou une spécialité",
|
||||
"loadError": "Impossible de charger le carnet d'adresses.",
|
||||
"emptyTitle": "Aucune entrée",
|
||||
"emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.",
|
||||
@@ -10304,6 +10295,17 @@
|
||||
"saveFailed": "L'entrée n'a pas pu être enregistrée.",
|
||||
"deleteTitle": "Supprimer cette entrée ?",
|
||||
"deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.",
|
||||
"deleteFailed": "L'entrée n'a pas pu être supprimée."
|
||||
"deleteFailed": "L'entrée n'a pas pu être supprimée.",
|
||||
"noSpecialtyGroup": "Sans spécialité"
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
"add": "Ajouter",
|
||||
"done": "Terminé",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"searchPlaceholder": "Rechercher",
|
||||
"searchNoMatch": "Aucun résultat",
|
||||
"remove": "Retirer"
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -349,9 +349,7 @@
|
||||
"medication": "Farmaco",
|
||||
"medicationDescription": "Segna una dose come assunta",
|
||||
"mood": "Umore",
|
||||
"moodDescription": "Registra umore e tag",
|
||||
"water": "Acqua",
|
||||
"waterDescription": "Aggiungi rapidamente l’acqua bevuta"
|
||||
"moodDescription": "Registra umore e tag"
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
@@ -455,7 +453,6 @@
|
||||
"quickAddMeasurement": "Registra misurazione",
|
||||
"quickAddMood": "Registra umore",
|
||||
"quickAddMedicationIntake": "Registra assunzione",
|
||||
"quickAddWater": "Registra acqua",
|
||||
"customizeDashboard": "Personalizza dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registra assunzione del farmaco",
|
||||
@@ -9654,17 +9651,11 @@
|
||||
"dayInvalid": "Le voci riportavano una data che il server non ha potuto accettare. Questo può succedere quando l'orologio o il fuso orario dal lato che invia i dati non sono corretti: vale la pena controllare.",
|
||||
"upsertFailed": "Queste voci hanno raggiunto il server, ma un problema da questa parte ne ha impedito il salvataggio. Non devi fare nulla.",
|
||||
"unknown": "Queste voci sono arrivate ma non è stato possibile salvarle."
|
||||
}
|
||||
},
|
||||
"moduleEnableError": "Non è stato possibile attivare il modulo nutrienti. Riprova."
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA",
|
||||
"quickAddTitle": "Aggiungi acqua",
|
||||
"quickAddCustomLabel": "Quantità personalizzata (mL)",
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Aggiungi",
|
||||
"quickAddError": "Salvataggio non riuscito. Riprova.",
|
||||
"quickAddSuccess": "Aggiunti {amount} mL di acqua.",
|
||||
"editTotal": "Modifica il totale di oggi"
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno · EFSA"
|
||||
@@ -10285,7 +10276,7 @@
|
||||
"description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.",
|
||||
"linkLabel": "Rubrica",
|
||||
"add": "Voce",
|
||||
"searchPlaceholder": "Cerca nome o studio",
|
||||
"searchPlaceholder": "Cerca nome, studio o specializzazione",
|
||||
"loadError": "Impossibile caricare la rubrica.",
|
||||
"emptyTitle": "Nessuna voce",
|
||||
"emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.",
|
||||
@@ -10304,6 +10295,17 @@
|
||||
"saveFailed": "Non è stato possibile salvare la voce.",
|
||||
"deleteTitle": "Eliminare questa voce?",
|
||||
"deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.",
|
||||
"deleteFailed": "Non è stato possibile eliminare la voce."
|
||||
"deleteFailed": "Non è stato possibile eliminare la voce.",
|
||||
"noSpecialtyGroup": "Senza specializzazione"
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
"add": "Aggiungi",
|
||||
"done": "Fatto",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"searchPlaceholder": "Cerca",
|
||||
"searchNoMatch": "Nessun risultato",
|
||||
"remove": "Rimuovi"
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -349,9 +349,7 @@
|
||||
"medication": "Lek",
|
||||
"medicationDescription": "Oznacz dawkę jako przyjętą",
|
||||
"mood": "Nastrój",
|
||||
"moodDescription": "Zapisz nastrój i tagi",
|
||||
"water": "Woda",
|
||||
"waterDescription": "Szybko dodaj ilość wypitej wody"
|
||||
"moodDescription": "Zapisz nastrój i tagi"
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
@@ -455,7 +453,6 @@
|
||||
"quickAddMeasurement": "Zarejestruj pomiar",
|
||||
"quickAddMood": "Zarejestruj nastrój",
|
||||
"quickAddMedicationIntake": "Zarejestruj przyjęcie",
|
||||
"quickAddWater": "Zapisz wodę",
|
||||
"customizeDashboard": "Dostosuj pulpit",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Zarejestruj przyjęcie leku",
|
||||
@@ -9654,17 +9651,11 @@
|
||||
"dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.",
|
||||
"upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.",
|
||||
"unknown": "Te wpisy dotarły, ale nie udało się ich zapisać."
|
||||
}
|
||||
},
|
||||
"moduleEnableError": "Nie udało się włączyć modułu składników odżywczych. Spróbuj ponownie."
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA",
|
||||
"quickAddTitle": "Dodaj wodę",
|
||||
"quickAddCustomLabel": "Własna ilość (mL)",
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Dodaj",
|
||||
"quickAddError": "Nie udało się zapisać — spróbuj ponownie.",
|
||||
"quickAddSuccess": "Dodano {amount} mL wody.",
|
||||
"editTotal": "Edytuj dzisiejszą sumę"
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień · EFSA"
|
||||
@@ -10285,7 +10276,7 @@
|
||||
"description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.",
|
||||
"linkLabel": "Książka adresowa",
|
||||
"add": "Wpis",
|
||||
"searchPlaceholder": "Szukaj nazwiska lub gabinetu",
|
||||
"searchPlaceholder": "Szukaj nazwiska, gabinetu lub specjalizacji",
|
||||
"loadError": "Nie udało się wczytać książki adresowej.",
|
||||
"emptyTitle": "Brak wpisów",
|
||||
"emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.",
|
||||
@@ -10304,6 +10295,17 @@
|
||||
"saveFailed": "Nie udało się zapisać wpisu.",
|
||||
"deleteTitle": "Usunąć ten wpis?",
|
||||
"deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.",
|
||||
"deleteFailed": "Nie udało się usunąć wpisu."
|
||||
"deleteFailed": "Nie udało się usunąć wpisu.",
|
||||
"noSpecialtyGroup": "Bez specjalizacji"
|
||||
},
|
||||
"links": {
|
||||
"picker": {
|
||||
"add": "Dodaj",
|
||||
"done": "Gotowe",
|
||||
"selectAll": "Zaznacz wszystko",
|
||||
"searchPlaceholder": "Szukaj",
|
||||
"searchNoMatch": "Brak wyników",
|
||||
"remove": "Usuń"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,9 +193,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/insights/mood/mood-tag-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/nutrients/water-quick-add-sheet.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
|
||||
"src/components/insights/recommendation-feedback.tsx": { "text-success": 3 },
|
||||
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
|
||||
|
||||
@@ -40,7 +40,6 @@ interface Messages {
|
||||
* Hinzufügen, Hinzufügen, Hinzufügen" with no way to discriminate.
|
||||
*/
|
||||
quickAddMedicationIntake: string;
|
||||
quickAddWater: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,23 +68,18 @@ describe("dashboard quick-add submenu labels", () => {
|
||||
const measurement = messages.dashboard.quickAddMeasurement;
|
||||
const mood = messages.dashboard.quickAddMood;
|
||||
const medicationIntake = messages.dashboard.quickAddMedicationIntake;
|
||||
const water = messages.dashboard.quickAddWater;
|
||||
const trigger = messages.common.add;
|
||||
|
||||
// Non-empty
|
||||
expect(measurement.trim().length).toBeGreaterThan(0);
|
||||
expect(mood.trim().length).toBeGreaterThan(0);
|
||||
expect(medicationIntake.trim().length).toBeGreaterThan(0);
|
||||
expect(water.trim().length).toBeGreaterThan(0);
|
||||
|
||||
// Distinct from each other — the icon is decorative (aria-hidden),
|
||||
// so the only thing distinguishing the rows is the visible text.
|
||||
expect(measurement).not.toBe(mood);
|
||||
expect(measurement).not.toBe(medicationIntake);
|
||||
expect(mood).not.toBe(medicationIntake);
|
||||
expect(measurement).not.toBe(water);
|
||||
expect(mood).not.toBe(water);
|
||||
expect(medicationIntake).not.toBe(water);
|
||||
|
||||
// Distinct from the trigger label. The trigger sits ABOVE the menu
|
||||
// and announces itself first; if a menu item then repeats the same
|
||||
@@ -93,7 +87,6 @@ describe("dashboard quick-add submenu labels", () => {
|
||||
expect(measurement).not.toBe(trigger);
|
||||
expect(mood).not.toBe(trigger);
|
||||
expect(medicationIntake).not.toBe(trigger);
|
||||
expect(water).not.toBe(trigger);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -50,10 +50,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
// Both plaintext columns, because a person looks a practice up by
|
||||
// whichever of the two they remember: the doctor's name on the referral
|
||||
// or the practice name on the door. Searching only the name made the
|
||||
// second attempt return nothing and read as "not in the address book".
|
||||
// Three plaintext columns, because a person looks a practice up by
|
||||
// whichever they remember: the doctor's name on the referral, the
|
||||
// practice name on the door, or the field they need ("Zahnmedizin").
|
||||
// Searching only name + practice made the specialty attempt return
|
||||
// nothing and read as "not in the address book".
|
||||
...(parsed.data.q
|
||||
? {
|
||||
OR: [
|
||||
@@ -69,6 +70,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
mode: "insensitive" as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
specialty: {
|
||||
contains: parsed.data.q,
|
||||
mode: "insensitive" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function InsightsNutrientsPage() {
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
onError: () => toast.error(t("nutrients.hydration.quickAddError")),
|
||||
onError: () => toast.error(t("nutrients.page.moduleEnableError")),
|
||||
});
|
||||
|
||||
if (!nutrientsEnabled) {
|
||||
|
||||
@@ -369,33 +369,27 @@ describe("linking a document to an illness episode", () => {
|
||||
});
|
||||
|
||||
describe("the capture picker's kinds", () => {
|
||||
const ALL = ["measurement", "medication", "mood", "water"] as const;
|
||||
const ALL = ["measurement", "medication", "mood"] as const;
|
||||
|
||||
it("offers everything in the caller's own record", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, true, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood", "water"]);
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood"]);
|
||||
});
|
||||
|
||||
it("offers a delegate only what the delegation admits", () => {
|
||||
// A reading and a dose are admitted verbs. A mood entry and a glass of
|
||||
// water are not, and the server refuses them under a switch.
|
||||
// A reading and a dose are admitted verbs. A mood entry is not, and the
|
||||
// server refuses it under a switch.
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: false }, true, [...ALL]),
|
||||
visibleCaptureKinds({ canAdd: true, canManage: false }, [...ALL]),
|
||||
).toEqual(["measurement", "medication"]);
|
||||
});
|
||||
|
||||
it("offers a read-only delegate nothing", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: false, canManage: false }, true, [...ALL]),
|
||||
visibleCaptureKinds({ canAdd: false, canManage: false }, [...ALL]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still honours the module gate for the owner", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, false, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood"]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -471,13 +465,10 @@ describe("a form opened before the record answered", () => {
|
||||
* top of the file applies.
|
||||
*/
|
||||
it("withdraws a capture form the shrunken offer no longer holds", () => {
|
||||
// The chooser offered four; the answer arrives and offers two.
|
||||
// The chooser offered three; the answer arrives and offers two.
|
||||
expect(admittedCaptureKind("mood", ["measurement", "medication"])).toBe(
|
||||
null,
|
||||
);
|
||||
expect(admittedCaptureKind("water", ["measurement", "medication"])).toBe(
|
||||
null,
|
||||
);
|
||||
// …and leaves an admitted one exactly where it was.
|
||||
expect(
|
||||
admittedCaptureKind("medication", ["measurement", "medication"]),
|
||||
@@ -488,7 +479,6 @@ describe("a form opened before the record answered", () => {
|
||||
it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => {
|
||||
const DELEGATE = { canAdd: true, canManage: false };
|
||||
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("water", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
|
||||
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
|
||||
"medicationIntake",
|
||||
@@ -497,24 +487,14 @@ describe("a form opened before the record answered", () => {
|
||||
|
||||
it("withdraws every quick-entry sheet from a read-only delegate", () => {
|
||||
const READER = { canAdd: false, canManage: false };
|
||||
for (const sheet of [
|
||||
"measurement",
|
||||
"mood",
|
||||
"medicationIntake",
|
||||
"water",
|
||||
] as const) {
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, READER), sheet).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the owner's own sheets alone", () => {
|
||||
const OWNER_CAPS = { canAdd: true, canManage: true };
|
||||
for (const sheet of [
|
||||
"measurement",
|
||||
"mood",
|
||||
"medicationIntake",
|
||||
"water",
|
||||
] as const) {
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
|
||||
}
|
||||
expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null);
|
||||
|
||||
@@ -16,13 +16,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const moduleGate = vi.hoisted(() => ({ nutrientsEnabled: true }));
|
||||
|
||||
vi.mock("@/hooks/use-module-enabled", () => ({
|
||||
useModuleEnabled: (moduleKey: string) =>
|
||||
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
@@ -77,29 +70,18 @@ describe("<DashboardHeader> — greeting", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — nutrients module gate", () => {
|
||||
it("hides only the water quick-add item when nutrients are disabled", () => {
|
||||
moduleGate.nutrientsEnabled = false;
|
||||
|
||||
describe("<DashboardHeader> — quick-add menu", () => {
|
||||
it("offers measurement, mood and medication, and no water entry", () => {
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Log water");
|
||||
expect(html).toContain("Log measurement");
|
||||
expect(html).toContain("Log mood");
|
||||
expect(html).toContain("Log medication intake");
|
||||
});
|
||||
|
||||
it("keeps the water quick-add item available when nutrients are enabled", () => {
|
||||
moduleGate.nutrientsEnabled = true;
|
||||
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
|
||||
expect(html).toContain("Log water");
|
||||
// Water logging was removed from the app; the dashboard quick-add offers
|
||||
// no water entry.
|
||||
expect(html).not.toContain("Log water");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Activity, GlassWater, Pill, Plus, Waves, Wrench } from "lucide-react";
|
||||
import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -24,7 +24,6 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { getHourForTimeZone } from "@/components/dashboard/range-display";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
@@ -37,11 +36,10 @@ export function DashboardHeader({
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const nutrientsEnabled = useModuleEnabled("nutrients");
|
||||
// v1.36.x — the dashboard quick-add offers four kinds; a delegation admits
|
||||
// two of them (a reading, a dose). Mood and water stay with the account
|
||||
// whose record it is, and the customize shortcut points at a settings page
|
||||
// sharing does not cover at all.
|
||||
// v1.36.x — the dashboard quick-add offers three kinds; a delegation admits
|
||||
// two of them (a reading, a dose). Mood stays with the account whose record
|
||||
// it is, and the customize shortcut points at a settings page sharing does
|
||||
// not cover at all.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
|
||||
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
|
||||
@@ -166,12 +164,6 @@ export function DashboardHeader({
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
{nutrientsEnabled && canManage && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
|
||||
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddWater")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Quick-entry sheets for the dashboard: measurement, mood, medication
|
||||
* intake, and water behind the shared ResponsiveSheet primitive
|
||||
* Quick-entry sheets for the dashboard: measurement, mood, and medication
|
||||
* intake behind the shared ResponsiveSheet primitive
|
||||
* (bottom-sheet on `<md`, centred Dialog on `md+`), plus the
|
||||
* confirm-before-discard guard for dirty forms.
|
||||
*
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
@@ -32,15 +31,14 @@ import {
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
|
||||
export type QuickEntryDialog =
|
||||
"measurement" | "mood" | "medicationIntake" | "water" | null;
|
||||
"measurement" | "mood" | "medicationIntake" | null;
|
||||
|
||||
/**
|
||||
* v1.36.x — which of the four the delegation admits, and the same rule the
|
||||
* v1.36.x — which of the three the delegation admits, and the same rule the
|
||||
* capture picker states at length.
|
||||
*
|
||||
* A WRITE grant covers entering a reading and marking a dose. A mood entry and
|
||||
* a glass of water are not admitted verbs and the server refuses both under a
|
||||
* switch.
|
||||
* A WRITE grant covers entering a reading and marking a dose. A mood entry is
|
||||
* not an admitted verb and the server refuses it under a switch.
|
||||
*/
|
||||
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
new Set(["measurement", "medicationIntake"]);
|
||||
@@ -52,7 +50,7 @@ const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
* whole gate — so it held only for as long as the answer was already known.
|
||||
* `resolveRecordCapabilities(undefined)` reads as the caller's own record
|
||||
* until `/api/auth/me` settles, and the switch flow ends in a hard reload that
|
||||
* wipes the persisted cache, so a delegate's first paint carries all four menu
|
||||
* wipes the persisted cache, so a delegate's first paint carries all menu
|
||||
* items. One of them opened a sheet that then stayed open on a form the server
|
||||
* refuses.
|
||||
*
|
||||
@@ -215,11 +213,6 @@ export function QuickEntrySheets({
|
||||
footerSlot={medicationIntakeFooterEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
<WaterQuickAddSheet
|
||||
open={openSheet === "water"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
onSuccess={onClose}
|
||||
/>
|
||||
|
||||
{/* v1.11.3 F3 — confirm before discarding a partly-filled
|
||||
quick-entry form when the sheet is dismissed by an overlay
|
||||
|
||||
@@ -2,121 +2,39 @@
|
||||
|
||||
/**
|
||||
* The visit form's three optional link blocks: documents, lab results,
|
||||
* conditions.
|
||||
* conditions. Each is the shared {@link EntityLinkPicker} — an inline summary
|
||||
* (removable chips + an add button) over a searchable, grouped sheet.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** A module the
|
||||
* account turned off leaves nothing behind here — no heading, no empty list,
|
||||
* no "0 selected". An empty picker for a switched-off module advertises a
|
||||
* feature that is not there and invites a person to wonder what is wrong. This
|
||||
* is the same shape the dashboard snapshot uses for a disabled module.
|
||||
* feature that is not there. This is the same shape the dashboard snapshot
|
||||
* uses for a disabled module.
|
||||
*
|
||||
* The encounter itself carries NO module gate. A visit is core, like the
|
||||
* checkups page it lives on; what it may point AT follows the modules that own
|
||||
* those things, which is why the gate lives here and not on the route.
|
||||
*
|
||||
* Nothing in this block can block a save. Every list starts empty and stays
|
||||
* valid empty.
|
||||
* valid empty. The picker raises only the FETCH limit so grouping has enough
|
||||
* to group; the link cap stays authoritative on the server.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
|
||||
/** How many candidates one block offers before the person must use the sheet. */
|
||||
const OPTION_LIMIT = 25;
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
/** ISO-8601, or null for a target with no date of its own. */
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
function LinkBlock({
|
||||
icon: Icon,
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
pending,
|
||||
slot,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onToggle: (id: string) => void;
|
||||
pending: boolean;
|
||||
slot: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot={slot}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{selected.length > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{selected.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{pending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("encounters.form.linkNothingToOffer")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{options.map((option) => {
|
||||
const on = selected.includes(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onToggle(option.id)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.date ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(option.date)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(list: string[], id: string): string[] {
|
||||
return list.includes(id)
|
||||
? list.filter((entry) => entry !== id)
|
||||
: [...list, id];
|
||||
}
|
||||
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
|
||||
const PICKER_FETCH_LIMIT = 200;
|
||||
|
||||
interface LabListPage {
|
||||
results: Array<{
|
||||
@@ -149,6 +67,7 @@ export function EncounterLinkPickers({
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
const documentsOn = modules?.inboundDocuments === true;
|
||||
const labsOn = modules?.labs === true;
|
||||
@@ -159,7 +78,7 @@ export function EncounterLinkPickers({
|
||||
enabled: documentsOn,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -175,7 +94,7 @@ export function EncounterLinkPickers({
|
||||
enabled: labsOn,
|
||||
queryFn: () =>
|
||||
apiGet<LabListPage>(
|
||||
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
|
||||
`/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -184,63 +103,100 @@ export function EncounterLinkPickers({
|
||||
enabled: illnessOn,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
|
||||
`/api/illness/episodes?includeResolved=true&limit=${PICKER_FETCH_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!documentsOn && !labsOn && !illnessOn) return null;
|
||||
|
||||
// Documents group by month; the caller localizes the heading.
|
||||
const documentOptions: EntityLinkOption[] = (
|
||||
documents.data?.documents ?? []
|
||||
).map((doc) => {
|
||||
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? format.date(date) : null,
|
||||
group: date
|
||||
? {
|
||||
key: date.slice(0, 7),
|
||||
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
|
||||
// underneath. A visit links the whole panel from a day in one "select all".
|
||||
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
|
||||
(result) => {
|
||||
const dayLabel = format.date(result.takenAt);
|
||||
const groupLabel = result.panel
|
||||
? `${result.panel} · ${dayLabel}`
|
||||
: dayLabel;
|
||||
return {
|
||||
id: result.id,
|
||||
label: result.analyte,
|
||||
dateLabel: dayLabel,
|
||||
group: {
|
||||
key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`,
|
||||
label: groupLabel,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Episodes stay flat (a bounded list), newest onset first from the route.
|
||||
const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map(
|
||||
(episode) => ({
|
||||
id: episode.id,
|
||||
label: episode.label,
|
||||
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
|
||||
{documentsOn ? (
|
||||
<LinkBlock
|
||||
<EntityLinkPicker
|
||||
icon={FolderOpen}
|
||||
title={t("encounters.form.linkDocuments")}
|
||||
slot="encounter-link-documents"
|
||||
pending={documents.isPending}
|
||||
selected={documentIds}
|
||||
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
|
||||
options={(documents.data?.documents ?? []).map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
|
||||
}))}
|
||||
onChange={(ids) => onChange({ documentIds: ids })}
|
||||
options={documentOptions}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("encounters.form.linkNothingToOffer")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{labsOn ? (
|
||||
<LinkBlock
|
||||
<EntityLinkPicker
|
||||
icon={FlaskConical}
|
||||
title={t("encounters.form.linkLabResults")}
|
||||
slot="encounter-link-labs"
|
||||
pending={labs.isPending}
|
||||
selected={labResultIds}
|
||||
onToggle={(id) =>
|
||||
onChange({ labResultIds: toggle(labResultIds, id) })
|
||||
}
|
||||
options={(labs.data?.results ?? []).map((result) => ({
|
||||
id: result.id,
|
||||
label: result.panel
|
||||
? `${result.panel} · ${result.analyte}`
|
||||
: result.analyte,
|
||||
date: result.takenAt,
|
||||
}))}
|
||||
onChange={(ids) => onChange({ labResultIds: ids })}
|
||||
options={labOptions}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("encounters.form.linkNothingToOffer")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{illnessOn ? (
|
||||
<LinkBlock
|
||||
<EntityLinkPicker
|
||||
icon={Stethoscope}
|
||||
title={t("encounters.form.linkConditions")}
|
||||
slot="encounter-link-conditions"
|
||||
pending={episodes.isPending}
|
||||
selected={episodeIds}
|
||||
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
|
||||
options={(episodes.data ?? []).map((episode) => ({
|
||||
id: episode.id,
|
||||
label: episode.label,
|
||||
date: episode.onsetAt,
|
||||
}))}
|
||||
onChange={(ids) => onChange({ episodeIds: ids })}
|
||||
options={episodeOptions}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("encounters.form.linkNothingToOffer")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -128,9 +128,11 @@ export function PractitionerCombobox({
|
||||
<span className="text-foreground block truncate">
|
||||
{row.name}
|
||||
</span>
|
||||
{row.practice ? (
|
||||
{row.practice || row.specialty ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{row.practice}
|
||||
{[row.practice, row.specialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The hydration card is a display-only surface: it shows the synced day
|
||||
* total and the 30-day bar chart, and it must NOT offer any in-app water
|
||||
* entry (the manual quick-add was removed — water arrives by sync only).
|
||||
* The card and its sync-fed display stay; the add button and its sheet go.
|
||||
*/
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
unit: "mL",
|
||||
days: [{ day: "2026-08-08", amount: 1500 }],
|
||||
reference: { value: 2000 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/charts/nutrient-daily-bar-chart-dynamic", () => ({
|
||||
NutrientDailyBarChartDynamic: () => <div data-testid="hydration-bar-chart" />,
|
||||
}));
|
||||
|
||||
import { HydrationCard } from "../hydration-card";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HydrationCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HydrationCard> — display only, no in-app water entry", () => {
|
||||
it("renders the synced day total and chart", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="nutrients-hydration-today"');
|
||||
expect(html).toContain('data-testid="hydration-bar-chart"');
|
||||
});
|
||||
|
||||
it("offers no water-entry affordance", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="nutrients-hydration-add"');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GlassWater, Plus } from "lucide-react";
|
||||
import { GlassWater } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
|
||||
import { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -24,14 +21,14 @@ const WINDOW_DAYS = 30;
|
||||
* Today's total (all sources summed) as the content value, a muted EFSA
|
||||
* reference-intake meta line (omitted when the profile has no sex on
|
||||
* file — the catalog's own contract), a 30-day bar chart with a dashed
|
||||
* reference line, and the quick-add entry point. No ring, no attainment
|
||||
* colour — the bar chart with a reference line IS the context
|
||||
* (UI-STANDARDS: value stays foreground, never coloured by attainment).
|
||||
* reference line. No ring, no attainment colour — the bar chart with a
|
||||
* reference line IS the context (UI-STANDARDS: value stays foreground,
|
||||
* never coloured by attainment). Display only: water arrives by sync
|
||||
* (Apple Health etc.), there is no in-app water entry here.
|
||||
*/
|
||||
export function HydrationCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.nutrientDaily("water", WINDOW_DAYS),
|
||||
@@ -50,22 +47,7 @@ export function HydrationCard() {
|
||||
return (
|
||||
<Card data-slot="nutrients-hydration-card">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={GlassWater}
|
||||
title={t("nutrients.names.water")}
|
||||
right={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
data-slot="nutrients-hydration-add"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{t("nutrients.hydration.quickAddTitle")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TileHeader icon={GlassWater} title={t("nutrients.names.water")} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading || !data ? (
|
||||
@@ -100,11 +82,6 @@ export function HydrationCard() {
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<WaterQuickAddSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
todayTotalMl={Math.round(todayTotal)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.29 — manual water quick-add sheet, opened from the hydration card.
|
||||
*
|
||||
* Chips (+200/+300/+500 mL) and custom amounts are one-shot writes:
|
||||
* the sheet closes and clears only after the API confirms success. A
|
||||
* rejected write leaves the sheet and input untouched so the user can retry.
|
||||
*
|
||||
* Every write hits `POST /api/nutrients/water`, which owns ONLY the
|
||||
* `source="MANUAL"` row (migration 0249) — never the Apple-synced row.
|
||||
*/
|
||||
|
||||
const QUICK_CHIPS_ML = [200, 300, 500] as const;
|
||||
|
||||
interface WaterQuickAddSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called after a confirmed write; bypasses dirty-dismiss guards. */
|
||||
onSuccess?: () => void;
|
||||
/** Today's combined total. Omit outside the hydration detail surface. */
|
||||
todayTotalMl?: number;
|
||||
}
|
||||
|
||||
export function WaterQuickAddSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
todayTotalMl,
|
||||
}: WaterQuickAddSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editAmount, setEditAmount] = useState(() =>
|
||||
todayTotalMl == null ? "" : String(todayTotalMl),
|
||||
);
|
||||
const writePendingRef = useRef(false);
|
||||
const closeAfterSuccess = onSuccess ?? (() => onOpenChange(false));
|
||||
|
||||
// v1.29.x — the Today digest joins the invalidation. The write route
|
||||
// already hard-evicts the server snapshot bucket
|
||||
// (`invalidateUserDashboardSnapshot`), but the client digest/snapshot
|
||||
// queries are typically unmounted while this sheet is open (the user is
|
||||
// on `/insights/nutrients`, not the dashboard), so a default ("active")
|
||||
// invalidation would mark them stale without refetching them —
|
||||
// `refetchInactiveDailyReads` forces the inactive refetch, mirroring the
|
||||
// measurement / mood / medication fix.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.nutrientsRoot() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
};
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (amountMl: number) =>
|
||||
apiPost("/api/nutrients/water", { amountMl, mode: "add" as const }),
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t("nutrients.hydration.quickAddError")),
|
||||
});
|
||||
|
||||
const setMutation = useMutation({
|
||||
mutationFn: (amountMl: number) =>
|
||||
apiPost("/api/nutrients/water", { amountMl, mode: "set" as const }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setEditMode(false);
|
||||
},
|
||||
onError: () => toast.error(t("nutrients.hydration.quickAddError")),
|
||||
});
|
||||
|
||||
const addWater = async (amount: number) => {
|
||||
if (writePendingRef.current) return;
|
||||
writePendingRef.current = true;
|
||||
try {
|
||||
await addMutation.mutateAsync(amount);
|
||||
toast.success(
|
||||
t("nutrients.hydration.quickAddSuccess", { amount: String(amount) }),
|
||||
);
|
||||
setCustomAmount("");
|
||||
closeAfterSuccess();
|
||||
} catch {
|
||||
// The mutation owns the translated error toast. Preserve all input.
|
||||
} finally {
|
||||
writePendingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomAdd = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const amount = Number(customAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return;
|
||||
void addWater(amount);
|
||||
};
|
||||
|
||||
const handleEditSave = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const amount = Number(editAmount);
|
||||
if (writePendingRef.current || !Number.isFinite(amount) || amount < 0) {
|
||||
return;
|
||||
}
|
||||
writePendingRef.current = true;
|
||||
try {
|
||||
await setMutation.mutateAsync(amount);
|
||||
toast.success(
|
||||
t("nutrients.hydration.quickAddSuccess", { amount: String(amount) }),
|
||||
);
|
||||
closeAfterSuccess();
|
||||
} catch {
|
||||
// The mutation owns the translated error toast. Preserve all input.
|
||||
} finally {
|
||||
writePendingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("nutrients.hydration.quickAddTitle")}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2" data-slot="water-quick-add-chips">
|
||||
{QUICK_CHIPS_ML.map((amountMl) => (
|
||||
<Button
|
||||
key={amountMl}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-8"
|
||||
disabled={addMutation.isPending || setMutation.isPending}
|
||||
onClick={() => void addWater(amountMl)}
|
||||
>
|
||||
+{amountMl} mL
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="flex items-end gap-2" onSubmit={handleCustomAdd}>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="water-custom-amount">
|
||||
{t("nutrients.hydration.quickAddCustomLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="water-custom-amount"
|
||||
name="amountMl"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={20000}
|
||||
placeholder={t("nutrients.hydration.quickAddCustomPlaceholder")}
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={
|
||||
addMutation.isPending || setMutation.isPending || !customAmount
|
||||
}
|
||||
>
|
||||
{t("nutrients.hydration.quickAddSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{todayTotalMl != null && editMode ? (
|
||||
<form
|
||||
className="border-border/70 space-y-1.5 border-t pt-4"
|
||||
onSubmit={handleEditSave}
|
||||
>
|
||||
<Label htmlFor="water-edit-total">
|
||||
{t("nutrients.hydration.editTotal")}
|
||||
</Label>
|
||||
<div className="flex items-end gap-2">
|
||||
<Input
|
||||
id="water-edit-total"
|
||||
name="totalMl"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={20000}
|
||||
className="flex-1"
|
||||
value={editAmount}
|
||||
onChange={(e) => setEditAmount(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={addMutation.isPending || setMutation.isPending}
|
||||
>
|
||||
{t("nutrients.hydration.quickAddSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : todayTotalMl != null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground min-h-11 self-start sm:min-h-8"
|
||||
onClick={() => {
|
||||
setEditAmount(String(todayTotalMl));
|
||||
setEditMode(true);
|
||||
}}
|
||||
>
|
||||
{t("nutrients.hydration.editTotal")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ const hookState = vi.hoisted(() => ({
|
||||
}));
|
||||
const captureState = vi.hoisted(() => ({
|
||||
dirty: true,
|
||||
nutrientsEnabled: true,
|
||||
}));
|
||||
|
||||
// v1.36.x — the picker asks what the record allows before it offers a kind.
|
||||
@@ -52,10 +51,6 @@ vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-module-enabled", () => ({
|
||||
useModuleEnabled: () => captureState.nutrientsEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/quick-entry-sheets", () => ({
|
||||
sheetBodyHasUnsavedInput: () => captureState.dirty,
|
||||
}));
|
||||
@@ -90,9 +85,6 @@ vi.mock("@/components/mood/mood-form", () => ({
|
||||
vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
|
||||
MedicationIntakeQuickAdd: markedComponent("MedicationIntakeQuickAdd"),
|
||||
}));
|
||||
vi.mock("@/components/insights/nutrients/water-quick-add-sheet", () => ({
|
||||
WaterQuickAddSheet: markedComponent("WaterQuickAddSheet"),
|
||||
}));
|
||||
|
||||
import { CapturePicker } from "../capture-picker";
|
||||
|
||||
@@ -129,68 +121,53 @@ function findMarked(tree: ReactNode, displayName: string): TestElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
class WaterDraftHarness {
|
||||
private mounted = false;
|
||||
private amount = "";
|
||||
|
||||
observe(tree: ReactNode): TestElement | null {
|
||||
const sheet = findMarked(tree, "WaterQuickAddSheet");
|
||||
if (!sheet) {
|
||||
this.mounted = false;
|
||||
this.amount = "";
|
||||
} else if (!this.mounted) {
|
||||
this.mounted = true;
|
||||
this.amount = "";
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
fill(value: string) {
|
||||
expect(this.mounted).toBe(true);
|
||||
this.amount = value;
|
||||
}
|
||||
|
||||
value() {
|
||||
return this.amount;
|
||||
}
|
||||
/**
|
||||
* The chosen capture surface lives in the second `ResponsiveSheet` (the
|
||||
* first hosts the kind chooser). Its `open` prop reflects the picked kind
|
||||
* and its `onOpenChange` is the dirty-dismiss interceptor under test.
|
||||
*/
|
||||
function formSheet(tree: ReactNode): TestElement {
|
||||
const sheets = elementsIn(tree).filter(
|
||||
(element) =>
|
||||
typeof element.type === "function" &&
|
||||
"displayName" in element.type &&
|
||||
element.type.displayName === "ResponsiveSheet",
|
||||
);
|
||||
expect(sheets.length).toBeGreaterThanOrEqual(2);
|
||||
return sheets[1];
|
||||
}
|
||||
|
||||
describe("<CapturePicker> — confirmed water discard", () => {
|
||||
describe("<CapturePicker> — confirmed capture-form discard", () => {
|
||||
beforeEach(() => {
|
||||
hookState.cursor = 0;
|
||||
hookState.values.length = 0;
|
||||
captureState.dirty = true;
|
||||
captureState.nutrientsEnabled = true;
|
||||
});
|
||||
|
||||
it("reopens with a fresh custom amount after confirmed discard", () => {
|
||||
const draft = new WaterDraftHarness();
|
||||
it("asks before discarding a dirty form and closes on confirm", () => {
|
||||
let tree = renderPicker();
|
||||
draft.observe(tree);
|
||||
|
||||
const waterOption = findByTestId(tree, "capture-picker-water");
|
||||
(waterOption.props.onClick as () => void)();
|
||||
// Choose the mood capture; the form sheet opens with the mood form.
|
||||
const moodOption = findByTestId(tree, "capture-picker-mood");
|
||||
(moodOption.props.onClick as () => void)();
|
||||
|
||||
tree = renderPicker();
|
||||
const waterSheet = draft.observe(tree);
|
||||
expect(waterSheet?.props.open).toBe(true);
|
||||
draft.fill("375");
|
||||
expect(formSheet(tree).props.open).toBe(true);
|
||||
expect(findMarked(tree, "MoodForm")).not.toBeNull();
|
||||
|
||||
(waterSheet?.props.onOpenChange as (open: boolean) => void)(false);
|
||||
// A dirty dismiss must not close outright — it raises the confirm.
|
||||
(formSheet(tree).props.onOpenChange as (open: boolean) => void)(false);
|
||||
tree = renderPicker();
|
||||
const confirmDiscard = findMarked(tree, "AlertDialogAction");
|
||||
expect(confirmDiscard).not.toBeNull();
|
||||
expect(draft.observe(tree)?.props.open).toBe(true);
|
||||
expect(draft.value()).toBe("375");
|
||||
// The form stays open behind the confirm.
|
||||
expect(formSheet(tree).props.open).toBe(true);
|
||||
expect(findMarked(tree, "MoodForm")).not.toBeNull();
|
||||
|
||||
// Confirming the discard closes the form.
|
||||
(confirmDiscard?.props.onClick as () => void)();
|
||||
|
||||
tree = renderPicker();
|
||||
expect(draft.observe(tree)).toBeNull();
|
||||
|
||||
const reopenedWaterOption = findByTestId(tree, "capture-picker-water");
|
||||
(reopenedWaterOption.props.onClick as () => void)();
|
||||
tree = renderPicker();
|
||||
expect(draft.observe(tree)?.props.open).toBe(true);
|
||||
expect(draft.value()).toBe("");
|
||||
expect(formSheet(tree).props.open).toBe(false);
|
||||
expect(findMarked(tree, "MoodForm")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const moduleGate = vi.hoisted(() => ({ nutrientsEnabled: true }));
|
||||
|
||||
vi.mock("@/hooks/use-module-enabled", () => ({
|
||||
useModuleEnabled: (moduleKey: string) =>
|
||||
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
|
||||
}));
|
||||
|
||||
// v1.36.x — the picker asks what the record allows before it offers a kind.
|
||||
// These fixtures are the caller's own record, which is every kind; the
|
||||
// delegation cases are covered in
|
||||
@@ -45,10 +38,6 @@ vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
|
||||
MedicationIntakeQuickAdd: () => <div data-testid="medication-form" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/insights/nutrients/water-quick-add-sheet", () => ({
|
||||
WaterQuickAddSheet: () => <div data-testid="water-quick-add-sheet" />,
|
||||
}));
|
||||
|
||||
import { CapturePicker } from "../capture-picker";
|
||||
|
||||
function render() {
|
||||
@@ -59,25 +48,15 @@ function render() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CapturePicker> — nutrients module gate", () => {
|
||||
it("hides and unmounts only water capture when nutrients are disabled", () => {
|
||||
moduleGate.nutrientsEnabled = false;
|
||||
|
||||
describe("<CapturePicker> — offered kinds", () => {
|
||||
it("offers measurement, medication and mood, and no water entry", () => {
|
||||
const html = render();
|
||||
|
||||
expect(html).not.toContain('data-testid="capture-picker-water"');
|
||||
expect(html).not.toContain('data-testid="water-quick-add-sheet"');
|
||||
expect(html).toContain('data-testid="capture-picker-measurement"');
|
||||
expect(html).toContain('data-testid="capture-picker-medication"');
|
||||
expect(html).toContain('data-testid="capture-picker-mood"');
|
||||
});
|
||||
|
||||
it("keeps water capture available when nutrients are enabled", () => {
|
||||
moduleGate.nutrientsEnabled = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="capture-picker-water"');
|
||||
expect(html).not.toContain('data-testid="water-quick-add-sheet"');
|
||||
// Water logging was removed from the app; the picker offers no water
|
||||
// entry (water arrives by sync only).
|
||||
expect(html).not.toContain('data-testid="capture-picker-water"');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity, GlassWater, Pill, Waves } from "lucide-react";
|
||||
import { Activity, Pill, Waves } from "lucide-react";
|
||||
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
@@ -29,12 +27,11 @@ import {
|
||||
/**
|
||||
* The center "Log" capture action (iOS parity — the bottom bar's
|
||||
* middle slot is a capture CTA, not a destination). It opens a small
|
||||
* picker that routes to four existing quick-entry surfaces:
|
||||
* picker that routes to three existing quick-entry surfaces:
|
||||
*
|
||||
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
|
||||
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
|
||||
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
|
||||
* - Water → `<WaterQuickAddSheet>` (the hydration quick-add)
|
||||
*
|
||||
* The picker reuses each existing capture component rather than
|
||||
* rebuilding parallel forms.
|
||||
@@ -50,16 +47,16 @@ import {
|
||||
* instead of closing unconditionally.
|
||||
*/
|
||||
|
||||
type CaptureKind = "measurement" | "medication" | "mood" | "water";
|
||||
type CaptureKind = "measurement" | "medication" | "mood";
|
||||
|
||||
/**
|
||||
* v1.36.x — which of the four capture surfaces a delegation admits.
|
||||
* v1.36.x — which of the capture surfaces a delegation admits.
|
||||
*
|
||||
* A WRITE grant covers entering a reading and marking a dose. It does not
|
||||
* cover a mood entry or a glass of water: those verbs stay on the owner's own
|
||||
* authentication and the server refuses them under a switch. The picker is the
|
||||
* fastest path into all four on a phone, so it is the surface where offering
|
||||
* an unadmitted one costs the most.
|
||||
* cover a mood entry: that verb stays on the owner's own authentication and
|
||||
* the server refuses it under a switch. The picker is the fastest path into
|
||||
* all three on a phone, so it is the surface where offering an unadmitted one
|
||||
* costs the most.
|
||||
*/
|
||||
const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
|
||||
"measurement",
|
||||
@@ -71,7 +68,6 @@ const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
"water",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -81,7 +77,7 @@ const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
* matters. `resolveRecordCapabilities(undefined)` answers "your own record"
|
||||
* until `/api/auth/me` settles — deliberately, so no add button blanks on
|
||||
* every cold load — and the switch flow ends in a hard reload that wipes the
|
||||
* persisted query cache. So a delegate's first paint offers all four kinds for
|
||||
* persisted query cache. So a delegate's first paint offers all kinds for
|
||||
* as long as that query takes. `chooseKind` refused an unoffered kind at tap
|
||||
* time and then never asked again, so a tap landing inside that window opened
|
||||
* a form and left it open once the answer arrived: the delegate ends up
|
||||
@@ -103,17 +99,15 @@ export function admittedCaptureKind(
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture kinds to offer, given what the record allows and whether the
|
||||
* nutrients module is on. Exported pure so the delegation rule can be pinned
|
||||
* without opening a sheet: an SSR test cannot tap the button that opens it.
|
||||
* The capture kinds to offer, given what the record allows. Exported pure so
|
||||
* the delegation rule can be pinned without opening a sheet: an SSR test
|
||||
* cannot tap the button that opens it.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
nutrientsEnabled: boolean,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter((kind) => {
|
||||
if (kind === "water" && !nutrientsEnabled) return false;
|
||||
if (caps.canManage) return true;
|
||||
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
|
||||
});
|
||||
@@ -128,13 +122,8 @@ interface CapturePickerProps {
|
||||
|
||||
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const nutrientsEnabled = useModuleEnabled("nutrients");
|
||||
const capabilities = useRecordCapabilities();
|
||||
const offered = visibleCaptureKinds(
|
||||
capabilities,
|
||||
nutrientsEnabled,
|
||||
CAPTURE_KIND_ORDER,
|
||||
);
|
||||
const offered = visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER);
|
||||
const [kind, setKind] = useState<CaptureKind | null>(null);
|
||||
// Re-derived on every render, never latched: the offer can shrink under a
|
||||
// sheet that is already open. See `admittedCaptureKind`.
|
||||
@@ -193,12 +182,6 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
description: t("nav.capture.moodDescription"),
|
||||
icon: Waves,
|
||||
},
|
||||
{
|
||||
kind: "water",
|
||||
label: t("nav.capture.water"),
|
||||
description: t("nav.capture.waterDescription"),
|
||||
icon: GlassWater,
|
||||
},
|
||||
];
|
||||
const options = allOptions.filter((opt) => offered.includes(opt.kind));
|
||||
|
||||
@@ -206,7 +189,6 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
measurement: t("measurements.addMeasurement"),
|
||||
medication: t("nav.capture.medication"),
|
||||
mood: t("mood.addEntry"),
|
||||
water: t("nutrients.hydration.quickAddTitle"),
|
||||
};
|
||||
// `openKind === null` keeps the form sheet closed (the title is unread
|
||||
// then); the mood label is the harmless default, matching the prior
|
||||
@@ -250,7 +232,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
|
||||
{/* The chosen capture surface, reusing the existing form. */}
|
||||
<ResponsiveSheet
|
||||
open={openKind !== null && openKind !== "water"}
|
||||
open={openKind !== null}
|
||||
onOpenChange={handleFormOpenChange}
|
||||
title={formTitle}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
@@ -278,14 +260,6 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{openKind === "water" && (
|
||||
<WaterQuickAddSheet
|
||||
open
|
||||
onOpenChange={handleFormOpenChange}
|
||||
onSuccess={closeForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* v1.30.1 — confirm before discarding a partly-filled capture
|
||||
form when the sheet is dismissed by an overlay tap, Escape,
|
||||
or a mobile swipe-down. Copy shared with the dashboard
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
filterOptions,
|
||||
groupOptions,
|
||||
toggleAll,
|
||||
type EntityLinkOption,
|
||||
} from "../entity-link-picker";
|
||||
|
||||
const OPTIONS: EntityLinkOption[] = [
|
||||
{
|
||||
id: "a",
|
||||
label: "LDL cholesterol",
|
||||
meta: "mg/dL",
|
||||
dateLabel: "12.05.2026",
|
||||
group: { key: "2026-05-12|Lipids", label: "Lipids · 12.05.2026" },
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
label: "HDL cholesterol",
|
||||
meta: "mg/dL",
|
||||
dateLabel: "12.05.2026",
|
||||
group: { key: "2026-05-12|Lipids", label: "Lipids · 12.05.2026" },
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
label: "Glucose",
|
||||
meta: "mg/dL",
|
||||
dateLabel: "03.02.2026",
|
||||
group: { key: "2026-02-03|Metabolic", label: "Metabolic · 03.02.2026" },
|
||||
},
|
||||
];
|
||||
|
||||
describe("filterOptions", () => {
|
||||
it("returns everything for an empty term", () => {
|
||||
expect(filterOptions(OPTIONS, "")).toHaveLength(3);
|
||||
expect(filterOptions(OPTIONS, " ")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("matches the label, case-insensitively", () => {
|
||||
const hits = filterOptions(OPTIONS, "glucose");
|
||||
expect(hits.map((o) => o.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("matches the group heading, so a panel search finds its members", () => {
|
||||
const hits = filterOptions(OPTIONS, "lipids");
|
||||
expect(hits.map((o) => o.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("matches the date and the meta line", () => {
|
||||
expect(filterOptions(OPTIONS, "03.02").map((o) => o.id)).toEqual(["c"]);
|
||||
expect(filterOptions(OPTIONS, "mg/dl")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupOptions", () => {
|
||||
it("collapses options into ordered groups, first-seen order", () => {
|
||||
const groups = groupOptions(OPTIONS);
|
||||
expect(groups.map((g) => g.label)).toEqual([
|
||||
"Lipids · 12.05.2026",
|
||||
"Metabolic · 03.02.2026",
|
||||
]);
|
||||
expect(groups[0].options.map((o) => o.id)).toEqual(["a", "b"]);
|
||||
expect(groups[1].options.map((o) => o.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("returns one flat, headingless group when nothing carries a group", () => {
|
||||
const flat: EntityLinkOption[] = [
|
||||
{ id: "x", label: "Knee injury" },
|
||||
{ id: "y", label: "Migraine" },
|
||||
];
|
||||
const groups = groupOptions(flat);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].key).toBeNull();
|
||||
expect(groups[0].label).toBeNull();
|
||||
expect(groups[0].options).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty option set", () => {
|
||||
expect(groupOptions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleAll", () => {
|
||||
it("adds the missing ids when the group is not fully selected", () => {
|
||||
expect(toggleAll(["a"], ["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("removes the whole group when every id is already selected", () => {
|
||||
expect(toggleAll(["a", "b", "c"], ["a", "b"])).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("never duplicates an already-selected id", () => {
|
||||
expect(toggleAll(["a", "b"], ["b", "c"])).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<EntityLinkPicker> inline summary", () => {
|
||||
const base = {
|
||||
icon: FolderOpen,
|
||||
title: "Lab results",
|
||||
slot: "test-link",
|
||||
searchPlaceholder: "Search",
|
||||
emptyLabel: "Nothing to link",
|
||||
onChange: () => undefined,
|
||||
};
|
||||
|
||||
it("shows a chip per selected option and the count badge", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={OPTIONS}
|
||||
pending={false}
|
||||
selected={["a", "c"]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="test-link-chips"');
|
||||
// One chip per selected id, labelled from the option.
|
||||
expect(html).toContain("LDL cholesterol");
|
||||
expect(html).toContain("Glucose");
|
||||
// The add button opens the sheet.
|
||||
expect(html).toContain('data-slot="test-link-add"');
|
||||
});
|
||||
|
||||
it("renders the empty label when there is nothing to offer", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker {...base} options={[]} pending={false} selected={[]} />,
|
||||
);
|
||||
expect(html).toContain("Nothing to link");
|
||||
expect(html).not.toContain('data-slot="test-link-add"');
|
||||
});
|
||||
|
||||
it("renders a skeleton while the options load", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker {...base} options={[]} pending={true} selected={[]} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
expect(html).not.toContain("Nothing to link");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The shared entity link picker: a searchable, grouped, multi-select surface
|
||||
* for attaching records (documents, lab results, illness episodes, …) to a
|
||||
* visit or a vaccination.
|
||||
*
|
||||
* One pattern, two consumers. The visit form mounts one per family (documents,
|
||||
* labs, conditions); the vaccination form mounts the documents one. Both used
|
||||
* to render a flat capped list with no search — 30 lab values arrived as 25
|
||||
* undifferentiated rows and anything past the cap was unreachable. This block
|
||||
* replaces that with an inline summary (removable chips + an add button) and a
|
||||
* sheet that searches, groups and multi-selects.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** The caller mounts
|
||||
* this only for a module that is on; a switched-off module leaves nothing here.
|
||||
*
|
||||
* Nothing here can block a save: the selection starts empty and stays valid
|
||||
* empty, and the link cap stays authoritative on the server. This surface only
|
||||
* ever raises the FETCH limit so grouping has something to group; it never
|
||||
* raises what may be linked.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, Plus, Search, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EntityLinkOption {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Already-localized date string, or null for a target with no date. */
|
||||
dateLabel?: string | null;
|
||||
/** Optional secondary muted line (e.g. the analyte under a panel group). */
|
||||
meta?: string | null;
|
||||
/**
|
||||
* Grouping key + already-localized heading. Omit or null for a flat list.
|
||||
* Options with the same key collapse under one heading, in first-seen order.
|
||||
*/
|
||||
group?: { key: string; label: string } | null;
|
||||
}
|
||||
|
||||
/** Lowercased haystack for the client search over one option. */
|
||||
function haystack(option: EntityLinkOption): string {
|
||||
return [option.label, option.meta, option.group?.label, option.dateLabel]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Client-side search: the whole term must appear in the option's haystack. */
|
||||
export function filterOptions(
|
||||
options: EntityLinkOption[],
|
||||
term: string,
|
||||
): EntityLinkOption[] {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) return options;
|
||||
return options.filter((option) => haystack(option).includes(needle));
|
||||
}
|
||||
|
||||
export interface EntityLinkGroup {
|
||||
/** Group key, or null for the flat (ungrouped) bucket. */
|
||||
key: string | null;
|
||||
/** Heading to render, or null when the list is flat. */
|
||||
label: string | null;
|
||||
options: EntityLinkOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse options into ordered groups. A group's position is where its first
|
||||
* member appears, so the caller's own ordering (usually newest first) carries
|
||||
* through. When no option carries a group, the whole list is one flat bucket
|
||||
* with a null heading.
|
||||
*/
|
||||
export function groupOptions(options: EntityLinkOption[]): EntityLinkGroup[] {
|
||||
const anyGrouped = options.some((option) => option.group != null);
|
||||
if (!anyGrouped) {
|
||||
return options.length ? [{ key: null, label: null, options }] : [];
|
||||
}
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, EntityLinkGroup>();
|
||||
for (const option of options) {
|
||||
const key = option.group?.key ?? "__ungrouped__";
|
||||
const label = option.group?.label ?? null;
|
||||
let group = byKey.get(key);
|
||||
if (!group) {
|
||||
group = { key, label, options: [] };
|
||||
byKey.set(key, group);
|
||||
order.push(key);
|
||||
}
|
||||
group.options.push(option);
|
||||
}
|
||||
return order.map((key) => byKey.get(key)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a whole group's ids: if every id is already selected, remove them
|
||||
* all; otherwise add the missing ones. Preserves order and never duplicates.
|
||||
*/
|
||||
export function toggleAll(selected: string[], ids: string[]): string[] {
|
||||
const selectedSet = new Set(selected);
|
||||
const allOn = ids.every((id) => selectedSet.has(id));
|
||||
if (allOn) {
|
||||
const drop = new Set(ids);
|
||||
return selected.filter((id) => !drop.has(id));
|
||||
}
|
||||
const merged = [...selected];
|
||||
for (const id of ids) if (!selectedSet.has(id)) merged.push(id);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function toggleOne(selected: string[], id: string): string[] {
|
||||
return selected.includes(id)
|
||||
? selected.filter((entry) => entry !== id)
|
||||
: [...selected, id];
|
||||
}
|
||||
|
||||
export function EntityLinkPicker({
|
||||
icon: Icon,
|
||||
title,
|
||||
slot,
|
||||
options,
|
||||
pending,
|
||||
selected,
|
||||
onChange,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
/** data-slot on the inline block; the module-gate tests assert this. */
|
||||
slot: string;
|
||||
options: EntityLinkOption[];
|
||||
pending: boolean;
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
searchPlaceholder: string;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
|
||||
const optionById = useMemo(() => {
|
||||
const map = new Map<string, EntityLinkOption>();
|
||||
for (const option of options) map.set(option.id, option);
|
||||
return map;
|
||||
}, [options]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupOptions(filterOptions(options, term)),
|
||||
[options, term],
|
||||
);
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const selectedChips = selected
|
||||
.map((id) => optionById.get(id))
|
||||
.filter((option): option is EntityLinkOption => option != null);
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot={slot}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{selected.length > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{selected.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedChips.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-1.5" data-slot={`${slot}-chips`}>
|
||||
{selectedChips.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={`${slot}-chip`}
|
||||
onClick={() => onChange(toggleOne(selected, option.id))}
|
||||
className="border-border bg-muted/40 hover:bg-muted focus-visible:ring-ring/50 flex min-h-8 items-center gap-1.5 rounded-full border px-3 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-foreground max-w-[12rem] truncate">
|
||||
{option.label}
|
||||
</span>
|
||||
<X
|
||||
className="text-muted-foreground size-3 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{t("links.picker.remove")}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
data-slot={`${slot}-add`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("links.picker.add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
contentWidth="lg"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
className="pl-9"
|
||||
data-slot={`${slot}-search`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-muted-foreground px-1 py-6 text-center text-sm">
|
||||
{t("links.picker.searchNoMatch")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[50vh] space-y-4 overflow-y-auto overscroll-contain">
|
||||
{groups.map((group) => {
|
||||
const groupIds = group.options.map((option) => option.id);
|
||||
return (
|
||||
<div key={group.key ?? "__flat__"} className="space-y-1">
|
||||
{group.label ? (
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<span
|
||||
className="text-muted-foreground text-xs font-medium"
|
||||
data-slot={`${slot}-group-heading`}
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={`${slot}-group-select`}
|
||||
onClick={() =>
|
||||
onChange(toggleAll(selected, groupIds))
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("links.picker.selectAll")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="space-y-1">
|
||||
{group.options.map((option) => {
|
||||
const on = selectedSet.has(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() =>
|
||||
onChange(toggleOne(selected, option.id))
|
||||
}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
on ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.meta ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{option.meta}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{option.dateLabel ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{option.dateLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end border-t pt-3">
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
{t("links.picker.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,6 +277,83 @@ describe("<VorsorgeSection> loading + empty", () => {
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("offers the file-visit action on a free-text appointment reminder", () => {
|
||||
// A free-text reminder (no measurementType) stands for a real practice
|
||||
// visit: "you went" is only ever recorded by filing a visit, so the
|
||||
// affordance belongs here.
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "appointment",
|
||||
label: "Dentist appointment",
|
||||
measurementType: null,
|
||||
intervalDays: 365,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain('data-slot="vorsorge-file-visit"');
|
||||
});
|
||||
|
||||
it("hides the file-visit action on a self-measurement reminder", () => {
|
||||
// A typed reminder (Gewicht, Blutdruck, a screening check-in) is
|
||||
// satisfied by a reading or a check-in inside the app — there is no
|
||||
// appointment behind it, so the visit affordance must not appear.
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "self-measure",
|
||||
label: "Measure blood pressure",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).not.toContain('data-slot="vorsorge-file-visit"');
|
||||
});
|
||||
|
||||
it("hides the file-visit action on a screening check-in reminder", () => {
|
||||
// A screening reminder (PHQ-9 / GAD-7) is a self-administered check-in,
|
||||
// not a practice visit.
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "screening-visit",
|
||||
label: "PHQ-9 check-in",
|
||||
measurementType: "PHQ9_SCORE",
|
||||
intervalDays: 28,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).not.toContain('data-slot="vorsorge-file-visit"');
|
||||
});
|
||||
|
||||
it("translates a COACH-origin label i18n key and shows the neutral badge", () => {
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "c1",
|
||||
|
||||
@@ -940,19 +940,26 @@ function VorsorgeCard({
|
||||
* checkbox uses, so a person who does both gets one satisfaction and no
|
||||
* error.
|
||||
*/
|
||||
const fileVisitButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
data-slot="vorsorge-file-visit"
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{t("measurementReminders.fileVisit")}
|
||||
</Button>
|
||||
) : null;
|
||||
// The file-visit action belongs only to a free-text reminder — a real
|
||||
// practice visit, where "you went" is recorded by filing a visit. A typed
|
||||
// reminder (Gewicht, Blutdruck, a screening check-in) is satisfied by a
|
||||
// reading or a check-in inside the app, so it carries no visit affordance.
|
||||
// `resolve.ts` states the same boundary server-side: an appointment reminder
|
||||
// is free-text by construction.
|
||||
const fileVisitButton =
|
||||
canManage && !isLinked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
data-slot="vorsorge-file-visit"
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{t("measurementReminders.fileVisit")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const primaryButton = canManage ? (
|
||||
<Button
|
||||
@@ -1047,8 +1054,9 @@ function VorsorgeCard({
|
||||
) : null}
|
||||
{/* Icon-only in the dense list, labelled in the cards branch —
|
||||
the same action either way, never one branch offering what
|
||||
the other withholds. */}
|
||||
{canManage ? (
|
||||
the other withholds. Free-text reminders only: a typed /
|
||||
screening reminder has no practice visit behind it. */}
|
||||
{canManage && !isLinked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Practitioner } from "@/hooks/use-practitioners";
|
||||
|
||||
/**
|
||||
* The address book groups its rows by specialty. A long list reads by field
|
||||
* ("Zahnmedizin", "Hausarzt", …) instead of one undifferentiated column, the
|
||||
* unspecified rows collect into one group that sits last, and when nothing
|
||||
* carries a specialty the headings are suppressed so a lone "no specialty"
|
||||
* heading never floats over every row.
|
||||
*/
|
||||
|
||||
const listMock = vi.fn();
|
||||
vi.mock("@/hooks/use-practitioners", () => ({
|
||||
usePractitioners: () => listMock(),
|
||||
usePractitionerMutations: () => ({
|
||||
remove: { mutate: vi.fn(), isPending: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-debounced-value", () => ({
|
||||
useDebouncedValue: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../practitioner-sheet", () => ({
|
||||
PractitionerSheet: () => null,
|
||||
}));
|
||||
|
||||
import { PractitionerList } from "../practitioner-list";
|
||||
|
||||
function row(partial: Partial<Practitioner> & { id: string }): Practitioner {
|
||||
return {
|
||||
name: partial.id,
|
||||
specialty: null,
|
||||
practice: null,
|
||||
location: null,
|
||||
phone: null,
|
||||
note: null,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
...partial,
|
||||
} as Practitioner;
|
||||
}
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<PractitionerList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<PractitionerList> specialty grouping", () => {
|
||||
it("renders a heading per specialty with the unspecified group last", () => {
|
||||
listMock.mockReturnValue({
|
||||
data: [
|
||||
row({ id: "a", name: "Dr. Meyer", specialty: "Zahnmedizin" }),
|
||||
row({ id: "b", name: "Dr. Braun", specialty: "Allgemeinmedizin" }),
|
||||
row({ id: "c", name: "Dr. Klein", specialty: null }),
|
||||
],
|
||||
isPending: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const html = render();
|
||||
|
||||
// Named groups render, alphabetically sorted, plus the fallback group.
|
||||
expect(html).toContain('data-slot="practitioner-group-heading"');
|
||||
expect(html).toContain('data-specialty="Zahnmedizin"');
|
||||
expect(html).toContain('data-specialty="Allgemeinmedizin"');
|
||||
expect(html).toContain("No specialty");
|
||||
|
||||
// Allgemeinmedizin precedes Zahnmedizin (alphabetical), and the
|
||||
// unspecified group sits after both.
|
||||
const allgemein = html.indexOf('data-specialty="Allgemeinmedizin"');
|
||||
const zahn = html.indexOf('data-specialty="Zahnmedizin"');
|
||||
const unspecified = html.indexOf("No specialty");
|
||||
expect(allgemein).toBeLessThan(zahn);
|
||||
expect(zahn).toBeLessThan(unspecified);
|
||||
});
|
||||
|
||||
it("suppresses headings when no row carries a specialty", () => {
|
||||
listMock.mockReturnValue({
|
||||
data: [
|
||||
row({ id: "a", name: "Dr. Meyer" }),
|
||||
row({ id: "b", name: "Dr. Braun" }),
|
||||
],
|
||||
isPending: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).not.toContain('data-slot="practitioner-group-heading"');
|
||||
expect(html).not.toContain("No specialty");
|
||||
// The rows themselves still render.
|
||||
expect(html).toContain("Dr. Meyer");
|
||||
expect(html).toContain("Dr. Braun");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
* has every reason to fear their history goes with it.
|
||||
*/
|
||||
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -60,7 +60,32 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const rows = list.data ?? [];
|
||||
const rows = useMemo(() => list.data ?? [], [list.data]);
|
||||
|
||||
// Group the flat list by specialty so a long address book reads by field
|
||||
// ("Zahnmedizin", "Hausarzt", …) instead of one undifferentiated column.
|
||||
// Specialty is exact free-text: each distinct string is its own group,
|
||||
// named groups sorted alphabetically, the unspecified rows collected into
|
||||
// one group that always sits last. When nothing carries a specialty the
|
||||
// headers are suppressed — a lone "no specialty" heading over every row is
|
||||
// noise, so the list degrades to the flat form it had before.
|
||||
const groups = useMemo(() => {
|
||||
const bySpecialty = new Map<string | null, Practitioner[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.specialty?.trim() ? row.specialty.trim() : null;
|
||||
const bucket = bySpecialty.get(key);
|
||||
if (bucket) bucket.push(row);
|
||||
else bySpecialty.set(key, [row]);
|
||||
}
|
||||
const named = [...bySpecialty.entries()]
|
||||
.filter((entry): entry is [string, Practitioner[]] => entry[0] !== null)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
const unspecified = bySpecialty.get(null);
|
||||
return unspecified
|
||||
? [...named, [null, unspecified] as [null, Practitioner[]]]
|
||||
: named;
|
||||
}, [rows]);
|
||||
const showGroupHeadings = groups.some(([specialty]) => specialty !== null);
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
@@ -127,57 +152,72 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2" data-slot="practitioner-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id}>
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{row.name}
|
||||
</p>
|
||||
{row.practice || row.specialty ? (
|
||||
<p className="text-foreground truncate text-sm">
|
||||
{[row.practice, row.specialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{row.location ? (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{row.location}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => openSheet(row)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
<div className="space-y-4" data-slot="practitioner-list">
|
||||
{groups.map(([specialty, groupRows]) => (
|
||||
<div key={specialty ?? "__unspecified__"} className="space-y-2">
|
||||
{showGroupHeadings ? (
|
||||
<h2
|
||||
className="text-muted-foreground px-1 text-xs font-medium"
|
||||
data-slot="practitioner-group-heading"
|
||||
data-specialty={specialty ?? ""}
|
||||
>
|
||||
{specialty ?? t("practitioners.noSpecialtyGroup")}
|
||||
</h2>
|
||||
) : null}
|
||||
<ul className="space-y-2">
|
||||
{groupRows.map((row) => (
|
||||
<li key={row.id}>
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{row.name}
|
||||
</p>
|
||||
{row.practice || row.specialty ? (
|
||||
<p className="text-foreground truncate text-sm">
|
||||
{[row.practice, row.specialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{row.location ? (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{row.location}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => openSheet(row)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PractitionerSheet
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
/**
|
||||
* The from-the-record document link: attach the scanned page a dose was
|
||||
* transcribed from.
|
||||
* transcribed from. The shared {@link EntityLinkPicker} — an inline summary
|
||||
* (removable chips + an add button) over a searchable, month-grouped sheet.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** When the
|
||||
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
|
||||
@@ -15,15 +16,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
|
||||
const OPTION_LIMIT = 25;
|
||||
const PICKER_FETCH_LIMIT = 200;
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
@@ -47,77 +49,42 @@ export function VaccinationDocumentPicker({
|
||||
enabled,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
onChange(
|
||||
documentIds.includes(id)
|
||||
? documentIds.filter((entry) => entry !== id)
|
||||
: [...documentIds, id],
|
||||
);
|
||||
|
||||
const options = (documents.data?.documents ?? []).map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
|
||||
}));
|
||||
const options: EntityLinkOption[] = (documents.data?.documents ?? []).map(
|
||||
(doc) => {
|
||||
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? format.date(date) : null,
|
||||
group: date
|
||||
? {
|
||||
key: date.slice(0, 7),
|
||||
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 border-t pt-4"
|
||||
data-slot="vaccination-document-picker"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
<span className="text-sm font-medium">
|
||||
{t("vaccinations.form.linkDocuments")}
|
||||
</span>
|
||||
{documentIds.length > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{documentIds.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{documents.isPending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("vaccinations.form.linkNothingToOffer")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{options.map((option) => {
|
||||
const on = documentIds.includes(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
data-slot="vaccination-document-option"
|
||||
onClick={() => toggle(option.id)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.date ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(option.date)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="border-t pt-4" data-slot="vaccination-document-picker">
|
||||
<EntityLinkPicker
|
||||
icon={FolderOpen}
|
||||
title={t("vaccinations.form.linkDocuments")}
|
||||
slot="vaccination-document"
|
||||
pending={documents.isPending}
|
||||
selected={documentIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Practitioner search covers the specialty column, against real Postgres.
|
||||
*
|
||||
* The property lives in SQL: the route's `where` OR filters name, practice
|
||||
* AND specialty case-insensitively. A mocked client cannot go red on it —
|
||||
* the filter it is meant to prove runs in the database, not in the handler.
|
||||
* So this seeds a row whose ONLY hit for "Zahn" is its specialty and asserts
|
||||
* the route returns it, plus a control row that must not come back.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { GET as listPractitioners } from "@/app/api/practitioners/route";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "practitioner-search-owner";
|
||||
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function seedSession() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "practitioner-search-owner",
|
||||
email: "practitioner-search-owner@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
expiresAt: daysFromNow(7),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function search(q: string): Promise<{ id: string; name: string }[]> {
|
||||
const res = await listPractitioners(
|
||||
new Request(
|
||||
`http://localhost/api/practitioners?q=${encodeURIComponent(q)}`,
|
||||
) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: { id: string; name: string }[] }).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedSession();
|
||||
});
|
||||
|
||||
describe("practitioner search by specialty", () => {
|
||||
it("returns a row whose only 'Zahn' hit is its specialty", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// Name + practice deliberately hold no "Zahn"; the field does.
|
||||
await prisma.practitioner.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Dr. Meyer",
|
||||
practice: "Praxis am Park",
|
||||
specialty: "Zahnmedizin",
|
||||
},
|
||||
});
|
||||
// A control row that shares nothing with the query on any column.
|
||||
await prisma.practitioner.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Dr. Braun",
|
||||
practice: "Hausarztpraxis Nord",
|
||||
specialty: "Allgemeinmedizin",
|
||||
},
|
||||
});
|
||||
|
||||
const hits = await search("Zahn");
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].name).toBe("Dr. Meyer");
|
||||
});
|
||||
|
||||
it("still matches on name and practice, case-insensitively", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.practitioner.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Dr. Meyer",
|
||||
practice: "Praxis am Park",
|
||||
specialty: "Zahnmedizin",
|
||||
},
|
||||
});
|
||||
await prisma.practitioner.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Dr. Braun",
|
||||
practice: "Hausarztpraxis Nord",
|
||||
specialty: "Allgemeinmedizin",
|
||||
},
|
||||
});
|
||||
|
||||
expect(await search("meyer")).toHaveLength(1);
|
||||
expect(await search("nord")).toHaveLength(1);
|
||||
expect(await search("medizin")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user