mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user