GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.
The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.
This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
The water quick-add validated calendar realism but never the upper day bound the
batch route applies, so a far-future day was accepted and — every read filters
`day >= since` with no ceiling — became the permanent latest day on the settings
card and the dashboard tile. Hoist the bound into one shared helper both routes
use.
`mode: "add"` bounded a single request to the catalog's plausible daily max but
nothing across requests, so repeated quick-adds drove the stored total
arbitrarily high while the batch route enforced the same cap strictly. Clamp
after the increment so the write stays atomic.
Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a
microgram-read-as-milligram sample landed on precisely the cap and the `>`
comparison let it through. 500 still clears any real supplement dose.
NutrientIntakeDay was in no export path, which contradicted the schema's own
reason for denormalising the unit column. Carry it in the full backup with its
unit and source — source is part of the composite key, so without it a restore
cannot tell a manual entry from a synced day total.
NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.
- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
modes against the MANUAL row only, module-gated, rate-limited,
idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
nutrient (summed across sources) plus the EFSA reference resolved
against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
helper both new routes and the insights cards share.
docs/api/openapi.yaml regenerated for the two new routes.
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).
Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.