mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
1 parent
5dab12472f
commit
98d64c2052
31 files changed
+1573
-236
No files matched your search
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -11364,7 +11364,9 @@
|
||||
"molybdenum": "몰리브덴",
|
||||
"iodine": "요오드",
|
||||
"water": "수분",
|
||||
"caffeine": "카페인"
|
||||
"caffeine": "카페인",
|
||||
"dha": "DHA (오메가-3)",
|
||||
"epa": "EPA (오메가-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "영양소 섭취",
|
||||
|
||||
+3
-1
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user