fix(cycle): count the cycle day from the logged start, not the grid window

The day count was the length of the labelled run inside the calendar
grid, so the same day read as day 11 over a month and day 23 over three:
the run was cut off at the window's edge rather than at the cycle's
start. That was a latent contract question while every caller asked for
the same ninety days, and it stopped being latent when the month grid
began reading its own month, which is thirty days by construction.

The verdict now anchors on the period start the record holds and falls
back to walking the grid only when no caller supplied one, so the ring's
arcs stay grid-derived (they are about what can be drawn) while the day
is about what the person logged. The overdue ceiling then measures the
real elapsed count too.

Covers D-list edge 15, both shapes: a window that clips the run and a
window that excludes the cycle start altogether.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 15:57:21 +02:00
1 parent 47d41a13f4
commit 8e3834f38d
2 files changed
+88 -3

No files matched your search

@@ -467,6 +467,79 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
});
});
/**
* D-list edge 15 — the day count under a narrowed `from` window.
*
* The count used to be the length of the labelled run inside the grid, so a
* caller that asked for a shorter span got a smaller number for the same day:
* the run was cut off at the window's edge, not at the cycle's start. That was
* a latent contract question while every caller asked for the same ninety
* days. It stopped being latent when the month grid started reading its own
* month, which is a thirty-day window by construction.
*/
describe("under a narrowed window", () => {
async function callWindow(
from: string,
to: string,
): Promise<Record<string, unknown>> {
const res = await GET(
new NextRequest(
`http://localhost/api/cycle/calendar?from=${from}&to=${to}`,
),
);
const body = JSON.parse(await res.text());
return (body.data as Record<string, unknown>).verdict as Record<
string,
unknown
>;
}
it("counts from the logged start, not from the window's edge", async () => {
const today = "2026-06-11";
const lastStart = "2026-05-20"; // day 23 of the open cycle
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
cycleRows([
addDays(lastStart, -84),
addDays(lastStart, -56),
addDays(lastStart, -28),
lastStart,
]) as never,
);
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
vi.useFakeTimers();
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
const wide = await callWindow(addDays(today, -90), addDays(today, 180));
expect(wide.dayOfCycle).toBe(23);
// The month the grid would be showing: the cycle start is inside it, but
// the window begins after several earlier cycles.
const narrow = await callWindow("2026-06-01", "2026-06-30");
expect(narrow.dayOfCycle).toBe(23);
expect(narrow.cycleStartDate).toBe(lastStart);
});
it("counts correctly when the window excludes the start entirely", async () => {
const today = "2026-06-25";
const lastStart = "2026-05-20";
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
cycleRows([
addDays(lastStart, -56),
addDays(lastStart, -28),
lastStart,
]) as never,
);
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
vi.useFakeTimers();
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
// June only: the cycle opened in May, outside the window entirely.
const narrow = await callWindow("2026-06-01", "2026-06-30");
expect(narrow.cycleStartDate).toBe(lastStart);
expect(narrow.dayOfCycle).toBe(37);
});
});
it("says INSUFFICIENT_DATA when nothing has been logged", async () => {
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+15 -3
View File
@@ -327,9 +327,21 @@ export function resolveCycleVerdict(
}
const startIdx = findCycleStartIndex(sorted, todayIdx);
const cycleStartDate = sorted[startIdx].date;
const run = sorted.slice(startIdx, todayIdx + 1);
const dayOfCycle = run.length;
// The day count comes from the logged start when the caller supplied one,
// and only falls back to the length of the labelled run when it did not.
// The run is bounded by the grid, so counting rows made the answer depend on
// how much calendar the caller happened to ask for: the same day reads as
// day 11 over a month and day 23 over three. That was latent while every
// caller asked for the same ninety days, and stopped being latent when the
// month grid started reading its own month. The arcs below stay grid-derived
// — they are about what the ring can draw, not about what day it is.
const anchoredStart =
input.lastPeriodStart && dayDiff(today, input.lastPeriodStart) >= 0
? input.lastPeriodStart
: sorted[startIdx].date;
const cycleStartDate = anchoredStart;
const dayOfCycle = dayDiff(today, anchoredStart) + 1;
// Honest ceiling: once the run overshoots the typical length + grace, the
// count reflects the engine's open-ended cycle window, not an observed