diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cdfad400..379b630f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [1.29.3] — 2026-07-17 + +- **Hydration and micronutrients now have a home.** Your fluid intake and the + vitamins and minerals your device records get a surface. A water tile on the + dashboard shows the day's total with quick-add amounts; a new **Nutrients** + page under Insights shows hydration over the last month, caffeine when you + record it, and the vitamins and minerals you actually have data for — each + against its reference daily intake where that applies, shown as context, never + as a verdict. Manual water entries and device-synced totals now coexist + instead of overwriting each other. Sparse logging is never read as a + deficiency. + + The nutrients tracking stays opt-in — enable it in Settings, or with the + one-tap prompt on the page. Device-synced micronutrients flow once your client + sends them. + +One migration (0249). No breaking changes. + ## [1.29.2] — 2026-07-17 - **WHOOP workouts carry their real sport.** WHOOP was the only source that diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f26730199..1965f713d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.29.2 + version: 1.29.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -10788,6 +10788,69 @@ paths: "403": *a17 "422": *a3 "429": *a4 + /api/nutrients/daily: + get: + tags: + - Nutrients + summary: One nutrient's day-bucketed series (v1.29) + description: Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar + day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a + MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the + EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never + guessed). Behind the opt-in `nutrients` module. + parameters: + - in: query + name: nutrient + schema: + $ref: "#/components/schemas/NutrientCode" + required: true + description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy, + macros and sodium/potassium are out of scope. + - in: query + name: days + schema: + default: 30 + type: integer + minimum: 1 + maximum: 90 + responses: + "200": + description: The day-bucketed series + resolved reference. + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientDailySeriesEnvelope" + "401": *a1 + "403": *a17 + "422": *a3 + "429": *a4 + /api/nutrients/water: + post: + tags: + - Nutrients + summary: Manual water quick-add (v1.29) + description: "Writes ONLY the `source=\"MANUAL\"` row for `(day, \"water\")` — never the `source=\"APPLE_HEALTH\"` row + the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: + \"add\"` increments the manual day total (quick-add chips); `mode: \"set\"` overwrites it (the \"edit today's + total\" undo path — there is no per-entry ledger). `day` defaults to the caller's current local day when + omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientWaterWriteRequest" + responses: + "200": + description: The MANUAL water row after the write. + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientWaterWriteEnvelope" + "401": *a1 + "403": *a17 + "422": *a3 + "429": *a4 /api/integrations/healthkit: get: tags: @@ -12347,7 +12410,7 @@ components: - order tiles: minItems: 1 - maxItems: 61 + maxItems: 62 type: array items: type: object @@ -12406,6 +12469,7 @@ components: - wrist-temperature - mood - medications + - nutrients - blutdruck - puls - sauerstoff @@ -12945,6 +13009,26 @@ components: - caffeine description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy, macros and sodium/potassium are out of scope. + NutrientWaterWriteRequest: + type: object + properties: + amountMl: + type: number + minimum: 1 + maximum: 20000 + mode: + type: string + enum: + - add + - set + day: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + required: + - amountMl + - mode + description: Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites + it. Never touches the APPLE_HEALTH row. HealthKitConfigPatchRequest: type: object properties: @@ -29987,6 +30071,123 @@ components: additionalProperties: false description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted." + NutrientDailySeriesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/NutrientDailySeries" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NutrientDailySeries: + type: object + properties: + nutrient: + $ref: "#/components/schemas/NutrientCode" + unit: + type: string + windowDays: + type: integer + minimum: 1 + maximum: 9007199254740991 + days: + type: array + items: + type: object + properties: + day: + type: string + amount: + type: number + required: + - day + - amount + additionalProperties: false + reference: + anyOf: + - type: object + properties: + kind: + type: string + enum: + - PRI + - AI + - safeLevel + direction: + type: string + enum: + - target + - upperGuidance + value: + type: number + source: + type: string + required: + - kind + - direction + - value + - source + additionalProperties: false + - type: "null" + required: + - nutrient + - unit + - windowDays + - days + - reference + additionalProperties: false + description: Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across + sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the + profile). + NutrientWaterWriteEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/NutrientWaterWriteResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NutrientWaterWriteResponse: + type: object + properties: + day: + type: string + nutrient: + type: string + const: water + source: + type: string + const: MANUAL + amount: + type: number + unit: + type: string + required: + - day + - nutrient + - source + - amount + - unit + additionalProperties: false + description: The MANUAL water row after the add/set write. HealthKitConfigEnvelope: type: object properties: @@ -30451,7 +30652,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 61 + maxItems: 62 type: array items: type: object @@ -30510,6 +30711,7 @@ components: - wrist-temperature - mood - medications + - nutrients - blutdruck - puls - sauerstoff diff --git a/messages/de.json b/messages/de.json index 0e7bda121..d37ac34c6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2486,6 +2486,7 @@ "navPulse": "Puls", "navMood": "Stimmung", "navMedication": "Medikamente", + "navNutrients": "Nährstoffe", "navBmi": "BMI", "navSleep": "Schlaf", "navHrv": "HRV", @@ -8548,7 +8549,36 @@ "iodine": "Jod", "water": "Wasser", "caffeine": "Koffein" - } + }, + "page": { + "title": "Nährstoffaufnahme", + "description": "Flüssigkeits- und Vitamin-/Mineralstoffaufnahme aus der Gesundheits-App deines Telefons, plus manuelle Wassereinträge.", + "moduleOffTitle": "Nährstoffaufnahme ist aus", + "moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.", + "moduleOffCta": "Nährstoffe aktivieren", + "emptyTitle": "Noch keine Nährstoffdaten", + "emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag." + }, + "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.", + "editTotal": "Heutige Summe bearbeiten" + }, + "caffeine": { + "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag · EFSA" + }, + "micronutrients": { + "title": "Vitamine & Mineralstoffe", + "rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)", + "rowMetaNoRef": "{days} von {window} Tagen", + "footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.", + "emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert" + }, + "sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel — die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme." }, "daily": { "line": { diff --git a/messages/en.json b/messages/en.json index c2195f3dc..e6046c31f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2486,6 +2486,7 @@ "navPulse": "Pulse", "navMood": "Mood", "navMedication": "Medication", + "navNutrients": "Nutrients", "navBmi": "BMI", "navSleep": "Sleep", "navHrv": "HRV", @@ -8548,7 +8549,36 @@ "iodine": "Iodine", "water": "Water", "caffeine": "Caffeine" - } + }, + "page": { + "title": "Nutrient intake", + "description": "Hydration and vitamin/mineral intake synced from your phone's health app, plus manual water entries.", + "moduleOffTitle": "Nutrient intake is off", + "moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.", + "moduleOffCta": "Turn on nutrients", + "emptyTitle": "No nutrient data yet", + "emptyDescription": "Data arrives from your phone's health app sync or a manual water entry." + }, + "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.", + "editTotal": "Edit today's total" + }, + "caffeine": { + "ceilingMeta": "Safe-level ceiling {value} {unit}/day · EFSA" + }, + "micronutrients": { + "title": "Vitamins & minerals", + "rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)", + "rowMetaNoRef": "{days} of {window} days", + "footer": "{tracked} of {total} tracked micronutrients have data in this window.", + "emptyTitle": "No micronutrient data synced yet" + }, + "sparseNote": "A low number here usually means sparse logging, not a deficiency — Apple Health day totals reflect what your apps logged, not total dietary intake." }, "daily": { "line": { diff --git a/messages/es.json b/messages/es.json index a29101256..78dc73526 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2486,6 +2486,7 @@ "navPulse": "Pulso", "navMood": "Estado de ánimo", "navMedication": "Medicamento", + "navNutrients": "Nutrientes", "navBmi": "IMC", "navSleep": "Sueño", "navHrv": "VFC", @@ -8548,7 +8549,36 @@ "iodine": "Yodo", "water": "Agua", "caffeine": "Cafeína" - } + }, + "page": { + "title": "Ingesta de nutrientes", + "description": "Hidratación e ingesta de vitaminas/minerales sincronizadas desde la app de salud de tu teléfono, más entradas manuales de agua.", + "moduleOffTitle": "La ingesta de nutrientes está desactivada", + "moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.", + "moduleOffCta": "Activar nutrientes", + "emptyTitle": "Aún no hay datos de nutrientes", + "emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua." + }, + "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.", + "editTotal": "Editar el total de hoy" + }, + "caffeine": { + "ceilingMeta": "Límite de seguridad {value} {unit}/día · EFSA" + }, + "micronutrients": { + "title": "Vitaminas y minerales", + "rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)", + "rowMetaNoRef": "{days} de {window} días", + "footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.", + "emptyTitle": "Aún no hay datos de micronutrientes sincronizados" + }, + "sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total." }, "daily": { "line": { diff --git a/messages/fr.json b/messages/fr.json index d3726d425..26b85adea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2486,6 +2486,7 @@ "navPulse": "Pouls", "navMood": "Humeur", "navMedication": "Médicament", + "navNutrients": "Nutriments", "navBmi": "IMC", "navSleep": "Sommeil", "navHrv": "VFC", @@ -8548,7 +8549,36 @@ "iodine": "Iode", "water": "Eau", "caffeine": "Caféine" - } + }, + "page": { + "title": "Apports en nutriments", + "description": "Hydratation et apports en vitamines/minéraux synchronisés depuis l'appli santé de votre téléphone, plus les entrées manuelles d'eau.", + "moduleOffTitle": "Le suivi des nutriments est désactivé", + "moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.", + "moduleOffCta": "Activer les nutriments", + "emptyTitle": "Pas encore de données nutritionnelles", + "emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau." + }, + "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.", + "editTotal": "Modifier le total du jour" + }, + "caffeine": { + "ceilingMeta": "Plafond de sécurité {value} {unit}/jour · EFSA" + }, + "micronutrients": { + "title": "Vitamines et minéraux", + "rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)", + "rowMetaNoRef": "{days} sur {window} jours", + "footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.", + "emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant" + }, + "sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence — les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total." }, "daily": { "line": { diff --git a/messages/it.json b/messages/it.json index b07a3cf88..0b879650e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2486,6 +2486,7 @@ "navPulse": "Polso", "navMood": "Umore", "navMedication": "Farmaco", + "navNutrients": "Nutrienti", "navBmi": "IMC", "navSleep": "Sonno", "navHrv": "HRV", @@ -8548,7 +8549,36 @@ "iodine": "Iodio", "water": "Acqua", "caffeine": "Caffeina" - } + }, + "page": { + "title": "Apporto di nutrienti", + "description": "Idratazione e apporto di vitamine/minerali sincronizzati dall'app salute del telefono, più le voci di acqua inserite manualmente.", + "moduleOffTitle": "L'apporto di nutrienti è disattivato", + "moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.", + "moduleOffCta": "Attiva nutrienti", + "emptyTitle": "Ancora nessun dato sui nutrienti", + "emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente." + }, + "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.", + "editTotal": "Modifica il totale di oggi" + }, + "caffeine": { + "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno · EFSA" + }, + "micronutrients": { + "title": "Vitamine e minerali", + "rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)", + "rowMetaNoRef": "{days} di {window} giorni", + "footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.", + "emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato" + }, + "sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza — i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale." }, "daily": { "line": { diff --git a/messages/pl.json b/messages/pl.json index 592f41484..7c09659ab 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2486,6 +2486,7 @@ "navPulse": "Tętno", "navMood": "Nastrój", "navMedication": "Lek", + "navNutrients": "Składniki odżywcze", "navBmi": "BMI", "navSleep": "Sen", "navHrv": "HRV", @@ -8548,7 +8549,36 @@ "iodine": "Jod", "water": "Woda", "caffeine": "Kofeina" - } + }, + "page": { + "title": "Spożycie składników odżywczych", + "description": "Nawodnienie oraz spożycie witamin/minerałów zsynchronizowane z aplikacji zdrowia telefonu, plus ręczne wpisy wody.", + "moduleOffTitle": "Śledzenie składników odżywczych jest wyłączone", + "moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.", + "moduleOffCta": "Włącz składniki odżywcze", + "emptyTitle": "Brak jeszcze danych o składnikach odżywczych", + "emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody." + }, + "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.", + "editTotal": "Edytuj dzisiejszą sumę" + }, + "caffeine": { + "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień · EFSA" + }, + "micronutrients": { + "title": "Witaminy i minerały", + "rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)", + "rowMetaNoRef": "{days} z {window} dni", + "footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.", + "emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach" + }, + "sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór — dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie." }, "daily": { "line": { diff --git a/package.json b/package.json index 0006031b8..48cfb4e95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.29.2", + "version": "1.29.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0249_nutrient_intake_day_source/migration.sql b/prisma/migrations/0249_nutrient_intake_day_source/migration.sql new file mode 100644 index 000000000..861d9a849 --- /dev/null +++ b/prisma/migrations/0249_nutrient_intake_day_source/migration.sql @@ -0,0 +1,19 @@ +-- v1.29 — `source` joins the `nutrient_intake_days` primary key so a +-- manual water entry and the Apple-synced day total coexist as two rows +-- instead of the manual write being silently clobbered by the next +-- Apple last-writer-wins sync (the batch route always upserts the +-- (userId, day, nutrient) row with the CURRENT running total, so a +-- manual increment folded into that same row would vanish on the next +-- sync — a data-loss bug by construction, not a rare race). +-- +-- No data rewrite: every existing row already carries +-- `source = 'APPLE_HEALTH'` (the column default since migration 0241), +-- so dropping and recreating the PRIMARY KEY over the four columns is a +-- pure constraint change — same rows, same values, wider key. The +-- existing `(user_id, nutrient, day DESC)` index is untouched; it does +-- not reference `source` and continues to serve the window-scan reads. +ALTER TABLE "nutrient_intake_days" DROP CONSTRAINT "nutrient_intake_days_pkey"; + +ALTER TABLE "nutrient_intake_days" + ADD CONSTRAINT "nutrient_intake_days_pkey" + PRIMARY KEY ("user_id", "day", "nutrient", "source"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3dafaa7b7..e71bca08a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6349,13 +6349,20 @@ model NutrientIntakeDay { /// denormalised on purpose: rows stay self-describing in exports /// even if the catalog ever drifts. unit String + /// "APPLE_HEALTH" | "MANUAL" (closed set enforced in app code, not a + /// DB enum). v1.29 — joined the composite PK (migration 0249) so a + /// manual water entry and the Apple-synced day total coexist as two + /// rows instead of the manual write being silently clobbered by the + /// next Apple last-writer-wins sync. Apple owns its row (LWW on + /// re-post); manual owns its row (increment/set via + /// `POST /api/nutrients/water`); reads SUM across sources per day. source String @default("APPLE_HEALTH") externalSourceVersion String? @map("external_source_version") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@id([userId, day, nutrient]) + @@id([userId, day, nutrient, source]) @@index([userId, nutrient, day(sort: Desc)]) @@map("nutrient_intake_days") } diff --git a/public/sw.js b/public/sw.js index 4d02ccf2d..898e2e542 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.29.2"; + /* @sw-version-fallback */ "v1.29.3"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index c0608d9a7..a16ec9b41 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -111,8 +111,9 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => { // silhouette. bp paints sys + dia = 2. v1.28.52 added seven vitals + // body-composition strip tiles (hrv, oxygenSaturation, respiratoryRate, // wristTemperature, muscleMass, totalBodyWater, boneMass), each - // single-count: 11 + 7 = 18. - expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(18); + // single-count: 11 + 7 = 18. v1.29 added the fluid-intake strip tile + // (waterIntake), single-count: 18 + 1 = 19. + expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(19); }); it("ignores chart-only and iOS-pin-only widget ids", () => { diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 140ef2529..5afdb0047 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -267,10 +267,11 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({ where: { - userId_day_nutrient: { + userId_day_nutrient_source: { userId: "user-1", day, nutrient: "vitamin_d", + source: "APPLE_HEALTH", }, }, create: { @@ -279,6 +280,7 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { nutrient: "vitamin_d", amount: 22.5, unit: "ug", + source: "APPLE_HEALTH", externalSourceVersion: "yazio-9.9", }, update: { diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index ae78c001a..73506e330 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -165,11 +165,18 @@ async function postBatch(request: NextRequest): Promise { } try { + // v1.29 — `source` joined the composite PK (migration 0249) so a + // manual water entry (source=MANUAL, written by + // `POST /api/nutrients/water`) coexists with the Apple-synced day + // total instead of one clobbering the other on the next sync. The + // batch route always owns the APPLE_HEALTH row — byte-identical + // upsert behaviour to pre-0249, just against the wider key. const key = { - userId_day_nutrient: { + userId_day_nutrient_source: { userId: user.id, day: entry.day, nutrient: entry.nutrient, + source: "APPLE_HEALTH", }, }; @@ -189,6 +196,7 @@ async function postBatch(request: NextRequest): Promise { nutrient: entry.nutrient, amount: entry.amount, unit: definition.unit, + source: "APPLE_HEALTH", externalSourceVersion: entry.externalSourceVersion ?? null, }, update: { diff --git a/src/app/api/nutrients/daily/__tests__/route.test.ts b/src/app/api/nutrients/daily/__tests__/route.test.ts new file mode 100644 index 000000000..e8a7ed9a3 --- /dev/null +++ b/src/app/api/nutrients/daily/__tests__/route.test.ts @@ -0,0 +1,165 @@ +/** + * v1.29 — `GET /api/nutrients/daily` contract tests. + * + * Pins the per-day-bucketed series feeding the hydration/caffeine + * charts: module gate, query validation, the dense (zero-filled) day + * range, summing across sources within a day, and the sex-resolved + * (or omitted) EFSA reference. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + nutrientIntakeDay: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { apiError } from "@/lib/api-response"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +function sessionFor(gender: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + timezone: "Europe/Berlin", + gender, + }, + }; +} + +function getReq(query: string): NextRequest { + return new NextRequest(`http://localhost/api/nutrients/daily${query}`); +} + +interface DailyResponse { + data: { + nutrient: string; + unit: string; + windowDays: number; + days: Array<{ day: string; amount: number }>; + reference: { kind: string; direction: string; value: number } | null; + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(sessionFor("FEMALE") as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/nutrients/daily", () => { + it("refuses with the 403 module.disabled envelope when the module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError('Module "nutrients" is not enabled', 403, { + errorCode: "module.disabled", + module: "nutrients", + }), + } as never); + const res = await GET(getReq("?nutrient=water&days=30")); + expect(res.status).toBe(403); + expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled(); + }); + + it("rejects an unknown nutrient code with 422", async () => { + const res = await GET(getReq("?nutrient=not-a-code&days=30")); + expect(res.status).toBe(422); + }); + + it("rejects an out-of-range days value with 422", async () => { + for (const bad of ["0", "91", "abc"]) { + const res = await GET(getReq(`?nutrient=water&days=${bad}`)); + expect(res.status, `days=${bad}`).toBe(422); + } + }); + + it("defaults to a 30-day dense window with zero-filled days for a fresh account", async () => { + const res = await GET(getReq("?nutrient=water")); + expect(res.status).toBe(200); + const body = (await res.json()) as DailyResponse; + expect(body.data.windowDays).toBe(30); + expect(body.data.unit).toBe("ml"); + expect(body.data.days).toHaveLength(30); + expect(body.data.days.every((d) => d.amount === 0)).toBe(true); + // Ascending, contiguous day keys, ending on "today". + for (let i = 1; i < body.data.days.length; i++) { + const prev = new Date(`${body.data.days[i - 1].day}T00:00:00.000Z`); + const curr = new Date(`${body.data.days[i].day}T00:00:00.000Z`); + expect(curr.getTime() - prev.getTime()).toBe(24 * 60 * 60 * 1000); + } + }); + + it("sums amounts across sources within the same day before bucketing", async () => { + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day: "2026-07-16", amount: 900 }, + { day: "2026-07-16", amount: 600 }, + { day: "2026-07-15", amount: 400 }, + ] as never); + + const res = await GET(getReq("?nutrient=water&days=3")); + const body = (await res.json()) as DailyResponse; + const byDay = new Map(body.data.days.map((d) => [d.day, d.amount])); + expect(byDay.get("2026-07-16")).toBe(1500); + expect(byDay.get("2026-07-15")).toBe(400); + }); + + it("resolves the sex-split reference against the caller's profile sex", async () => { + vi.mocked(getSession).mockResolvedValue(sessionFor("FEMALE") as never); + const res = await GET(getReq("?nutrient=water&days=7")); + const body = (await res.json()) as DailyResponse; + expect(body.data.reference).toEqual({ + kind: "AI", + direction: "target", + value: 2000, + source: expect.stringContaining("EFSA"), + }); + }); + + it("omits the reference when the profile has no sex on file — never guesses", async () => { + vi.mocked(getSession).mockResolvedValue(sessionFor(null) as never); + const res = await GET(getReq("?nutrient=water&days=7")); + const body = (await res.json()) as DailyResponse; + expect(body.data.reference).toBeNull(); + }); + + it("scopes the query to the authenticated user and the requested nutrient", async () => { + await GET(getReq("?nutrient=caffeine&days=14")); + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + nutrient: "caffeine", + day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }, + }), + }), + ); + }); +}); diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts new file mode 100644 index 000000000..dd0bfd4c6 --- /dev/null +++ b/src/app/api/nutrients/daily/route.ts @@ -0,0 +1,91 @@ +/** + * `GET /api/nutrients/daily?nutrient=&days=N` — per-day summed + * series for one nutrient, plus its resolved EFSA reference (v1.29). + * + * Feeds the `/insights/nutrients` hydration + caffeine charts: a dense + * day-bucketed series (one row per calendar day in the window, 0 for a + * day with no logged data) summed ACROSS sources — a day can carry an + * APPLE_HEALTH row and a MANUAL row since migration 0249, and the + * chart shows one honest bar per day. The window is anchored to the + * caller's own local "today" (`User.timezone`), not a UTC floor — + * unlike the coarser window on `GET /api/nutrients`, a chart with a + * one-day edge wobble reads as a visible bug. + * + * The reference resolves against the caller's profile sex (from the + * session's own `User` row — no extra query) and is `null` when the + * profile has no sex on file, matching the catalog's own contract: + * omit, never guess. Module-gated like every other nutrients route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + NUTRIENT_CATALOG, + resolveNutrientReference, +} from "@/lib/nutrients/catalog"; +import { nutrientDailyQuerySchema } from "@/lib/validations/nutrients"; +import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "nutrients"); + if (!gate.enabled) return gate.response; + + const parsed = nutrientDailyQuerySchema.safeParse({ + nutrient: request.nextUrl.searchParams.get("nutrient") ?? undefined, + days: request.nextUrl.searchParams.get("days") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "nutrient.daily.invalid", + }); + } + const { nutrient, days } = parsed.data; + const definition = NUTRIENT_CATALOG[nutrient]; + + const userTz = user.timezone || DEFAULT_TIMEZONE; + const todayKey = userDayKey(new Date(), userTz); + const sinceKey = shiftDateKey(todayKey, -(days - 1)); + + const rows = await prisma.nutrientIntakeDay.findMany({ + where: { userId: user.id, nutrient, day: { gte: sinceKey } }, + select: { day: true, amount: true }, + }); + + // Sum across sources within a day BEFORE bucketing — the same fold + // `GET /api/nutrients` applies (migration 0249 note there). + const sumByDay = new Map(); + for (const row of rows) { + sumByDay.set(row.day, (sumByDay.get(row.day) ?? 0) + row.amount); + } + + const daySeries: Array<{ day: string; amount: number }> = []; + for (let i = 0; i < days; i++) { + const key = shiftDateKey(sinceKey, i); + daySeries.push({ day: key, amount: sumByDay.get(key) ?? 0 }); + } + + const sex = + user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; + const reference = resolveNutrientReference(nutrient, sex); + + annotate({ + action: { name: "nutrient.daily.read" }, + meta: { nutrient, window_days: days, row_count: rows.length }, + }); + + return apiSuccess({ + nutrient, + unit: definition.unit, + windowDays: days, + days: daySeries, + reference, + }); +}); diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 0204ab006..29b87314b 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -49,23 +49,29 @@ export const GET = apiHandler(async (request: NextRequest) => { select: { nutrient: true, unit: true, day: true, amount: true }, }); - // Fold to one summary row per nutrient. Rows arrive day-DESC inside - // each nutrient, so the first row seen per code is the latest. + // v1.29 — `source` joined the PK (migration 0249): a day can now carry + // an APPLE_HEALTH row AND a MANUAL row, so the fold sums amounts + // WITHIN (nutrient, day) before folding across days. Rows arrive + // day-DESC inside each nutrient, so the first DAY seen per code is + // the latest; a second row for that same day (the other source) adds + // to the running total instead of opening a new "day". const byCode = new Map< string, { unit: string; latestDay: string; latestAmount: number; days: number } >(); for (const row of rows) { const existing = byCode.get(row.nutrient); - if (existing) { - existing.days += 1; - } else { + if (!existing) { byCode.set(row.nutrient, { unit: row.unit, latestDay: row.day, latestAmount: row.amount, days: 1, }); + } else if (row.day === existing.latestDay) { + existing.latestAmount += row.amount; + } else { + existing.days += 1; } } diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts new file mode 100644 index 000000000..8c9bf7a1e --- /dev/null +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -0,0 +1,218 @@ +/** + * v1.29 — `POST /api/nutrients/water` contract tests. + * + * Pins the manual quick-add posture: module gate first, rate limit, + * `add` vs `set` upsert shape, the MANUAL-only key (never touches the + * APPLE_HEALTH row), the local-day default, and the dashboard-snapshot + * hard-evict on success. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + nutrientIntakeDay: { + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserDashboardSnapshot: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { apiError } from "@/lib/api-response"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + timezone: "Europe/Berlin", + gender: "FEMALE", + }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/nutrients/water", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +interface WaterResponse { + data: { + day: string; + nutrient: string; + source: string; + amount: number; + unit: string; + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({ + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + amount: 500, + unit: "ml", + } as never); +}); + +describe("POST /api/nutrients/water", () => { + it("refuses with the 403 module.disabled envelope when the module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError('Module "nutrients" is not enabled', 403, { + errorCode: "module.disabled", + module: "nutrients", + }), + } as never); + const res = await POST(postReq({ amountMl: 200, mode: "add" })); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + }); + + it("returns 429 when the rate limit is exceeded", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST(postReq({ amountMl: 200, mode: "add" })); + expect(res.status).toBe(429); + expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + }); + + it("rejects an out-of-range or missing amountMl with 422", async () => { + for (const bad of [ + { amountMl: 0, mode: "add" }, + { amountMl: -5, mode: "add" }, + { amountMl: 30000, mode: "add" }, + { mode: "add" }, + ]) { + const res = await POST(postReq(bad)); + expect(res.status, JSON.stringify(bad)).toBe(422); + } + }); + + it("rejects an unknown mode with 422", async () => { + const res = await POST(postReq({ amountMl: 200, mode: "double" })); + expect(res.status).toBe(422); + }); + + it("rejects a malformed day with 422 (calendar-invalid)", async () => { + const res = await POST( + postReq({ amountMl: 200, mode: "add", day: "2026-02-31" }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("nutrient.water.invalid_day"); + }); + + it("add mode upserts the MANUAL row with an atomic increment — never the APPLE_HEALTH row", async () => { + const res = await POST( + postReq({ amountMl: 250, mode: "add", day: "2026-07-16" }), + ); + expect(res.status).toBe(200); + expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({ + where: { + userId_day_nutrient_source: { + userId: "user-1", + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + }, + }, + create: { + userId: "user-1", + day: "2026-07-16", + nutrient: "water", + amount: 250, + unit: "ml", + source: "MANUAL", + }, + update: { amount: { increment: 250 } }, + }); + expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1"); + }); + + it("set mode overwrites the MANUAL row's amount instead of incrementing", async () => { + await POST(postReq({ amountMl: 1800, mode: "set", day: "2026-07-16" })); + expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith( + expect.objectContaining({ update: { amount: 1800 } }), + ); + }); + + it("defaults `day` to the caller's current local day when omitted", async () => { + await POST(postReq({ amountMl: 200, mode: "add" })); + const call = vi.mocked(prisma.nutrientIntakeDay.upsert).mock + .calls[0][0] as { + where: { userId_day_nutrient_source: { day: string } }; + }; + expect(call.where.userId_day_nutrient_source.day).toMatch( + /^\d{4}-\d{2}-\d{2}$/, + ); + }); + + it("returns the MANUAL row shape on success", async () => { + const res = await POST( + postReq({ amountMl: 250, mode: "add", day: "2026-07-16" }), + ); + const body = (await res.json()) as WaterResponse; + expect(body.data).toEqual({ + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + amount: 500, + unit: "ml", + }); + }); +}); diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts new file mode 100644 index 000000000..d6d0f225f --- /dev/null +++ b/src/app/api/nutrients/water/route.ts @@ -0,0 +1,131 @@ +/** + * `POST /api/nutrients/water` — manual water quick-add (v1.29). + * + * Writes ONLY the `source="MANUAL"` row for `(userId, day, "water")` — + * migration 0249 widened the composite PK so this never touches the + * `source="APPLE_HEALTH"` row the batch route owns. `mode: "add"` + * increments the manual day total (the quick-add chips: +200/+300/ + * +500 mL + a custom amount); `mode: "set"` overwrites it (the "edit + * today's total" undo path — there is no per-entry ledger, honest to + * the day-total storage model). `day` defaults to the caller's current + * local day (`User.timezone`) when omitted. + * + * Module gate first, like every other nutrients route. Idempotency-key + * aware (`withIdempotency`) so a network retry of a quick-add tap can + * never double-increment the day total. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; +import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; + +const WRITE_RATE_LIMIT_MAX = 60; +const WRITE_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +/** Calendar sanity for a client-supplied YYYY-MM-DD key (2026-02-31 etc). */ +function isRealCalendarDay(day: string): boolean { + const [y, m, d] = day.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return ( + dt.getUTCFullYear() === y && + dt.getUTCMonth() === m - 1 && + dt.getUTCDate() === d + ); +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postWater)); + +async function postWater(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "nutrients"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `nutrients:water:${user.id}`, + WRITE_RATE_LIMIT_MAX, + WRITE_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = nutrientWaterWriteSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "nutrient.water.invalid", + }); + } + const { amountMl, mode } = parsed.data; + + const userTz = user.timezone || DEFAULT_TIMEZONE; + const day = parsed.data.day ?? userDayKey(new Date(), userTz); + if (!isRealCalendarDay(day)) { + return apiError("Invalid day", 422, { + errorCode: "nutrient.water.invalid_day", + }); + } + + const definition = NUTRIENT_CATALOG.water; + const key = { + userId_day_nutrient_source: { + userId: user.id, + day, + nutrient: "water", + source: "MANUAL", + }, + }; + + const row = await prisma.nutrientIntakeDay.upsert({ + where: key, + create: { + userId: user.id, + day, + nutrient: "water", + amount: amountMl, + unit: definition.unit, + source: "MANUAL", + }, + update: + mode === "add" + ? { amount: { increment: amountMl } } + : { amount: amountMl }, + }); + + // Interactive single-entry write — hard-evict (not mark-stale) so the + // dashboard water tile reflects the new total on the very next read, + // matching the mood / medication / measurement posture. + invalidateUserDashboardSnapshot(user.id); + + annotate({ + action: { name: "nutrient.water.write" }, + meta: { mode, day, amount_ml: amountMl }, + }); + + return apiSuccess({ + day: row.day, + nutrient: "water" as const, + source: "MANUAL" as const, + amount: row.amount, + unit: row.unit, + }); +} diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx new file mode 100644 index 000000000..5f123e74c --- /dev/null +++ b/src/app/insights/nutrients/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Leaf } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { HydrationCard } from "@/components/insights/nutrients/hydration-card"; +import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card"; +import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types"; + +const WINDOW_DAYS = 30; + +/** + * `/insights/nutrients` — hydration + micronutrients (v1.29). + * + * Custom sub-page (NOT `HealthKitMetricPage` — that scaffold is + * Measurement-backed; this store is `NutrientIntakeDay`). Three cards: + * hydration hero (always renders once the module is on — even at 0 mL, + * the quick-add entry point IS the first-run invitation), caffeine + * (self-gates to nothing without data), micronutrients (self-gates to + * an EmptyState without data). Degradation ladder: + * + * - module off → one EmptyState with an in-context enable CTA. The + * module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt + * on the device is not visible consent to a server / Coach holding + * a supplement pattern); this page just makes the toggle + * discoverable in context instead of buried in Settings. + * - module on, zero rows anywhere in the window → one EmptyState + * explaining where data comes from (phone health-app sync or + * manual water entry) instead of three near-empty cards. + * - otherwise → the three-card spine. + */ +export default function InsightsNutrientsPage() { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + + const nutrientsEnabled = user?.modules?.nutrients === true; + + const overview = useQuery({ + queryKey: queryKeys.nutrientIntake(WINDOW_DAYS), + queryFn: () => + apiGet(`/api/nutrients?days=${WINDOW_DAYS}`), + enabled: nutrientsEnabled, + }); + + const enableModule = useMutation({ + mutationFn: () => apiPatch("/api/auth/me/modules", { nutrients: true }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + }, + onError: () => toast.error(t("nutrients.hydration.quickAddError")), + }); + + if (!nutrientsEnabled) { + return ( + + } + title={t("nutrients.page.moduleOffTitle")} + description={t("nutrients.page.moduleOffDescription")} + ctaSize="lg" + action={ + + } + /> + + ); + } + + const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0; + if (!overview.isLoading && !hasAnyData) { + return ( + + } + title={t("nutrients.page.emptyTitle")} + description={t("nutrients.page.emptyDescription")} + /> + + ); + } + + return ( + + + + + + ); +} diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 73125d354..2f99a5eec 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -12,6 +12,7 @@ import { Dumbbell, Footprints, Gauge, + GlassWater, Heart, HeartPulse, Moon, @@ -173,6 +174,9 @@ const TILE_INSIGHT_HREF: Record = { muscleMass: "/insights/muscle-mass", totalBodyWater: "/insights/body-water", boneMass: "/insights/bone-mass", + // v1.29 — fluid intake links to the nutrients surface (hydration hero + + // quick-add), not a Measurement-backed detail page. + waterIntake: "/insights/nutrients", }; function tileInsightHref(id: string): string | null { @@ -394,6 +398,10 @@ export default function DashboardPageClient() { const muscleMassSummary = data?.summaries?.MUSCLE_MASS; const bodyWaterSummary = data?.summaries?.TOTAL_BODY_WATER; const boneMassSummary = data?.summaries?.BONE_MASS; + // v1.29 — fluid intake strip tile. Synthetic summary key, not a real + // MeasurementType — derived server-side from `NutrientIntakeDay` + // (nutrient="water", summed across sources); see `dashboard/snapshot.ts`. + const waterIntakeSummary = data?.summaries?.NUTRIENT_WATER; const moodSummary = moodData?.summary; // Resolve full dashboard layout — controls visibility + order of every widget @@ -478,6 +486,8 @@ export default function DashboardPageClient() { const hasMuscleMass = (muscleMassSummary?.count ?? 0) > 0; const hasBodyWater = (bodyWaterSummary?.count ?? 0) > 0; const hasBoneMass = (boneMassSummary?.count ?? 0) > 0; + // v1.29 — data floor for the fluid-intake strip tile. + const hasWaterIntake = (waterIntakeSummary?.count ?? 0) > 0; /** * v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %" * placeholder doesn't ride on the dashboard when none of the user's @@ -526,6 +536,9 @@ export default function DashboardPageClient() { const showMuscleMassTile = isTileVisible("muscleMass") && hasMuscleMass; const showBodyWaterTile = isTileVisible("totalBodyWater") && hasBodyWater; const showBoneMassTile = isTileVisible("boneMass") && hasBoneMass; + // v1.29 — fluid intake strip tile gate: layout toggle AND a non-empty + // summary, same precedent as the vitals/body-composition tiles above. + const showWaterIntakeTile = isTileVisible("waterIntake") && hasWaterIntake; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; // Chart (lower row) gates — controlled by the legacy `visible` flag. @@ -1280,6 +1293,32 @@ export default function DashboardPageClient() { ), }); } + // v1.29 — fluid intake strip tile. Neutral sentiment: more water is + // not unambiguously "better" (over-hydration is a real risk), so no + // up/down framing — same posture as wrist temperature / body water. + if (showWaterIntakeTile) { + trendCards.push({ + id: "waterIntake", + order: widgetOrder("waterIntake"), + node: ( + + ), + }); + } if (showBpInTargetTile) { /* v1.4.22 A2 — feature parity with every other tile. Synthesise a slope from the difference between the 7-day diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index e17e14850..4368e24d3 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -43,3 +43,4 @@ export { DrugLevelChart } from "@/components/medications/drug-level-chart"; export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy-chart"; export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart"; export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart"; +export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart"; diff --git a/src/components/charts/nutrient-daily-bar-chart-dynamic.tsx b/src/components/charts/nutrient-daily-bar-chart-dynamic.tsx new file mode 100644 index 000000000..2295076e0 --- /dev/null +++ b/src/components/charts/nutrient-daily-bar-chart-dynamic.tsx @@ -0,0 +1,32 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { ComponentProps, ReactElement } from "react"; + +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; + +/** + * v1.29 — single `next/dynamic` boundary for ``, + * mirroring `health-chart-dynamic.tsx`. Both the hydration card and the + * caffeine card on `/insights/nutrients` share this one lazy boundary so + * recharts stays off the route's first-load JS and is fetched once. + */ +const NutrientDailyBarChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.NutrientDailyBarChart }), + ), + { ssr: false, loading: () => }, +); + +export function NutrientDailyBarChartDynamic( + props: ComponentProps, +): ReactElement { + return ( + + + + ); +} diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx new file mode 100644 index 000000000..84bd26eaa --- /dev/null +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bar, + CartesianGrid, + ComposedChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { formatDateShort } from "@/lib/format"; + +import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; + +/** + * v1.29 — 30-day daily bar chart for the nutrients hydration / caffeine + * cards. One bar per day (dense series — 0 for an unlogged day, summed + * across sources), an optional thin dashed EFSA reference line, no + * attainment colouring (UI-STANDARDS: value stays foreground, the bar + + * reference line ARE the context). Registered in + * `src/components/charts/chart-runtime.ts` — never import recharts + * directly at the call site. + */ + +interface NutrientDailyBarChartProps { + /** Dense day series, ascending (oldest first). */ + days: ReadonlyArray<{ day: string; amount: number }>; + unit: string; + /** Already-localised tooltip row label (e.g. "Water"). */ + valueLabel: string; + /** Optional dashed reference line value (EFSA target / ceiling). */ + referenceValue?: number | null; +} + +/** Noon UTC so every viewer timezone renders the same calendar day. */ +function dayToDate(day: string): Date { + return new Date(`${day}T12:00:00.000Z`); +} + +export function NutrientDailyBarChart({ + days, + unit, + valueLabel, + referenceValue, +}: NutrientDailyBarChartProps) { + const points = useMemo( + () => days.map((d) => ({ day: d.day, amount: Math.round(d.amount) })), + [days], + ); + + const yDomain = useMemo<[number, number]>(() => { + const values = points.map((p) => p.amount); + if (referenceValue != null) values.push(referenceValue); + const max = values.length > 0 ? Math.max(...values) : 1; + return [0, max > 0 ? max * 1.15 : 1]; + }, [points, referenceValue]); + + const animate = !prefersReducedMotion(); + const barColor = "var(--info)"; + + return ( + + + + formatDateShort(dayToDate(day))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + stroke="var(--border)" + minTickGap={24} + /> + + {referenceValue != null ? ( + + ) : null} + { + const active = props.active ?? false; + const payload = props.payload as + | ReadonlyArray<{ payload?: { day: string; amount: number } }> + | undefined; + const point = payload?.[0]?.payload; + if (!active || !point) { + return ; + } + const rows: RichTooltipRow[] = [ + { + name: valueLabel, + value: `${point.amount} ${unit}`, + color: barColor, + }, + ]; + return ( + + ); + }} + /> + + + + ); +} diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index e3f2caaf5..af861d41d 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -64,6 +64,9 @@ const TILE_CAPABLE_WIDGET_IDS = new Set([ "muscleMass", "totalBodyWater", "boneMass", + // v1.29 — fluid intake strip tile: paints one strip card and self-gates + // on having a NUTRIENT_WATER sample. + "waterIntake", ]); /** diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index c4e01849a..c4e34fa26 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -106,6 +106,21 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // dashboard tile so navigation between surfaces is a free cache hit. const workoutsProbe = useWorkouts({ limit: 1 }); + // v1.29 — nutrients-presence gate. The nutrients pill gates on at + // least one `NutrientIntakeDay` row in a 1-day probe window (any + // nutrient, any source). Enabled only when the opt-in module is on — + // the route 403s "module.disabled" otherwise, so probing while off + // would just burn a request every mount. + const nutrientsEnabled = user?.modules?.nutrients === true; + const nutrientsProbe = useQuery({ + queryKey: queryKeys.nutrientIntake(1), + queryFn: () => + apiGet<{ nutrients: Array<{ nutrient: string }> }>( + "/api/nutrients?days=1", + ), + enabled: isAuthenticated && nutrientsEnabled, + }); + // v1.4.31 — memoise the `availability` prop so an unchanged // payload doesn't recreate the object on every cache-write of // analytics or comprehensive. The strip is wrapped in @@ -117,10 +132,18 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0; const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0; const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0; + const hasNutrients = (nutrientsProbe.data?.nutrients.length ?? 0) > 0; const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; - return { summaries, hasMood, hasMedication, hasWorkouts }; - }, [isAuthenticated, summaries, hasMood, hasMedication, hasWorkouts]); + return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients }; + }, [ + isAuthenticated, + summaries, + hasMood, + hasMedication, + hasWorkouts, + hasNutrients, + ]); // v1.15.14 W2 — couple the tab strip to the saved insights layout. // A sub-page pill now shows iff its metric has data AND its slug is diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 400a74126..34339ce0b 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -343,6 +343,8 @@ export const SUB_PAGE_TABS: Record< mood: { labelKey: "insights.navMood", metric: "MOOD" }, // ── events ── medications: { labelKey: "insights.navMedication", metric: "MEDICATION" }, + // v1.29 — nutrient intake (hydration + micronutrients). + nutrients: { labelKey: "insights.navNutrients", metric: "NUTRIENTS" }, }; /** @@ -364,6 +366,9 @@ const SUB_PAGE_MODULE: Partial> = { "breathing-disturbances": "sleep", "blood-glucose": "glucose", workouts: "workouts", + // v1.29 — the nutrients pill gates on the opt-in `nutrients` module on + // top of the `hasNutrients` data floor. + nutrients: "nutrients", }; /** diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx new file mode 100644 index 000000000..e89733df1 --- /dev/null +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Coffee } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/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 { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { NutrientDailySeries } from "./types"; + +const WINDOW_DAYS = 30; + +/** + * v1.29 — caffeine card on `/insights/nutrients`. Rendered ONLY when the + * window carries at least one logged day (a query-error also collapses + * to nothing rather than a card the user can do nothing with — the + * hydration card above already carries the surface's error affordance). + * The EFSA safe-level value is phrased as a ceiling, never "limit + * exceeded" colouring: a day above the dashed line is just a bar above + * a dashed line. + */ +export function CaffeineCard() { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.nutrientDaily("caffeine", WINDOW_DAYS), + queryFn: () => + apiGet( + `/api/nutrients/daily?nutrient=caffeine&days=${WINDOW_DAYS}`, + ), + }); + + if (isLoading) { + return ( + + + + ); + } + + const hasData = (data?.days ?? []).some((d) => d.amount > 0); + if (!data || !hasData) return null; + + const todayTotal = data.days.at(-1)?.amount ?? 0; + + return ( + + + + + +
+

