feat(nutrients): add DHA, EPA, input units and upper limits to the catalogue, and count supplement doses as a nutrient source

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:19:45 +02:00
1 parent 5dab12472f
commit 98d64c2052
31 files changed
+1573 -236

No files matched your search

+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "Molybdän",
"iodine": "Jod",
"water": "Wasser",
"caffeine": "Koffein"
"caffeine": "Koffein",
"dha": "DHA (Omega-3)",
"epa": "EPA (Omega-3)"
},
"page": {
"title": "Nährstoffaufnahme",
+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "Molybdenum",
"iodine": "Iodine",
"water": "Water",
"caffeine": "Caffeine"
"caffeine": "Caffeine",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
},
"page": {
"title": "Nutrient intake",
+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "Molibdeno",
"iodine": "Yodo",
"water": "Agua",
"caffeine": "Cafeína"
"caffeine": "Cafeína",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
},
"page": {
"title": "Ingesta de nutrientes",
+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "Molybdène",
"iodine": "Iode",
"water": "Eau",
"caffeine": "Caféine"
"caffeine": "Caféine",
"dha": "DHA (oméga-3)",
"epa": "EPA (oméga-3)"
},
"page": {
"title": "Apports en nutriments",
+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "Molibdeno",
"iodine": "Iodio",
"water": "Acqua",
"caffeine": "Caffeina"
"caffeine": "Caffeina",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
},
"page": {
"title": "Apporto di nutrienti",
+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "몰리브덴",
"iodine": "요오드",
"water": "수분",
"caffeine": "카페인"
"caffeine": "카페인",
"dha": "DHA (오메가-3)",
"epa": "EPA (오메가-3)"
},
"page": {
"title": "영양소 섭취",
+3 -1
View File
@@ -11364,7 +11364,9 @@
"molybdenum": "Molibden",
"iodine": "Jod",
"water": "Woda",
"caffeine": "Kofeina"
"caffeine": "Kofeina",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
},
"page": {
"title": "Spożycie składników odżywczych",
@@ -54,16 +54,11 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"environment.travel_suggestions.get",
],
"labs/[id]/links/route.ts": ["labs.link."],
"medications/[id]/ingredients/extract/route.ts": [
"medications.ingredients.extract",
],
"medications/[id]/ingredients/route.ts": ["medications.ingredients.put"],
"medications/[id]/links/route.ts": ["medication.link."],
"nutrients/catalog/route.ts": ["nutrients.catalog.get"],
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 23;
const REGISTER_SIZE = 20;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
@@ -8,20 +8,50 @@
* re-counts every taken dose of this medication into the day's micronutrients
* (`recompute.queued`). Needs the `nutrients` module.
*
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
* composition lands the stub resolves the caller's own record only.
* MANAGE: a composition rewrites what every past dose of the medication adds
* to the record's micronutrients, the kind of change the level names. C1 (the
* rate bucket keys on the actor) and C4 (the audit row files the composition
* it replaced).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import {
canonicalComposition,
compositionSummary,
toIngredientDtos,
} from "@/lib/medications/ingredients/composition";
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients";
type RouteParams = { params: Promise<{ id: string }> };
const PUT_RATE_LIMIT = 30;
const PUT_WINDOW_MS = 60_000;
const INGREDIENT_SELECT = {
nutrient: true,
amountPerUnit: true,
unit: true,
enteredAmount: true,
enteredUnit: true,
} as const;
export const PUT = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(putIngredients),
);
@@ -30,11 +60,37 @@ async function putIngredients(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
const { user } = await requireAuth();
await params;
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const medication = await prisma.medication.findUnique({
where: { id },
select: {
id: true,
userId: true,
ingredients: { select: INGREDIENT_SELECT },
},
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404, {
errorCode: "medications.notFound",
});
}
// C1 — keyed on the ACTOR, so a manager burns their own allowance.
const rl = await checkRateLimit(
`medication-ingredients:put:${actor.id}`,
PUT_RATE_LIMIT,
PUT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
@@ -46,5 +102,81 @@ async function putIngredients(
});
}
return notImplemented("medications.ingredients.put");
const check = canonicalComposition(parsed.data.ingredients);
if (!check.ok) {
if (check.kind === "unknownNutrient") {
return apiError("Unknown nutrient code", 422, {
errorCode: "medications.ingredients.invalid",
index: check.index,
});
}
return apiError("This unit is not offered for the nutrient", 422, {
errorCode: "medications.ingredients.unitMismatch",
index: check.index,
allowedUnits: check.allowedUnits,
});
}
const source = parsed.data.source;
const stored = await prisma.$transaction(async (tx) => {
await tx.medicationIngredient.deleteMany({
where: { medicationId: id, userId: user.id },
});
if (check.ingredients.length > 0) {
await tx.medicationIngredient.createMany({
data: check.ingredients.map((ingredient) => ({
userId: user.id,
medicationId: id,
nutrient: ingredient.nutrient,
amountPerUnit: ingredient.amountPerUnit,
unit: ingredient.unit,
enteredAmount: ingredient.enteredAmount,
enteredUnit: ingredient.enteredUnit,
source,
})),
});
}
return tx.medicationIngredient.findMany({
where: { medicationId: id, userId: user.id },
select: INGREDIENT_SELECT,
});
});
await auditLog("medication.ingredients.update", {
userId: user.id,
ipAddress: getClientIp(request),
// C4 — the composition this replaced, as one comparable line per side.
details: {
medicationId: id,
source,
count: stored.length,
...overwriteDetails({
before: { composition: compositionSummary(medication.ingredients) },
after: { composition: compositionSummary(stored) },
}),
},
});
// Every taken dose of this medication, back to the first, counts with the
// new composition: the account's whole SUPPLEMENT history is recomputed.
const recompute = await enqueueSupplementNutrientRecompute(user.id);
invalidateUserMedications(user.id, { evict: true });
annotate({
action: {
name: "medication.ingredients.update",
entity_type: "medication",
entity_id: id,
},
meta: {
count: stored.length,
source,
recompute_queued: recompute.queued,
},
});
return apiSuccess({
ingredients: toIngredientDtos(stored, actor.locale ?? user.locale ?? null),
recompute,
});
}
@@ -1,4 +1,8 @@
import { prisma } from "@/lib/db";
import {
enqueueSupplementNutrientRecompute,
medicationHasComposition,
} from "@/lib/medications/ingredients/recompute-job";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -63,6 +67,11 @@ export const DELETE = apiHandler(
where: { medicationId: id, userId: user.id },
});
// v1.43 — the purged doses no longer count toward the micronutrients.
if (await medicationHasComposition(user.id, id)) {
await enqueueSupplementNutrientRecompute(user.id);
}
await auditLog("medication.intake.purge", {
userId: user.id,
ipAddress: getClientIp(request),
+32
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { toIngredientDtos } from "@/lib/medications/ingredients/composition";
import {
enqueueSupplementNutrientRecompute,
medicationHasComposition,
} from "@/lib/medications/ingredients/recompute-job";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
@@ -260,6 +265,18 @@ export const GET = apiHandler(
Number(medication.unitsPerDose),
);
// v1.43 — the composition per unit, labelled in the account language.
const ingredientRows = await prisma.medicationIngredient.findMany({
where: { medicationId: id, userId: user.id },
select: {
nutrient: true,
amountPerUnit: true,
unit: true,
enteredAmount: true,
enteredUnit: true,
},
});
// v1.40 (#1024) — the courses, resolved on the server.
const courseFields = (
await resolveCourseFields(
@@ -294,6 +311,7 @@ export const GET = apiHandler(
stockUnitsRemaining,
stockDosesRemaining,
runwayDays,
ingredients: toIngredientDtos(ingredientRows, user.locale ?? null),
});
},
);
@@ -1122,6 +1140,13 @@ export const PUT = apiHandler(
const resolvedCategory = (await resolveMedicationCategories([id]))[id];
const normalizedCategory = resolvedCategory?.category ?? "OTHER";
// v1.43 — a changed units-per-dose (on the medication or on a slot)
// changes what every taken dose added, so a medication with a composition
// has its doses recomputed. Cheap and coalesced when nothing moved.
if (await medicationHasComposition(user.id, id)) {
await enqueueSupplementNutrientRecompute(user.id);
}
await auditLog("medication.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -1225,6 +1250,10 @@ export const DELETE = apiHandler(
data: { revoked: true },
});
// v1.43 — whether its doses counted toward the micronutrients, read
// before the delete cascades the composition away.
const hadComposition = await medicationHasComposition(user.id, id);
await deleteMedicationCategory(id);
await prisma.medication.delete({ where: { id } });
@@ -1246,6 +1275,9 @@ export const DELETE = apiHandler(
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id, { evict: true });
// Its doses are gone with it, so the days they counted on are recomputed.
if (hadComposition) await enqueueSupplementNutrientRecompute(user.id);
return apiSuccess({ deleted: true });
},
);
+22 -2
View File
@@ -8,8 +8,11 @@
* and never convert units themselves.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { nutrientLabel } from "@/lib/medications/ingredients/composition";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
export const dynamic = "force-dynamic";
@@ -18,5 +21,22 @@ export const GET = apiHandler(async () => {
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
return notImplemented("nutrients.catalog.get");
const entries = NUTRIENT_DEFINITIONS.map((def) => ({
code: def.code,
unit: def.unit,
labelKey: def.labelKey,
label: nutrientLabel(def.code, user.locale ?? null),
syncable: def.syncable,
inputUnits: def.inputUnits.map((u) => ({
unit: u.unit,
factorToCanonical: u.factorToCanonical,
})),
upperLimit: def.upperLimit,
}));
annotate({
action: { name: "nutrient.catalog.read" },
meta: { entry_count: entries.length },
});
return apiSuccess({ entries });
});
+13 -23
View File
@@ -18,7 +18,6 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -29,7 +28,8 @@ import {
resolveNutrientReference,
} from "@/lib/nutrients/catalog";
import { nutrientDailyQuerySchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { readNutrientDaily } from "@/lib/nutrients/read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -51,34 +51,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { nutrient, days } = parsed.data;
const definition = NUTRIENT_CATALOG[nutrient];
// Summed across sources within a day, split by source and judged against
// the upper intake level in `readNutrientDaily` (v1.43), the same read the
// overview and the MCP tool use.
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 { days: daySeries, rowCount } = await readNutrientDaily(
user.id,
userTz,
nutrient,
days,
);
const sex = binaryReferenceSex(user.gender);
const reference = resolveNutrientReference(nutrient, sex);
annotate({
action: { name: "nutrient.daily.read" },
meta: { nutrient, window_days: days, row_count: rows.length },
meta: { nutrient, window_days: days, row_count: rowCount },
});
return apiSuccess({
@@ -87,5 +76,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
windowDays: days,
days: daySeries,
reference,
upperLimit: definition.upperLimit,
});
});
+12 -68
View File
@@ -21,9 +21,9 @@ import { apiHandler, requireRecordAuth } 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_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
import { readNutrientOverview } from "@/lib/nutrients/read";
import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -109,72 +109,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.30 (DATAINT L4) — window floor anchored on the caller's own local
// "today" (`User.timezone`), mirroring the sibling `GET
// /api/nutrients/daily` route. The stored `day` is a local-timezone key,
// so a UTC floor was off by up to a calendar day at the window edge for
// any non-UTC user.
// /api/nutrients/daily` route. v1.43 — the fold (sum across sources, one
// day counted once however many source rows it has) lives in
// `readNutrientOverview`, which also keeps the latest day's split by source
// and judges it against the upper intake level.
const userTz = user.timezone || DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), userTz);
const since = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, day: { gte: since } },
orderBy: [{ nutrient: "asc" }, { day: "desc" }],
select: { nutrient: true, unit: true, day: true, amount: true },
});
// v1.29 — `source` joined the PK (migration 0249): a day can now carry
// an APPLE_HEALTH row AND a MANUAL row. 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".
//
// v1.30 (DATAINT M3) — `daysWithData` used to increment on every row
// whose `day` differed from the PINNED `latestDay` field, so a second
// source's row on an already-counted OLDER day (e.g. water logged via
// both APPLE_HEALTH and MANUAL on the same non-latest day) opened a
// second "day" for that same calendar date. `daysSeen` is a per-nutrient
// set of distinct day keys — every row adds to it, so a repeated day
// (any number of source rows) is counted exactly once.
const byCode = new Map<
string,
{
unit: string;
latestDay: string;
latestAmount: number;
daysSeen: Set<string>;
}
>();
for (const row of rows) {
const existing = byCode.get(row.nutrient);
if (!existing) {
byCode.set(row.nutrient, {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
daysSeen: new Set([row.day]),
});
continue;
}
existing.daysSeen.add(row.day);
if (row.day === existing.latestDay) {
existing.latestAmount += row.amount;
}
}
// Catalog order; a stored code that ever fell out of the catalog is
// dropped from the card rather than crashing the label lookup.
const nutrients = NUTRIENT_CODES.filter(
(code) => isNutrientCode(code) && byCode.has(code),
).map((code) => {
const summary = byCode.get(code)!;
return {
nutrient: code,
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latestAmount,
daysWithData: summary.daysSeen.size,
};
});
const { nutrients, rowCount } = await readNutrientOverview(
user.id,
userTz,
days,
);
// The account holder's only signal that a sync ever arrived is this
// lookup — the read path stays honest instead of a bare "no data yet"
@@ -226,7 +170,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: {
window_days: days,
nutrient_count: nutrients.length,
row_count: rows.length,
row_count: rowCount,
},
});
+5
View File
@@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { jobBudget } from "@/lib/jobs/job-budget";
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import {
@@ -849,6 +850,10 @@ async function workMedicationIntakeImport(
if (outcome.result.imported > 0) {
invalidateUserMedications(outcome.userId, { evict: true });
queueMedicationIntakeSync({ userId: outcome.userId });
// v1.43 — the chunks recomputed their compliance days inside their
// own transactions; the supplement nutrient rows follow once, now
// that every imported dose is committed.
await enqueueSupplementNutrientRecompute(outcome.userId);
}
return outcome.result;
}
+3
View File
@@ -214,6 +214,9 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
"step-consolidation-repair": short(
"One account's tombstoned step rows inside the retention window.",
),
"supplement-nutrient-recompute": short(
"One account's taken doses of medications with a composition: a few thousand rows for years of a daily supplement.",
),
"cumulative-pr-rederive": short(
"One account's record detection; measured at 20 seconds for 1.25 million rows.",
),
+29
View File
@@ -45,6 +45,12 @@ import {
enqueueBootTimeMedicationComplianceBackfill,
type MedicationComplianceBackfillPayload,
} from "@/lib/rollups/medication-compliance-rollups";
import {
SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE,
SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY,
runSupplementNutrientRecompute,
type SupplementNutrientRecomputePayload,
} from "@/lib/medications/ingredients/recompute-job";
import {
drainPerSampleCumulative,
DRAIN_CUMULATIVE_CUTOFF_HOURS,
@@ -177,6 +183,9 @@ const allQueues = [
// drain schedule silently no-ops and the per-sample APPLE_HEALTH rows
// never collapse.
DRAIN_CUMULATIVE_QUEUE,
// v1.43 — one account's supplement doses recomputed into the day's
// micronutrients after a change that spans more than one day.
SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -214,6 +223,11 @@ const queuePolicies: QueuePolicyTable = {
reason:
"Same shape as the measurement recompute: re-reads the bucket at run time, so only queued duplicates may be collapsed.",
},
[SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE]: {
policy: "short",
reason:
"Per-account recompute of supplement nutrient rows; the pass reads the live doses and composition when it starts, so a queued duplicate may be collapsed, while a send during a running pass must still queue the next one.",
},
// The rest are per-user, self-converging boot/discovery backfills. A second
// concurrent run is pure duplicated work, and the discovery predicate
@@ -406,6 +420,21 @@ export async function registerRollupQueues(
},
);
// v1.43 — supplement nutrient recompute. One account per job, serial.
await createAndWork<SupplementNutrientRecomputePayload>(
boss,
SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE,
{ localConcurrency: SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY },
async (jobs) => {
let rowsWritten = 0;
for (const job of jobs) {
const result = await runSupplementNutrientRecompute(job.data);
rowsWritten += result.rowsWritten;
}
return jobDone({ users: jobs.length, rows_written: rowsWritten });
},
);
// v1.30.3 — cumulative-PersonalRecord rederivation worker (QA F4). The
// boot enqueue helper below sends one job per user still holding a
// suspect pre-fix inflated cumulative-type record; this handler deletes
+1
View File
@@ -82,6 +82,7 @@ const subsystemSurface = {
"step-consolidation": { audience: "system" },
"step-consolidation-repair": { audience: "system" },
"cumulative-pr-rederive": { audience: "system" },
"supplement-nutrient-recompute": { audience: "system" },
"mean-consolidation": { audience: "system" },
"dense-intraday-retention": { audience: "system" },
"dense-intraday-hourly-rebuild": { audience: "system" },
+42 -82
View File
@@ -27,15 +27,22 @@ import {
isNutrientCode,
resolveNutrientReference,
type NutrientCode,
type NutrientUpperLimit,
type ResolvedNutrientReference,
} from "@/lib/nutrients/catalog";
import {
readNutrientDaily,
readNutrientOverview,
type NutrientAmountBySource,
type NutrientDailyEntry,
} from "@/lib/nutrients/read";
import { defaultLocale } from "@/lib/i18n/config";
import {
buildLocalisedLabelIndex,
foldLabel,
labelIn,
} from "@/lib/i18n/localised-label-index";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** The i18n key the app already labels a nutrient with, in every bundle. */
function nutrientLabelKey(code: NutrientCode): string {
@@ -104,6 +111,9 @@ export interface NutrientsOverviewResult {
latestDay: string;
latestAmount: number;
daysWithData: number;
latestBySource: NutrientAmountBySource;
upperLimit: NutrientUpperLimit | null;
latestAboveUpperLimit: boolean | null;
}>;
}
@@ -114,8 +124,9 @@ export interface NutrientsDailyResult {
label?: string;
unit?: string;
windowDays?: number;
days?: Array<{ day: string; amount: number }>;
days?: NutrientDailyEntry[];
reference?: ResolvedNutrientReference | null;
upperLimit?: NutrientUpperLimit | null;
}
/**
@@ -140,67 +151,27 @@ export function resolveNutrientCode(input: string): NutrientCode | null {
return NUTRIENT_NAME_INDEX.index.get(foldLabel(raw)) ?? null;
}
/** Presence overview across every logged code — mirrors `GET /api/nutrients`. */
/**
* Presence overview across every logged code — the same read as
* `GET /api/nutrients` (`readNutrientOverview`), labels added.
*/
async function readOverview(
userId: string,
userTz: string,
days: number,
): Promise<NutrientsOverviewResult> {
const todayKey = userDayKey(new Date(), userTz);
const since = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId, day: { gte: since } },
orderBy: [{ nutrient: "asc" }, { day: "desc" }],
select: { nutrient: true, unit: true, day: true, amount: true },
});
// v1.29 — `source` joined the PK (migration 0249): a day can carry an
// APPLE_HEALTH row AND a MANUAL row. 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. `daysSeen` is
// a per-nutrient SET of distinct day keys so a repeated day (any number of
// source rows) counts exactly once — the same fold `GET /api/nutrients`
// applies (see that route's docblock).
const byCode = new Map<
string,
{
unit: string;
latestDay: string;
latestAmount: number;
daysSeen: Set<string>;
}
>();
for (const row of rows) {
const existing = byCode.get(row.nutrient);
if (!existing) {
byCode.set(row.nutrient, {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
daysSeen: new Set([row.day]),
});
continue;
}
existing.daysSeen.add(row.day);
if (row.day === existing.latestDay) {
existing.latestAmount += row.amount;
}
}
const nutrients = NUTRIENT_CODES.filter(
(code) => isNutrientCode(code) && byCode.has(code),
).map((code) => {
const summary = byCode.get(code)!;
return {
nutrient: code,
label: NUTRIENT_LABELS[code],
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latestAmount,
daysWithData: summary.daysSeen.size,
};
});
const { nutrients: rows } = await readNutrientOverview(userId, userTz, days);
const nutrients = rows.map((row) => ({
nutrient: row.nutrient,
label: NUTRIENT_LABELS[row.nutrient],
unit: row.unit,
latestDay: row.latestDay,
latestAmount: row.latestAmount,
daysWithData: row.daysWithData,
latestBySource: row.latestBySource,
upperLimit: row.upperLimit,
latestAboveUpperLimit: row.latestAboveUpperLimit,
}));
return {
present: nutrients.length > 0,
@@ -210,7 +181,10 @@ async function readOverview(
};
}
/** One nutrient's per-day series + reference — mirrors `GET /api/nutrients/daily`. */
/**
* One nutrient's per-day series + reference — the same read as
* `GET /api/nutrients/daily` (`readNutrientDaily`).
*/
async function readDaily(
userId: string,
userTz: string,
@@ -219,38 +193,24 @@ async function readDaily(
days: number,
): Promise<NutrientsDailyResult> {
const definition = NUTRIENT_CATALOG[nutrient];
const todayKey = userDayKey(new Date(), userTz);
const sinceKey = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId, nutrient, day: { gte: sinceKey } },
select: { day: true, amount: true },
});
// Sum across sources within a day BEFORE bucketing — the same fold `GET
// /api/nutrients/daily` applies.
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 { days: daySeries, rowCount } = await readNutrientDaily(
userId,
userTz,
nutrient,
days,
);
const reference = resolveNutrientReference(nutrient, sex);
return {
present: rows.length > 0,
...(rows.length === 0 ? { reason: "no_data" } : {}),
present: rowCount > 0,
...(rowCount === 0 ? { reason: "no_data" } : {}),
nutrient,
label: NUTRIENT_LABELS[nutrient],
unit: definition.unit,
windowDays: days,
days: daySeries,
reference,
upperLimit: definition.upperLimit,
};
}
+9
View File
@@ -401,6 +401,15 @@ function plainNutrientText(
ref.direction === "upperGuidance" ? "guidance ceiling" : "target";
parts.push(`EFSA ${verb} ${round1(ref.value)}${unit}`);
}
// v1.43 — the upper-limit verdict is the server's, read off the day.
const limit = result.upperLimit;
if (limit && latestDay?.aboveUpperLimit) {
const share =
limit.scope === "supplemental" ? " for the supplement share" : "";
parts.push(
`that day is above the EFSA upper intake level of ${round1(limit.value)}${unit}${share}`,
);
}
return parts.length > 0
? `${label}: ${parts.join(", ")}.`
: `${label}: data is on file for the recent window.`;
@@ -0,0 +1,150 @@
/**
* v1.43 — a supplement's composition per unit (tablet, capsule): checking
* what was entered against the nutrient catalogue, storing it canonically,
* and publishing it.
*
* Every amount is stored in the catalogue's canonical unit for its code. An
* amount entered in another input unit (vitamin D in IU) is converted through
* the catalogue and the entered amount and unit are kept beside it, so the
* form shows the person what they typed. The catalogue is the only place a
* unit or a factor is spelled; nothing here names one.
*/
import { coerceLocale } from "@/lib/i18n/config";
import { labelIn } from "@/lib/i18n/localised-label-index";
import {
NUTRIENT_CATALOG,
catalogEntryFor,
isCanonicalNutrientUnit,
isNutrientCode,
toCanonicalNutrientAmount,
type NutrientCode,
} from "@/lib/nutrients/catalog";
/** One ingredient as the PUT body carries it. */
export interface IngredientInput {
nutrient: string;
amount: number;
unit: string;
}
/** One ingredient as it is stored. */
export interface CanonicalIngredient {
nutrient: NutrientCode;
amountPerUnit: number;
unit: string;
enteredAmount: number | null;
enteredUnit: string | null;
}
export type CompositionCheck =
| { ok: true; ingredients: CanonicalIngredient[] }
| { ok: false; kind: "unknownNutrient"; index: number }
| {
ok: false;
kind: "unitMismatch";
index: number;
allowedUnits: string[];
};
/**
* Check each entry against the catalogue and convert it to the canonical
* unit. The first entry that fails decides the answer, with its index, so
* the form can point at the row.
*/
export function canonicalComposition(
inputs: ReadonlyArray<IngredientInput>,
): CompositionCheck {
const ingredients: CanonicalIngredient[] = [];
for (const [index, input] of inputs.entries()) {
if (!isNutrientCode(input.nutrient)) {
return { ok: false, kind: "unknownNutrient", index };
}
const code = input.nutrient;
const canonical = toCanonicalNutrientAmount(code, input.amount, input.unit);
if (canonical === null || !(canonical > 0)) {
return {
ok: false,
kind: "unitMismatch",
index,
allowedUnits: NUTRIENT_CATALOG[code].inputUnits.map((u) => u.unit),
};
}
const converted = !isCanonicalNutrientUnit(code, input.unit);
ingredients.push({
nutrient: code,
amountPerUnit: canonical,
unit: NUTRIENT_CATALOG[code].unit,
enteredAmount: converted ? input.amount : null,
enteredUnit: converted ? input.unit : null,
});
}
return { ok: true, ingredients };
}
/** One ingredient on the wire (`MedicationIngredient`). */
export interface MedicationIngredientDto {
nutrient: string;
amountPerUnit: number;
unit: string;
enteredAmount: number | null;
enteredUnit: string | null;
labelKey: string;
label: string;
}
/** The nutrient's name in `locale`, falling back to the code itself. */
export function nutrientLabel(code: string, locale: string | null): string {
const entry = catalogEntryFor(code);
if (!entry) return code;
return labelIn(coerceLocale(locale), entry.labelKey) ?? code;
}
/**
* Publish stored rows in catalogue order, each labelled in the account's
* language. A stored code the catalogue no longer knows still travels, under
* its raw code, rather than vanishing from the person's medication.
*/
export function toIngredientDtos(
rows: ReadonlyArray<{
nutrient: string;
amountPerUnit: number;
unit: string;
enteredAmount: number | null;
enteredUnit: string | null;
}>,
locale: string | null,
): MedicationIngredientDto[] {
const order = (code: string) => {
const entry = catalogEntryFor(code);
return entry ? entry.order : Number.MAX_SAFE_INTEGER;
};
return [...rows]
.sort((a, b) => order(a.nutrient) - order(b.nutrient))
.map((row) => ({
nutrient: row.nutrient,
amountPerUnit: row.amountPerUnit,
unit: row.unit,
enteredAmount: row.enteredAmount,
enteredUnit: row.enteredUnit,
labelKey: catalogEntryFor(row.nutrient)?.labelKey ?? row.nutrient,
label: nutrientLabel(row.nutrient, locale),
}));
}
/**
* One comparable line for a composition (`dha=250 mg; vitamin_d=25 ug`), for
* the audit row's before and after. Codes sorted, so an unchanged set reads
* as unchanged; the empty set is the empty string.
*/
export function compositionSummary(
rows: ReadonlyArray<{
nutrient: string;
amountPerUnit: number;
unit: string;
}>,
): string {
return [...rows]
.sort((a, b) => a.nutrient.localeCompare(b.nutrient))
.map((row) => `${row.nutrient}=${row.amountPerUnit} ${row.unit}`)
.join("; ");
}
@@ -0,0 +1,87 @@
/**
* v1.43 — the `supplement-nutrient-recompute` queue: one account's whole
* SUPPLEMENT history, recomputed after something that touches more than one
* day changed (a composition saved, a medication deleted, its doses purged,
* an import landed).
*
* The queue carries the `short` policy with a per-account `singletonKey`, the
* shape the rollup recompute uses: sends that pile up while a pass is still
* QUEUED collapse into it, which is safe because the handler reads the
* account's live rows when it starts. A send issued while a pass is already
* RUNNING queues a second one, so a change that lands mid-pass is never
* stranded. The pass itself takes a per-account advisory lock.
*
* The queue MUST be registered in the rollup registrar
* (`src/lib/jobs/reminder/register-rollup.ts`) so pg-boss provisions it.
*/
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import { recomputeSupplementNutrientsForUser } from "./recompute";
export const SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE =
"supplement-nutrient-recompute";
/** Serial: a pass is a handful of queries per account. */
export const SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY = 1;
export interface SupplementNutrientRecomputePayload {
userId: string;
enqueuedAt: string;
}
/**
* Queue the account's full recompute. `queued: true` when the worker has it
* (a fresh job, or one already waiting that will read this change). When no
* queue is reachable the pass runs inline so the change is never lost, and
* the answer is `queued: false`.
*/
export async function enqueueSupplementNutrientRecompute(
userId: string,
): Promise<{ queued: boolean }> {
const boss = getGlobalBoss();
if (boss) {
try {
const payload: SupplementNutrientRecomputePayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
await boss.send(SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, payload, {
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
singletonKey: `supplement-nutrient-recompute|${userId}`,
});
// A null id means a pass for this account is already queued and will
// read this change when it starts.
return { queued: true };
} catch (err) {
annotate({
action: { name: "supplement_nutrients.enqueue_failed" },
meta: { reason: err instanceof Error ? err.name : "unknown" },
});
}
}
await recomputeSupplementNutrientsForUser(userId);
return { queued: false };
}
/** The pg-boss handler body for one job. */
export async function runSupplementNutrientRecompute(
payload: SupplementNutrientRecomputePayload,
): Promise<{ rowsWritten: number; ran: boolean }> {
return recomputeSupplementNutrientsForUser(payload.userId);
}
/** Whether a medication has a stored composition (its doses add nutrients). */
export async function medicationHasComposition(
userId: string,
medicationId: string,
): Promise<boolean> {
const row = await prisma.medicationIngredient.findFirst({
where: { userId, medicationId },
select: { id: true },
});
return row !== null;
}
@@ -0,0 +1,309 @@
/**
* v1.43 — supplement doses to micronutrients.
*
* A medication with a stored composition (`MedicationIngredient`) adds, for
* every dose that was taken, `amountPerUnit × unitsPerDose` of each
* ingredient to the day's `NutrientIntakeDay` row with `source = SUPPLEMENT`.
*
* The row is RECOMPUTED, never incremented: each pass reads the taken doses
* of a day (or of the whole history), sums them, and replaces the day's
* SUPPLEMENT rows with the sum. That is what makes every path idempotent and
* self-correcting — a dose marked taken twice, a correction, an undo, a
* deleted dose, a changed composition all converge on the same rows the next
* pass computes, and a day left with no contribution loses its row.
*
* Which doses count: live rows (`deletedAt` null) with `takenAt` set that are
* neither skipped nor auto-missed; an automatically recorded dose counts.
* Rows sharing one `(medication, scheduledFor)` slot (a reminder row and an
* API row for the same dose) count once, the way the compliance rollup folds
* them. A dose belongs to the local day of its `scheduledFor` in the account's
* timezone, the same day the dose list and the compliance rollup put it on.
* The units per dose resolve per slot exactly as the inventory consumption
* does (`resolveUnitsPerDose`), so a half tablet at noon counts as half.
*
* Module `nutrients` off: nothing is written and existing rows are left as
* they are, like the other sources' rows.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { resolveUnitsPerDose } from "@/lib/medications/inventory/consumption";
import { isModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CATALOG, isNutrientCode } from "@/lib/nutrients/catalog";
import { NUTRIENT_SOURCE } from "@/lib/nutrients/sources";
import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format";
import { localDayWindow } from "@/lib/tz/local-day";
type Tx = Prisma.TransactionClient;
/** One supplement nutrient row as the pass writes it. */
export interface SupplementNutrientRow {
day: string;
nutrient: string;
amount: number;
unit: string;
}
/** A taken dose slot of a medication that has a composition. */
export interface TakenSupplementSlot {
medicationId: string;
scheduledFor: Date;
}
/** What the pass needs to know about each medication with a composition. */
export interface SupplementMedication {
id: string;
unitsPerDose: Prisma.Decimal;
schedules: ReadonlyArray<{
timesOfDay: string[];
windowStart: string;
unitsPerDose: Prisma.Decimal | null;
}>;
ingredients: ReadonlyArray<{
nutrient: string;
amountPerUnit: number;
}>;
}
function safeTimezone(tz: string | null | undefined): string {
return tz && isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
}
/** Round away binary noise from the sums (25 × 2 stays 50). */
function round(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
/**
* Sum taken slots into day rows. Pure. Duplicate rows of one slot count once;
* an ingredient whose code fell out of the catalogue is skipped rather than
* written under a unit nobody can vouch for.
*/
export function supplementNutrientRows(
slots: ReadonlyArray<TakenSupplementSlot>,
medications: ReadonlyArray<SupplementMedication>,
timezone: string,
): SupplementNutrientRow[] {
const byId = new Map(medications.map((m) => [m.id, m]));
const seen = new Set<string>();
const sums = new Map<string, SupplementNutrientRow>();
for (const slot of slots) {
const slotKey = `${slot.medicationId}|${slot.scheduledFor.getTime()}`;
if (seen.has(slotKey)) continue;
seen.add(slotKey);
const medication = byId.get(slot.medicationId);
if (!medication || medication.ingredients.length === 0) continue;
const units = resolveUnitsPerDose({
scheduledFor: slot.scheduledFor,
timeZone: timezone,
medicationUnitsPerDose: medication.unitsPerDose,
schedules: medication.schedules,
});
if (!(units > 0)) continue;
const day = userDayKey(slot.scheduledFor, timezone);
for (const ingredient of medication.ingredients) {
if (!isNutrientCode(ingredient.nutrient)) continue;
const key = `${day}|${ingredient.nutrient}`;
const row = sums.get(key) ?? {
day,
nutrient: ingredient.nutrient,
amount: 0,
unit: NUTRIENT_CATALOG[ingredient.nutrient].unit,
};
row.amount += ingredient.amountPerUnit * units;
sums.set(key, row);
}
}
return [...sums.values()]
.map((row) => ({ ...row, amount: round(row.amount) }))
.filter((row) => row.amount > 0)
.sort((a, b) =>
a.day === b.day
? a.nutrient.localeCompare(b.nutrient)
: a.day.localeCompare(b.day),
);
}
/** Serialise every pass for one account (transaction-scoped). */
async function lockAccount(tx: Tx, userId: string): Promise<void> {
await tx.$queryRaw`SELECT 1 AS "locked" FROM (SELECT pg_advisory_xact_lock(hashtextextended(${`supplement-nutrients:${userId}`}, 0))) AS lock`;
}
async function loadSupplementMedications(
tx: Tx,
userId: string,
): Promise<SupplementMedication[]> {
return tx.medication.findMany({
where: { userId, ingredients: { some: {} } },
select: {
id: true,
unitsPerDose: true,
schedules: {
select: { timesOfDay: true, windowStart: true, unitsPerDose: true },
},
ingredients: { select: { nutrient: true, amountPerUnit: true } },
},
});
}
async function loadTakenSlots(
tx: Tx,
userId: string,
medicationIds: string[],
window: { start: Date; end: Date } | null,
): Promise<TakenSupplementSlot[]> {
if (medicationIds.length === 0) return [];
return tx.medicationIntakeEvent.findMany({
where: {
userId,
medicationId: { in: medicationIds },
deletedAt: null,
takenAt: { not: null },
skipped: false,
autoMissed: false,
...(window
? { scheduledFor: { gte: window.start, lt: window.end } }
: {}),
},
select: { medicationId: true, scheduledFor: true },
});
}
async function writeRows(
tx: Tx,
userId: string,
rows: SupplementNutrientRow[],
): Promise<void> {
if (rows.length === 0) return;
await tx.nutrientIntakeDay.createMany({
data: rows.map((row) => ({
userId,
day: row.day,
nutrient: row.nutrient,
amount: row.amount,
unit: row.unit,
source: NUTRIENT_SOURCE.SUPPLEMENT,
})),
});
}
export interface SupplementRecomputeResult {
/** False when the module is off and nothing was touched. */
ran: boolean;
rowsWritten: number;
}
/**
* Recompute one local day's SUPPLEMENT rows for the account, across every
* medication with a composition.
*/
export async function recomputeSupplementNutrientsForDay(
userId: string,
dayKey: string,
timezone: string | null | undefined,
): Promise<SupplementRecomputeResult> {
if (!(await isModuleEnabled(userId, "nutrients"))) {
return { ran: false, rowsWritten: 0 };
}
const tz = safeTimezone(timezone);
const { dayStart, dayEnd } = localDayWindow(dayKey, tz);
return prisma.$transaction(async (tx) => {
await lockAccount(tx, userId);
const medications = await loadSupplementMedications(tx, userId);
const slots = await loadTakenSlots(
tx,
userId,
medications.map((m) => m.id),
{ start: dayStart, end: dayEnd },
);
// A slot near midnight is read by its UTC window; the local-day filter
// keeps exactly the day asked for.
const rows = supplementNutrientRows(slots, medications, tz).filter(
(row) => row.day === dayKey,
);
await tx.nutrientIntakeDay.deleteMany({
where: { userId, day: dayKey, source: NUTRIENT_SOURCE.SUPPLEMENT },
});
await writeRows(tx, userId, rows);
return { ran: true, rowsWritten: rows.length };
});
}
/**
* Recompute every SUPPLEMENT row of the account over the whole history: after
* a composition changes, a medication or its doses are deleted, or an import
* lands. Bounded by the account's own taken doses of medications that have a
* composition, which is a few thousand rows for years of a daily supplement.
*/
export async function recomputeSupplementNutrientsForUser(
userId: string,
): Promise<SupplementRecomputeResult> {
if (!(await isModuleEnabled(userId, "nutrients"))) {
return { ran: false, rowsWritten: 0 };
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
if (!user) return { ran: false, rowsWritten: 0 };
const tz = safeTimezone(user.timezone);
return prisma.$transaction(
async (tx) => {
await lockAccount(tx, userId);
const medications = await loadSupplementMedications(tx, userId);
const slots = await loadTakenSlots(
tx,
userId,
medications.map((m) => m.id),
null,
);
const rows = supplementNutrientRows(slots, medications, tz);
await tx.nutrientIntakeDay.deleteMany({
where: { userId, source: NUTRIENT_SOURCE.SUPPLEMENT },
});
await writeRows(tx, userId, rows);
return { ran: true, rowsWritten: rows.length };
},
{ timeout: 60_000 },
);
}
/**
* The write-path hook: after a dose of `medicationId` on `dayKey` changed
* (taken, skipped, corrected, deleted, undone, recorded automatically).
* Cheap when the medication has no composition, which is almost always: one
* indexed existence read and nothing else. Best-effort, like every derived
* row: a failure is reported and never fails the dose write.
*/
export async function refreshSupplementNutrientsForDose(input: {
userId: string;
medicationId: string;
dayKey: string;
timezone: string | null | undefined;
}): Promise<void> {
try {
const hasComposition = await prisma.medicationIngredient.findFirst({
where: { medicationId: input.medicationId, userId: input.userId },
select: { id: true },
});
if (!hasComposition) return;
await recomputeSupplementNutrientsForDay(
input.userId,
input.dayKey,
input.timezone,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
supplement_nutrient_refresh_failed: true,
supplement_nutrient_refresh_error: message,
},
});
console.error("[supplement-nutrients] day refresh failed:", message);
}
}
+7 -2
View File
@@ -83,7 +83,8 @@ export async function buildMedicationsList(
] = await Promise.all([
prisma.medication.findMany({
where: { userId },
include: { schedules: true },
// v1.43 — the ingredient count only, for `hasIngredients`.
include: { schedules: true, _count: { select: { ingredients: true } } },
orderBy: { createdAt: "desc" },
}),
prisma.medicationIntakeEvent.groupBy({
@@ -310,8 +311,12 @@ export async function buildMedicationsList(
liveSchedulesDto,
Number(m.unitsPerDose),
);
const { _count: counts, ...row } = m;
return {
...m,
...row,
// v1.43 — whether a composition per unit is stored; the detail read
// carries the composition itself.
hasIngredients: counts.ingredients > 0,
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
// the string Prisma would otherwise serialise a Decimal to.
unitsPerDose: Number(m.unitsPerDose),
+128 -26
View File
@@ -15,13 +15,25 @@ import {
NUTRIENT_CATALOG,
NUTRIENT_CODES,
NUTRIENT_DEFINITIONS,
SYNCABLE_NUTRIENT_CODES,
isAboveUpperLimit,
isNutrientCode,
isSyncableNutrientCode,
resolveNutrientReference,
toCanonicalNutrientAmount,
} from "../catalog";
/** The codes HealthKit sends, which carry an HK identifier and a reference. */
const SYNCABLE = NUTRIENT_DEFINITIONS.filter((d) => d.syncable);
describe("nutrient catalog", () => {
it("carries exactly the 26 maintainer-scoped codes (no sodium, no potassium, no macros)", () => {
expect(NUTRIENT_CODES).toHaveLength(26);
it("carries exactly the 26 syncable codes plus the two supplement-only ones (no sodium, no potassium, no macros)", () => {
expect(SYNCABLE_NUTRIENT_CODES).toHaveLength(26);
expect(NUTRIENT_CODES).toHaveLength(28);
expect(NUTRIENT_CODES.filter((c) => !isSyncableNutrientCode(c))).toEqual([
"dha",
"epa",
]);
expect(Object.keys(NUTRIENT_CATALOG).sort()).toEqual(
[...NUTRIENT_CODES].sort(),
);
@@ -45,14 +57,14 @@ describe("nutrient catalog", () => {
it("codes and HealthKit identifiers are unique, and every HK id is a Dietary quantity type", () => {
const codes = new Set<string>();
const hkIds = new Set<string>();
for (const def of NUTRIENT_DEFINITIONS) {
for (const def of SYNCABLE) {
expect(def.hkIdentifier).not.toBeNull();
const hk = def.hkIdentifier!;
expect(codes.has(def.code)).toBe(false);
expect(hkIds.has(def.hkIdentifier)).toBe(false);
expect(hkIds.has(hk)).toBe(false);
codes.add(def.code);
hkIds.add(def.hkIdentifier);
expect(def.hkIdentifier).toMatch(
/^HKQuantityTypeIdentifierDietary[A-Za-z0-9]+$/,
);
hkIds.add(hk);
expect(hk).toMatch(/^HKQuantityTypeIdentifierDietary[A-Za-z0-9]+$/);
}
});
@@ -93,10 +105,20 @@ describe("nutrient catalog", () => {
expect(NUTRIENT_CATALOG.water.plausibleDailyMax).toBe(20000);
});
it("every code has a reference entry carrying kind, direction, a citation, and a resolvable value", () => {
for (const def of NUTRIENT_DEFINITIONS) {
const ref = def.reference;
expect(ref, `missing reference: ${def.code}`).toBeDefined();
it("the supplement-only codes carry no HealthKit identifier and no reference of their own", () => {
for (const code of ["dha", "epa"] as const) {
const def = NUTRIENT_CATALOG[code];
expect(def.hkIdentifier).toBeNull();
expect(def.syncable).toBe(false);
expect(def.unit).toBe("mg");
expect(def.reference).toBeNull();
}
});
it("every syncable code has a reference entry carrying kind, direction, a citation, and a resolvable value", () => {
for (const def of SYNCABLE) {
const ref = def.reference!;
expect(ref, `missing reference: ${def.code}`).toBeTruthy();
expect(["PRI", "AI", "safeLevel"]).toContain(ref.kind);
expect(["target", "upperGuidance"]).toContain(ref.direction);
expect(ref.source.length).toBeGreaterThan(0);
@@ -114,29 +136,29 @@ describe("nutrient catalog", () => {
});
it("caffeine is the one upperGuidance reference; everything else is a target", () => {
for (const def of NUTRIENT_DEFINITIONS) {
expect(def.reference.direction).toBe(
for (const def of SYNCABLE) {
expect(def.reference!.direction).toBe(
def.code === "caffeine" ? "upperGuidance" : "target",
);
}
expect(NUTRIENT_CATALOG.caffeine.reference.kind).toBe("safeLevel");
expect(NUTRIENT_CATALOG.caffeine.reference.adult).toBe(400);
expect(NUTRIENT_CATALOG.caffeine.reference!.kind).toBe("safeLevel");
expect(NUTRIENT_CATALOG.caffeine.reference!.adult).toBe(400);
});
it("every citation names EFSA, except the documented chromium exception (EFSA sets no DRV)", () => {
for (const def of NUTRIENT_DEFINITIONS) {
for (const def of SYNCABLE) {
if (def.code === "chromium") {
expect(def.reference.source).toContain("NIH");
expect(def.reference.source).toContain("EFSA");
expect(def.reference!.source).toContain("NIH");
expect(def.reference!.source).toContain("EFSA");
continue;
}
expect(def.reference.source, `citation: ${def.code}`).toContain("EFSA");
expect(def.reference!.source, `citation: ${def.code}`).toContain("EFSA");
}
});
it("iron carries the EFSA sex split (11 male / 16 female)", () => {
expect(NUTRIENT_CATALOG.iron.reference.male).toBe(11);
expect(NUTRIENT_CATALOG.iron.reference.female).toBe(16);
expect(NUTRIENT_CATALOG.iron.reference!.male).toBe(11);
expect(NUTRIENT_CATALOG.iron.reference!.female).toBe(16);
});
it("every labelKey follows nutrients.names.<code> and resolves in the EN bundle", () => {
@@ -161,7 +183,7 @@ describe("resolveNutrientReference (v1.29)", () => {
expect(ref?.value).toBe(950);
expect(ref?.kind).toBe("PRI");
expect(ref?.direction).toBe("target");
expect(ref?.source).toBe(NUTRIENT_CATALOG.calcium.reference.source);
expect(ref?.source).toBe(NUTRIENT_CATALOG.calcium.reference!.source);
}
});
@@ -185,12 +207,17 @@ describe("resolveNutrientReference (v1.29)", () => {
kind: "safeLevel",
direction: "upperGuidance",
value: 400,
source: NUTRIENT_CATALOG.caffeine.reference.source,
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) {
it("resolves no reference for a supplement-only code", () => {
expect(resolveNutrientReference("dha", "MALE")).toBeNull();
expect(resolveNutrientReference("epa", "FEMALE")).toBeNull();
});
it("resolves every syncable code to a non-null reference for at least one sex", () => {
for (const code of SYNCABLE_NUTRIENT_CODES) {
const resolved =
resolveNutrientReference(code, "MALE") ??
resolveNutrientReference(code, "FEMALE");
@@ -198,3 +225,78 @@ describe("resolveNutrientReference (v1.29)", () => {
}
});
});
describe("input units and the converter (v1.43)", () => {
it("lists the canonical unit first with factor 1 on every code", () => {
for (const def of NUTRIENT_DEFINITIONS) {
expect(def.inputUnits[0]).toEqual({
unit: def.unit,
factorToCanonical: 1,
});
const units = def.inputUnits.map((u) => u.unit);
expect(new Set(units).size).toBe(units.length);
for (const u of def.inputUnits) {
expect(u.factorToCanonical).toBeGreaterThan(0);
}
}
});
it("converts vitamin D from IU at 0.025 µg per IU", () => {
expect(toCanonicalNutrientAmount("vitamin_d", 1000, "IU")).toBe(25);
expect(toCanonicalNutrientAmount("vitamin_d", 25, "ug")).toBe(25);
expect(toCanonicalNutrientAmount("vitamin_d", 0.025, "mg")).toBe(25);
});
it("converts across the metric ladder", () => {
expect(toCanonicalNutrientAmount("magnesium", 0.4, "g")).toBe(400);
expect(toCanonicalNutrientAmount("magnesium", 500, "ug")).toBe(0.5);
expect(toCanonicalNutrientAmount("dha", 0.5, "g")).toBe(500);
expect(toCanonicalNutrientAmount("water", 1.5, "l")).toBe(1500);
});
it("refuses a unit the code does not list rather than guessing", () => {
// Vitamin E has no IU: its factor depends on the natural or synthetic form.
expect(toCanonicalNutrientAmount("vitamin_e", 400, "IU")).toBeNull();
expect(toCanonicalNutrientAmount("magnesium", 10, "ml")).toBeNull();
expect(toCanonicalNutrientAmount("water", 10, "mg")).toBeNull();
});
});
describe("upper intake levels (v1.43)", () => {
it("carries a cited EFSA upper limit in the code's canonical unit where one exists", () => {
for (const def of NUTRIENT_DEFINITIONS) {
if (!def.upperLimit) continue;
expect(def.upperLimit.unit, def.code).toBe(def.unit);
expect(def.upperLimit.value).toBeGreaterThan(0);
expect(def.upperLimit.source).toContain("EFSA");
}
expect(NUTRIENT_CATALOG.vitamin_d.upperLimit?.value).toBe(100);
expect(NUTRIENT_CATALOG.magnesium.upperLimit?.scope).toBe("supplemental");
expect(NUTRIENT_CATALOG.vitamin_c.upperLimit).toBeNull();
expect(NUTRIENT_CATALOG.dha.upperLimit).toBeNull();
});
it("compares a total-scope limit against the day's total", () => {
expect(
isAboveUpperLimit("vitamin_d", { total: 120, supplemental: 50 }),
).toBe(true);
expect(
isAboveUpperLimit("vitamin_d", { total: 100, supplemental: 100 }),
).toBe(false);
});
it("compares a supplemental-scope limit against the supplement share only", () => {
expect(
isAboveUpperLimit("magnesium", { total: 600, supplemental: 200 }),
).toBe(false);
expect(
isAboveUpperLimit("magnesium", { total: 300, supplemental: 300 }),
).toBe(true);
});
it("answers null, not false, where no limit exists", () => {
expect(
isAboveUpperLimit("vitamin_c", { total: 5000, supplemental: 5000 }),
).toBeNull();
});
});
+303 -6
View File
@@ -2,8 +2,10 @@
* Nutrient catalog — the closed code set for the micronutrient-intake
* sync (v1.28).
*
* 26 codes: the 24 HealthKit `Dietary*` vitamin/mineral quantity types
* plus DietaryWater and DietaryCaffeine. Energy, carbohydrates,
* 26 syncable codes: the 24 HealthKit `Dietary*` vitamin/mineral quantity
* types plus DietaryWater and DietaryCaffeine. v1.43 adds two codes no
* HealthKit type carries (`dha`, `epa`), which only a supplement's
* composition produces; they appear in responses and never in the batch. Energy, carbohydrates,
* protein, fat, sugar, fiber AND sodium/potassium are deliberately NOT
* here — the anti-goal against diet-app territory stands, and
* sodium/potassium are food-composition signals, not "substances
@@ -38,7 +40,12 @@
import type { BinaryReferenceSex } from "@/lib/profile/sex";
export const NUTRIENT_CODES = [
/**
* The codes HealthKit may send in the nutrient batch. `NUTRIENT_CODES` adds
* the supplement-only codes below them; the batch validates against this
* tuple, so its contract stays the 26 codes it always was.
*/
export const SYNCABLE_NUTRIENT_CODES = [
// 13 vitamins
"vitamin_a",
"thiamin",
@@ -70,11 +77,62 @@ export const NUTRIENT_CODES = [
"caffeine",
] as const;
/**
* v1.43 — codes that come only from a supplement's composition. No HealthKit
* quantity type carries them, so the batch never accepts them and they appear
* in responses only.
*/
export const SUPPLEMENT_ONLY_NUTRIENT_CODES = ["dha", "epa"] as const;
export const NUTRIENT_CODES = [
...SYNCABLE_NUTRIENT_CODES,
...SUPPLEMENT_ONLY_NUTRIENT_CODES,
] as const;
export type NutrientCode = (typeof NUTRIENT_CODES)[number];
export type SyncableNutrientCode = (typeof SYNCABLE_NUTRIENT_CODES)[number];
/** Canonical storage units. `ug` is µg on the wire — ASCII on purpose. */
export type NutrientUnit = "mg" | "ug" | "ml";
/**
* v1.43 — every unit an amount may be ENTERED in. The canonical units plus
* gram, litre and the international unit. IU exists only where one IU maps to
* one fixed mass (vitamin D, preformed vitamin A); vitamin E has none, because
* its IU depends on whether the form is natural or synthetic.
*/
export type NutrientInputUnitCode = NutrientUnit | "g" | "l" | "IU";
/** The unit vocabulary, spelled once. Every other file reads it from here. */
export const NUTRIENT_UNIT = {
mg: "mg",
ug: "ug",
ml: "ml",
g: "g",
l: "l",
IU: "IU",
} as const satisfies Record<NutrientInputUnitCode, NutrientInputUnitCode>;
export interface NutrientInputUnit {
unit: NutrientInputUnitCode;
/** Multiply an entered amount by this to get the canonical amount. */
factorToCanonical: number;
}
/**
* v1.43 — a tolerable upper intake level (UL): the highest daily intake not
* expected to cause harm. `scope: "total"` applies to everything taken in a
* day; `scope: "supplemental"` applies to the part from supplements only
* (magnesium salts, folic acid), which the server reads as the SUPPLEMENT
* source. `null` on a code where no UL is established.
*/
export interface NutrientUpperLimit {
value: number;
unit: NutrientUnit;
scope: "total" | "supplemental";
source: string;
}
export interface NutrientReference {
/** EFSA value type, named in Coach prose later (Slice B). */
kind: "PRI" | "AI" | "safeLevel";
@@ -91,8 +149,13 @@ export interface NutrientReference {
export interface NutrientDefinition {
code: NutrientCode;
/** HealthKit quantity type identifier iOS reads this code from. */
hkIdentifier: string;
/**
* HealthKit quantity type identifier iOS reads this code from. `null` for a
* supplement-only code (`dha`, `epa`).
*/
hkIdentifier: string | null;
/** Whether HealthKit may send this code in the nutrient batch. */
syncable: boolean;
/** Canonical storage unit AND the pinned HK query unit. */
unit: NutrientUnit;
/** i18n key for the display name (settings card + exports). */
@@ -103,7 +166,146 @@ export interface NutrientDefinition {
* supplement reality so only unit confusion / corruption trips it.
*/
plausibleDailyMax: number;
reference: NutrientReference;
/** The reference intake, or `null` where none is set per code. */
reference: NutrientReference | null;
/** v1.43 — the units an amount may be entered in, canonical first. */
inputUnits: ReadonlyArray<NutrientInputUnit>;
/** v1.43 — the tolerable upper intake level, or `null`. */
upperLimit: NutrientUpperLimit | null;
}
/**
* The input units each canonical unit offers by default: the unit itself and
* its neighbours in the metric ladder. A code adds IU on top where one exists.
*/
const METRIC_INPUT_UNITS: Readonly<
Record<NutrientUnit, ReadonlyArray<NutrientInputUnit>>
> = {
mg: [
{ unit: NUTRIENT_UNIT.mg, factorToCanonical: 1 },
{ unit: NUTRIENT_UNIT.ug, factorToCanonical: 0.001 },
{ unit: NUTRIENT_UNIT.g, factorToCanonical: 1000 },
],
ug: [
{ unit: NUTRIENT_UNIT.ug, factorToCanonical: 1 },
{ unit: NUTRIENT_UNIT.mg, factorToCanonical: 1000 },
],
ml: [
{ unit: NUTRIENT_UNIT.ml, factorToCanonical: 1 },
{ unit: NUTRIENT_UNIT.l, factorToCanonical: 1000 },
],
};
/**
* The international unit where one IU is one fixed mass of the canonical unit.
* Vitamin D: 1 IU = 0.025 µg cholecalciferol. Vitamin A: 1 IU = 0.3 µg
* retinol (preformed vitamin A, the form supplements state in IU).
*/
const IU_FACTOR: Partial<Record<NutrientCode, number>> = {
vitamin_d: 0.025,
vitamin_a: 0.3,
};
/**
* The tolerable upper intake levels for adults, from EFSA's overview of the
* tolerable upper intake levels (2024 edition), each citing the opinion it
* rests on. A code that is absent has no established UL: EFSA set none
* (vitamin C, K, B1, B2, B5, B12, biotin, phosphorus, chromium), set a safe
* level of intake instead of a UL (iron 2024, manganese 2023), or set one that
* depends on a chemical form the composition cannot say (niacin: nicotinic
* acid 10 mg, nicotinamide 900 mg). EPA and DHA have none: EFSA (2012) found
* supplemental intakes up to 5 g a day of both combined raise no concern, which
* is a statement about the pair, not a per-nutrient limit.
*/
const UPPER_LIMITS: Partial<Record<NutrientCode, NutrientUpperLimit>> = {
vitamin_a: {
value: 3000,
unit: "ug",
scope: "total",
source:
"EFSA UL overview 2024 (SCF 2002): preformed vitamin A (retinol), adults",
},
vitamin_b6: {
value: 12,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
},
folate: {
value: 1000,
unit: "ug",
scope: "supplemental",
source:
"EFSA UL overview 2024 (NDA Panel 2023): folic acid from supplements and fortified foods, adults",
},
vitamin_d: {
value: 100,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
},
vitamin_e: {
value: 300,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2024): alpha-tocopherol, adults",
},
calcium: {
value: 2500,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2012): adults",
},
magnesium: {
value: 250,
unit: "mg",
scope: "supplemental",
source:
"EFSA UL overview 2024 (SCF 2001): readily dissociable magnesium salts in supplements, adults",
},
zinc: {
value: 25,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2003): adults",
},
copper: {
value: 5,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2003): adults",
},
selenium: {
value: 255,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
},
molybdenum: {
value: 600,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2000): adults",
},
iodine: {
value: 600,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2002): adults",
},
};
function inputUnitsFor(
code: NutrientCode,
unit: NutrientUnit,
): ReadonlyArray<NutrientInputUnit> {
const iu = IU_FACTOR[code];
return iu === undefined
? METRIC_INPUT_UNITS[unit]
: [
...METRIC_INPUT_UNITS[unit],
{ unit: NUTRIENT_UNIT.IU, factorToCanonical: iu },
];
}
function def(
@@ -116,10 +318,34 @@ function def(
return {
code,
hkIdentifier: `HKQuantityTypeIdentifierDietary${hkSuffix}`,
syncable: true,
unit,
labelKey: `nutrients.names.${code}`,
plausibleDailyMax,
reference,
inputUnits: inputUnitsFor(code, unit),
upperLimit: UPPER_LIMITS[code] ?? null,
};
}
/** A code that only a supplement's composition produces (v1.43). */
function supplementOnly(
code: NutrientCode,
unit: NutrientUnit,
plausibleDailyMax: number,
): NutrientDefinition {
return {
code,
hkIdentifier: null,
syncable: false,
unit,
labelKey: `nutrients.names.${code}`,
plausibleDailyMax,
// EFSA's adequate intake for these (250 mg a day) is set for EPA and DHA
// combined, so neither code carries a reference of its own.
reference: null,
inputUnits: inputUnitsFor(code, unit),
upperLimit: UPPER_LIMITS[code] ?? null,
};
}
@@ -300,6 +526,11 @@ export const NUTRIENT_CATALOG: Readonly<
adult: 400,
source: "EFSA 2015 scientific opinion on the safety of caffeine (adults)",
}),
// ── Supplement-only (v1.43) ──────────────────────────────────────
// Omega-3 fatty acids from fish or algae oil capsules. The cap sits well
// above the 5 g a day EFSA reviewed, so only a unit slip trips it.
dha: supplementOnly("dha", "mg", 20000),
epa: supplementOnly("epa", "mg", 20000),
});
/** Catalog rows in canonical display order (the tuple order above). */
@@ -313,6 +544,71 @@ export function isNutrientCode(code: string): code is NutrientCode {
return NUTRIENT_CODE_SET.has(code);
}
const SYNCABLE_CODE_SET: ReadonlySet<string> = new Set(SYNCABLE_NUTRIENT_CODES);
/** True when HealthKit may send `code` in the nutrient batch. */
export function isSyncableNutrientCode(
code: string,
): code is SyncableNutrientCode {
return SYNCABLE_CODE_SET.has(code);
}
/**
* A code's catalogue entry with its position in display order, or `null` for
* a code the catalogue does not know.
*/
export function catalogEntryFor(
code: string,
): (NutrientDefinition & { order: number }) | null {
if (!isNutrientCode(code)) return null;
return { ...NUTRIENT_CATALOG[code], order: NUTRIENT_CODES.indexOf(code) };
}
/** Round away binary noise from a unit factor (1000 IU × 0.025 = 25 µg). */
function roundAmount(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
/**
* v1.43 — convert an entered amount to the code's canonical unit. `null` when
* `unit` is not one of the code's input units: the caller refuses, it never
* guesses. This is the only unit conversion for nutrient amounts.
*/
export function toCanonicalNutrientAmount(
code: NutrientCode,
amount: number,
unit: string,
): number | null {
const input = NUTRIENT_CATALOG[code].inputUnits.find((u) => u.unit === unit);
if (!input) return null;
return roundAmount(amount * input.factorToCanonical);
}
/** Whether `unit` is the code's canonical unit (nothing to convert). */
export function isCanonicalNutrientUnit(
code: NutrientCode,
unit: string,
): boolean {
return NUTRIENT_CATALOG[code].unit === unit;
}
/**
* v1.43 — whether a day is above the code's upper limit. `total` is the day's
* sum across sources; `supplemental` the part from taken supplement doses.
* `null` when the code has no upper limit, so "not above" and "no limit to be
* above" stay two different answers.
*/
export function isAboveUpperLimit(
code: NutrientCode,
amounts: { total: number; supplemental: number },
): boolean | null {
const limit = NUTRIENT_CATALOG[code].upperLimit;
if (!limit) return null;
const compared =
limit.scope === "supplemental" ? amounts.supplemental : amounts.total;
return compared > limit.value;
}
/** A catalog reference resolved to one concrete number for a profile. */
export interface ResolvedNutrientReference {
kind: NutrientReference["kind"];
@@ -336,6 +632,7 @@ export function resolveNutrientReference(
sex: BinaryReferenceSex | null,
): ResolvedNutrientReference | null {
const ref = NUTRIENT_CATALOG[code].reference;
if (!ref) return null;
const value =
ref.adult ??
(sex === "MALE" ? ref.male : sex === "FEMALE" ? ref.female : undefined);
+191
View File
@@ -0,0 +1,191 @@
/**
* The nutrient reads every surface shares: `GET /api/nutrients`,
* `GET /api/nutrients/daily` and the MCP `get_nutrients` tool.
*
* A day can carry one row per source (migration 0249 put `source` in the
* key, v1.43 added `SUPPLEMENT`), so every read sums a day across its rows
* and, since v1.43, also keeps the split by source and judges the day
* against the nutrient's upper intake level. The judgement is made here,
* once, so no client and no prompt ever compares a number against a limit
* of its own.
*/
import { prisma } from "@/lib/db";
import {
NUTRIENT_CATALOG,
NUTRIENT_CODES,
isAboveUpperLimit,
isNutrientCode,
type NutrientCode,
type NutrientUpperLimit,
} from "@/lib/nutrients/catalog";
import {
NUTRIENT_SOURCE,
isNutrientSource,
type NutrientSource,
} from "@/lib/nutrients/sources";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
/** A day's amount split by source; a source with nothing is absent. */
export type NutrientAmountBySource = Partial<Record<NutrientSource, number>>;
interface StoredRow {
nutrient: string;
day: string;
amount: number;
unit: string;
source: string;
}
interface DayFold {
total: number;
bySource: NutrientAmountBySource;
}
function addToDay(fold: DayFold, source: string, amount: number): void {
fold.total += amount;
// A source outside the closed set still counts toward the day's total; it
// only cannot be named in the split.
if (isNutrientSource(source)) {
fold.bySource[source] = (fold.bySource[source] ?? 0) + amount;
}
}
/** Whether one folded day is above the code's upper limit (null without one). */
function dayAboveLimit(code: NutrientCode, fold: DayFold): boolean | null {
return isAboveUpperLimit(code, {
total: fold.total,
supplemental: fold.bySource[NUTRIENT_SOURCE.SUPPLEMENT] ?? 0,
});
}
/** The window's first day key, anchored on the caller's local today. */
export function nutrientWindowStart(timezone: string, days: number): string {
return shiftDateKey(userDayKey(new Date(), timezone), -(days - 1));
}
export interface NutrientOverviewRow {
nutrient: NutrientCode;
unit: string;
latestDay: string;
latestAmount: number;
daysWithData: number;
latestBySource: NutrientAmountBySource;
upperLimit: NutrientUpperLimit | null;
latestAboveUpperLimit: boolean | null;
}
/**
* Per nutrient with data inside the window: the latest day, its total and its
* split, the count of distinct days with data, and the upper-limit verdict for
* the latest day. Catalog order; a stored code that fell out of the catalog is
* dropped rather than mislabelled.
*/
export async function readNutrientOverview(
userId: string,
timezone: string,
days: number,
): Promise<{ nutrients: NutrientOverviewRow[]; rowCount: number }> {
const since = nutrientWindowStart(timezone, days);
const rows: StoredRow[] = await prisma.nutrientIntakeDay.findMany({
where: { userId, day: { gte: since } },
orderBy: [{ nutrient: "asc" }, { day: "desc" }],
select: {
nutrient: true,
unit: true,
day: true,
amount: true,
source: true,
},
});
const byCode = new Map<
string,
{ unit: string; latestDay: string; latest: DayFold; daysSeen: Set<string> }
>();
for (const row of rows) {
let summary = byCode.get(row.nutrient);
if (!summary) {
// Rows arrive day-DESC inside each nutrient: the first day seen is the
// latest one.
summary = {
unit: row.unit,
latestDay: row.day,
latest: { total: 0, bySource: {} },
daysSeen: new Set(),
};
byCode.set(row.nutrient, summary);
}
// A set of distinct day keys, so a day with several source rows counts
// once.
summary.daysSeen.add(row.day);
if (row.day === summary.latestDay) {
addToDay(summary.latest, row.source, row.amount);
}
}
const nutrients = NUTRIENT_CODES.filter(
(code) => isNutrientCode(code) && byCode.has(code),
).map((code) => {
const summary = byCode.get(code)!;
return {
nutrient: code,
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latest.total,
daysWithData: summary.daysSeen.size,
latestBySource: summary.latest.bySource,
upperLimit: NUTRIENT_CATALOG[code].upperLimit,
latestAboveUpperLimit: dayAboveLimit(code, summary.latest),
};
});
return { nutrients, rowCount: rows.length };
}
export interface NutrientDailyEntry {
day: string;
amount: number;
bySource: NutrientAmountBySource;
aboveUpperLimit: boolean | null;
}
/**
* One nutrient's dense day series over the window: one entry per calendar
* day, 0 and an empty split on a day without data, each judged against the
* upper limit.
*/
export async function readNutrientDaily(
userId: string,
timezone: string,
nutrient: NutrientCode,
days: number,
): Promise<{ days: NutrientDailyEntry[]; rowCount: number }> {
const since = nutrientWindowStart(timezone, days);
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId, nutrient, day: { gte: since } },
select: { day: true, amount: true, source: true },
});
const folds = new Map<string, DayFold>();
for (const row of rows) {
let fold = folds.get(row.day);
if (!fold) {
fold = { total: 0, bySource: {} };
folds.set(row.day, fold);
}
addToDay(fold, row.source, row.amount);
}
const series: NutrientDailyEntry[] = [];
for (let i = 0; i < days; i++) {
const day = shiftDateKey(since, i);
const fold = folds.get(day) ?? { total: 0, bySource: {} };
series.push({
day,
amount: fold.total,
bySource: fold.bySource,
aboveUpperLimit: dayAboveLimit(nutrient, fold),
});
}
return { days: series, rowCount: rows.length };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Where a nutrient day row came from. A closed set enforced here rather than
* by a database enum (`NutrientIntakeDay.source` is text and part of the key):
*
* - `APPLE_HEALTH`: the day total HealthKit synced (`POST /api/nutrients/batch`);
* - `MANUAL`: water entered by hand (`POST /api/nutrients/water`);
* - `SUPPLEMENT` (v1.43): the sum of the day's taken supplement doses, from
* each medication's composition. Derived and recomputed, never entered.
*/
export const NUTRIENT_SOURCE = {
APPLE_HEALTH: "APPLE_HEALTH",
MANUAL: "MANUAL",
SUPPLEMENT: "SUPPLEMENT",
} as const;
export type NutrientSource =
(typeof NUTRIENT_SOURCE)[keyof typeof NUTRIENT_SOURCE];
const SOURCES: ReadonlySet<string> = new Set(Object.values(NUTRIENT_SOURCE));
export function isNutrientSource(value: string): value is NutrientSource {
return SOURCES.has(value);
}
+3 -3
View File
@@ -48,9 +48,9 @@ const moduleDisabled = {
* OpenAPI file is the one authoritative home of the code ↔ HK ↔ unit
* contract (µg is `ug` on the wire; water is mL; never IU).
*/
const catalogTable = NUTRIENT_DEFINITIONS.map(
(d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`,
).join("\n");
const catalogTable = NUTRIENT_DEFINITIONS.filter((d) => d.syncable)
.map((d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`)
.join("\n");
const nutrientCatalogEntry = z
.object({
@@ -50,6 +50,7 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { refreshSupplementNutrientsForDose } from "@/lib/medications/ingredients/recompute";
import { annotate } from "@/lib/logging/context";
import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format";
import { localDayWindow, startOfLocalDayKey } from "@/lib/tz/local-day";
@@ -203,6 +204,21 @@ export async function recomputeMedicationComplianceForDay(
AND "scheduled_for" < ${windowEnd}
)
`;
// v1.43 — the day's other derived row: the micronutrients its taken
// supplement doses add (`source = SUPPLEMENT`). Every dose write path
// already funnels through this recompute, so this is the one place the
// hook needs to live. Only on the base client: a caller inside its own
// transaction (the dose import) has not committed the doses yet and
// queues the account's full recompute once it has.
if (client === prisma) {
await refreshSupplementNutrientsForDose({
userId,
medicationId,
dayKey,
timezone: safeTz,
});
}
}
/**
+17 -3
View File
@@ -9,14 +9,28 @@
*/
import { z } from "zod/v4";
import { NUTRIENT_CATALOG, NUTRIENT_CODES } from "@/lib/nutrients/catalog";
import {
NUTRIENT_CATALOG,
NUTRIENT_CODES,
SYNCABLE_NUTRIENT_CODES,
} from "@/lib/nutrients/catalog";
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
export const nutrientCodeEnum = z.enum(NUTRIENT_CODES).meta({
id: "NutrientCode",
description:
"Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy, macros and sodium/potassium are out of scope.",
"Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine, and (v1.43, responses only) `dha` and `epa`, which only a supplement's composition produces. Energy, macros and sodium/potassium are out of scope. Decode unknown values leniently.",
});
/**
* v1.43 — the codes the batch accepts: the catalogue without the
* supplement-only codes, so the HealthKit sync contract stays unchanged.
*/
export const syncableNutrientCodeEnum = z.enum(SYNCABLE_NUTRIENT_CODES).meta({
id: "SyncableNutrientCode",
description:
"A nutrient code HealthKit may send: the 24 Dietary vitamin/mineral types plus water and caffeine. Never `dha` or `epa`.",
});
/**
@@ -30,7 +44,7 @@ export const nutrientCodeEnum = z.enum(NUTRIENT_CODES).meta({
export const nutrientEntrySchema = z
.object({
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
nutrient: nutrientCodeEnum,
nutrient: syncableNutrientCodeEnum,
unit: z.string().min(1).max(10),
amount: z.number().finite().min(0),
externalSourceVersion: z.string().min(1).max(120).optional(),