mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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