fix(cycle): keep the cycle day when the phase band is withheld

The cold-start gate suppresses the calendar's phase band until three
cycles are observed, because a population-framed phase word off one or
two of them is not earned. The verdict read the day count off those same
labels, so the gate took the count with it: a record holding three
logged period starts resolved INSUFFICIENT_DATA and rendered an empty
ring under an offer to log a first period.

The count is not the claim the gate is about. It is the number of days
since a start the person entered themselves. The verdict now takes that
start from the cycle rows and falls back to counting from it whenever
today carries no phase, with the same overdue ceiling and no phase
claim. INSUFFICIENT_DATA is left for the case that really is one: no
period start on record at or before today.

The sheet's day header was already written for this state and could
never reach it — it renders a bare day number when no phase is
trustworthy.

- verdict round trip covers the still-learning day count and its overdue
  side through the real route
- unit tests pin the anchor path, the absent-from-grid path, and that a
  future-dated start is not a cycle anyone is in
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 15:05:50 +02:00
1 parent 6db2f6f9ae
commit ed274de0a0
4 files changed
+188 -2

No files matched your search

@@ -401,6 +401,72 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
});
});
/**
* The cold-start gate suppresses the PHASE band while the engine is still
* learning — a population-framed phase word off one or two cycles is not
* earned. The day count is not a guess: it is the number of days since a
* start the person logged themselves. Reading it off the phase labels meant
* the gate took both, so a record with three logged periods rendered an
* empty ring and offered to log a first period.
*/
describe("while the engine is still learning", () => {
it("still counts the cycle day off the logged start", async () => {
const today = "2026-06-11";
const lastStart = addDays(today, -10);
// Three starts → two observed lengths → under the three-cycle gate.
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`));
const { body } = await callCalendar();
const data = body.data as Record<string, unknown>;
// The gate really is on, so the phase below is suppressed rather than absent.
expect(data.stillLearning).toBe(true);
const verdict = data.verdict as Record<string, unknown>;
expect(verdict.state).toBe("IN_CYCLE");
expect(verdict.dayOfCycle).toBe(11);
expect(verdict.cycleStartDate).toBe(lastStart);
// No phase claim — that part of the gate stands.
expect(verdict.phase).toBeNull();
// The ring still draws a full wheel rather than an empty circle.
expect(verdict.spans).toHaveLength(4);
});
it("still says how late a period is", async () => {
const today = "2026-06-11";
const lastStart = addDays(today, -50);
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`));
const { body } = await callCalendar();
const verdict = (body.data as Record<string, unknown>).verdict as Record<
string,
unknown
>;
// Day 51 against a stated 28-day typical plus 14 days of grace.
expect(verdict.state).toBe("OVERDUE");
expect(verdict.overdueDays).toBe(23);
expect(verdict.cycleStartDate).toBe(lastStart);
});
});
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);
+6
View File
@@ -195,6 +195,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
nextPeriodStart: predictionDto?.nextPeriodStart ?? null,
fertileWindowStart: predictionDto?.fertileWindowStart ?? null,
fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null,
// The latest start the person logged, read off the cycle rows rather than
// the grid. The grid's phase band is withheld while the engine is still
// learning, and the day count must not go with it — `cycles` is ordered by
// startDate ascending, so the last one at or before today is the anchor.
lastPeriodStart:
cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
});
annotate({