mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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({
|
||||
|
||||
@@ -163,6 +163,60 @@ describe("resolveCycleVerdict — what the record supports", () => {
|
||||
expect(v.cycleLength).toBe(8);
|
||||
});
|
||||
|
||||
it("counts off the logged start when the grid withholds its phase labels", () => {
|
||||
// What the calendar hands over while the engine is still learning: a grid
|
||||
// with every phase suppressed. The count is not the suppressed claim.
|
||||
const days = [day("2026-06-01", null), day("2026-06-11", null)];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-11",
|
||||
profile: { typicalCycleLength: 28 },
|
||||
lastPeriodStart: "2026-06-01",
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(11);
|
||||
expect(v.cycleStartDate).toBe("2026-06-01");
|
||||
expect(v.phase).toBeNull();
|
||||
expect(v.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("says how late a period is with no phase labels to read", () => {
|
||||
const days = [day("2026-06-11", null)];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-11",
|
||||
profile: { typicalCycleLength: 28 },
|
||||
lastPeriodStart: "2026-04-22",
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
expect(v.overdueDays).toBe(23);
|
||||
expect(v.cycleStartDate).toBe("2026-04-22");
|
||||
});
|
||||
|
||||
it("counts off the logged start when today is absent from the grid", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-09",
|
||||
lastPeriodStart: "2026-06-01",
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(9);
|
||||
});
|
||||
|
||||
it("keeps INSUFFICIENT_DATA when the anchor is ahead of today", () => {
|
||||
// A start dated in the future is not a cycle the person is in.
|
||||
const days = [day("2026-06-02", null)];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-02",
|
||||
lastPeriodStart: "2026-06-20",
|
||||
});
|
||||
expect(v.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(v.cycleStartDate).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the canonical four arcs for a low-data tracker", () => {
|
||||
// A two-day first-ever run: the observed-share math would let MENSTRUAL
|
||||
// fill nearly the whole circle, so the ring falls back to the profile's
|
||||
|
||||
@@ -116,6 +116,21 @@ export interface ResolveCycleVerdictInput {
|
||||
/** Already goal- and cold-start-gated fertile window bounds. */
|
||||
fertileWindowStart?: string | null;
|
||||
fertileWindowEnd?: string | null;
|
||||
/**
|
||||
* The latest LOGGED period start at or before today, or null when the record
|
||||
* holds none. Read straight off the cycle rows, never off the grid.
|
||||
*
|
||||
* The grid's phase labels are gated: the calendar suppresses the whole phase
|
||||
* band while the engine is still learning (fewer than three observed cycles),
|
||||
* because a population-framed phase word off one or two cycles is not earned.
|
||||
* The day count is not that kind of claim — it is the number of days since a
|
||||
* start the person entered themselves — but deriving it from the labelled run
|
||||
* meant the gate took both, and a record with three logged periods rendered an
|
||||
* empty ring under an offer to log a first period. This anchor keeps the count
|
||||
* and the honest overdue judgement available when the labels are withheld;
|
||||
* the phase stays null, which is the part the gate is actually about.
|
||||
*/
|
||||
lastPeriodStart?: string | null;
|
||||
}
|
||||
|
||||
/** Below this many labelled days in the current cycle run, the ring would
|
||||
@@ -234,6 +249,41 @@ function resolveFertileWindow(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict for a record whose grid carries no phase for today but whose
|
||||
* cycle rows carry a logged period start: count from that start, and apply the
|
||||
* same overdue ceiling. No phase is claimed — that is exactly the claim the
|
||||
* missing labels were withholding. Null when no start precedes today, which is
|
||||
* the one case that really is INSUFFICIENT_DATA.
|
||||
*/
|
||||
function verdictFromLoggedStart(
|
||||
input: ResolveCycleVerdictInput,
|
||||
fertileWindow: CycleVerdict["fertileWindow"],
|
||||
daysUntilNext: number | null,
|
||||
): CycleVerdict | null {
|
||||
const { today, profile, lastPeriodStart } = input;
|
||||
if (!lastPeriodStart) return null;
|
||||
const elapsed = dayDiff(today, lastPeriodStart);
|
||||
if (elapsed < 0) return null;
|
||||
|
||||
const dayOfCycle = elapsed + 1;
|
||||
const typicalLength = typicalCycleLength(profile);
|
||||
const ideal = idealizedSpans(profile);
|
||||
const overdue = dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS;
|
||||
|
||||
return {
|
||||
state: overdue ? "OVERDUE" : "IN_CYCLE",
|
||||
dayOfCycle: overdue ? null : dayOfCycle,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: null,
|
||||
spans: ideal.spans,
|
||||
cycleStartDate: lastPeriodStart,
|
||||
overdueDays: overdue ? dayOfCycle - typicalLength : null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a client needs to render the cycle ring and its caption.
|
||||
* The only place in the product that decides any of it.
|
||||
@@ -259,12 +309,22 @@ export function resolveCycleVerdict(
|
||||
fertileWindow,
|
||||
};
|
||||
|
||||
if (!todayDay || todayDay.phase == null) return nothingToSay;
|
||||
if (!todayDay || todayDay.phase == null) {
|
||||
return (
|
||||
verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ??
|
||||
nothingToSay
|
||||
);
|
||||
}
|
||||
|
||||
// Sort dates ascending and find today's index.
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0) return nothingToSay;
|
||||
if (todayIdx < 0) {
|
||||
return (
|
||||
verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ??
|
||||
nothingToSay
|
||||
);
|
||||
}
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const cycleStartDate = sorted[startIdx].date;
|
||||
|
||||
Reference in new issue
Block a user