feat(nutrients): remove in-app water logging, keep the sync path

Manual water entry is gone from the app: the water item on the capture
picker, the dashboard quick-add menu and the hydration card's add button
are removed, and the manual quick-add sheet is deleted. Water that syncs
in through Apple Health still arrives and still shows on the hydration
card with its daily total, history and reference line.

The POST /api/nutrients/water endpoint and the batch sync path stay
untouched. The nutrients page's module-enable error, which had borrowed
a water-quick-add string, gets its own key across all six locales.
Input-only water keys (nav.capture.water*, nutrients.hydration.quickAdd*)
are removed.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 06:46:28 +02:00
1 parent 18e9a80ea9
commit 4335b38e02
23 files changed
+164 -693

No files matched your search

+4
View File
@@ -10,6 +10,10 @@
- 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 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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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,
-148
View File
@@ -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);
});
});
+4 -13
View File
@@ -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"
+4 -13
View File
@@ -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"
+4 -13
View File
@@ -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"
+4 -13
View File
@@ -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"
+4 -13
View File
@@ -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"
+4 -13
View File
@@ -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"
@@ -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);
},
);
});
+1 -1
View File
@@ -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");
});
});
+5 -13
View File
@@ -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
@@ -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"');
});
});
+14 -40
View File
@@ -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