+ {fmt.integer(Math.round(todayTotal))} {data.unit} +

+ {data.reference ? ( +

+ {t("nutrients.caffeine.ceilingMeta", { + value: fmt.integer(Math.round(data.reference.value)), + unit: data.unit, + })} +

+ ) : null} +
+ +
+
+ ); +} diff --git a/src/components/insights/nutrients/hydration-card.tsx b/src/components/insights/nutrients/hydration-card.tsx new file mode 100644 index 000000000..ed7d30b69 --- /dev/null +++ b/src/components/insights/nutrients/hydration-card.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { GlassWater, Plus } 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"; +import type { NutrientDailySeries } from "./types"; + +const WINDOW_DAYS = 30; + +/** + * v1.29 — hydration hero card on `/insights/nutrients`. + * + * 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). + */ +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), + queryFn: () => + apiGet( + `/api/nutrients/daily?nutrient=water&days=${WINDOW_DAYS}`, + ), + }); + + if (isError) { + return ; + } + + const todayTotal = data?.days.at(-1)?.amount ?? 0; + + return ( + + + setSheetOpen(true)} + data-slot="nutrients-hydration-add" + > + + + {isLoading || !data ? ( + + ) : ( + <> +
+

+ {fmt.integer(Math.round(todayTotal))} {data.unit} +

