From 44ab74e1253337a439ae7bc62b0c3ffbb9d2999b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:38 +0200 Subject: [PATCH] fix(nutrients): count each day once and anchor the overview window on user tz GET /api/nutrients had two correctness bugs: - `daysWithData` incremented on every row whose day differed from the PINNED latest-day field, so a second source's row on an already-seen older day (water logged via both Apple Health and a manual entry on the same day) opened a phantom second "day". Track a per-nutrient set of distinct day keys instead. - The window floor used a UTC day key against a local-timezone `day` column, off by up to a day at the edge for non-UTC users. Anchor on the caller's own local "today" instead, mirroring the sibling `GET /api/nutrients/daily` route. --- src/app/api/nutrients/__tests__/route.test.ts | 26 ++++++++++ src/app/api/nutrients/route.ts | 50 ++++++++++++------- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/app/api/nutrients/__tests__/route.test.ts b/src/app/api/nutrients/__tests__/route.test.ts index a3ae3fac1..5e8ae5db3 100644 --- a/src/app/api/nutrients/__tests__/route.test.ts +++ b/src/app/api/nutrients/__tests__/route.test.ts @@ -132,6 +132,32 @@ describe("GET /api/nutrients", () => { ]); }); + it("counts a multi-source day once (DATAINT M3 regression)", async () => { + // v1.29 migration 0249 lets a day carry one row per source. A non-latest + // 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 }, + ] as never); + + const res = await GET(getReq("?days=7")); + const body = (await res.json()) as OverviewResponse; + const water = body.data.nutrients.find((n) => n.nutrient === "water"); + expect(water).toEqual({ + nutrient: "water", + unit: "ml", + latestDay: "2026-07-13", + latestAmount: 900, + // Three distinct days (13th, 12th, 11th) — the buggy version counted + // four, since the 12th's second source row didn't match the pinned + // `latestDay` (the 13th) and opened a phantom extra day. + daysWithData: 3, + }); + }); + it("scopes the query to the authenticated user and the window floor", async () => { await GET(getReq("?days=14")); expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith( diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 29b87314b..2cd5d8824 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -17,6 +17,7 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog"; import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients"; +import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -36,12 +37,14 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { days } = parsed.data; - // Window floor as a UTC day key. The stored `day` is a local-timezone - // key, so the UTC floor is off by at most one calendar day at the - // window edge — fine for a "last N days" summary card. - const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); + // 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. + 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 } }, @@ -50,14 +53,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }); // v1.29 — `source` joined the PK (migration 0249): a day can now carry - // an APPLE_HEALTH row AND a MANUAL row, so the fold sums amounts - // WITHIN (nutrient, day) before folding across days. 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". + // 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; days: number } + { + unit: string; + latestDay: string; + latestAmount: number; + daysSeen: Set; + } >(); for (const row of rows) { const existing = byCode.get(row.nutrient); @@ -66,12 +81,13 @@ export const GET = apiHandler(async (request: NextRequest) => { unit: row.unit, latestDay: row.day, latestAmount: row.amount, - days: 1, + daysSeen: new Set([row.day]), }); - } else if (row.day === existing.latestDay) { + continue; + } + existing.daysSeen.add(row.day); + if (row.day === existing.latestDay) { existing.latestAmount += row.amount; - } else { - existing.days += 1; } } @@ -86,7 +102,7 @@ export const GET = apiHandler(async (request: NextRequest) => { unit: summary.unit, latestDay: summary.latestDay, latestAmount: summary.latestAmount, - daysWithData: summary.days, + daysWithData: summary.daysSeen.size, }; });