mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(cycle): keep counting an open cycle past the predicted period start
The engine ended the open cycle's phase window at the predicted next start, so the day after a missed forecast carried no phase at all and the verdict resolver read that as today falling outside every known cycle window. An ordinary three-days-late period therefore reported INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words insufficient data, while the reminder cron was pushing a period-confirm prompt for the same day. OVERDUE was only reachable when the engine itself forecast a cycle longer than the grace window. The open cycle now runs to the predicted start or through today, whichever is later, and stops the moment the next start is logged. The count is bounded on the far side where it already was: past the typical length plus its grace window the verdict degrades to OVERDUE and says how late the period is. The extension pins the open cycle's ovulation anchor to the predicted length. Left to the span, an unconfirmed ovulation is back-calculated from the window's own length, so a window that grows with the delay would walk the estimate and every phase boundary drawn from it. - buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's today; the insights route and the coach snapshot pass it - verdict round-trip covers the predicted start day, three days past it, past the grace window, and the case where a new start closes the run
This commit is contained in:
1 parent
d5f176ac4d
commit
c5a471f7fa
4 files changed
+182
-5
No files matched your search
@@ -103,6 +103,16 @@ const LONG_CYCLE_STARTS = [
|
||||
"2026-04-30",
|
||||
];
|
||||
|
||||
/**
|
||||
* Four period starts exactly 28 days apart — the textbook regular record. The
|
||||
* last start is open, so where "today" sits relative to `lastStart + 28`
|
||||
* decides what the verdict may claim. Built from a supplied last start so a
|
||||
* test can place today a chosen number of days past the predicted one.
|
||||
*/
|
||||
function regularStarts(lastStart: string): string[] {
|
||||
return [-84, -56, -28, 0].map((offset) => addDays(lastStart, offset));
|
||||
}
|
||||
|
||||
function cycleRows(starts: readonly string[]) {
|
||||
return starts.map((startDate, i) => ({
|
||||
id: `cyc-${i}`,
|
||||
@@ -249,6 +259,7 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
cycleRows(LONG_CYCLE_STARTS) as never,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
resolveLuteal(profile() as never),
|
||||
"2026-05-20",
|
||||
);
|
||||
expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle);
|
||||
expect(verdict.phase).toBe(fromEngine.phase);
|
||||
@@ -276,6 +287,120 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected));
|
||||
});
|
||||
|
||||
/**
|
||||
* D-list edge 1 — the verdict at and after the predicted start day, driven
|
||||
* through the real route.
|
||||
*
|
||||
* A regular 28-day record whose period has not arrived is the ORDINARY way
|
||||
* to be late, and it is the case the ring was silent about: the engine ends
|
||||
* the open cycle's phase window at the predicted start, so the day after it
|
||||
* carried no phase, and the verdict resolver read "no phase today" as "no
|
||||
* data at all". The person saw an empty ring and the words insufficient data
|
||||
* on the third day of a late period. `OVERDUE` was only ever reachable when
|
||||
* the engine itself forecast a cycle longer than the grace window.
|
||||
*/
|
||||
describe("a period that has not arrived", () => {
|
||||
/** Place today N days past `lastStart + 28`, the predicted next start. */
|
||||
function overdueBy(days: number): { today: string; lastStart: string } {
|
||||
const today = "2026-06-11";
|
||||
return { today, lastStart: addDays(today, -(28 + days)) };
|
||||
}
|
||||
|
||||
function seedRegular(lastStart: string): void {
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows(regularStarts(lastStart)) as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("keeps counting the open cycle past the predicted start", async () => {
|
||||
const { today, lastStart } = overdueBy(3);
|
||||
seedRegular(lastStart);
|
||||
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>;
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
const prediction = data.prediction as { nextPeriodStart: string } | null;
|
||||
|
||||
// The predicted start really is behind us — otherwise this asserts nothing.
|
||||
expect(prediction?.nextPeriodStart).toBe(addDays(today, -3));
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(32);
|
||||
expect(verdict.phase).toBe("LUTEAL");
|
||||
expect(verdict.cycleStartDate).toBe(lastStart);
|
||||
// There is no "until" once the predicted start is behind us.
|
||||
expect(verdict.daysUntilNext).toBeNull();
|
||||
});
|
||||
|
||||
it("says OVERDUE, with the day count, once the grace window is spent", async () => {
|
||||
// Typical 28 + 14 days of grace = 42. Day 44 is past it.
|
||||
const { today, lastStart } = overdueBy(15);
|
||||
seedRegular(lastStart);
|
||||
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
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("OVERDUE");
|
||||
expect(verdict.dayOfCycle).toBeNull();
|
||||
expect(verdict.phase).toBeNull();
|
||||
expect(verdict.overdueDays).toBe(16);
|
||||
expect(verdict.cycleStartDate).toBe(lastStart);
|
||||
expect(verdict.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("holds the count on the predicted start day itself", async () => {
|
||||
const { today, lastStart } = overdueBy(0);
|
||||
seedRegular(lastStart);
|
||||
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 29 of a 28-day cycle: the period is due today and has not been
|
||||
// logged, which is a cycle day, not an absence of data.
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(29);
|
||||
expect(verdict.daysUntilNext).toBe(0);
|
||||
});
|
||||
|
||||
it("stops extending the open window once the next period is logged", async () => {
|
||||
// The same record with one more start logged four days ago: the earlier
|
||||
// cycle closes at that start and today is day 5 of the new one.
|
||||
const { today, lastStart } = overdueBy(3);
|
||||
const withNewStart = [...regularStarts(lastStart), addDays(today, -4)];
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows(withNewStart) 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
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(5);
|
||||
expect(verdict.cycleStartDate).toBe(addDays(today, -4));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -176,6 +176,7 @@ export const GET = apiHandler(async () => {
|
||||
profile.lutealPhaseLength ?? LUTEAL_DEFAULT,
|
||||
from,
|
||||
today,
|
||||
today,
|
||||
);
|
||||
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
|
||||
Reference in new issue
Block a user