mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
46 files changed
+2361
-56
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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}$/) },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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).",
|
||||
});
|
||||
Reference in new issue
Block a user