Merge pull request #518 from MBombeck/release/v1.29.3

release/v1.29.3
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-17 16:29:00 +02:00
commit 546c5c7d8f
46 files changed
+2361 -56

No files matched your search

+18
View File
@@ -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
+205 -3
View File
@@ -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
+31 -1
View File
@@ -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": {
+31 -1
View File
@@ -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": {
+31 -1
View File
@@ -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": {
+31 -1
View File
@@ -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": {
+31 -1
View File
@@ -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": {
+31 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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");
+8 -1
View File
@@ -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")
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.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/*`
@@ -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", () => {
@@ -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: {
+9 -1
View File
@@ -165,11 +165,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
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<Response> {
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
source: "APPLE_HEALTH",
externalSourceVersion: entry.externalSourceVersion ?? null,
},
update: {
@@ -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}$/) },
}),
}),
);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* `GET /api/nutrients/daily?nutrient=<code>&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<string, number>();
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,
});
});
+11 -5
View File
@@ -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;
}
}
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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",
});
});
});
+131
View File
@@ -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<Response> {
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,
});
}
+112
View File
@@ -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<NutrientIntakeOverview>(`/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 (
<SubPageShell title={t("nutrients.page.title")}>
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.moduleOffTitle")}
description={t("nutrients.page.moduleOffDescription")}
ctaSize="lg"
action={
<Button
size="sm"
onClick={() => enableModule.mutate()}
disabled={enableModule.isPending}
data-slot="nutrients-enable-module"
>
{t("nutrients.page.moduleOffCta")}
</Button>
}
/>
</SubPageShell>
);
}
const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0;
if (!overview.isLoading && !hasAnyData) {
return (
<SubPageShell
title={t("nutrients.page.title")}
description={t("nutrients.page.description")}
>
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.emptyTitle")}
description={t("nutrients.page.emptyDescription")}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("nutrients.page.title")}
description={t("nutrients.page.description")}
>
<HydrationCard />
<CaffeineCard />
<MicronutrientsCard />
</SubPageShell>
);
}
+39
View File
@@ -12,6 +12,7 @@ import {
Dumbbell,
Footprints,
Gauge,
GlassWater,
Heart,
HeartPulse,
Moon,
@@ -173,6 +174,9 @@ const TILE_INSIGHT_HREF: Record<string, string> = {
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: (
<TrendCard
key="waterIntake"
label={t("nutrients.names.water")}
latest={waterIntakeSummary?.latest ?? null}
unit="mL"
avg7={waterIntakeSummary?.avg7 ?? null}
avg30={waterIntakeSummary?.avg30 ?? null}
slope30={waterIntakeSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
icon={GlassWater}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(waterIntakeSummary)}
staleDays={tileStaleDays("NUTRIENT_WATER")}
/>
),
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
+1
View File
@@ -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";
@@ -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 `<NutrientDailyBarChart>`,
* 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: () => <ChartSkeleton /> },
);
export function NutrientDailyBarChartDynamic(
props: ComponentProps<typeof NutrientDailyBarChartLazy>,
): ReactElement {
return (
<ChartErrorBoundary>
<NutrientDailyBarChartLazy {...props} />
</ChartErrorBoundary>
);
}
@@ -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 (
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="day"
tickFormatter={(day: string) => formatDateShort(dayToDate(day))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={24}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
/>
{referenceValue != null ? (
<ReferenceLine
y={referenceValue}
stroke="var(--muted-foreground)"
strokeOpacity={0.6}
strokeDasharray="4 4"
/>
) : null}
<Tooltip
content={(props) => {
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 <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: valueLabel,
value: `${point.amount} ${unit}`,
color: barColor,
},
];
return (
<RichChartTooltip
active
label={formatDateShort(dayToDate(point.day))}
rows={rows}
/>
);
}}
/>
<Bar
dataKey="amount"
fill={barColor}
radius={[2, 2, 0, 0]}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
@@ -64,6 +64,9 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
"muscleMass",
"totalBodyWater",
"boneMass",
// v1.29 — fluid intake strip tile: paints one strip card and self-gates
// on having a NUTRIENT_WATER sample.
"waterIntake",
]);
/**
@@ -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
@@ -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<Record<SubPageSlug, ModuleKey>> = {
"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",
};
/**
@@ -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<NutrientDailySeries>(
`/api/nutrients/daily?nutrient=caffeine&days=${WINDOW_DAYS}`,
),
});
if (isLoading) {
return (
<Card data-slot="nutrients-caffeine-card">
<CardContent className="py-6" aria-hidden="true">
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
);
}
const hasData = (data?.days ?? []).some((d) => d.amount > 0);
if (!data || !hasData) return null;
const todayTotal = data.days.at(-1)?.amount ?? 0;
return (
<Card data-slot="nutrients-caffeine-card">
<CardHeader>
<TileHeader icon={Coffee} title={t("nutrients.names.caffeine")} />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-2xl font-bold tabular-nums">
{fmt.integer(Math.round(todayTotal))} {data.unit}
</p>
{data.reference ? (
<p className="text-muted-foreground text-xs">
{t("nutrients.caffeine.ceilingMeta", {
value: fmt.integer(Math.round(data.reference.value)),
unit: data.unit,
})}
</p>
) : null}
</div>
<NutrientDailyBarChartDynamic
days={data.days}
unit={data.unit}
valueLabel={t("nutrients.names.caffeine")}
referenceValue={data.reference?.value ?? null}
/>
</CardContent>
</Card>
);
}
@@ -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<NutrientDailySeries>(
`/api/nutrients/daily?nutrient=water&days=${WINDOW_DAYS}`,
),
});
if (isError) {
return <QueryErrorCard onRetry={refetch} />;
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
return (
<Card data-slot="nutrients-hydration-card">
<CardHeader>
<TileHeader
icon={GlassWater}
title={t("nutrients.names.water")}
right={
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setSheetOpen(true)}
data-slot="nutrients-hydration-add"
>
<Plus className="size-4" aria-hidden="true" />
{t("nutrients.hydration.quickAddTitle")}
</Button>
}
/>
</CardHeader>
<CardContent className="space-y-4">
{isLoading || !data ? (
<div className="space-y-3" aria-hidden="true">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-[240px] w-full" />
</div>
) : (
<>
<div className="space-y-1">
<p
className="text-2xl font-bold tabular-nums"
data-slot="nutrients-hydration-today"
>
{fmt.integer(Math.round(todayTotal))} {data.unit}
</p>
{data.reference ? (
<p className="text-muted-foreground text-xs">
{t("nutrients.hydration.referenceMeta", {
value: fmt.integer(Math.round(data.reference.value)),
unit: data.unit,
})}
</p>
) : null}
</div>
<NutrientDailyBarChartDynamic
days={data.days}
unit={data.unit}
valueLabel={t("nutrients.names.water")}
referenceValue={data.reference?.value ?? null}
/>
</>
)}
</CardContent>
<WaterQuickAddSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
todayTotalMl={Math.round(todayTotal)}
/>
</Card>
);
}
@@ -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<string, string> = { 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<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
});
const rows = (data?.nutrients ?? []).filter(
(row) =>
isNutrientCode(row.nutrient) &&
row.nutrient !== "water" &&
row.nutrient !== "caffeine",
);
return (
<Card data-slot="nutrients-micronutrients-card">
<CardHeader>
<TileHeader icon={Leaf} title={t("nutrients.micronutrients.title")} />
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="space-y-1" aria-hidden="true">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
) : rows.length === 0 ? (
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.micronutrients.emptyTitle")}
size="compact"
/>
) : (
<>
<ul
className="divide-border divide-y rounded-md border"
role="list"
>
{rows.map((row) => {
const reference = resolveNutrientReference(
row.nutrient as NutrientCode,
sex,
);
const unitLabel = UNIT_LABELS[row.unit] ?? row.unit;
return (
<li
key={row.nutrient}
data-slot="nutrients-micronutrient-row"
className="flex items-baseline justify-between gap-3 p-2"
>
<span className="text-sm">
{t(`nutrients.names.${row.nutrient}`)}
</span>
<span className="flex flex-col items-end gap-0.5 text-right">
<span className="text-sm font-medium tabular-nums">
{formatAmount(fmt, row.latestAmount)} {unitLabel}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{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,
})}
</span>
</span>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-xs">
{t("nutrients.micronutrients.footer", {
tracked: rows.length,
total: MICRONUTRIENT_CODES.length,
})}
</p>
</>
)}
<p className="text-muted-foreground text-sm">
{t("nutrients.sparseNote")}
</p>
</CardContent>
</Card>
);
}
@@ -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[];
}
@@ -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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("nutrients.hydration.quickAddTitle")}
>
<div className="flex flex-wrap gap-2" data-slot="water-quick-add-chips">
{QUICK_CHIPS_ML.map((amountMl) => (
<Button
key={amountMl}
type="button"
variant="outline"
size="sm"
disabled={addMutation.isPending}
onClick={() => {
addMutation.mutate(amountMl);
toast.success(`+${amountMl} mL`);
}}
>
+{amountMl} mL
</Button>
))}
</div>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label htmlFor="water-custom-amount">
{t("nutrients.hydration.quickAddCustomLabel")}
</Label>
<Input
id="water-custom-amount"
type="number"
inputMode="numeric"
min={1}
max={20000}
placeholder={t("nutrients.hydration.quickAddCustomPlaceholder")}
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
/>
</div>
<Button
type="button"
onClick={handleCustomAdd}
disabled={addMutation.isPending || !customAmount}
>
{t("nutrients.hydration.quickAddSubmit")}
</Button>
</div>
{editMode ? (
<div className="border-border/70 space-y-1.5 border-t pt-4">
<Label htmlFor="water-edit-total">
{t("nutrients.hydration.editTotal")}
</Label>
<div className="flex items-end gap-2">
<Input
id="water-edit-total"
type="number"
inputMode="numeric"
min={0}
max={20000}
className="flex-1"
value={editAmount}
onChange={(e) => setEditAmount(e.target.value)}
/>
<Button
type="button"
onClick={handleEditSave}
disabled={setMutation.isPending}
>
{t("nutrients.hydration.quickAddSubmit")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground self-start"
onClick={() => {
setEditAmount(String(todayTotalMl));
setEditMode(true);
}}
>
{t("nutrients.hydration.editTotal")}
</Button>
)}
</ResponsiveSheet>
);
}
@@ -162,6 +162,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
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",
};
/**
+11 -8
View File
@@ -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);
}
+10
View File
@@ -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 },
],
};
+38 -4
View File
@@ -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,
+64
View File
@@ -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<string, number>();
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 +
+30 -19
View File
@@ -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<Record<string, ModuleKey>> = {
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<Record<MeasurementType, ModuleKey>>` to
* `Partial<Record<string, ModuleKey>>` 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<Record<MeasurementType, ModuleKey>> =
{
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<Record<string, ModuleKey>> = {
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",
};
+14 -1
View File
@@ -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.
+9
View File
@@ -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<string, readonly string[]>;
export type SubPageSlug = keyof typeof SUB_PAGE_METRIC;
@@ -305,6 +311,9 @@ const FLAT_SLUG_MANAGER_GROUP: Partial<Record<SubPageSlug, ManagerGroup>> = {
sleep: "sleep",
mood: "mood",
medications: "events",
// v1.29 — nutrient intake filed with the metabolic cluster (alongside
// blood glucose / skin / wrist temperature).
nutrients: "metabolic",
};
/**
@@ -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();
}
});
});
+35
View File
@@ -305,3 +305,38 @@ const NUTRIENT_CODE_SET: ReadonlySet<string> = 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,
};
}
+57
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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,
},
},
},
};
+7
View File
@@ -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=<code>&days=N`. Feeds the
* `/insights/nutrients` hydration + caffeine charts.
*/
nutrientDaily: (nutrient: string, days: number) =>
["nutrients", "daily", nutrient, days] as const,
};
+70 -1
View File
@@ -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).",
});