diff --git a/messages/de.json b/messages/de.json index f467801d0..aed18e997 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 6afd60c39..35723dfa1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 8a5ba0bf7..64529adbb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 5d3bd5fd1..4d1c39ec0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index adaf75f83..6d895f5d6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index e958a666a..89f97da2a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11364,7 +11364,9 @@ "molybdenum": "몰리브덴", "iodine": "요오드", "water": "수분", - "caffeine": "카페인" + "caffeine": "카페인", + "dha": "DHA (오메가-3)", + "epa": "EPA (오메가-3)" }, "page": { "title": "영양소 섭취", diff --git a/messages/pl.json b/messages/pl.json index 4cf8b75a8..2330eb0e8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 0d71bcd34..8c2b4784d 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -54,16 +54,11 @@ const CONTRACT_STUBS: Readonly> = { "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"] }) diff --git a/src/app/api/medications/[id]/ingredients/route.ts b/src/app/api/medications/[id]/ingredients/route.ts index 5a025adce..5f24af7c6 100644 --- a/src/app/api/medications/[id]/ingredients/route.ts +++ b/src/app/api/medications/[id]/ingredients/route.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 { - 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, + }); } diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index da2e249ec..4f9e4b87b 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -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), diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 212716afe..535d2f844 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/nutrients/catalog/route.ts b/src/app/api/nutrients/catalog/route.ts index fa47186ec..3f63755c5 100644 --- a/src/app/api/nutrients/catalog/route.ts +++ b/src/app/api/nutrients/catalog/route.ts @@ -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 }); }); diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts index 69758e324..faf15b4a1 100644 --- a/src/app/api/nutrients/daily/route.ts +++ b/src/app/api/nutrients/daily/route.ts @@ -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(); - 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, }); }); diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 73d459334..c90ea487e 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -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; - } - >(); - 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, }, }); diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index a79a71ed4..16e1d80fc 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -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; } diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 4efb8728d..319df60e9 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -214,6 +214,9 @@ export const QUEUE_RUNTIME: Readonly> = { "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.", ), diff --git a/src/lib/jobs/reminder/register-rollup.ts b/src/lib/jobs/reminder/register-rollup.ts index ef38e1ddd..41609001e 100644 --- a/src/lib/jobs/reminder/register-rollup.ts +++ b/src/lib/jobs/reminder/register-rollup.ts @@ -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( + 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 diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index c4f51546c..ed4dfa171 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -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" }, diff --git a/src/lib/mcp/nutrients-read.ts b/src/lib/mcp/nutrients-read.ts index 1696fd5a6..9b9cd33d2 100644 --- a/src/lib/mcp/nutrients-read.ts +++ b/src/lib/mcp/nutrients-read.ts @@ -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 { - 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; - } - >(); - 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 { 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(); - 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, }; } diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index d00699618..ac1723bb7 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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.`; diff --git a/src/lib/medications/ingredients/composition.ts b/src/lib/medications/ingredients/composition.ts new file mode 100644 index 000000000..1b8b3da8f --- /dev/null +++ b/src/lib/medications/ingredients/composition.ts @@ -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, +): 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("; "); +} diff --git a/src/lib/medications/ingredients/recompute-job.ts b/src/lib/medications/ingredients/recompute-job.ts new file mode 100644 index 000000000..9e08a2d57 --- /dev/null +++ b/src/lib/medications/ingredients/recompute-job.ts @@ -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 { + const row = await prisma.medicationIngredient.findFirst({ + where: { userId, medicationId }, + select: { id: true }, + }); + return row !== null; +} diff --git a/src/lib/medications/ingredients/recompute.ts b/src/lib/medications/ingredients/recompute.ts new file mode 100644 index 000000000..720c4b95f --- /dev/null +++ b/src/lib/medications/ingredients/recompute.ts @@ -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, + medications: ReadonlyArray, + timezone: string, +): SupplementNutrientRow[] { + const byId = new Map(medications.map((m) => [m.id, m])); + const seen = new Set(); + const sums = new Map(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 32f6301e8..f5c1a0785 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -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), diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts index 3974c7353..28b6d9bc4 100644 --- a/src/lib/nutrients/__tests__/catalog.test.ts +++ b/src/lib/nutrients/__tests__/catalog.test.ts @@ -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(); const hkIds = new Set(); - 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. 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(); + }); +}); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index d69956529..f3079c885 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -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; + +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; + /** 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> +> = { + 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> = { + 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> = { + 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 { + 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 = 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); diff --git a/src/lib/nutrients/read.ts b/src/lib/nutrients/read.ts new file mode 100644 index 000000000..1f8a3329e --- /dev/null +++ b/src/lib/nutrients/read.ts @@ -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>; + +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 } + >(); + 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(); + 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 }; +} diff --git a/src/lib/nutrients/sources.ts b/src/lib/nutrients/sources.ts new file mode 100644 index 000000000..483f28734 --- /dev/null +++ b/src/lib/nutrients/sources.ts @@ -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 = new Set(Object.values(NUTRIENT_SOURCE)); + +export function isNutrientSource(value: string): value is NutrientSource { + return SOURCES.has(value); +} diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts index fb2016342..3b4f8a872 100644 --- a/src/lib/openapi/routes/nutrients.ts +++ b/src/lib/openapi/routes/nutrients.ts @@ -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({ diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 3fd256272..46f9e2e29 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -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, + }); + } } /** diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index f7c462eb8..2fad5c706 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -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(),