+ {data.reference ? ( +

+ {t("nutrients.hydration.referenceMeta", { + value: fmt.integer(Math.round(data.reference.value)), + unit: data.unit, + })} +

+ ) : null} +
+ + + )} +
+ +
+ ); +} diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx new file mode 100644 index 000000000..5919d438c --- /dev/null +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Leaf } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { + NUTRIENT_CODES, + isNutrientCode, + resolveNutrientReference, + type NutrientCode, +} from "@/lib/nutrients/catalog"; +import type { NutrientIntakeOverview } from "./types"; + +const WINDOW_DAYS = 30; + +/** Wire `ug` renders as µg; mg / ml pass through. */ +const UNIT_LABELS: Record = { mg: "mg", ug: "µg", ml: "ml" }; + +/** The 24 vitamin/mineral codes — water + caffeine ride their own cards. */ +const MICRONUTRIENT_CODES = NUTRIENT_CODES.filter( + (code) => code !== "water" && code !== "caffeine", +); + +function formatAmount( + fmt: { number: (v: number, d?: number) => string }, + amount: number, +): string { + return fmt.number(Math.round(amount * 10) / 10, 1); +} + +/** + * v1.29 — micronutrients card on `/insights/nutrients`. + * + * Rows ONLY for codes with data in the last 30 days (catalog order), + * each carrying a latest-day total + a muted "N of 30 days · ref X + * (EFSA)" meta line — reference omitted when the profile has no sex on + * file. A muted footer counts "M of 24 tracked"; zero rows collapse the + * card to a single honest `EmptyState`, never 24 empty rows. No + * sparkline in slice 1 — sparse series make sparklines lie. + */ +export function MicronutrientsCard() { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { user } = useAuth(); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.nutrientIntake(WINDOW_DAYS), + queryFn: () => + apiGet(`/api/nutrients?days=${WINDOW_DAYS}`), + }); + + const rows = (data?.nutrients ?? []).filter( + (row) => + isNutrientCode(row.nutrient) && + row.nutrient !== "water" && + row.nutrient !== "caffeine", + ); + + return ( + + + + + + {isLoading ? ( + + ) : rows.length === 0 ? ( + } + title={t("nutrients.micronutrients.emptyTitle")} + size="compact" + /> + ) : ( + <> +
    + {rows.map((row) => { + const reference = resolveNutrientReference( + row.nutrient as NutrientCode, + sex, + ); + const unitLabel = UNIT_LABELS[row.unit] ?? row.unit; + return ( +
  • + + {t(`nutrients.names.${row.nutrient}`)} + + + + {formatAmount(fmt, row.latestAmount)} {unitLabel} + + + {reference + ? t("nutrients.micronutrients.rowMeta", { + days: row.daysWithData, + window: WINDOW_DAYS, + value: formatAmount(fmt, reference.value), + unit: unitLabel, + }) + : t("nutrients.micronutrients.rowMetaNoRef", { + days: row.daysWithData, + window: WINDOW_DAYS, + })} + + +
  • + ); + })} +
+

+ {t("nutrients.micronutrients.footer", { + tracked: rows.length, + total: MICRONUTRIENT_CODES.length, + })} +

+ + )} +

