mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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) => ({
|
||||
|
||||
@@ -242,6 +242,7 @@ export async function buildCycleSnapshotBlock(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
|
||||
// Next predicted event — period range + confidence + method, fertile window
|
||||
@@ -280,6 +281,7 @@ export async function buildCycleSnapshotBlock(
|
||||
lutealLength,
|
||||
from,
|
||||
today,
|
||||
today,
|
||||
);
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
type: m.type,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type CyclePhase,
|
||||
type PhaseCycle,
|
||||
resolveLuteal,
|
||||
clampLuteal,
|
||||
POPULATION_DEFAULT_CYCLE,
|
||||
} from "@/lib/cycle";
|
||||
import type {
|
||||
CycleProfile,
|
||||
@@ -129,9 +131,30 @@ function periodLengthOf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's
|
||||
* span ends where the next begins; the latest open cycle runs to the
|
||||
* predicted next-period start). Used to label every calendar day's phase.
|
||||
* Build a contiguous `PhaseCycle[]` from the observed cycles: each closed
|
||||
* cycle's span ends where the next begins, and the latest open cycle runs to
|
||||
* the predicted next-period start — or, when that day has come and gone with
|
||||
* no period logged, on through `today`.
|
||||
*
|
||||
* That trailing extension is the difference between a late period and no
|
||||
* record at all. A cycle stays open until the next one starts, and a forecast
|
||||
* is a forecast: the day after a missed prediction is still a day of the cycle
|
||||
* the person is in. Ending the span at the predicted start instead made every
|
||||
* day past it carry no phase, which the verdict resolver could only read as
|
||||
* "today falls outside every known cycle window" — so an ordinary three-days-
|
||||
* late period reported INSUFFICIENT_DATA, and the honest OVERDUE state was
|
||||
* unreachable unless the engine itself forecast a cycle longer than the grace
|
||||
* window. The count is bounded on the other side: `resolveCycleVerdict` stops
|
||||
* claiming a cycle day once the run passes the profile's typical length plus
|
||||
* its grace window.
|
||||
*
|
||||
* The extension deliberately does NOT move the ovulation estimate. Left to
|
||||
* `phaseForDay`, an ovulation with no confirmed date is back-calculated from
|
||||
* the span's own length, so a window that grows a day per day of delay would
|
||||
* walk the estimate — and every FOLLICULAR/OVULATORY/LUTEAL boundary already
|
||||
* drawn for that cycle — forward with it. The open cycle therefore carries an
|
||||
* explicit anchor derived from the PREDICTED length, which is the same date
|
||||
* `phaseForDay` computes when the span ends where the forecast said it would.
|
||||
*/
|
||||
export function buildPhaseCycles(
|
||||
cycles: readonly Pick<
|
||||
@@ -140,18 +163,37 @@ export function buildPhaseCycles(
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
/** Today in the USER's timezone. The open cycle runs at least this far. */
|
||||
today: string,
|
||||
): PhaseCycle[] {
|
||||
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
|
||||
const out: PhaseCycle[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
const next = sorted[i + 1];
|
||||
if (next) {
|
||||
out.push({
|
||||
startDate: c.startDate,
|
||||
nextStart: next.startDate,
|
||||
ovulationDate: c.ovulationDate,
|
||||
periodLength: periodLengthOf(c),
|
||||
lutealLength,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The open cycle. `phaseForDay` treats `nextStart` as exclusive, so the
|
||||
// window has to reach the day AFTER today for today to carry a phase.
|
||||
const forecastEnd =
|
||||
predictedNextStart ?? addDays(c.startDate, POPULATION_DEFAULT_CYCLE);
|
||||
const throughToday = addDays(today, 1);
|
||||
const nextStart =
|
||||
next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28);
|
||||
dayDiff(throughToday, forecastEnd) > 0 ? throughToday : forecastEnd;
|
||||
out.push({
|
||||
startDate: c.startDate,
|
||||
nextStart,
|
||||
ovulationDate: c.ovulationDate,
|
||||
ovulationDate:
|
||||
c.ovulationDate ?? addDays(forecastEnd, -clampLuteal(lutealLength)),
|
||||
periodLength: periodLengthOf(c),
|
||||
lutealLength,
|
||||
});
|
||||
@@ -186,11 +228,14 @@ export function phaseForDate(
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
/** Today in the USER's timezone — how far a still-open cycle runs. */
|
||||
today: string,
|
||||
): { phase: CyclePhase | null; dayOfCycle: number | null } {
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
predictedNextStart,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
for (const pc of phaseCycles) {
|
||||
const r = phaseForDay(date, pc);
|
||||
@@ -219,11 +264,14 @@ export function buildPhaseDayMap(
|
||||
lutealLength: number,
|
||||
from: string,
|
||||
to: string,
|
||||
/** Today in the USER's timezone — how far a still-open cycle runs. */
|
||||
today: string,
|
||||
): Map<string, CyclePhase> {
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
predictedNextStart,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
const out = new Map<string, CyclePhase>();
|
||||
const span = dayDiff(to, from);
|
||||
@@ -304,6 +352,7 @@ export function buildCalendar(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
|
||||
// Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).
|
||||
|
||||
Reference in new issue
Block a user