mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user