feat(nutrients): source-aware day totals; manual water write path

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 16:08:39 +02:00
1 parent bef3b9dd3f
commit da2de015fe
15 files changed
+1075 -11

No files matched your search

@@ -16,6 +16,7 @@ import {
NUTRIENT_CODES,
NUTRIENT_DEFINITIONS,
isNutrientCode,
resolveNutrientReference,
} from "../catalog";
describe("nutrient catalog", () => {
@@ -151,3 +152,49 @@ describe("nutrient catalog", () => {
}
});
});
describe("resolveNutrientReference (v1.29)", () => {
it("resolves a uniform-adult reference (calcium) regardless of sex, including unknown", () => {
for (const sex of ["MALE", "FEMALE", null] as const) {
const ref = resolveNutrientReference("calcium", sex);
expect(ref).not.toBeNull();
expect(ref?.value).toBe(950);
expect(ref?.kind).toBe("PRI");
expect(ref?.direction).toBe("target");
expect(ref?.source).toBe(NUTRIENT_CATALOG.calcium.reference.source);
}
});
it("resolves a sex-split reference (water, iron) to the matching value per sex", () => {
expect(resolveNutrientReference("water", "MALE")?.value).toBe(2500);
expect(resolveNutrientReference("water", "FEMALE")?.value).toBe(2000);
expect(resolveNutrientReference("iron", "MALE")?.value).toBe(11);
expect(resolveNutrientReference("iron", "FEMALE")?.value).toBe(16);
});
it("omits a sex-split reference when sex is unknown — never guesses", () => {
expect(resolveNutrientReference("water", null)).toBeNull();
expect(resolveNutrientReference("iron", null)).toBeNull();
expect(resolveNutrientReference("magnesium", null)).toBeNull();
expect(resolveNutrientReference("vitamin_a", null)).toBeNull();
});
it("resolves caffeine's upperGuidance ceiling regardless of sex", () => {
const ref = resolveNutrientReference("caffeine", null);
expect(ref).toEqual({
kind: "safeLevel",
direction: "upperGuidance",
value: 400,
source: NUTRIENT_CATALOG.caffeine.reference.source,
});
});
it("resolves every catalog code to a non-null reference for at least one sex", () => {
for (const code of NUTRIENT_CODES) {
const resolved =
resolveNutrientReference(code, "MALE") ??
resolveNutrientReference(code, "FEMALE");
expect(resolved, `no resolvable reference: ${code}`).not.toBeNull();
}
});
});
+35
View File
@@ -305,3 +305,38 @@ const NUTRIENT_CODE_SET: ReadonlySet<string> = new Set(NUTRIENT_CODES);
export function isNutrientCode(code: string): code is NutrientCode {
return NUTRIENT_CODE_SET.has(code);
}
/** A catalog reference resolved to one concrete number for a profile. */
export interface ResolvedNutrientReference {
kind: NutrientReference["kind"];
direction: NutrientReference["direction"];
value: number;
source: string;
}
/**
* Resolve a catalog reference against the user's profile sex (v1.29).
*
* `adult` values (uniform across sexes) resolve unconditionally. A
* sex-split reference (`male` / `female`, no `adult`) resolves only
* when `sex` is known — a profile without sex on file OMITS the
* reference rather than guessing, the catalog's own documented
* contract (see the module docblock). Every insights surface honours
* this: no reference line, no meta sentence, nothing inferred.
*/
export function resolveNutrientReference(
code: NutrientCode,
sex: "MALE" | "FEMALE" | null,
): ResolvedNutrientReference | null {
const ref = NUTRIENT_CATALOG[code].reference;
const value =
ref.adult ??
(sex === "MALE" ? ref.male : sex === "FEMALE" ? ref.female : undefined);
if (value === undefined) return null;
return {
kind: ref.kind,
direction: ref.direction,
value,
source: ref.source,
};
}
+57
View File
@@ -14,8 +14,12 @@ import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
import {
nutrientBatchSchema,
nutrientBatchResponseSchema,
nutrientDailyQuerySchema,
nutrientDailySeriesSchema,
nutrientOverviewQuerySchema,
nutrientOverviewSchema,
nutrientWaterWriteSchema,
nutrientWaterWriteResponseSchema,
} from "@/lib/validations/nutrients";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -90,4 +94,57 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/nutrients/daily": {
get: {
tags: ["Nutrients"],
summary: "One nutrient's day-bucketed series (v1.29)",
description:
"Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never guessed). Behind the opt-in `nutrients` module.",
requestParams: { query: nutrientDailyQuerySchema },
responses: {
"200": {
description: "The day-bucketed series + resolved reference.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientDailySeriesSchema,
"NutrientDailySeriesEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
"/api/nutrients/water": {
post: {
tags: ["Nutrients"],
summary: "Manual water quick-add (v1.29)",
description:
'Writes ONLY the `source="MANUAL"` row for `(day, "water")` — never the `source="APPLE_HEALTH"` row the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: "add"` increments the manual day total (quick-add chips); `mode: "set"` overwrites it (the "edit today\'s total" undo path — there is no per-entry ledger). `day` defaults to the caller\'s current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module.',
requestBody: {
required: true,
content: {
"application/json": { schema: nutrientWaterWriteSchema },
},
},
responses: {
"200": {
description: "The MANUAL water row after the write.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientWaterWriteResponseSchema,
"NutrientWaterWriteEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
};
+7
View File
@@ -6,4 +6,11 @@
export const nutrientKeys = {
/** Per-nutrient window summary from `GET /api/nutrients?days=N`. */
nutrientIntake: (days: number) => ["nutrients", "intake", days] as const,
/**
* v1.29 — one nutrient's day-bucketed series from
* `GET /api/nutrients/daily?nutrient=<code>&days=N`. Feeds the
* `/insights/nutrients` hydration + caffeine charts.
*/
nutrientDaily: (nutrient: string, days: number) =>
["nutrients", "daily", nutrient, days] as const,
};
+70 -1
View File
@@ -9,7 +9,7 @@
*/
import { z } from "zod/v4";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
import { NUTRIENT_CATALOG, NUTRIENT_CODES } from "@/lib/nutrients/catalog";
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
@@ -106,3 +106,72 @@ export const nutrientOverviewSchema = z
description:
"Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted.",
});
/**
* `POST /api/nutrients/water` body (v1.29) — manual water quick-add.
*
* Water only in slice 1 (manual vitamin entry is out of scope — see
* the design memo). `amountMl` is bounded to the catalog's own
* per-day plausibility cap for water; `mode: "add"` increments the
* MANUAL row for `day`, `mode: "set"` overwrites it (the "edit
* today's total" undo path). `day` defaults server-side to the
* caller's current local day when omitted.
*/
export const nutrientWaterWriteSchema = z
.object({
amountMl: z
.number()
.finite()
.min(1)
.max(NUTRIENT_CATALOG.water.plausibleDailyMax),
mode: z.enum(["add", "set"]),
day: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
})
.meta({
id: "NutrientWaterWriteRequest",
description:
"Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites it. Never touches the APPLE_HEALTH row.",
});
export const nutrientWaterWriteResponseSchema = z
.object({
day: z.string(),
nutrient: z.literal("water"),
source: z.literal("MANUAL"),
amount: z.number(),
unit: z.string(),
})
.meta({
id: "NutrientWaterWriteResponse",
description: "The MANUAL water row after the add/set write.",
});
/** `GET /api/nutrients/daily` query — one nutrient's day-bucketed series. */
export const nutrientDailyQuerySchema = z.object({
nutrient: nutrientCodeEnum,
days: z.coerce.number().int().min(1).max(90).default(30),
});
const resolvedNutrientReferenceSchema = z.object({
kind: z.enum(["PRI", "AI", "safeLevel"]),
direction: z.enum(["target", "upperGuidance"]),
value: z.number(),
source: z.string(),
});
export const nutrientDailySeriesSchema = z
.object({
nutrient: nutrientCodeEnum,
unit: z.string(),
windowDays: z.number().int().min(1),
days: z.array(z.object({ day: z.string(), amount: z.number() })),
reference: resolvedNutrientReferenceSchema.nullable(),
})
.meta({
id: "NutrientDailySeries",
description:
"Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the profile).",
});