mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(medications): let a delegate manage a composition, bypass the proxy for label uploads, and keep the guards current
This commit is contained in:
1 parent
eb3cc4237f
commit
9d32a8acb0
19 files changed
+293
-30
No files matched your search
+2
-1
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt",
|
||||
"lifeEventCreate": "{name} hat ein Lebensereignis hinzugefügt",
|
||||
"lifeEventUpdate": "{name} hat ein Lebensereignis geändert",
|
||||
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt"
|
||||
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
|
||||
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+2
-1
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} removed an own vaccine",
|
||||
"lifeEventCreate": "{name} added a life event",
|
||||
"lifeEventUpdate": "{name} changed a life event",
|
||||
"lifeEventDelete": "{name} removed a life event"
|
||||
"lifeEventDelete": "{name} removed a life event",
|
||||
"medicationIngredientsUpdate": "{name} changed what a supplement contains"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+4
-3
@@ -11365,8 +11365,8 @@
|
||||
"iodine": "Yodo",
|
||||
"water": "Agua",
|
||||
"caffeine": "Cafeína",
|
||||
"dha": "DHA (omega-3)",
|
||||
"epa": "EPA (omega-3)"
|
||||
"dha": "DHA (ácido graso omega-3)",
|
||||
"epa": "EPA (ácido graso omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Ingesta de nutrientes",
|
||||
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} quitó una vacuna propia",
|
||||
"lifeEventCreate": "{name} añadió un acontecimiento vital",
|
||||
"lifeEventUpdate": "{name} cambió un acontecimiento vital",
|
||||
"lifeEventDelete": "{name} eliminó un acontecimiento vital"
|
||||
"lifeEventDelete": "{name} eliminó un acontecimiento vital",
|
||||
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+2
-1
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} a retiré un vaccin personnel",
|
||||
"lifeEventCreate": "{name} a ajouté un événement de vie",
|
||||
"lifeEventUpdate": "{name} a modifié un événement de vie",
|
||||
"lifeEventDelete": "{name} a supprimé un événement de vie"
|
||||
"lifeEventDelete": "{name} a supprimé un événement de vie",
|
||||
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+4
-3
@@ -11365,8 +11365,8 @@
|
||||
"iodine": "Iodio",
|
||||
"water": "Acqua",
|
||||
"caffeine": "Caffeina",
|
||||
"dha": "DHA (omega-3)",
|
||||
"epa": "EPA (omega-3)"
|
||||
"dha": "DHA (acido grasso omega-3)",
|
||||
"epa": "EPA (acido grasso omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Apporto di nutrienti",
|
||||
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} ha rimosso un vaccino personale",
|
||||
"lifeEventCreate": "{name} ha aggiunto un evento della vita",
|
||||
"lifeEventUpdate": "{name} ha modificato un evento della vita",
|
||||
"lifeEventDelete": "{name} ha rimosso un evento della vita"
|
||||
"lifeEventDelete": "{name} ha rimosso un evento della vita",
|
||||
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+2
-1
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요",
|
||||
"lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요",
|
||||
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요"
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
|
||||
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+4
-3
@@ -11365,8 +11365,8 @@
|
||||
"iodine": "Jod",
|
||||
"water": "Woda",
|
||||
"caffeine": "Kofeina",
|
||||
"dha": "DHA (omega-3)",
|
||||
"epa": "EPA (omega-3)"
|
||||
"dha": "DHA (kwas omega-3)",
|
||||
"epa": "EPA (kwas omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Spożycie składników odżywczych",
|
||||
@@ -12030,7 +12030,8 @@
|
||||
"customVaccineDelete": "{name} usunął własną szczepionkę",
|
||||
"lifeEventCreate": "{name} dodał wydarzenie życiowe",
|
||||
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe"
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe",
|
||||
"medicationIngredientsUpdate": "{name} zmienił skład suplementu"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
@@ -139,3 +139,77 @@ describe("insights unit-literal guard", () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 — no nutrient unit spelled outside the nutrient catalogue.
|
||||
*
|
||||
* A supplement's composition converts an entered amount to the catalogue's
|
||||
* canonical unit, and the nutrient reads judge a day against an upper limit
|
||||
* in that unit. A µg/mg slip is a 1000× error, and the way it gets in is a
|
||||
* second place that spells a unit or a factor: a converter that knows "IU"
|
||||
* itself, a read that compares against "mg". So the rule is that the code
|
||||
* under `src/lib/nutrients/` and `src/lib/medications/ingredients/` names no
|
||||
* unit as a string literal; units and factors come from
|
||||
* `src/lib/nutrients/catalog.ts` (`NUTRIENT_UNIT`, `inputUnits`,
|
||||
* `toCanonicalNutrientAmount`, `parseNutrientInputUnit`), the one file allowed
|
||||
* to spell them.
|
||||
*/
|
||||
const NUTRIENT_UNIT_DIRS = [
|
||||
join(process.cwd(), "src/lib/nutrients"),
|
||||
join(process.cwd(), "src/lib/medications/ingredients"),
|
||||
];
|
||||
const NUTRIENT_CATALOG_FILE = join(
|
||||
process.cwd(),
|
||||
"src/lib/nutrients/catalog.ts",
|
||||
);
|
||||
|
||||
/** A string literal that is nothing but a nutrient unit. */
|
||||
const UNIT_LITERAL =
|
||||
/(["'`])(mg|ug|µg|μg|mcg|g|kg|ml|mL|l|L|IU|IE|UI|iu|ie|ui)\1/g;
|
||||
|
||||
function sourceFilesUnder(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
out.push(...sourceFilesUnder(full));
|
||||
} else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("nutrient unit-literal guard", () => {
|
||||
const files = NUTRIENT_UNIT_DIRS.flatMap(sourceFilesUnder);
|
||||
|
||||
it("scans the nutrient and composition code", () => {
|
||||
expect(files.length).toBeGreaterThanOrEqual(6);
|
||||
expect(files).toContain(NUTRIENT_CATALOG_FILE);
|
||||
});
|
||||
|
||||
it("the matcher sees the catalogue's own spellings", () => {
|
||||
// The catalogue is where units live, so a matcher that finds nothing
|
||||
// there would make the next assertion vacuously true.
|
||||
const catalog = readFileSync(NUTRIENT_CATALOG_FILE, "utf8");
|
||||
const seen = new Set([...catalog.matchAll(UNIT_LITERAL)].map((m) => m[2]));
|
||||
for (const unit of ["mg", "ug", "IU", "ml"]) {
|
||||
expect(seen.has(unit), `expected to see "${unit}" in the catalogue`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("names no unit outside the catalogue", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
if (file === NUTRIENT_CATALOG_FILE) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(UNIT_LITERAL)) {
|
||||
offenders.push(`${file.slice(file.indexOf("src/"))}: ${match[0]}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
|
||||
"folds duplicate rows of doses already recorded",
|
||||
"lib/doctor-report/collect.ts":
|
||||
"administration ledger lists recorded doses; rates gate via expectsDoses",
|
||||
"lib/medications/ingredients/recompute.ts":
|
||||
"sums the nutrients of doses that were taken; a recorded dose counts whatever the switch says",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,6 +96,10 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
// as stored, schedule and switch included.
|
||||
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
|
||||
"lib/export/restore-backup.ts": "restore writes the stored rows back",
|
||||
// v1.43 — the units a taken dose consumed, resolved per slot exactly as
|
||||
// the inventory does. It reads only doses already recorded as taken.
|
||||
"lib/medications/ingredients/recompute.ts":
|
||||
"units per slot of doses already taken",
|
||||
"app/api/export/route.ts": "data export of the stored record",
|
||||
"app/api/export/medications/route.ts": "medication CSV export of the record",
|
||||
// Intake WRITE helpers: they run only when a dose is being recorded, bind
|
||||
|
||||
@@ -74,7 +74,9 @@ describe("proxy matcher", () => {
|
||||
const routes = apiRoutePaths();
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
|
||||
for (const path of PROXY_BYPASS_ROUTES) {
|
||||
for (const route of PROXY_BYPASS_ROUTES) {
|
||||
// A dynamic segment is filled the way `apiRoutePaths` fills it.
|
||||
const path = route.replace(/\[[^\]]+\]/g, "x");
|
||||
expect(routes, `${path} is not a route`).toContain(path);
|
||||
expect(re.test(path), `${path} still passes the proxy`).toBe(false);
|
||||
expect(re.test(`${path}/`), `${path}/ still passes the proxy`).toBe(
|
||||
@@ -98,6 +100,8 @@ describe("proxy matcher", () => {
|
||||
"/api/user/avatar/abc",
|
||||
"/api/documents/inbound/abc/extract",
|
||||
"/api/import/csv/preview",
|
||||
// The composition PUT beside the label upload stays behind the proxy.
|
||||
"/api/medications/abc/ingredients",
|
||||
]) {
|
||||
expect(re.test(path), path).toBe(true);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
// v1.43 — no composition: the GET carries `ingredients: []` and a PUT
|
||||
// queues no supplement recompute.
|
||||
medicationIngredient: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { updateMany: vi.fn() },
|
||||
medicationComplianceRollup: { deleteMany: vi.fn() },
|
||||
// v1.43 — no composition, so no supplement recompute is queued.
|
||||
medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -890,7 +890,11 @@ describe("GET /api/medications — provenance echo (v1.32.25)", () => {
|
||||
end: new Date("2026-07-24T23:59:59Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue(
|
||||
medications as never,
|
||||
// v1.43 — every row carries the ingredient count the list reads.
|
||||
medications.map((m) => ({
|
||||
_count: { ingredients: 0 },
|
||||
...(m as object),
|
||||
})) as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
|
||||
[] as never,
|
||||
@@ -1018,6 +1022,8 @@ describe("GET /api/medications — intake actions follow the course (#1040)", ()
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
// v1.43 — the ingredient count the list reads for `hasIngredients`.
|
||||
_count: { ingredients: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* per-nutrient summaries (latest day + total, days-with-data), the
|
||||
* module gate, and the `days` query validation.
|
||||
*/
|
||||
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -111,11 +112,41 @@ describe("GET /api/nutrients", () => {
|
||||
// Rows arrive nutrient ASC, day DESC (the route's orderBy) — the
|
||||
// fold takes the first row per nutrient as the latest.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
|
||||
{ nutrient: "caffeine", unit: "mg", day: "2026-07-13", amount: 310 },
|
||||
{ nutrient: "caffeine", unit: "mg", day: "2026-07-12", amount: 250 },
|
||||
{ nutrient: "caffeine", unit: "mg", day: "2026-07-11", amount: 400 },
|
||||
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-13", amount: 22.5 },
|
||||
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-10", amount: 20 },
|
||||
{
|
||||
nutrient: "caffeine",
|
||||
unit: "mg",
|
||||
day: "2026-07-13",
|
||||
amount: 310,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "caffeine",
|
||||
unit: "mg",
|
||||
day: "2026-07-12",
|
||||
amount: 250,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "caffeine",
|
||||
unit: "mg",
|
||||
day: "2026-07-11",
|
||||
amount: 400,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
day: "2026-07-13",
|
||||
amount: 22.5,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
day: "2026-07-10",
|
||||
amount: 20,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(getReq("?days=7"));
|
||||
@@ -129,6 +160,9 @@ describe("GET /api/nutrients", () => {
|
||||
latestDay: "2026-07-13",
|
||||
latestAmount: 22.5,
|
||||
daysWithData: 2,
|
||||
latestBySource: { APPLE_HEALTH: 22.5 },
|
||||
upperLimit: NUTRIENT_CATALOG.vitamin_d.upperLimit,
|
||||
latestAboveUpperLimit: false,
|
||||
},
|
||||
{
|
||||
nutrient: "caffeine",
|
||||
@@ -136,6 +170,9 @@ describe("GET /api/nutrients", () => {
|
||||
latestDay: "2026-07-13",
|
||||
latestAmount: 310,
|
||||
daysWithData: 3,
|
||||
latestBySource: { APPLE_HEALTH: 310 },
|
||||
upperLimit: null,
|
||||
latestAboveUpperLimit: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -145,10 +182,34 @@ describe("GET /api/nutrients", () => {
|
||||
// day logged via both APPLE_HEALTH and MANUAL must still count as ONE
|
||||
// day, not two.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-13", amount: 900 },
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 600 },
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 400 },
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-11", amount: 500 },
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-13",
|
||||
amount: 900,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-12",
|
||||
amount: 600,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-12",
|
||||
amount: 400,
|
||||
source: "MANUAL",
|
||||
},
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-11",
|
||||
amount: 500,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(getReq("?days=7"));
|
||||
@@ -163,6 +224,59 @@ describe("GET /api/nutrients", () => {
|
||||
// four, since the 12th's second source row didn't match the pinned
|
||||
// `latestDay` (the 13th) and opened a phantom extra day.
|
||||
daysWithData: 3,
|
||||
latestBySource: { APPLE_HEALTH: 900 },
|
||||
upperLimit: null,
|
||||
latestAboveUpperLimit: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("splits the latest day by source and judges it against the upper limit (v1.43)", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
|
||||
{
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
day: "2026-07-13",
|
||||
amount: 30,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
day: "2026-07-13",
|
||||
amount: 80,
|
||||
source: "SUPPLEMENT",
|
||||
},
|
||||
{
|
||||
nutrient: "magnesium",
|
||||
unit: "mg",
|
||||
day: "2026-07-13",
|
||||
amount: 300,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "magnesium",
|
||||
unit: "mg",
|
||||
day: "2026-07-13",
|
||||
amount: 200,
|
||||
source: "SUPPLEMENT",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(getReq("?days=7"));
|
||||
const body = (await res.json()) as OverviewResponse;
|
||||
const byCode = new Map(body.data.nutrients.map((n) => [n.nutrient, n]));
|
||||
// 110 µg in total against a total-scope limit of 100.
|
||||
expect(byCode.get("vitamin_d")).toMatchObject({
|
||||
latestAmount: 110,
|
||||
latestBySource: { APPLE_HEALTH: 30, SUPPLEMENT: 80 },
|
||||
latestAboveUpperLimit: true,
|
||||
});
|
||||
// 500 mg in total, but the limit covers the supplement share only (200).
|
||||
expect(byCode.get("magnesium")).toMatchObject({
|
||||
latestAmount: 500,
|
||||
latestBySource: { APPLE_HEALTH: 300, SUPPLEMENT: 200 },
|
||||
upperLimit: { value: 250, scope: "supplemental" },
|
||||
latestAboveUpperLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
"/api/medications/intake/dose-history-import",
|
||||
// Lab scan upload, 12 MB.
|
||||
"/api/labs/ocr/extract",
|
||||
// Supplement label photo, 10 MB (v1.43). The one entry with a dynamic
|
||||
// segment: `[id]` stands for any one path segment.
|
||||
"/api/medications/[id]/ingredients/extract",
|
||||
// Avatar image, 2 MB.
|
||||
"/api/user/avatar",
|
||||
// Native-client batches: measurements 4 MB, workouts 5 MB, the rest 2 MB.
|
||||
@@ -56,10 +59,26 @@ export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
"/api/cycle/day-logs/bulk",
|
||||
];
|
||||
|
||||
/** An entry with a `[segment]` matches any one path segment there. */
|
||||
const DYNAMIC_BYPASS_PATTERNS: readonly RegExp[] = PROXY_BYPASS_ROUTES.filter(
|
||||
(route) => route.includes("["),
|
||||
).map(
|
||||
(route) =>
|
||||
new RegExp(
|
||||
`^${route
|
||||
.split("/")
|
||||
.map((part) => (/^\[[^\]/]+\]$/.test(part) ? "[^/]+" : part))
|
||||
.join("/")}$`,
|
||||
),
|
||||
);
|
||||
|
||||
export function isProxyBypassRoute(pathname: string): boolean {
|
||||
const normalised =
|
||||
pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
||||
return PROXY_BYPASS_ROUTES.includes(normalised);
|
||||
return (
|
||||
PROXY_BYPASS_ROUTES.includes(normalised) ||
|
||||
DYNAMIC_BYPASS_PATTERNS.some((pattern) => pattern.test(normalised))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,9 +156,27 @@ describe("getNutrients — overview mode (no `nutrient` arg)", () => {
|
||||
it("sums a day across sources and reports latest day / total / days-with-data", async () => {
|
||||
vi.mocked(nutrientIntakeDay.findMany).mockResolvedValue([
|
||||
// Two sources on the SAME latest day — must sum, not double-count the day.
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-10", amount: 1200 },
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-10", amount: 500 },
|
||||
{ nutrient: "water", unit: "ml", day: "2026-07-09", amount: 2000 },
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-10",
|
||||
amount: 1200,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-10",
|
||||
amount: 500,
|
||||
source: "MANUAL",
|
||||
},
|
||||
{
|
||||
nutrient: "water",
|
||||
unit: "ml",
|
||||
day: "2026-07-09",
|
||||
amount: 2000,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const result = overview(await getNutrients("user-1", {}));
|
||||
@@ -171,6 +189,9 @@ describe("getNutrients — overview mode (no `nutrient` arg)", () => {
|
||||
latestDay: "2026-07-10",
|
||||
latestAmount: 1700,
|
||||
daysWithData: 2,
|
||||
latestBySource: { APPLE_HEALTH: 1200, MANUAL: 500 },
|
||||
upperLimit: null,
|
||||
latestAboveUpperLimit: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -83,5 +83,5 @@ export async function medicationHasComposition(
|
||||
where: { userId, medicationId },
|
||||
select: { id: true },
|
||||
});
|
||||
return row !== null;
|
||||
return Boolean(row);
|
||||
}
|
||||
@@ -255,6 +255,10 @@ export function recordActivityVerbLine(
|
||||
});
|
||||
case "medication.delete":
|
||||
return t("recordSharing.activityVerb.medicationDelete", { name });
|
||||
case "medication.ingredients.update":
|
||||
return t("recordSharing.activityVerb.medicationIngredientsUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.glp1.update":
|
||||
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
|
||||
case "medication.schedule_revision.created":
|
||||
|
||||
+1
-1
@@ -825,6 +825,6 @@ export const config = {
|
||||
// the matcher and `apiHandler` performs the proxy's duties for it. The
|
||||
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
|
||||
// compiles this exactly as Next does and checks.
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user