+ {t("nutrients.sparseNote")} +

+
+
+ ); +} diff --git a/src/components/insights/nutrients/types.ts b/src/components/insights/nutrients/types.ts new file mode 100644 index 000000000..9e448fba4 --- /dev/null +++ b/src/components/insights/nutrients/types.ts @@ -0,0 +1,35 @@ +/** + * v1.29 — shared client-side DTOs for the `/insights/nutrients` cards. + * Mirror the Zod response schemas in `src/lib/validations/nutrients.ts` + * (`nutrientDailySeriesSchema` / `nutrientOverviewSchema`) — kept as + * plain interfaces here so the client components stay decoupled from + * the server-only Zod module graph. + */ + +export interface ResolvedNutrientReferenceDto { + kind: "PRI" | "AI" | "safeLevel"; + direction: "target" | "upperGuidance"; + value: number; + source: string; +} + +export interface NutrientDailySeries { + nutrient: string; + unit: string; + windowDays: number; + days: Array<{ day: string; amount: number }>; + reference: ResolvedNutrientReferenceDto | null; +} + +export interface NutrientOverviewRow { + nutrient: string; + unit: string; + latestDay: string; + latestAmount: number; + daysWithData: number; +} + +export interface NutrientIntakeOverview { + windowDays: number; + nutrients: NutrientOverviewRow[]; +} diff --git a/src/components/insights/nutrients/water-quick-add-sheet.tsx b/src/components/insights/nutrients/water-quick-add-sheet.tsx new file mode 100644 index 000000000..2b60136cd --- /dev/null +++ b/src/components/insights/nutrients/water-quick-add-sheet.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } 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 } from "@/lib/query-keys"; + +/** + * v1.29 — manual water quick-add sheet, opened from the hydration card. + * + * Chips (+200/+300/+500 mL) fire immediately (mode "add") and keep the + * sheet open so a run of taps composes one round number; the sheet stays + * a lightweight stepper, not a form the user has to re-open per tap. The + * custom-amount field and the "edit today's total" disclosure both close + * on submit — the two are one-shot actions. + * + * 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; + /** Today's combined (all-source) total, for the "edit total" pre-fill. */ + todayTotalMl: number; +} + +export function WaterQuickAddSheet({ + open, + onOpenChange, + todayTotalMl, +}: WaterQuickAddSheetProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [customAmount, setCustomAmount] = useState(""); + const [editMode, setEditMode] = useState(false); + const [editAmount, setEditAmount] = useState(() => String(todayTotalMl)); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["nutrients"] }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + }; + + 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); + onOpenChange(false); + }, + onError: () => toast.error(t("nutrients.hydration.quickAddError")), + }); + + const handleCustomAdd = () => { + const amount = Number(customAmount); + if (!Number.isFinite(amount) || amount <= 0) return; + addMutation.mutate(amount); + setCustomAmount(""); + onOpenChange(false); + }; + + const handleEditSave = () => { + const amount = Number(editAmount); + if (!Number.isFinite(amount) || amount < 0) return; + setMutation.mutate(amount); + }; + + return ( + +
+ {QUICK_CHIPS_ML.map((amountMl) => ( + + ))} +
+ +
+
+ + setCustomAmount(e.target.value)} + /> +
+ +
+ + {editMode ? ( +
+ +
+ setEditAmount(e.target.value)} + /> + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 2837c8ee7..3ee72adf8 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -162,6 +162,8 @@ const WIDGET_LABEL_KEYS: Record = { walkingSteadiness: "measurements.typeWalkingSteadiness", // v1.18.2 — Vorsorge preventive-care summary card. vorsorge: "measurementReminders.sectionTitle", + // v1.29 — fluid intake strip tile (nutrients-store-backed). + waterIntake: "nutrients.names.water", }; /** diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index bf3a3a3c1..8624620f6 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -447,10 +447,13 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { // web-writable (iOS-only 11 → 9), and muscle mass is a brand-new // writable id, so the server-known set grew 25 → 28 and the catalogue // 36 → 37. - expect(DASHBOARD_WIDGET_IDS).toHaveLength(28); + // v1.29 — the nutrients-store-backed fluid-intake strip tile + // (`waterIntake`) added one server-known id, so the catalogue grew + // 37 → 38. + expect(DASHBOARD_WIDGET_IDS).toHaveLength(29); expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9); - expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(37); - expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(37); + expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38); + expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38); }); it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => { @@ -677,17 +680,17 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { expect(ids).not.toContain("totally-made-up"); }); - it("keeps the default layout at the 28 web tiles (no iOS-only seeded)", () => { + it("keeps the default layout at the 29 web tiles (no iOS-only seeded)", () => { const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id); - expect(ids).toHaveLength(28); + expect(ids).toHaveLength(29); for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) { expect(ids).not.toContain(iosId); } }); it("does NOT auto-append iOS-only ids when a web-only layout is read", () => { - // A web account that saved only `weight` must auto-upgrade to the 28 - // web defaults — never to the 37 catalogue. iOS-only ids appear only + // A web account that saved only `weight` must auto-upgrade to the 29 + // web defaults — never to the 38 catalogue. iOS-only ids appear only // once a native client has explicitly sent them. const partial = { version: 1, @@ -695,7 +698,7 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { }; const resolved = resolveDashboardLayout(partial); const ids = resolved.widgets.map((w) => w.id); - expect(ids).toHaveLength(28); + expect(ids).toHaveLength(29); for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) { expect(ids).not.toContain(iosId); } diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 685d5d222..8c42009ba 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -72,6 +72,10 @@ export const DASHBOARD_WIDGET_IDS = [ // user opts in via Settings → Dashboard. Always-on data surface (no // toggleable module gate), so it behaves as a core opt-in tile. "vorsorge", + // v1.29 — fluid intake (drinking water). Nutrients-store-backed (see + // `NUTRIENT_WATER` in the dashboard snapshot), gated on the `nutrients` + // module; self-gates on data like the v1.28.52 strip tiles. + "waterIntake", ] as const; export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; @@ -611,6 +615,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // preventive-care reminders surface on / by default; the card self-gates // (see page.tsx) and shows nothing for accounts without due reminders. { id: "vorsorge", visible: true, tileVisible: false, order: 27 }, + // v1.29 — fluid intake strip tile. On by default (`tileVisible: true`) + // like the v1.28.52 vitals cluster; self-gates on having water data + // (see `showWaterIntakeTile` in page-client.tsx) AND on the `nutrients` + // module (opt-in, default off), so an account that never enabled + // nutrients or never logged water sees a clean dashboard. + { id: "waterIntake", visible: false, tileVisible: true, order: 28 }, ], }; diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index b237f2646..f267aa23e 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -124,6 +124,8 @@ const emptySummary = { const fakePrisma = { measurement: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.29 — the fluid-intake dashboard tile block reads this table. + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -168,6 +170,7 @@ beforeEach(() => { }); fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); + fakePrisma.nutrientIntakeDay.findMany.mockResolvedValue([]); hasAnyConfiguredProvider.mockResolvedValue(true); buildMedsTodayBlock.mockResolvedValue({ activeCount: 1, @@ -749,11 +752,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () => isFullyCovered.mockReturnValue(false); }); - it("emits all 37 catalogue ids with visibility + order", async () => { + it("emits all 38 catalogue ids with visibility + order", async () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); - expect(snap.layoutCatalogue).toHaveLength(37); + expect(snap.layoutCatalogue).toHaveLength(38); const ids = new Set(snap.layoutCatalogue.map((w) => w.id)); - expect(ids.size).toBe(37); + expect(ids.size).toBe(38); // iOS-only ids appended default-invisible. const walkingSpeed = snap.layoutCatalogue.find( (w) => w.id === "walkingSpeed", @@ -826,7 +829,7 @@ describe("buildDashboardSnapshot — additive proof", () => { expect(snap.layout).toHaveProperty("version"); expect(Array.isArray(snap.layout.widgets)).toBe(true); // Resolved layout still carries only server-known widget ids. - expect(snap.layout.widgets.length).toBeLessThanOrEqual(28); + expect(snap.layout.widgets.length).toBeLessThanOrEqual(29); // tiles shape unchanged. expect(snap.tiles).toHaveProperty("summaries"); @@ -852,6 +855,8 @@ describe("module gating maps — every toggleable-domain widget is mapped (v1.18 "stairAscentSpeed", "stairDescentSpeed", "breathingDisturbances", + // v1.29 — fluid-intake strip tile, nutrients-store-backed. + "waterIntake", ] as const; it("every toggleable-domain widget id has a module mapping", () => { @@ -896,6 +901,11 @@ describe("module gating maps — every toggleable-domain widget is mapped (v1.18 expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep"); expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep"); }); + + it("fluid-intake widget + synthetic summary type resolve to the nutrients module (v1.29)", () => { + expect(WIDGET_MODULE_BY_ID.waterIntake).toBe("nutrients"); + expect(SUMMARY_TYPE_MODULE.NUTRIENT_WATER).toBe("nutrients"); + }); }); describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { @@ -953,6 +963,11 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { fakePrisma.moodEntry.findMany.mockResolvedValue([ { date: new Date(), score: 4 }, ]); + // v1.29 — water rows so the fluid-intake tile would otherwise carry a + // summary (proving the nutrients module actually strips it below). + fakePrisma.nutrientIntakeDay.findMany.mockResolvedValue([ + { day: new Date().toISOString().slice(0, 10), amount: 1500 }, + ]); }); it("all modules on: every toggleable tile + its data is present", async () => { @@ -1025,6 +1040,25 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { expect(widget(snap, "achievements")!.visible).toBe(false); }); + it("nutrients on: fluid-intake tile widget carries the synthetic NUTRIENT_WATER summary (v1.29)", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap()), + }); + expect(snap.tiles.summaries.NUTRIENT_WATER).toBeDefined(); + expect(snap.tiles.summaries.NUTRIENT_WATER!.latest).toBe(1500); + }); + + it("nutrients disabled: waterIntake tile hidden + NUTRIENT_WATER stripped from summaries (v1.29)", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ nutrients: false })), + }); + expect(widget(snap, "waterIntake")!.visible).toBe(false); + expect(widget(snap, "waterIntake")!.tileVisible).toBe(false); + expect(cat(snap, "waterIntake")!.visible).toBe(false); + expect(snap.tiles.summaries.NUTRIENT_WATER).toBeUndefined(); + expect(snap.tiles.lastSeenByType.NUTRIENT_WATER).toBeUndefined(); + }); + it("insights disabled: Daily Briefing surface goes to disabled state", async () => { const snap = await buildDashboardSnapshot( fakePrisma, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 57413855d..c1ee08a27 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -514,6 +514,50 @@ async function buildMoodBlock( return { summary: series.summary, entries: series.entries }; } +/** + * v1.29 — fluid-intake dashboard tile block, read from `NutrientIntakeDay` + * (nutrient="water", last 30 day-keys, summed ACROSS SOURCES per day — + * migration 0249 can carry an APPLE_HEALTH row and a MANUAL row for the + * same day). ≤ 60 rows off the existing `(userId, nutrient, day desc)` + * index. Sparse by construction (only days with an actual write become a + * `DataPoint`) — matching the convention every real `MeasurementType` + * summary follows: an unlogged day is absent, not a zero reading. `null` + * when there is no water data in the window; the caller strips the + * synthetic `NUTRIENT_WATER` key entirely in that case (mirrors mood). + * Always fetched (cheap, indexed) and stripped post-hoc by + * `gateSummariesByModules` when the `nutrients` module is off — the same + * fetch-then-gate posture the sleep/glucose/recovery types already use. + */ +async function buildNutrientWaterBlock( + prisma: PrismaClient, + userId: string, +): Promise<{ summary: DataSummary; lastSeenAt: string } | null> { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const rows = await prisma.nutrientIntakeDay.findMany({ + where: { userId, nutrient: "water", day: { gte: since } }, + select: { day: true, amount: true }, + }); + if (rows.length === 0) return null; + + const sumByDay = new Map(); + for (const row of rows) { + sumByDay.set(row.day, (sumByDay.get(row.day) ?? 0) + row.amount); + } + const points: DataPoint[] = [...sumByDay.entries()] + .map(([day, amount]) => ({ + date: new Date(`${day}T00:00:00.000Z`), + value: amount, + })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + return { + summary: summarize(points), + lastSeenAt: points[points.length - 1].date.toISOString(), + }; +} + /** * Thick slice — BD-Zielbereich + per-context glucose + health score. * Only called by the builder when the rollup tier is warm for the @@ -1104,6 +1148,7 @@ export async function buildDashboardSnapshot( medsToday, modules, scoreRings, + nutrientWater, ] = await Promise.all([ // A5 — reuse the coverage map already probed above so the slice // doesn't re-run the identical `probeRollupCoverage` query. @@ -1131,8 +1176,27 @@ export async function buildDashboardSnapshot( await medsTodayPromise, ), ).catch(() => [] as DashboardScoreRing[]), + // v1.29 — fluid-intake tile block (see `buildNutrientWaterBlock`). + time("nutrientWater", () => buildNutrientWaterBlock(prisma, user.id)), ]); + // v1.29 — fold the water block into the slim slice under a synthetic + // `NUTRIENT_WATER` key BEFORE the module gate below strips it (like + // every other summary type) when `nutrients` is off. Not a real + // `MeasurementType` — see the widened `SUMMARY_TYPE_MODULE` note. + if (nutrientWater) { + const lastMs = new Date(nutrientWater.lastSeenAt).getTime(); + const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000)); + slimRaw.summaries = { + ...slimRaw.summaries, + NUTRIENT_WATER: nutrientWater.summary, + }; + slimRaw.lastSeenByType = { + ...slimRaw.lastSeenByType, + NUTRIENT_WATER: { lastSeenAt: nutrientWater.lastSeenAt, daysAgo }, + }; + } + // v1.18.0 — strip disabled-module data before it leaves the server. // Mood is blanked when the mood module is off; sleep / glucose summary // types are dropped from the slim slice; the glucose clinical panel + diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 661f45dbc..b03ddd973 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -10,10 +10,8 @@ * both for the server call sites and the existing tests. * * `ModuleKey` comes from `@/lib/modules/registry` (pure constants, no - * imports) and `MeasurementType` is a type-only import from the generated - * Prisma client, so this file stays browser-bundle-safe. + * imports), so this file stays browser-bundle-safe. */ -import type { MeasurementType } from "@/generated/prisma/client"; import type { ModuleKey } from "@/lib/modules/registry"; /** @@ -41,26 +39,39 @@ export const WIDGET_MODULE_BY_ID: Partial> = { stairAscentSpeed: "recovery", stairDescentSpeed: "recovery", breathingDisturbances: "sleep", + // v1.29 — fluid-intake strip tile, nutrients-store-backed (see + // `SUMMARY_TYPE_MODULE.NUTRIENT_WATER` below). + waterIntake: "nutrients", }; /** - * `MeasurementType` slim-summary keys that belong to a toggleable module. - * When the module is off the key is stripped from `tiles.summaries` / + * Slim-summary keys that belong to a toggleable module. When the module + * is off the key is stripped from `tiles.summaries` / * `tiles.lastSeenByType` (so `metricStates` and the client data-floor * gates also drop it) before the snapshot leaves the server. Core vital * types are absent here and always pass through. + * + * v1.29 — widened from `Partial>` to + * `Partial>` so the synthetic `NUTRIENT_WATER` + * key (a `NutrientIntakeDay`-derived summary, not a real + * `MeasurementType` — the abandoned `feat/water` branch's parallel + * `WATER_INTAKE` enum value is deliberately NOT added) can ride the same + * gate without widening the `MeasurementType` enum itself. */ -export const SUMMARY_TYPE_MODULE: Partial> = - { - SLEEP_DURATION: "sleep", - BLOOD_GLUCOSE: "glucose", - // v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page + - // its tiles are the recovery module's surface; when it is off these - // device-native signals must drop from the dashboard snapshot too. - CARDIO_RECOVERY: "recovery", - SIX_MINUTE_WALK_DISTANCE: "recovery", - STAIR_ASCENT_SPEED: "recovery", - STAIR_DESCENT_SPEED: "recovery", - // Per-night breathing-disturbance index is a sleep-page signal. - BREATHING_DISTURBANCES: "sleep", - }; +export const SUMMARY_TYPE_MODULE: Partial> = { + SLEEP_DURATION: "sleep", + BLOOD_GLUCOSE: "glucose", + // v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page + + // its tiles are the recovery module's surface; when it is off these + // device-native signals must drop from the dashboard snapshot too. + CARDIO_RECOVERY: "recovery", + SIX_MINUTE_WALK_DISTANCE: "recovery", + STAIR_ASCENT_SPEED: "recovery", + STAIR_DESCENT_SPEED: "recovery", + // Per-night breathing-disturbance index is a sleep-page signal. + BREATHING_DISTURBANCES: "sleep", + // v1.29 — fluid-intake dashboard tile summary, derived server-side from + // `NutrientIntakeDay` (nutrient="water", summed across sources). Gated + // on the `nutrients` module like the rest of that store's surfaces. + NUTRIENT_WATER: "nutrients", +}; diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 1d8dc20ba..434655e91 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -119,7 +119,12 @@ export type InsightMetric = | "GRIP_STRENGTH" | "PAIN_NRS" | "WAIST_CIRCUMFERENCE" - | "WAIST_TO_HEIGHT"; + | "WAIST_TO_HEIGHT" + // v1.29 — nutrient intake (hydration + micronutrients). Event/store + // driven (`NutrientIntakeDay`, not a `Measurement` series) like + // `MOOD` / `MEDICATION` / `WORKOUTS`, so it reads the boolean + // `hasNutrients` flag rather than a `summaries[…].count`. + | "NUTRIENTS"; /** * Inputs the gating helper consumes. The `summaries` shape mirrors @@ -141,6 +146,13 @@ export interface InsightInputs { * the helper treats `undefined` as "not available". */ hasWorkouts?: boolean; + /** + * v1.29 — whether the user has at least one `NutrientIntakeDay` row in + * the probe window (any nutrient, any source). Drives the nutrients + * pill. Optional so legacy mounts keep type-checking; the helper + * treats `undefined` as "not available". + */ + hasNutrients?: boolean; } /** @@ -156,6 +168,7 @@ export function hasMetricData( if (metric === "MOOD") return inputs.hasMood; if (metric === "MEDICATION") return inputs.hasMedication; if (metric === "WORKOUTS") return inputs.hasWorkouts === true; + if (metric === "NUTRIENTS") return inputs.hasNutrients === true; if (metric === "BMI") { // BMI is derived from WEIGHT + the profile height. The chart // mounts even at one weight reading; the gate matches. diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index f85ef3454..16180fd6a 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -135,6 +135,12 @@ const SUB_PAGE_METRIC = { mood: ["MOOD"], // ── events (medication adherence is event-driven; no measurement series) ── medications: [], + // v1.29 — nutrient intake (hydration + micronutrients). Backed by + // `NutrientIntakeDay`, not a `Measurement` series, so the metric list + // stays empty like `medications` / `workouts`; the tab-strip pill gates + // on `hasNutrients` (threaded from a lightweight probe read) instead of + // a `summaries[…].count`. + nutrients: [], } as const satisfies Record; export type SubPageSlug = keyof typeof SUB_PAGE_METRIC; @@ -305,6 +311,9 @@ const FLAT_SLUG_MANAGER_GROUP: Partial> = { sleep: "sleep", mood: "mood", medications: "events", + // v1.29 — nutrient intake filed with the metabolic cluster (alongside + // blood glucose / skin / wrist temperature). + nutrients: "metabolic", }; /** diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts index afbcf99e4..3974c7353 100644 --- a/src/lib/nutrients/__tests__/catalog.test.ts +++ b/src/lib/nutrients/__tests__/catalog.test.ts @@ -16,6 +16,7 @@ import { NUTRIENT_CODES, NUTRIENT_DEFINITIONS, isNutrientCode, + resolveNutrientReference, } from "../catalog"; describe("nutrient catalog", () => { @@ -151,3 +152,49 @@ describe("nutrient catalog", () => { } }); }); + +describe("resolveNutrientReference (v1.29)", () => { + it("resolves a uniform-adult reference (calcium) regardless of sex, including unknown", () => { + for (const sex of ["MALE", "FEMALE", null] as const) { + const ref = resolveNutrientReference("calcium", sex); + expect(ref).not.toBeNull(); + expect(ref?.value).toBe(950); + expect(ref?.kind).toBe("PRI"); + expect(ref?.direction).toBe("target"); + expect(ref?.source).toBe(NUTRIENT_CATALOG.calcium.reference.source); + } + }); + + it("resolves a sex-split reference (water, iron) to the matching value per sex", () => { + expect(resolveNutrientReference("water", "MALE")?.value).toBe(2500); + expect(resolveNutrientReference("water", "FEMALE")?.value).toBe(2000); + expect(resolveNutrientReference("iron", "MALE")?.value).toBe(11); + expect(resolveNutrientReference("iron", "FEMALE")?.value).toBe(16); + }); + + it("omits a sex-split reference when sex is unknown — never guesses", () => { + expect(resolveNutrientReference("water", null)).toBeNull(); + expect(resolveNutrientReference("iron", null)).toBeNull(); + expect(resolveNutrientReference("magnesium", null)).toBeNull(); + expect(resolveNutrientReference("vitamin_a", null)).toBeNull(); + }); + + it("resolves caffeine's upperGuidance ceiling regardless of sex", () => { + const ref = resolveNutrientReference("caffeine", null); + expect(ref).toEqual({ + kind: "safeLevel", + direction: "upperGuidance", + value: 400, + source: NUTRIENT_CATALOG.caffeine.reference.source, + }); + }); + + it("resolves every catalog code to a non-null reference for at least one sex", () => { + for (const code of NUTRIENT_CODES) { + const resolved = + resolveNutrientReference(code, "MALE") ?? + resolveNutrientReference(code, "FEMALE"); + expect(resolved, `no resolvable reference: ${code}`).not.toBeNull(); + } + }); +}); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index 03b5e273e..c8af5db0c 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -305,3 +305,38 @@ const NUTRIENT_CODE_SET: ReadonlySet = new Set(NUTRIENT_CODES); export function isNutrientCode(code: string): code is NutrientCode { return NUTRIENT_CODE_SET.has(code); } + +/** A catalog reference resolved to one concrete number for a profile. */ +export interface ResolvedNutrientReference { + kind: NutrientReference["kind"]; + direction: NutrientReference["direction"]; + value: number; + source: string; +} + +/** + * Resolve a catalog reference against the user's profile sex (v1.29). + * + * `adult` values (uniform across sexes) resolve unconditionally. A + * sex-split reference (`male` / `female`, no `adult`) resolves only + * when `sex` is known — a profile without sex on file OMITS the + * reference rather than guessing, the catalog's own documented + * contract (see the module docblock). Every insights surface honours + * this: no reference line, no meta sentence, nothing inferred. + */ +export function resolveNutrientReference( + code: NutrientCode, + sex: "MALE" | "FEMALE" | null, +): ResolvedNutrientReference | null { + const ref = NUTRIENT_CATALOG[code].reference; + const value = + ref.adult ?? + (sex === "MALE" ? ref.male : sex === "FEMALE" ? ref.female : undefined); + if (value === undefined) return null; + return { + kind: ref.kind, + direction: ref.direction, + value, + source: ref.source, + }; +} diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts index 1b96543ff..b08015045 100644 --- a/src/lib/openapi/routes/nutrients.ts +++ b/src/lib/openapi/routes/nutrients.ts @@ -14,8 +14,12 @@ import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog"; import { nutrientBatchSchema, nutrientBatchResponseSchema, + nutrientDailyQuerySchema, + nutrientDailySeriesSchema, nutrientOverviewQuerySchema, nutrientOverviewSchema, + nutrientWaterWriteSchema, + nutrientWaterWriteResponseSchema, } from "@/lib/validations/nutrients"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; @@ -90,4 +94,57 @@ export const nutrientPaths: NonNullable = { }, }, }, + "/api/nutrients/daily": { + get: { + tags: ["Nutrients"], + summary: "One nutrient's day-bucketed series (v1.29)", + description: + "Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never guessed). Behind the opt-in `nutrients` module.", + requestParams: { query: nutrientDailyQuerySchema }, + responses: { + "200": { + description: "The day-bucketed series + resolved reference.", + content: { + "application/json": { + schema: dataEnvelope( + nutrientDailySeriesSchema, + "NutrientDailySeriesEnvelope", + ), + }, + }, + }, + ...moduleDisabled, + ...stdResponses, + }, + }, + }, + "/api/nutrients/water": { + post: { + tags: ["Nutrients"], + summary: "Manual water quick-add (v1.29)", + description: + 'Writes ONLY the `source="MANUAL"` row for `(day, "water")` — never the `source="APPLE_HEALTH"` row the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: "add"` increments the manual day total (quick-add chips); `mode: "set"` overwrites it (the "edit today\'s total" undo path — there is no per-entry ledger). `day` defaults to the caller\'s current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module.', + requestBody: { + required: true, + content: { + "application/json": { schema: nutrientWaterWriteSchema }, + }, + }, + responses: { + "200": { + description: "The MANUAL water row after the write.", + content: { + "application/json": { + schema: dataEnvelope( + nutrientWaterWriteResponseSchema, + "NutrientWaterWriteEnvelope", + ), + }, + }, + }, + ...moduleDisabled, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/nutrients.ts b/src/lib/query-keys/nutrients.ts index 62d512401..0751e207b 100644 --- a/src/lib/query-keys/nutrients.ts +++ b/src/lib/query-keys/nutrients.ts @@ -6,4 +6,11 @@ export const nutrientKeys = { /** Per-nutrient window summary from `GET /api/nutrients?days=N`. */ nutrientIntake: (days: number) => ["nutrients", "intake", days] as const, + /** + * v1.29 — one nutrient's day-bucketed series from + * `GET /api/nutrients/daily?nutrient=&days=N`. Feeds the + * `/insights/nutrients` hydration + caffeine charts. + */ + nutrientDaily: (nutrient: string, days: number) => + ["nutrients", "daily", nutrient, days] as const, }; diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index ce7c1dc4b..506f67ad9 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -9,7 +9,7 @@ */ import { z } from "zod/v4"; -import { NUTRIENT_CODES } from "@/lib/nutrients/catalog"; +import { NUTRIENT_CATALOG, NUTRIENT_CODES } from "@/lib/nutrients/catalog"; export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500; @@ -106,3 +106,72 @@ export const nutrientOverviewSchema = z description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted.", }); + +/** + * `POST /api/nutrients/water` body (v1.29) — manual water quick-add. + * + * Water only in slice 1 (manual vitamin entry is out of scope — see + * the design memo). `amountMl` is bounded to the catalog's own + * per-day plausibility cap for water; `mode: "add"` increments the + * MANUAL row for `day`, `mode: "set"` overwrites it (the "edit + * today's total" undo path). `day` defaults server-side to the + * caller's current local day when omitted. + */ +export const nutrientWaterWriteSchema = z + .object({ + amountMl: z + .number() + .finite() + .min(1) + .max(NUTRIENT_CATALOG.water.plausibleDailyMax), + mode: z.enum(["add", "set"]), + day: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + }) + .meta({ + id: "NutrientWaterWriteRequest", + description: + "Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites it. Never touches the APPLE_HEALTH row.", + }); + +export const nutrientWaterWriteResponseSchema = z + .object({ + day: z.string(), + nutrient: z.literal("water"), + source: z.literal("MANUAL"), + amount: z.number(), + unit: z.string(), + }) + .meta({ + id: "NutrientWaterWriteResponse", + description: "The MANUAL water row after the add/set write.", + }); + +/** `GET /api/nutrients/daily` query — one nutrient's day-bucketed series. */ +export const nutrientDailyQuerySchema = z.object({ + nutrient: nutrientCodeEnum, + days: z.coerce.number().int().min(1).max(90).default(30), +}); + +const resolvedNutrientReferenceSchema = z.object({ + kind: z.enum(["PRI", "AI", "safeLevel"]), + direction: z.enum(["target", "upperGuidance"]), + value: z.number(), + source: z.string(), +}); + +export const nutrientDailySeriesSchema = z + .object({ + nutrient: nutrientCodeEnum, + unit: z.string(), + windowDays: z.number().int().min(1), + days: z.array(z.object({ day: z.string(), amount: z.number() })), + reference: resolvedNutrientReferenceSchema.nullable(), + }) + .meta({ + id: "NutrientDailySeries", + description: + "Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the profile).", + });