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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 05:06:24 +02:00
1 parent 71d7ca78cb
commit 44ab74e125
2 files changed
+59 -17

No files matched your search

@@ -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(
+33 -17
View File
@@ -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<string>;
}
>();
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,
};
});