mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge work/cycle-verdict
This commit is contained in:
commit
4f79b9d49a
27 files changed
+1958
-565
No files matched your search
@@ -15585,6 +15585,8 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/CyclePredictionDTO"
|
||||
- type: "null"
|
||||
verdict:
|
||||
$ref: "#/components/schemas/CycleVerdictDTO"
|
||||
stillLearning:
|
||||
type: boolean
|
||||
days:
|
||||
@@ -15697,6 +15699,7 @@ components:
|
||||
required:
|
||||
- profile
|
||||
- prediction
|
||||
- verdict
|
||||
- stillLearning
|
||||
- days
|
||||
- meta
|
||||
@@ -15771,6 +15774,100 @@ components:
|
||||
- SYMPTOTHERMAL
|
||||
- TEMPERATURE_TREND
|
||||
- BLENDED
|
||||
CycleVerdictDTO:
|
||||
type: object
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- IN_CYCLE
|
||||
- OVERDUE
|
||||
- INSUFFICIENT_DATA
|
||||
dayOfCycle:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: 1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA — beyond the typical length plus
|
||||
the server's grace window the count is no longer an observed fact.
|
||||
cycleLength:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: "Days the ring represents: the observed labelled run, or the profile-derived idealized cycle for a low-data
|
||||
tracker."
|
||||
phase:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/CyclePhase"
|
||||
- type: "null"
|
||||
spans:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
phase:
|
||||
$ref: "#/components/schemas/CyclePhase"
|
||||
fraction:
|
||||
type: number
|
||||
required:
|
||||
- phase
|
||||
- fraction
|
||||
additionalProperties: false
|
||||
cycleStartDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: First day of the current cycle (YYYY-MM-DD). Still set while OVERDUE — the last logged period start remains
|
||||
a fact once the count stops.
|
||||
overdueDays:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: How many days past the profile's typical cycle length the open cycle has run. Set only in OVERDUE.
|
||||
daysUntilNext:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: Days from today to the predicted next period start. Null when no prediction ran and null once that start is
|
||||
in the past.
|
||||
fertileWindow:
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
end:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
active:
|
||||
type: boolean
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- active
|
||||
additionalProperties: false
|
||||
required:
|
||||
- state
|
||||
- dayOfCycle
|
||||
- cycleLength
|
||||
- phase
|
||||
- spans
|
||||
- cycleStartDate
|
||||
- overdueDays
|
||||
- daysUntilNext
|
||||
- fertileWindow
|
||||
additionalProperties: false
|
||||
description: The resolved cycle verdict for the user's own timezone day. Render these values; do not recompute them. The
|
||||
grace window that decides OVERDUE is deliberately not published — read `state` and `overdueDays`.
|
||||
CyclePhase:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -37,20 +37,29 @@ function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The labelled run the fixture paints: 5 menstrual, 6 follicular, 1 ovulatory,
|
||||
* then luteal through today and one day past it. */
|
||||
const RUN_START_OFFSET = -12;
|
||||
const PHASE_PLAN: string[] = [
|
||||
...Array<string>(5).fill("MENSTRUAL"),
|
||||
...Array<string>(6).fill("FOLLICULAR"),
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
function runStart(): Date {
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() + RUN_START_OFFSET);
|
||||
return start;
|
||||
}
|
||||
|
||||
/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run
|
||||
* so the wheel resolves a current phase + day and the calendar paints cells. */
|
||||
* so the wheel has a phase + day to show and the calendar paints cells. */
|
||||
function buildCalendarDays(): Array<Record<string, unknown>> {
|
||||
const days: Array<Record<string, unknown>> = [];
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 12);
|
||||
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
|
||||
const plan: string[] = [
|
||||
...Array<string>(5).fill("MENSTRUAL"),
|
||||
...Array<string>(6).fill("FOLLICULAR"),
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
"LUTEAL",
|
||||
];
|
||||
const start = runStart();
|
||||
const plan = PHASE_PLAN;
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
@@ -72,6 +81,34 @@ function buildCalendarDays(): Array<Record<string, unknown>> {
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict the server resolves for that grid. The real route publishes this
|
||||
* and the wheel renders it, so a fixture that omitted it would paint an empty
|
||||
* ring — deriving it here from the SAME plan keeps the two from drifting.
|
||||
*/
|
||||
function buildVerdict(): Record<string, unknown> {
|
||||
const plan = PHASE_PLAN;
|
||||
const counts = plan.reduce<Record<string, number>>((acc, phase) => {
|
||||
acc[phase] = (acc[phase] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const order = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"];
|
||||
return {
|
||||
state: "IN_CYCLE",
|
||||
// The run starts 12 days back, so today is its 13th day.
|
||||
dayOfCycle: 1 - RUN_START_OFFSET,
|
||||
cycleLength: plan.length,
|
||||
phase: plan[-RUN_START_OFFSET],
|
||||
spans: order
|
||||
.filter((phase) => (counts[phase] ?? 0) > 0)
|
||||
.map((phase) => ({ phase, fraction: counts[phase] / plan.length })),
|
||||
cycleStartDate: ymd(runStart()),
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
}
|
||||
|
||||
const CALENDAR_BODY = {
|
||||
data: {
|
||||
profile: {
|
||||
@@ -81,6 +118,8 @@ const CALENDAR_BODY = {
|
||||
cyclesObserved: 1,
|
||||
},
|
||||
prediction: null,
|
||||
verdict: buildVerdict(),
|
||||
stillLearning: false,
|
||||
days: buildCalendarDays(),
|
||||
meta: { generatedAt: new Date().toISOString() },
|
||||
},
|
||||
|
||||
@@ -7513,6 +7513,7 @@
|
||||
"caption": "deines Zyklus",
|
||||
"firstPeriodCta": "Erste Periode erfassen",
|
||||
"periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
|
||||
"periodOverdueDays": "Deine Periode ist {days} Tage später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
|
||||
"logPeriodCta": "Periode erfassen"
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
@@ -7513,6 +7513,7 @@
|
||||
"caption": "of your cycle",
|
||||
"firstPeriodCta": "Log my first period",
|
||||
"periodOverdue": "Your period is later than usual — the day count pauses until the next period is logged.",
|
||||
"periodOverdueDays": "Your period is {days} days later than usual — the day count pauses until the next period is logged.",
|
||||
"logPeriodCta": "Log period"
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
@@ -7513,6 +7513,7 @@
|
||||
"caption": "de tu ciclo",
|
||||
"firstPeriodCta": "Registrar mi primer periodo",
|
||||
"periodOverdue": "Tu período llega más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.",
|
||||
"periodOverdueDays": "Tu período llega {days} días más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.",
|
||||
"logPeriodCta": "Registrar período"
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
@@ -7513,6 +7513,7 @@
|
||||
"caption": "de ton cycle",
|
||||
"firstPeriodCta": "Enregistrer mes premières règles",
|
||||
"periodOverdue": "Tes règles arrivent plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.",
|
||||
"periodOverdueDays": "Tes règles arrivent {days} jours plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.",
|
||||
"logPeriodCta": "Enregistrer les règles"
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
@@ -7513,6 +7513,7 @@
|
||||
"caption": "del tuo ciclo",
|
||||
"firstPeriodCta": "Registra il mio primo ciclo",
|
||||
"periodOverdue": "Il ciclo è in ritardo rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.",
|
||||
"periodOverdueDays": "Il ciclo è in ritardo di {days} giorni rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.",
|
||||
"logPeriodCta": "Registra mestruazione"
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
@@ -7513,6 +7513,7 @@
|
||||
"caption": "twojego cyklu",
|
||||
"firstPeriodCta": "Zapisz pierwszą miesiączkę",
|
||||
"periodOverdue": "Okres jest późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.",
|
||||
"periodOverdueDays": "Okres jest {days} dni późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.",
|
||||
"logPeriodCta": "Zapisz okres"
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* The cycle-verdict guard, checked against itself.
|
||||
*
|
||||
* A structural check that cannot fail reads as proof and is not. So each case
|
||||
* below is a disguise someone would plausibly reach for while putting the
|
||||
* client-side cycle derivation back — renaming the constant, inlining the
|
||||
* number, lifting it into a variable one line above the comparison (the move
|
||||
* that defeated a guard in this repo before), hiding it in an object, in a
|
||||
* default parameter, behind a helper — and each is asserted to be caught by
|
||||
* the SAME detector the real guard runs.
|
||||
*
|
||||
* The second half matters just as much: a detector that flags everything is
|
||||
* a detector nobody keeps. So the innocent shapes are pinned too, including
|
||||
* the one the client is SUPPOSED to write.
|
||||
*
|
||||
* These sources are strings on purpose. They are the shapes that must not
|
||||
* ship, so none of them may live in a file the app can import.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
derivationFindings,
|
||||
parse,
|
||||
type FindingKind,
|
||||
} from "./helpers/cycle-verdict-derivation";
|
||||
|
||||
const FILE = "disguise.tsx";
|
||||
|
||||
/** Every cycle-context source needs the vocabulary a real derivation would use. */
|
||||
const PREAMBLE = `import type { CalendarDay, CyclePhase } from "./types";\n`;
|
||||
|
||||
function kinds(source: string): FindingKind[] {
|
||||
return derivationFindings(parse(FILE, PREAMBLE + source)).map((f) => f.kind);
|
||||
}
|
||||
|
||||
describe("the guard catches the derivation it was written for", () => {
|
||||
it("catches the exact constant that was copied", () => {
|
||||
expect(
|
||||
kinds(`const OVERDUE_GRACE_DAYS = 14;
|
||||
export function wheel(days: CalendarDay[]) { return days; }`),
|
||||
).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches it renamed — it reads the shape, not the old name", () => {
|
||||
expect(kinds(`const graceWindow = 14;`)).toContain("grace-constant");
|
||||
expect(kinds(`const overdueAfterDays = 21;`)).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches it hidden in an object literal", () => {
|
||||
expect(
|
||||
kinds(`const tuning = { overdueGraceDays: 14, cycleLength: 28 };`),
|
||||
).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches it hidden in a default parameter", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`export function wheel(days: CalendarDay[], graceDays = 14) { return days.length > graceDays; }`,
|
||||
),
|
||||
).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches the verdict being computed rather than read", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const dayOfCycle = 43;
|
||||
const typical = 28;
|
||||
const periodOverdue = dayOfCycle > typical + 14;`,
|
||||
),
|
||||
).toContain("client-derived-verdict");
|
||||
});
|
||||
|
||||
it("catches the threshold LIFTED INTO A VARIABLE one line up", () => {
|
||||
// The move that defeated a guard here before: no literal and no sum sit
|
||||
// next to the comparison any more, and nothing is named suspiciously.
|
||||
// The detector follows the local binding instead of reading the line.
|
||||
expect(
|
||||
kinds(
|
||||
`const limit = 28;
|
||||
const slack = 14;
|
||||
const ceiling = limit + slack;
|
||||
const dayOfCycle = 43;
|
||||
const stale = dayOfCycle > ceiling;`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches it lifted twice", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const limit = 28;
|
||||
const slack = 14;
|
||||
const ceiling = limit + slack;
|
||||
const bound = ceiling;
|
||||
const dayOfCycle = 43;
|
||||
const stale = dayOfCycle > bound;`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches the sum written inline", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const limit = 28;
|
||||
const slack = 14;
|
||||
const dayOfCycle = 43;
|
||||
const stale = dayOfCycle > limit + slack;`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches the shape with no suspicious name anywhere", () => {
|
||||
// Nothing here is called grace, overdue, or late. The count compared
|
||||
// against a sum is the whole tell.
|
||||
expect(
|
||||
kinds(
|
||||
`function ring(dayOfCycle: number, a: number, b: number) {
|
||||
return dayOfCycle >= a + b ? null : dayOfCycle;
|
||||
}`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches it with the operands swapped", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`function ring(dayOfCycle: number, a: number, b: number) {
|
||||
return a + b < dayOfCycle;
|
||||
}`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches a class field carrying the window", () => {
|
||||
expect(kinds(`class Wheel { private overdueGrace = 14; }`)).toContain(
|
||||
"grace-constant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the guard stays quiet on innocent code", () => {
|
||||
it("says nothing about reading the published state — the correct shape", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const verdict = calendar.data?.verdict;
|
||||
const periodOverdue = verdict?.state === "OVERDUE";
|
||||
const dayOfCycle = verdict?.dayOfCycle ?? null;
|
||||
const overdueDays = verdict?.overdueDays ?? null;`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about drawing the ring from the published numbers", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`function ring(dayOfCycle: number | null, cycleLength: number | null) {
|
||||
if (dayOfCycle == null || cycleLength == null || cycleLength <= 0) return 0;
|
||||
return Math.min((dayOfCycle - 1) / cycleLength, 1);
|
||||
}`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about a cycle-day count printed in copy", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const label = dayOfCycle != null ? t("cycle.ring.dayOfCycle", { day: dayOfCycle }) : null;`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about an overdue medication dose", () => {
|
||||
// The same English word, a different domain, no cycle vocabulary — the
|
||||
// medication card decides its own overdue state and always has.
|
||||
expect(
|
||||
derivationFindings(
|
||||
parse(
|
||||
"medication-card.tsx",
|
||||
`const overdue = dueAt != null && dueAt + graceMinutes < now;
|
||||
const OVERDUE_GRACE_MINUTES = 30;`,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about an unrelated grace constant outside the cycle", () => {
|
||||
expect(
|
||||
derivationFindings(
|
||||
parse("tour-launcher.tsx", `const POST_WIZARD_GRACE_MS = 4000;`),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* One decider for the cycle verdict.
|
||||
*
|
||||
* The server resolves the cycle day, the ring's arcs, the fertile-window state
|
||||
* and the overdue judgement with its day count, and publishes them on
|
||||
* `GET /api/cycle/calendar`. Client code renders that. It does not recompute
|
||||
* any part of it, and it does not keep its own grace-period constant — while
|
||||
* such a constant exists in client code it can be copied into another client,
|
||||
* which is exactly how a cycle ring came to show a person a day their record
|
||||
* did not hold.
|
||||
*
|
||||
* Two halves, because either one alone is satisfiable by broken code:
|
||||
* nothing in the client tree may derive the verdict, AND the surfaces that
|
||||
* show it must still be reading the published field.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative as relativeTo } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
accessPaths,
|
||||
derivationFindings,
|
||||
parse,
|
||||
} from "./helpers/cycle-verdict-derivation";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
/** Client-side code: the component tree, the hooks, and the app's pages. */
|
||||
const CLIENT_ROOTS = ["src/components", "src/hooks", "src/app"];
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) {
|
||||
walk(p, out);
|
||||
} else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".test.ts") &&
|
||||
!name.endsWith(".test.tsx") &&
|
||||
// Route handlers under `src/app/api` are the server; the verdict is
|
||||
// resolved there on purpose.
|
||||
!p.includes(`${join("src", "app", "api")}`)
|
||||
) {
|
||||
out.push(relativeTo(ROOT, p));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clientFiles(): string[] {
|
||||
return CLIENT_ROOTS.flatMap((root) => walk(join(ROOT, root)));
|
||||
}
|
||||
|
||||
function parseFile(relative: string) {
|
||||
return parse(relative, readFileSync(join(ROOT, relative), "utf8"));
|
||||
}
|
||||
|
||||
describe("the cycle verdict is decided in one place", () => {
|
||||
it("finds no client file that re-derives it", () => {
|
||||
const files = clientFiles();
|
||||
// A guard over an empty file list proves nothing.
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
|
||||
const findings = files.flatMap((relative) =>
|
||||
derivationFindings(parseFile(relative)),
|
||||
);
|
||||
|
||||
expect(
|
||||
findings.map((f) => `${f.file}:${f.line} ${f.kind} — ${f.where}`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the grace window out of client code entirely", () => {
|
||||
// The specific constant that was copied. Named here so its return is loud
|
||||
// rather than merely structural.
|
||||
const offenders = clientFiles().filter((relative) =>
|
||||
readFileSync(join(ROOT, relative), "utf8").includes("OVERDUE_GRACE_DAYS"),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the surfaces that show the verdict actually read it", () => {
|
||||
const CONSUMERS = [
|
||||
"src/components/cycle/cycle-view.tsx",
|
||||
"src/components/cycle/cycle-ring-tile.tsx",
|
||||
"src/components/cycle/cycle-insight-summary-card.tsx",
|
||||
];
|
||||
|
||||
it.each(CONSUMERS)("%s reads the published verdict", (relative) => {
|
||||
const paths = accessPaths(parseFile(relative));
|
||||
const readsVerdict = [...paths].some((p) => p.endsWith(".verdict"));
|
||||
expect(readsVerdict).toBe(true);
|
||||
});
|
||||
|
||||
it("the temperature chart takes the cycle start from the verdict", () => {
|
||||
// The BBT chart used to walk the calendar itself to find the cycle start,
|
||||
// which was a second copy of the same walk. It now takes the resolved
|
||||
// value as a prop, and the cycle page feeds it from the verdict.
|
||||
const chart = readFileSync(
|
||||
join(ROOT, "src/components/cycle/bbt-chart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(chart).toContain("cycleStartDate");
|
||||
|
||||
const paths = accessPaths(parseFile("src/components/cycle/cycle-view.tsx"));
|
||||
expect([...paths]).toContain("verdict.cycleStartDate");
|
||||
});
|
||||
|
||||
it("the calendar response type declares the verdict as always present", () => {
|
||||
// An optional verdict is a plausible default that lets a client fall back
|
||||
// to deriving its own.
|
||||
const types = readFileSync(
|
||||
join(ROOT, "src/components/cycle/types.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(types).toContain("verdict: CycleVerdict;");
|
||||
expect(types).not.toContain("verdict?:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* The detector behind the cycle-verdict single-decider guard.
|
||||
*
|
||||
* The cycle ring's day count, its phase arcs and the judgement that a period is
|
||||
* late are resolved once, on the server, and published on
|
||||
* `GET /api/cycle/calendar`. A second copy of that reasoning in client code is
|
||||
* not a duplicate detail; it is a second answer about a person's body, and the
|
||||
* last time it existed it froze someone's ring on a day their record did not
|
||||
* hold.
|
||||
*
|
||||
* A text search would not hold this line. The grace window can be renamed,
|
||||
* inlined as a bare `14`, or lifted into a variable one statement above the
|
||||
* comparison — a defeat this repo has already watched happen to a different
|
||||
* guard. So this reads the syntax tree and looks for three things:
|
||||
*
|
||||
* 1. a numeric constant named like a grace / overdue window;
|
||||
* 2. a binding named like an overdue verdict whose value is COMPUTED from a
|
||||
* comparison rather than read from the published `state`;
|
||||
* 3. the threshold shape itself — a cycle-day count compared against a sum —
|
||||
* whatever the pieces happen to be called.
|
||||
*
|
||||
* Rule 3 is what survives the rename: the constant can go anywhere, but the
|
||||
* comparison has to exist somewhere for the client to reach a verdict, and it
|
||||
* has the same shape wherever it hides.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
|
||||
export type FindingKind =
|
||||
"grace-constant" | "client-derived-verdict" | "day-count-threshold";
|
||||
|
||||
export interface Finding {
|
||||
kind: FindingKind;
|
||||
/** The binding or expression text that tripped the rule. */
|
||||
where: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Names that read as a grace / overdue window. */
|
||||
const GRACE_NAME = /grace|overdue/i;
|
||||
/** Names that read as "is the period late?". */
|
||||
const VERDICT_NAME = /overdue|periodlate|islate|isoverdue/i;
|
||||
/** Names that read as a cycle-day count. */
|
||||
const DAY_COUNT_NAME = /day.?of.?cycle|cycleday|cyclerun|runlength/i;
|
||||
|
||||
/**
|
||||
* Markers that put a file in cycle territory. "Overdue" is a word other parts
|
||||
* of the product use honestly — a medication dose is overdue, a screening is
|
||||
* overdue — so the rules below only apply where the cycle's own vocabulary is
|
||||
* present. A file cannot re-derive the cycle verdict without touching the
|
||||
* calendar's types, its phase names, or the verdict's own fields, so nothing
|
||||
* that could actually commit the defect escapes by renaming one identifier.
|
||||
*/
|
||||
const CYCLE_MARKERS = new Set([
|
||||
"CalendarDay",
|
||||
"CalendarResponse",
|
||||
"CyclePhase",
|
||||
"CycleVerdict",
|
||||
"CycleVerdictState",
|
||||
"useCycleCalendar",
|
||||
"dayOfCycle",
|
||||
"cycleLength",
|
||||
"cycleStartDate",
|
||||
"typicalCycleLength",
|
||||
"lutealPhaseLength",
|
||||
"periodOverdue",
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
]);
|
||||
|
||||
/** The published states. A comparison against one of these is a READ. */
|
||||
const PUBLISHED_STATES = new Set(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]);
|
||||
|
||||
const COMPARISON_OPERATORS = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.GreaterThanToken,
|
||||
ts.SyntaxKind.GreaterThanEqualsToken,
|
||||
ts.SyntaxKind.LessThanToken,
|
||||
ts.SyntaxKind.LessThanEqualsToken,
|
||||
ts.SyntaxKind.EqualsEqualsToken,
|
||||
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
||||
ts.SyntaxKind.ExclamationEqualsToken,
|
||||
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
||||
]);
|
||||
|
||||
const ARITHMETIC_OPERATORS = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.PlusToken,
|
||||
ts.SyntaxKind.MinusToken,
|
||||
]);
|
||||
|
||||
export function parse(fileName: string, source: string): ts.SourceFile {
|
||||
return ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip parentheses, `as`, `satisfies`, `!` so the shape underneath shows. */
|
||||
function unwrap(node: ts.Expression): ts.Expression {
|
||||
let cur = node;
|
||||
for (;;) {
|
||||
if (
|
||||
ts.isParenthesizedExpression(cur) ||
|
||||
ts.isAsExpression(cur) ||
|
||||
ts.isSatisfiesExpression(cur) ||
|
||||
ts.isNonNullExpression(cur)
|
||||
) {
|
||||
cur = cur.expression;
|
||||
continue;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain number literal, with or without a leading minus. */
|
||||
function isNumericConstant(node: ts.Expression | undefined): boolean {
|
||||
if (!node) return false;
|
||||
const inner = unwrap(node);
|
||||
if (ts.isNumericLiteral(inner)) return true;
|
||||
return (
|
||||
ts.isPrefixUnaryExpression(inner) &&
|
||||
inner.operator === ts.SyntaxKind.MinusToken &&
|
||||
ts.isNumericLiteral(inner.operand)
|
||||
);
|
||||
}
|
||||
|
||||
/** Every identifier-ish name appearing anywhere under `node`. */
|
||||
function namesUnder(node: ts.Node): string[] {
|
||||
const out: string[] = [];
|
||||
function visit(n: ts.Node): void {
|
||||
if (ts.isIdentifier(n)) out.push(n.text);
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the expression compares against the PUBLISHED verdict state. */
|
||||
function readsPublishedState(node: ts.Node): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (ts.isStringLiteral(n) && PUBLISHED_STATES.has(n.text)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) &&
|
||||
ts.isPropertyAccessExpression(n) &&
|
||||
n.name.text === "state"
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Whether a comparison lives anywhere under `node`. */
|
||||
function containsComparison(node: ts.Node): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (
|
||||
ts.isBinaryExpression(n) &&
|
||||
COMPARISON_OPERATORS.has(n.operatorToken.kind)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The name of a declaration, when it has a plain one. */
|
||||
function declaredName(
|
||||
name: ts.Node | undefined,
|
||||
file: ts.SourceFile,
|
||||
): string | null {
|
||||
if (!name) return null;
|
||||
if (ts.isIdentifier(name)) return name.text;
|
||||
if (ts.isStringLiteral(name)) return name.text;
|
||||
if (ts.isComputedPropertyName(name)) return null;
|
||||
if (ts.isPrivateIdentifier(name)) return name.text;
|
||||
return name.getText(file);
|
||||
}
|
||||
|
||||
/** Every `const NAME = <expr>` in the file, so one line up is not a hiding place. */
|
||||
function localBindings(file: ts.SourceFile): Map<string, ts.Expression> {
|
||||
const bindings = new Map<string, ts.Expression>();
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer
|
||||
) {
|
||||
bindings.set(node.name.text, node.initializer);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(file);
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the expression is a sum, following local `const` bindings.
|
||||
*
|
||||
* `dayOfCycle > typical + grace` and
|
||||
* `const ceiling = typical + grace; dayOfCycle > ceiling` are the same
|
||||
* derivation written two ways, and lifting the sum into a variable one line
|
||||
* above the comparison is precisely how a structural guard was talked around
|
||||
* here before. Two hops is enough for any readable version of the move.
|
||||
*/
|
||||
function isSumLike(
|
||||
node: ts.Expression,
|
||||
bindings: Map<string, ts.Expression>,
|
||||
seen: Set<string> = new Set(),
|
||||
depth = 0,
|
||||
): boolean {
|
||||
const inner = unwrap(node);
|
||||
if (
|
||||
ts.isBinaryExpression(inner) &&
|
||||
ARITHMETIC_OPERATORS.has(inner.operatorToken.kind)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (depth >= 3) return false;
|
||||
if (ts.isIdentifier(inner) && !seen.has(inner.text)) {
|
||||
const bound = bindings.get(inner.text);
|
||||
if (bound) {
|
||||
return isSumLike(
|
||||
bound,
|
||||
bindings,
|
||||
new Set(seen).add(inner.text),
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the file speaks the cycle's vocabulary at all. */
|
||||
export function isCycleContext(file: ts.SourceFile): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (ts.isIdentifier(n) && CYCLE_MARKERS.has(n.text)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
if (ts.isStringLiteral(n) && CYCLE_MARKERS.has(n.text)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(file);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way the cycle verdict could be decided again in the file.
|
||||
* An empty array is the only acceptable result for client code.
|
||||
*/
|
||||
export function derivationFindings(file: ts.SourceFile): Finding[] {
|
||||
const out: Finding[] = [];
|
||||
if (!isCycleContext(file)) return out;
|
||||
const bindings = localBindings(file);
|
||||
|
||||
function record(kind: FindingKind, node: ts.Node, where: string): void {
|
||||
const { line } = file.getLineAndCharacterOfPosition(node.getStart(file));
|
||||
out.push({ kind, where, file: file.fileName, line: line + 1 });
|
||||
}
|
||||
|
||||
function checkBinding(
|
||||
node: ts.Node,
|
||||
nameNode: ts.Node | undefined,
|
||||
initializer: ts.Expression | undefined,
|
||||
): void {
|
||||
const name = declaredName(nameNode, file);
|
||||
if (name === null || initializer === undefined) return;
|
||||
|
||||
// 1. A grace / overdue window pinned to a number.
|
||||
if (GRACE_NAME.test(name) && isNumericConstant(initializer)) {
|
||||
record("grace-constant", node, name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. An overdue verdict COMPUTED here. Reading the published `state` is
|
||||
// exactly what the client is supposed to do, so that shape is allowed.
|
||||
if (
|
||||
VERDICT_NAME.test(name) &&
|
||||
containsComparison(initializer) &&
|
||||
!readsPublishedState(initializer)
|
||||
) {
|
||||
record("client-derived-verdict", node, name);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isPropertyAssignment(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isPropertyDeclaration(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isParameter(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isEnumMember(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
}
|
||||
|
||||
// 3. The threshold shape: a cycle-day count compared against a sum. The
|
||||
// constant can be renamed, inlined, or lifted into a variable one line
|
||||
// up; this comparison still has to exist for a client to decide.
|
||||
if (
|
||||
ts.isBinaryExpression(node) &&
|
||||
COMPARISON_OPERATORS.has(node.operatorToken.kind)
|
||||
) {
|
||||
const hasSum = [node.left, node.right].some((side) =>
|
||||
isSumLike(side, bindings),
|
||||
);
|
||||
const mentionsDayCount = namesUnder(node).some((n) =>
|
||||
DAY_COUNT_NAME.test(n),
|
||||
);
|
||||
if (hasSum && mentionsDayCount) {
|
||||
record("day-count-threshold", node, node.getText(file).slice(0, 120));
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(file);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every property-access path in the file, e.g. `calendar.data.verdict`. */
|
||||
export function accessPaths(file: ts.SourceFile): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
function pathOf(expression: ts.Expression): string | null {
|
||||
const inner = unwrap(expression);
|
||||
if (ts.isIdentifier(inner)) return inner.text;
|
||||
if (ts.isPropertyAccessExpression(inner)) {
|
||||
const base = pathOf(inner.expression);
|
||||
return base === null ? null : `${base}.${inner.name.text}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
const p = pathOf(node);
|
||||
if (p !== null) paths.add(p);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(file);
|
||||
return paths;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* `GET /api/cycle/calendar` — the resolved verdict actually reaches a client.
|
||||
*
|
||||
* Both ends of a payload field can be written correctly and the wire can still
|
||||
* carry nothing; this repo has shipped exactly that. So nothing here is
|
||||
* hand-built downstream of the route: the test seeds rows, calls the REAL
|
||||
* exported `GET`, reads the response body as text, parses it, and asserts on
|
||||
* what came back. The engine, the calendar builder, the verdict resolver, the
|
||||
* DTO mapping and the envelope all run for real.
|
||||
*
|
||||
* The scenario is the one that froze a cycle ring: several long observed
|
||||
* cycles push the predicted next start weeks into the future, so every day of
|
||||
* the gap carries a phase label and the raw count walks past the profile's
|
||||
* typical length. The verdict must stop counting and say how late the period
|
||||
* is — resolved against the USER's timezone day, not the process's.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
|
||||
// The cache persist is fire-and-forget and would reach the database.
|
||||
vi.mock("@/lib/cycle/prediction-cache", () => ({
|
||||
persistPredictionCache: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { phaseForDate } from "@/lib/cycle/engine-adapter";
|
||||
import { resolveLuteal } from "@/lib/cycle/prediction";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
|
||||
/** A user in UTC+14, so their calendar day runs ahead of the process's. */
|
||||
const AHEAD_TZ = "Pacific/Kiritimati";
|
||||
|
||||
function session(timezone: string) {
|
||||
return {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
gender: "FEMALE",
|
||||
timezone,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
userId: "user-1",
|
||||
goal: "GENERAL_HEALTH",
|
||||
// What the person says their cycle usually is. The observed cycles below
|
||||
// are much longer, which is what drives the prediction past today.
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
secondarySymptom: "MUCUS",
|
||||
cycleTrackingEnabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Four period starts 60 days apart; the last one is still open. */
|
||||
const LONG_CYCLE_STARTS = [
|
||||
"2025-11-01",
|
||||
"2025-12-31",
|
||||
"2026-03-01",
|
||||
"2026-04-30",
|
||||
];
|
||||
|
||||
function cycleRows(starts: readonly string[]) {
|
||||
return starts.map((startDate, i) => ({
|
||||
id: `cyc-${i}`,
|
||||
userId: "user-1",
|
||||
startDate,
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
isPredicted: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function callCalendar(): Promise<{
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}> {
|
||||
const res = await GET(new NextRequest("http://localhost/api/cycle/calendar"));
|
||||
// Read the WIRE, not the handler's return value.
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: JSON.parse(text) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(session(AHEAD_TZ) as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: profile(),
|
||||
} as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows(LONG_CYCLE_STARTS) as never,
|
||||
);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
it("puts the whole verdict on the wire", async () => {
|
||||
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { status, body } = await callCalendar();
|
||||
expect(status).toBe(200);
|
||||
|
||||
const data = body.data as Record<string, unknown>;
|
||||
expect(data).toHaveProperty("verdict");
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
|
||||
// Every field the contract promises, present on the response itself.
|
||||
expect(Object.keys(verdict).sort()).toEqual([
|
||||
"cycleLength",
|
||||
"cycleStartDate",
|
||||
"dayOfCycle",
|
||||
"daysUntilNext",
|
||||
"fertileWindow",
|
||||
"overdueDays",
|
||||
"phase",
|
||||
"spans",
|
||||
"state",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves the overdue judgement in the USER's timezone, with the day count", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// The user's day is 2026-06-11: 43 days into a cycle that opened on
|
||||
// 2026-04-30, past their stated 28-day typical plus the grace window. The
|
||||
// count stops and the delay is stated: 43 − 28 = 15.
|
||||
expect(verdict.state).toBe("OVERDUE");
|
||||
expect(verdict.dayOfCycle).toBeNull();
|
||||
expect(verdict.phase).toBeNull();
|
||||
expect(verdict.overdueDays).toBe(15);
|
||||
// The last logged period start survives the ceiling — the BBT chart scopes
|
||||
// to it and the ring still draws four arcs.
|
||||
expect(verdict.cycleStartDate).toBe("2026-04-30");
|
||||
expect(verdict.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("reads one day earlier for the same instant in UTC — the zone decides", async () => {
|
||||
// Same wall-clock instant, a user whose day has not rolled over yet. If
|
||||
// the route resolved `today` from the process instead of the account, this
|
||||
// case and the one above could not differ.
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12: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(42);
|
||||
expect(verdict.overdueDays).toBeNull();
|
||||
});
|
||||
|
||||
it("agrees with the phase engine the coach read uses", async () => {
|
||||
// Two independent server derivations of one number: the ring counts the
|
||||
// labelled run, the coach/MCP block asks the phase engine directly. They
|
||||
// must not be able to tell a person two different cycle days.
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-20T12: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;
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
|
||||
const fromEngine = phaseForDate(
|
||||
"2026-05-20",
|
||||
cycleRows(LONG_CYCLE_STARTS) as never,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
resolveLuteal(profile() as never),
|
||||
);
|
||||
expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle);
|
||||
expect(verdict.phase).toBe(fromEngine.phase);
|
||||
});
|
||||
|
||||
it("counts the days to the next predicted period", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-20T12: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;
|
||||
|
||||
expect(prediction).not.toBeNull();
|
||||
const expected = Math.round(
|
||||
(Date.parse(`${prediction!.nextPeriodStart}T12:00:00Z`) -
|
||||
Date.parse("2026-05-20T12:00:00Z")) /
|
||||
86_400_000,
|
||||
);
|
||||
expect(verdict.daysUntilNext).toBe(expected);
|
||||
expect(verdict.daysUntilNext).toBeGreaterThan(0);
|
||||
// Sanity: the predicted start really is ahead of today.
|
||||
expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected));
|
||||
});
|
||||
|
||||
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);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(verdict.dayOfCycle).toBeNull();
|
||||
expect(verdict.cycleStartDate).toBeNull();
|
||||
expect(verdict.spans).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
|
||||
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { BBT_WINDOW } from "@/lib/cycle/types";
|
||||
@@ -173,12 +174,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// "not a contraceptive method" caveat (QA H-1).
|
||||
const disclaimer = t.t(cycleDisclaimerKey(profile.goal));
|
||||
|
||||
const predictionDto = prediction
|
||||
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
|
||||
: null;
|
||||
|
||||
// The resolved verdict. Built from the SAME grid the response carries and
|
||||
// from the ALREADY-GATED prediction DTO, never the raw engine result — a
|
||||
// fertile window suppressed for the profile's goal or by the cold-start gate
|
||||
// cannot reappear here. `today` is the user's timezone day, which is the
|
||||
// other half of why this cannot be a client-side derivation: a browser in a
|
||||
// different zone resolves a different "today" and reads a different cycle day.
|
||||
const verdict = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: {
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
},
|
||||
nextPeriodStart: predictionDto?.nextPeriodStart ?? null,
|
||||
fertileWindowStart: predictionDto?.fertileWindowStart ?? null,
|
||||
fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.calendar.read" },
|
||||
meta: {
|
||||
days: days.length,
|
||||
cycles_observed: prediction?.cyclesObserved ?? 0,
|
||||
has_prediction: prediction !== null,
|
||||
verdict_state: verdict.state,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,9 +219,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// so a consumer never sees two divergent "cycles observed" values.
|
||||
cyclesObserved: prediction?.cyclesObserved ?? cycles.length,
|
||||
},
|
||||
prediction: prediction
|
||||
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
|
||||
: null,
|
||||
prediction: predictionDto,
|
||||
// The one resolved answer about today: cycle day, the ring's arcs, how
|
||||
// many days until the next period, and whether the period is late and by
|
||||
// how much. Every client renders this; none of them recomputes it.
|
||||
verdict,
|
||||
// Top-level cold-start flag (mirrors `prediction.stillLearning`): the
|
||||
// calendar grid has suppressed fertile/ovulation/phase output, so the
|
||||
// client renders a calm "learning your cycle" banner over the grid. Additive
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("<BbtChart>", () => {
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
cycleStartDate="2026-06-01"
|
||||
predictedOvulation={null}
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
@@ -60,6 +61,7 @@ describe("<BbtChart>", () => {
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
cycleStartDate="2026-06-01"
|
||||
predictedOvulation="2026-06-08"
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/hooks/use-unit-display", async () => {
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarResponse } from "../types";
|
||||
import type { CalendarResponse, CycleVerdict } from "../types";
|
||||
import type { CycleInsightsResponse } from "../use-cycle";
|
||||
import type { CyclePhaseCrosstabRow } from "../cycle-phase-crosstab";
|
||||
|
||||
@@ -80,8 +80,6 @@ vi.mock("../use-cycle", async () => {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
useCycleInsights: () => insightsState.current,
|
||||
// The card now reads the profile lengths for the low-data idealized ring.
|
||||
useCycleProfile: () => ({ data: undefined }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -93,9 +91,26 @@ function render(node: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a calendar read where `today` sits on a LUTEAL day at day-of-cycle 3
|
||||
* (a short MENSTRUAL run → LUTEAL today, so `deriveWheelState` resolves a
|
||||
* phase + day). */
|
||||
/** The verdict the server would resolve for that grid: LUTEAL, cycle day 3. */
|
||||
const LUTEAL_VERDICT: CycleVerdict = {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle: 3,
|
||||
cycleLength: 28,
|
||||
phase: "LUTEAL",
|
||||
spans: [
|
||||
{ phase: "MENSTRUAL", fraction: 5 / 28 },
|
||||
{ phase: "FOLLICULAR", fraction: 7 / 28 },
|
||||
{ phase: "OVULATORY", fraction: 2 / 28 },
|
||||
{ phase: "LUTEAL", fraction: 14 / 28 },
|
||||
],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
|
||||
/** A calendar read whose SERVER-RESOLVED verdict puts today on LUTEAL, day 3.
|
||||
* The card renders the verdict; it no longer derives anything from `days`. */
|
||||
function lutealCalendar(today: string): CalendarResponse {
|
||||
// Three consecutive days ending today: MENSTRUAL, FOLLICULAR, LUTEAL.
|
||||
const days = [
|
||||
@@ -107,6 +122,7 @@ function lutealCalendar(today: string): CalendarResponse {
|
||||
days: days as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
verdict: LUTEAL_VERDICT,
|
||||
} as CalendarResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarResponse } from "../types";
|
||||
import type { CalendarResponse, CycleVerdict } from "../types";
|
||||
|
||||
/**
|
||||
* v1.15.3 — the compact cycle ring as a wellness-strip tile.
|
||||
@@ -10,7 +10,7 @@ import type { CalendarResponse } from "../types";
|
||||
* The tile is mounted ONLY when `user.cycleTrackingEnabled` is true (a
|
||||
* page-level gate the Insights page owns, mirroring the summary card); these
|
||||
* tests cover the tile's own behaviour given that gate: it sources the phase +
|
||||
* cycle day from the SAME calendar read + `deriveWheelState` the wheel uses,
|
||||
* cycle day from the SAME server-resolved `verdict` the wheel reads,
|
||||
* renders the cycle ring labelled, deep-links to the cycle page, and stays
|
||||
* silent while resolving / on a hard error / when there is no active cycle so
|
||||
* the wellness strip never shows a half-painted dial (no gap, no placeholder).
|
||||
@@ -49,9 +49,6 @@ vi.mock("../use-cycle", async () => {
|
||||
return {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
// The tile now reads the profile lengths for the low-data idealized ring.
|
||||
// Mock it at the hook boundary too so the SSR snapshot needs no QueryClient.
|
||||
useCycleProfile: () => ({ data: undefined }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,7 +60,38 @@ function render(node: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A calendar read where `today` sits on a LUTEAL day at day-of-cycle 3. */
|
||||
/** The verdict the server would resolve for that grid: LUTEAL, cycle day 3. */
|
||||
const LUTEAL_VERDICT: CycleVerdict = {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle: 3,
|
||||
cycleLength: 28,
|
||||
phase: "LUTEAL",
|
||||
spans: [
|
||||
{ phase: "MENSTRUAL", fraction: 5 / 28 },
|
||||
{ phase: "FOLLICULAR", fraction: 7 / 28 },
|
||||
{ phase: "OVULATORY", fraction: 2 / 28 },
|
||||
{ phase: "LUTEAL", fraction: 14 / 28 },
|
||||
],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
|
||||
/** The verdict for an account with nothing to say about today. */
|
||||
const NO_CYCLE_VERDICT: CycleVerdict = {
|
||||
state: "INSUFFICIENT_DATA",
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
|
||||
/** A calendar read whose SERVER-RESOLVED verdict puts today on LUTEAL, day 3. */
|
||||
function lutealCalendar(today: string): CalendarResponse {
|
||||
const days = [
|
||||
{ date: shift(today, -2), phase: "MENSTRUAL" as const },
|
||||
@@ -74,6 +102,7 @@ function lutealCalendar(today: string): CalendarResponse {
|
||||
days: days as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
verdict: LUTEAL_VERDICT,
|
||||
} as CalendarResponse;
|
||||
}
|
||||
|
||||
@@ -138,6 +167,7 @@ describe("<CycleRingTile>", () => {
|
||||
days: [] as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
verdict: NO_CYCLE_VERDICT,
|
||||
} as CalendarResponse;
|
||||
calendarState.current = { data: empty, isLoading: false, isError: false };
|
||||
expect(render(<CycleRingTile />)).toBe("");
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { deriveWheelState, currentCycleStartDate } from "../wheel-state";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
function day(date: string, phase: CalendarDay["phase"]): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: phase === "MENSTRUAL",
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveWheelState", () => {
|
||||
it("returns a null state when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
const w = deriveWheelState(days, "2026-06-02");
|
||||
expect(w.dayOfCycle).toBeNull();
|
||||
expect(w.phase).toBeNull();
|
||||
expect(w.spans).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts the day-of-cycle from the most recent menstrual start", () => {
|
||||
// A run starting 2026-06-01 (menstrual) → today 2026-06-05 is cycle day 5.
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-05");
|
||||
expect(w.dayOfCycle).toBe(5);
|
||||
expect(w.phase).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("builds proportional phase spans summing to ~1", () => {
|
||||
// A well-populated run (>= 7 labelled days spanning all four phases) keeps
|
||||
// the OBSERVED-share spans. Eight forward-labelled days exercise the
|
||||
// observed path (above the sparse threshold).
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "MENSTRUAL"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
day("2026-06-06", "OVULATORY"),
|
||||
day("2026-06-07", "LUTEAL"),
|
||||
day("2026-06-08", "LUTEAL"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-05");
|
||||
const total = w.spans.reduce((s, x) => s + x.fraction, 0);
|
||||
expect(total).toBeCloseTo(1, 5);
|
||||
// All four observed phases are emitted, and the observed cycle length
|
||||
// matches the labelled run (8 days) — NOT the idealized fallback length.
|
||||
expect(w.spans.map((s) => s.phase).sort()).toEqual([
|
||||
"FOLLICULAR",
|
||||
"LUTEAL",
|
||||
"MENSTRUAL",
|
||||
"OVULATORY",
|
||||
]);
|
||||
expect(w.cycleLength).toBe(8);
|
||||
});
|
||||
|
||||
it("falls back to canonical four-phase spans for a low-data tracker", () => {
|
||||
// Only the first few days of a first-ever cycle are logged — a 2-day
|
||||
// menstrual run. The observed-share path would normalise this so MENSTRUAL
|
||||
// filled (nearly) the whole ring. The low-data fallback instead draws the
|
||||
// canonical four phases from the profile lengths.
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-02", {
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
});
|
||||
|
||||
// The marker read stays driven by the observed labels.
|
||||
expect(w.dayOfCycle).toBe(2);
|
||||
expect(w.phase).toBe("MENSTRUAL");
|
||||
|
||||
// FOUR sensible arcs, not one dominant sliver. The idealized cycle length
|
||||
// (5 + follicular + 2 + 14 = 28) is represented, and no single phase
|
||||
// swallows the ring.
|
||||
expect(w.spans).toHaveLength(4);
|
||||
expect(w.spans.map((s) => s.phase)).toEqual([
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
]);
|
||||
const total = w.spans.reduce((s, x) => s + x.fraction, 0);
|
||||
expect(total).toBeCloseTo(1, 5);
|
||||
for (const span of w.spans) {
|
||||
expect(span.fraction).toBeGreaterThan(0);
|
||||
expect(span.fraction).toBeLessThan(0.95);
|
||||
}
|
||||
expect(w.cycleLength).toBe(28);
|
||||
});
|
||||
|
||||
it("uses textbook defaults for the low-data ring when no profile is given", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
const w = deriveWheelState(days, "2026-06-01");
|
||||
expect(w.spans).toHaveLength(4);
|
||||
// Defaults: 28-day cycle.
|
||||
expect(w.cycleLength).toBe(28);
|
||||
expect(w.phase).toBe("MENSTRUAL");
|
||||
expect(w.dayOfCycle).toBe(1);
|
||||
expect(w.periodOverdue).toBe(false);
|
||||
});
|
||||
|
||||
// v1.27.5 — a months-old open cycle whose predicted next start moved into
|
||||
// the future labels every gap day LUTEAL; the wheel must not read that back
|
||||
// as "day 90 of your cycle".
|
||||
describe("overdue ceiling", () => {
|
||||
/** A run of 5 MENSTRUAL days + (n−5) LUTEAL days ending at `today`. */
|
||||
function longRun(n: number): { days: CalendarDay[]; today: string } {
|
||||
const start = Date.UTC(2026, 0, 1);
|
||||
const days: CalendarDay[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const date = new Date(start + i * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL"));
|
||||
}
|
||||
return { days, today: days[n - 1].date };
|
||||
}
|
||||
|
||||
it("stops asserting a day count beyond typical length + grace", () => {
|
||||
const { days, today } = longRun(90);
|
||||
const w = deriveWheelState(days, today, { typicalCycleLength: 28 });
|
||||
expect(w.periodOverdue).toBe(true);
|
||||
expect(w.dayOfCycle).toBeNull();
|
||||
expect(w.phase).toBeNull();
|
||||
// The ring still draws the canonical four-phase dial.
|
||||
expect(w.spans).toHaveLength(4);
|
||||
expect(w.cycleLength).toBe(28);
|
||||
});
|
||||
|
||||
it("keeps counting inside the grace window", () => {
|
||||
// Typical 28 + 14 grace = 42; day 42 is still an honest count.
|
||||
const { days, today } = longRun(42);
|
||||
const w = deriveWheelState(days, today, { typicalCycleLength: 28 });
|
||||
expect(w.periodOverdue).toBe(false);
|
||||
expect(w.dayOfCycle).toBe(42);
|
||||
});
|
||||
|
||||
it("respects a long typical cycle length from the profile", () => {
|
||||
// A 60-day typical cycle keeps day 49 honest.
|
||||
const { days, today } = longRun(49);
|
||||
const w = deriveWheelState(days, today, { typicalCycleLength: 60 });
|
||||
expect(w.periodOverdue).toBe(false);
|
||||
expect(w.dayOfCycle).toBe(49);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentCycleStartDate", () => {
|
||||
it("returns the most recent menstrual run-start at/before today", () => {
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
];
|
||||
expect(currentCycleStartDate(days, "2026-06-04")).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("returns null when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
expect(currentCycleStartDate(days, "2026-06-02")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when today is absent from the calendar", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
expect(currentCycleStartDate(days, "2026-06-09")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@
|
||||
/**
|
||||
* v1.15.0 — the basal-body-temperature (BBT) chart.
|
||||
*
|
||||
* Plots the user's logged BBT across the CURRENT cycle (the same window the
|
||||
* wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots,
|
||||
* Plots the user's logged BBT across the CURRENT cycle — the same window the
|
||||
* wheel shows, because both take the cycle start from the server's resolved
|
||||
* verdict rather than each walking the calendar themselves — as a line with
|
||||
* phase-tinted dots,
|
||||
* the symptothermal signal a fertility-aware reader expects. Fertility signs
|
||||
* already logged for a day — egg-white/watery mucus, a positive LH test —
|
||||
* surface in the tooltip so the temperature shift reads in context. When the
|
||||
@@ -42,7 +44,6 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
|
||||
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import { currentCycleStartDate } from "./wheel-state";
|
||||
|
||||
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
|
||||
const FALLBACK_WINDOW_DAYS = 35;
|
||||
@@ -60,6 +61,12 @@ interface BbtPoint {
|
||||
export interface BbtChartProps {
|
||||
days: CalendarDay[];
|
||||
today: string;
|
||||
/**
|
||||
* First day of the current cycle, from the calendar read's resolved
|
||||
* `verdict.cycleStartDate`. Null when no cycle is active, and the chart
|
||||
* falls back to a trailing window so recent temperatures still show.
|
||||
*/
|
||||
cycleStartDate: string | null;
|
||||
/** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */
|
||||
predictedOvulation: string | null;
|
||||
/** Whether the ovulation day was confirmed by a signal layer (solid marker). */
|
||||
@@ -76,6 +83,7 @@ function ymdToMs(d: string): number {
|
||||
export function BbtChart({
|
||||
days,
|
||||
today,
|
||||
cycleStartDate,
|
||||
predictedOvulation,
|
||||
ovulationConfirmed = false,
|
||||
rawChartMode,
|
||||
@@ -84,15 +92,15 @@ export function BbtChart({
|
||||
const fmt = useFormatters();
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// Scope to the current cycle (wheel window); fall back to a trailing window
|
||||
// when no cycle is active so a user still sees their recent temperatures.
|
||||
// Scope to the current cycle using the server's resolved start; fall back to
|
||||
// a trailing window when no cycle is active so a user still sees their
|
||||
// recent temperatures.
|
||||
const fromDate = useMemo(() => {
|
||||
const start = currentCycleStartDate(days, today);
|
||||
if (start) return start;
|
||||
if (cycleStartDate) return cycleStartDate;
|
||||
const fallback = new Date(ymdToMs(today));
|
||||
fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS);
|
||||
return fallback.toISOString().slice(0, 10);
|
||||
}, [days, today]);
|
||||
}, [cycleStartDate, today]);
|
||||
|
||||
const points = useMemo<BbtPoint[]>(() => {
|
||||
const fromMs = ymdToMs(fromDate);
|
||||
|
||||
@@ -8,22 +8,16 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import {
|
||||
localYmd,
|
||||
useCycleCalendar,
|
||||
useCycleInsights,
|
||||
useCycleProfile,
|
||||
} from "./use-cycle";
|
||||
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
|
||||
*
|
||||
* A compact TEASER + entry point, never a duplicate of the full cycle insights
|
||||
* tab. It surfaces three things and links into the source of truth:
|
||||
* 1. the current phase (name + hue dot) and cycle day, derived from the
|
||||
* SAME calendar read + `deriveWheelState` the cycle wheel uses, so the
|
||||
* two surfaces can never disagree on the day-of-cycle or phase;
|
||||
* 1. the current phase (name + hue dot) and cycle day, read from the SAME
|
||||
* server-resolved `verdict` on the calendar read the cycle wheel uses, so
|
||||
* the two surfaces can never disagree on the day-of-cycle or phase;
|
||||
* 2. the one headline phase finding, rendered through the SHARED
|
||||
* `<CyclePhaseHeadline>` (which already owns the FDR-gated copy + the
|
||||
* honest "not enough cycles yet" empty line) — no re-implementation;
|
||||
@@ -54,25 +48,13 @@ export function CycleInsightSummaryCard() {
|
||||
|
||||
// Mirror the cycle-view window so the cache key is shared (no extra request
|
||||
// when the user later opens the cycle page in the same session).
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
const insights = useCycleInsights();
|
||||
// Shared 5-min-cached profile read so a low-data tracker still gets the
|
||||
// canonical four-phase ring (no extra request — shared cache key).
|
||||
const profile = useCycleProfile();
|
||||
|
||||
const wheel = useMemo(
|
||||
() =>
|
||||
deriveWheelState(calendar.data?.days ?? [], today, {
|
||||
typicalCycleLength: profile.data?.typicalCycleLength,
|
||||
typicalPeriodLength: profile.data?.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.data?.lutealPhaseLength,
|
||||
}),
|
||||
[calendar.data, today, profile.data],
|
||||
);
|
||||
// The server's resolved verdict — the teaser reports it, never recomputes it.
|
||||
const verdict = calendar.data?.verdict;
|
||||
|
||||
// Stay silent until the calendar read resolves, and on a hard error: the
|
||||
// overview must never show a half-painted or error-framed cycle teaser. The
|
||||
@@ -80,10 +62,10 @@ export function CycleInsightSummaryCard() {
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) return null;
|
||||
|
||||
const phase = wheel.phase;
|
||||
const phase = verdict?.phase ?? null;
|
||||
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
|
||||
const phaseName = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none");
|
||||
const dayOfCycle = wheel.dayOfCycle;
|
||||
const dayOfCycle = verdict?.dayOfCycle ?? null;
|
||||
|
||||
const ariaLabel =
|
||||
phase && dayOfCycle != null
|
||||
|
||||
@@ -8,8 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle";
|
||||
import { localYmd, useCycleCalendar } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element.
|
||||
@@ -24,8 +23,9 @@ import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle";
|
||||
*
|
||||
* It is NOT a second `<CycleInsightSummaryCard>` — that teaser (phase finding +
|
||||
* deep-link) already lives further down the overview. This is the RING only,
|
||||
* as a wellness dial. Both surfaces derive day-of-cycle + phase from the SAME
|
||||
* `useCycleCalendar` read + `deriveWheelState`, so they can never disagree.
|
||||
* as a wellness dial. Both surfaces read day-of-cycle + phase from the SAME
|
||||
* server-resolved `verdict` on the calendar read, so they can never disagree —
|
||||
* and neither of them computes it.
|
||||
*
|
||||
* GATING IS THE CALLER'S JOB. The page mounts this only when
|
||||
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
|
||||
@@ -48,25 +48,14 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
|
||||
// Mirror the cycle-view + summary-card window so the cache key is shared —
|
||||
// no extra request when the user opens the cycle page in the same session.
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
// Shared 5-min-cached profile read (no extra request when the cycle page or
|
||||
// summary card already loaded it): its typical lengths let a low-data tracker
|
||||
// draw the canonical four-phase ring instead of one dominant arc.
|
||||
const profile = useCycleProfile();
|
||||
|
||||
const wheel = useMemo(
|
||||
() =>
|
||||
deriveWheelState(calendar.data?.days ?? [], today, {
|
||||
typicalCycleLength: profile.data?.typicalCycleLength,
|
||||
typicalPeriodLength: profile.data?.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.data?.lutealPhaseLength,
|
||||
}),
|
||||
[calendar.data, today, profile.data],
|
||||
);
|
||||
// The server already resolved the ring: cycle day, phase, and the arc
|
||||
// proportions (including the profile-derived idealized ring a low-data
|
||||
// tracker gets). The tile renders it.
|
||||
const verdict = calendar.data?.verdict;
|
||||
|
||||
// The signature reveal plays ONCE per browser session, gated on its own key
|
||||
// so a background calendar refetch never re-triggers the moment. The
|
||||
@@ -87,9 +76,9 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
// empty-framed cycle dial. The full cycle page owns the empty-state CTA.
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) return null;
|
||||
if (wheel.phase == null || wheel.dayOfCycle == null) return null;
|
||||
if (verdict?.phase == null || verdict.dayOfCycle == null) return null;
|
||||
|
||||
const hue = PHASE_HUE[wheel.phase];
|
||||
const hue = PHASE_HUE[verdict.phase];
|
||||
|
||||
return (
|
||||
// `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` — the
|
||||
@@ -102,7 +91,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
href="/cycle"
|
||||
data-slot="wellness-cycle-tile"
|
||||
data-metric="CYCLE"
|
||||
data-phase={wheel.phase}
|
||||
data-phase={verdict.phase}
|
||||
// Match the score `RingTile` chrome: the phase's `--tile-hue`
|
||||
// low-opacity mix over `--card` + the faint film grain (the
|
||||
// `.wellness-tile` family), with a calm once-per-session rise. The
|
||||
@@ -122,10 +111,10 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
dayOfCycle={verdict.dayOfCycle}
|
||||
cycleLength={verdict.cycleLength}
|
||||
phase={verdict.phase}
|
||||
spans={verdict.spans}
|
||||
animate={play}
|
||||
size={120}
|
||||
/>
|
||||
@@ -133,7 +122,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
data-slot="wellness-cycle-band-word"
|
||||
className="text-muted-foreground text-center text-xs"
|
||||
>
|
||||
{t(`cycle.phase.${wheel.phase}`)}
|
||||
{t(`cycle.phase.${verdict.phase}`)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import {
|
||||
localYmd,
|
||||
@@ -139,17 +138,9 @@ export function CycleView() {
|
||||
}
|
||||
});
|
||||
|
||||
// Pass the profile's typical lengths so a low-data tracker (few logged
|
||||
// cycles) draws the canonical four-phase ring instead of one dominant arc.
|
||||
const wheel = useMemo(
|
||||
() =>
|
||||
deriveWheelState(calendar.data?.days ?? [], today, {
|
||||
typicalCycleLength: profileQuery.data?.typicalCycleLength,
|
||||
typicalPeriodLength: profileQuery.data?.typicalPeriodLength,
|
||||
lutealPhaseLength: profileQuery.data?.lutealPhaseLength,
|
||||
}),
|
||||
[calendar.data, today, profileQuery.data],
|
||||
);
|
||||
// The server's resolved verdict — cycle day, the ring's arcs, the overdue
|
||||
// judgement and its day count. Nothing on this page recomputes any of it.
|
||||
const verdict = calendar.data?.verdict;
|
||||
|
||||
function openSheet(date: string) {
|
||||
setSelectedDate(date);
|
||||
@@ -158,7 +149,7 @@ export function CycleView() {
|
||||
|
||||
const loading = calendar.isLoading && !calendar.data;
|
||||
const calendarError = calendar.isError && !calendar.data;
|
||||
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
|
||||
const tileHue = verdict?.phase ? PHASE_HUE[verdict.phase] : undefined;
|
||||
const goal = calendar.data?.profile.goal;
|
||||
// Honesty-gate inputs for the phase-education card (Clue precedent): the
|
||||
// predictive phase framing only shows when prediction is on, not in
|
||||
@@ -244,32 +235,38 @@ export function CycleView() {
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
cycleLength={verdict?.cycleLength ?? null}
|
||||
phase={verdict?.phase ?? null}
|
||||
spans={verdict?.spans ?? []}
|
||||
animate={play}
|
||||
/>
|
||||
)}
|
||||
{!calendarError ? (
|
||||
<p className="text-muted-foreground max-w-56 text-center text-xs">
|
||||
{/* v1.27.5 — beyond typical length + grace the wheel stops
|
||||
asserting a day count; this caption says why instead of
|
||||
the "of your cycle" tagline under an empty ring. */}
|
||||
{wheel.periodOverdue
|
||||
? t("cycle.ring.periodOverdue")
|
||||
{/* Beyond the typical length plus the server's grace window
|
||||
the count stops being an observed fact, so the server
|
||||
resolves OVERDUE and says how late; this caption reports
|
||||
that instead of the "of your cycle" tagline under an
|
||||
empty ring. */}
|
||||
{verdict?.state === "OVERDUE"
|
||||
? verdict.overdueDays != null
|
||||
? t("cycle.ring.periodOverdueDays", {
|
||||
days: verdict.overdueDays,
|
||||
})
|
||||
: t("cycle.ring.periodOverdue")
|
||||
: t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
when the count paused on an overdue cycle. */}
|
||||
{!loading && !calendarError && wheel.dayOfCycle == null ? (
|
||||
{!loading && !calendarError && verdict?.dayOfCycle == null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => openSheet(today)}
|
||||
>
|
||||
{wheel.periodOverdue
|
||||
{verdict?.state === "OVERDUE"
|
||||
? t("cycle.ring.logPeriodCta")
|
||||
: t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
@@ -283,7 +280,7 @@ export function CycleView() {
|
||||
{!loading && !calendarError ? (
|
||||
<PhaseEducationCard
|
||||
animate={play}
|
||||
phase={wheel.phase}
|
||||
phase={verdict?.phase ?? null}
|
||||
symptomPatterns={insights.data?.symptomPatterns ?? []}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
@@ -388,6 +385,7 @@ export function CycleView() {
|
||||
<BbtChart
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
cycleStartDate={verdict?.cycleStartDate ?? null}
|
||||
predictedOvulation={
|
||||
calendar.data?.prediction?.predictedOvulation ?? null
|
||||
}
|
||||
@@ -446,9 +444,9 @@ export function CycleView() {
|
||||
onOpenChange={setSheetOpen}
|
||||
date={selectedDate}
|
||||
today={today}
|
||||
activePeriod={wheel.phase === "MENSTRUAL"}
|
||||
phase={wheel.phase}
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
activePeriod={verdict?.phase === "MENSTRUAL"}
|
||||
phase={verdict?.phase ?? null}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
goal={goal}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
|
||||
@@ -53,6 +53,29 @@ export interface CyclePrediction {
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's resolved answer about today (`src/lib/cycle/verdict.ts`).
|
||||
*
|
||||
* Render it. Do not recompute any part of it here: the grace window that
|
||||
* decides `OVERDUE`, the day count, the ring's arcs and the fertile-window
|
||||
* state are all resolved server-side against the user's own timezone day, and
|
||||
* a second copy of that logic in client code is how a cycle ring ends up
|
||||
* showing a person a day their record does not hold.
|
||||
*/
|
||||
export type CycleVerdictState = "IN_CYCLE" | "OVERDUE" | "INSUFFICIENT_DATA";
|
||||
|
||||
export interface CycleVerdict {
|
||||
state: CycleVerdictState;
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
cycleStartDate: string | null;
|
||||
overdueDays: number | null;
|
||||
daysUntilNext: number | null;
|
||||
fertileWindow: { start: string | null; end: string | null; active: boolean };
|
||||
}
|
||||
|
||||
export interface CalendarResponse {
|
||||
profile: {
|
||||
goal: CycleGoal;
|
||||
@@ -61,6 +84,8 @@ export interface CalendarResponse {
|
||||
cyclesObserved: number;
|
||||
};
|
||||
prediction: CyclePrediction | null;
|
||||
/** The resolved verdict — always present, so nothing here needs to derive one. */
|
||||
verdict: CycleVerdict;
|
||||
/**
|
||||
* Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
|
||||
* are observed. When set, `days` carries no fertile window, ovulation dot, or
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* v1.15.0 — derive the cycle-wheel state (day-of-cycle, current phase,
|
||||
* proportional phase spans) from the calendar read.
|
||||
*
|
||||
* The calendar read already labels each day with its `phase`; we walk
|
||||
* backward from today to the start of the current run of consecutive
|
||||
* non-null phase days to find the cycle's start, count the day-of-cycle, and
|
||||
* tally each phase's share of that run for the proportional ring segments.
|
||||
* Pure + deterministic so it matches the server engine's labels 1:1.
|
||||
*/
|
||||
import type { CalendarDay, CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface WheelState {
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
/**
|
||||
* v1.27.5 — true when the current run overshoots the profile's typical
|
||||
* cycle length by more than the overdue grace window. The wheel then stops
|
||||
* asserting a day-of-cycle / phase (both null) and the view renders the
|
||||
* honest "period overdue" caption instead: a day count like "90" inside
|
||||
* the ring is a population-frame artefact of the open cycle window, not an
|
||||
* observed fact (see `OVERDUE_GRACE_DAYS`).
|
||||
*/
|
||||
periodOverdue: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile-derived canonical phase lengths used to draw an idealized ring
|
||||
* for a low-data tracker (see `idealizedSpans`). All optional — every field
|
||||
* falls back to the textbook default when the profile has not set it.
|
||||
*/
|
||||
export interface CycleProfileLengths {
|
||||
/** Typical full cycle length in days (default 28). */
|
||||
typicalCycleLength?: number | null;
|
||||
/** Typical period (menstrual) length in days (default 5). */
|
||||
typicalPeriodLength?: number | null;
|
||||
/** Typical luteal-phase length in days (default 14). */
|
||||
lutealPhaseLength?: number | null;
|
||||
}
|
||||
|
||||
/** Below this many labelled days in the current cycle run, the ring would
|
||||
* normalise a 1-3 day partial run so a single phase filled the whole circle.
|
||||
* For such low-data trackers we draw the canonical four-phase proportions from
|
||||
* the profile instead, so the dial always reads as a real cycle wheel. A run
|
||||
* of 7+ labelled days already spans more than the menstrual phase, so the
|
||||
* observed-share path takes over from there. */
|
||||
const SPARSE_RUN_THRESHOLD = 7;
|
||||
|
||||
/**
|
||||
* Days PAST the profile's typical cycle length before the wheel stops
|
||||
* counting up. The engine keeps the open cycle's phase window alive until
|
||||
* the predicted next start, and a confirmed/anchored ovulation signal (BBT
|
||||
* trend, symptothermal double-check, LH surge) or a long estimated length
|
||||
* can push that prediction into the future while the last logged period is
|
||||
* months old — every day of the gap then carries a (population-framed)
|
||||
* LUTEAL label and the day-of-cycle walks up without bound ("day 90").
|
||||
* Beyond typical + grace the count is no longer an observed fact, so the
|
||||
* wheel degrades to the honest overdue state instead. Two weeks of grace
|
||||
* covers ordinary cycle-to-cycle variation without hiding a real delay.
|
||||
*/
|
||||
const OVERDUE_GRACE_DAYS = 14;
|
||||
|
||||
/**
|
||||
* The canonical four-phase span set for a low-data tracker, derived from the
|
||||
* profile's typical lengths (textbook defaults when unset): a 28-day cycle
|
||||
* with a 5-day period, a 2-day ovulatory window, a 14-day luteal phase, and
|
||||
* the follicular phase filling the remainder. Proportions, not day counts, so
|
||||
* the ring renders the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs
|
||||
* instead of one dominant sliver.
|
||||
*/
|
||||
function idealizedSpans(profile?: CycleProfileLengths): {
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
cycleLength: number;
|
||||
} {
|
||||
const cycle = Math.max(
|
||||
Math.round(profile?.typicalCycleLength ?? 28) || 28,
|
||||
7,
|
||||
);
|
||||
const period = Math.min(
|
||||
Math.max(Math.round(profile?.typicalPeriodLength ?? 5) || 5, 1),
|
||||
cycle - 3,
|
||||
);
|
||||
const luteal = Math.min(
|
||||
Math.max(Math.round(profile?.lutealPhaseLength ?? 14) || 14, 1),
|
||||
cycle - period - 1,
|
||||
);
|
||||
// A short ovulatory window sits at the fertile peak; the follicular phase
|
||||
// fills whatever remains between the period and the ovulatory window.
|
||||
const ovulatory = Math.min(2, Math.max(cycle - period - luteal - 1, 1));
|
||||
const follicular = Math.max(cycle - period - ovulatory - luteal, 1);
|
||||
|
||||
const days: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: period,
|
||||
FOLLICULAR: follicular,
|
||||
OVULATORY: ovulatory,
|
||||
LUTEAL: luteal,
|
||||
};
|
||||
const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL;
|
||||
const spans = PHASE_ORDER.map((phase) => ({
|
||||
phase,
|
||||
fraction: days[phase] / total,
|
||||
}));
|
||||
return { spans, cycleLength: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
|
||||
* the first day of the most recent MENSTRUAL block at/before today with no phase
|
||||
* gap. The ONE source of truth both `currentCycleStartDate` and
|
||||
* `deriveWheelState` use, so the BBT chart, the day-of-cycle count, and the ring
|
||||
* spans can never disagree on where the current cycle begins. Assumes
|
||||
* `sorted[todayIdx].phase != null` (the callers check first).
|
||||
*/
|
||||
function findCycleStartIndex(sorted: CalendarDay[], todayIdx: number): number {
|
||||
let startIdx = todayIdx;
|
||||
for (let i = todayIdx; i >= 0; i--) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
startIdx = i;
|
||||
if (
|
||||
d.phase === "MENSTRUAL" &&
|
||||
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return startIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first day (`YYYY-MM-DD`) of the current cycle, or null when today has no
|
||||
* phase (no active cycle). Scopes the BBT chart to the same cycle the wheel
|
||||
* shows.
|
||||
*/
|
||||
export function currentCycleStartDate(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
): string | null {
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
|
||||
return sorted[findCycleStartIndex(sorted, todayIdx)].date;
|
||||
}
|
||||
|
||||
export function deriveWheelState(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
profile?: CycleProfileLengths,
|
||||
): WheelState {
|
||||
const byDate = new Map(days.map((d) => [d.date, d]));
|
||||
const todayDay = byDate.get(today);
|
||||
if (!todayDay || todayDay.phase == null) {
|
||||
return {
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// Honest ceiling: once the run overshoots the typical length + grace, the
|
||||
// count reflects the engine's open-ended cycle window, not an observed
|
||||
// cycle day. Degrade to the idealized ring with no day / phase claim; the
|
||||
// view pairs it with the "period overdue" caption + log CTA.
|
||||
const typicalLength = Math.max(
|
||||
Math.round(profile?.typicalCycleLength ?? 28) || 28,
|
||||
7,
|
||||
);
|
||||
if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
dayOfCycle: null,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: null,
|
||||
spans: ideal.spans,
|
||||
periodOverdue: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Tally each phase's day-count across the run + the forward predicted run
|
||||
// (so the ring shows the whole cycle, not just elapsed days).
|
||||
const counts: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 0,
|
||||
OVULATORY: 0,
|
||||
LUTEAL: 0,
|
||||
};
|
||||
// Include forward days until the next MENSTRUAL block to bound a full cycle.
|
||||
let end = todayIdx;
|
||||
for (let i = todayIdx + 1; i < sorted.length; i++) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
if (d.phase === "MENSTRUAL") break;
|
||||
end = i;
|
||||
}
|
||||
const fullRun = sorted.slice(startIdx, end + 1);
|
||||
for (const d of fullRun) {
|
||||
if (d.phase) counts[d.phase] += 1;
|
||||
}
|
||||
|
||||
// Low-data tracker: too few labelled days to span a real cycle. The
|
||||
// observed-share math would normalise a 1-3 day partial run so a single
|
||||
// phase filled the whole ring (the "one dominant arc" bug). Fall back to the
|
||||
// canonical four-phase proportions derived from the profile's typical
|
||||
// lengths so the dial reads as a real cycle wheel. The current phase + the
|
||||
// day-of-cycle marker stay driven by the observed labels; only the arc
|
||||
// proportions and the ring's represented length come from the ideal.
|
||||
if (fullRun.length < SPARSE_RUN_THRESHOLD) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
dayOfCycle,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: todayDay.phase,
|
||||
spans: ideal.spans,
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
|
||||
const total = fullRun.length || 1;
|
||||
const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({
|
||||
phase,
|
||||
fraction: counts[phase] / total,
|
||||
}));
|
||||
|
||||
return {
|
||||
dayOfCycle,
|
||||
cycleLength: fullRun.length,
|
||||
phase: todayDay.phase,
|
||||
spans,
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The resolved cycle verdict.
|
||||
*
|
||||
* The load-bearing case is the overdue boundary. Beyond the profile's typical
|
||||
* length plus the server's grace window the day count stops being an observed
|
||||
* fact and the verdict must say so, with the number of days. One day either
|
||||
* side of that line is the difference between a person reading "day 43" and
|
||||
* reading "15 days later than usual", so both sides are pinned here, together
|
||||
* with the day counts each side produces.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { resolveCycleVerdict, type VerdictCalendarDay } from "../verdict";
|
||||
import type { CyclePhase } from "../types";
|
||||
|
||||
function day(
|
||||
date: string,
|
||||
phase: CyclePhase | null,
|
||||
isFertileWindow = false,
|
||||
): VerdictCalendarDay {
|
||||
return { date, phase, isFertileWindow };
|
||||
}
|
||||
|
||||
/** A run of 5 MENSTRUAL days then LUTEAL days, `n` long, ending at `today`. */
|
||||
function longRun(
|
||||
n: number,
|
||||
from = "2026-01-01",
|
||||
): {
|
||||
days: VerdictCalendarDay[];
|
||||
today: string;
|
||||
} {
|
||||
const start = Date.parse(`${from}T00:00:00Z`);
|
||||
const days: VerdictCalendarDay[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const date = new Date(start + i * 86_400_000).toISOString().slice(0, 10);
|
||||
days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL"));
|
||||
}
|
||||
return { days, today: days[n - 1].date };
|
||||
}
|
||||
|
||||
describe("resolveCycleVerdict — the overdue boundary", () => {
|
||||
it("still counts on the last honest day", () => {
|
||||
// Typical 28 + a 14-day grace window = 42. Day 42 is an observed fact.
|
||||
const { days, today } = longRun(42);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(42);
|
||||
expect(v.overdueDays).toBeNull();
|
||||
});
|
||||
|
||||
it("stops counting one day later, and says how late", () => {
|
||||
const { days, today } = longRun(43);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
expect(v.phase).toBeNull();
|
||||
// Measured against the typical length the person actually has, not against
|
||||
// typical + grace: 43 days into a 28-day cycle is 15 days later than usual.
|
||||
expect(v.overdueDays).toBe(15);
|
||||
});
|
||||
|
||||
it("keeps growing the overdue count as the delay grows", () => {
|
||||
const { days, today } = longRun(90);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.overdueDays).toBe(62);
|
||||
// The ring still draws the canonical four-phase dial rather than nothing.
|
||||
expect(v.spans).toHaveLength(4);
|
||||
expect(v.cycleLength).toBe(28);
|
||||
});
|
||||
|
||||
it("moves the boundary with the profile's own typical length", () => {
|
||||
// A 60-day typical cycle keeps day 49 honest where a 28-day one would not.
|
||||
const { days, today } = longRun(49);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 60 },
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(49);
|
||||
expect(v.overdueDays).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the cycle start even once the count stops", () => {
|
||||
// The last logged period start is still a fact; the BBT chart scopes to it.
|
||||
const { days, today } = longRun(60);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.cycleStartDate).toBe("2026-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCycleVerdict — what the record supports", () => {
|
||||
it("says INSUFFICIENT_DATA when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-02" });
|
||||
expect(v.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
expect(v.phase).toBeNull();
|
||||
expect(v.cycleStartDate).toBeNull();
|
||||
expect(v.spans).toEqual([]);
|
||||
});
|
||||
|
||||
it("says INSUFFICIENT_DATA when today is absent from the grid", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-09" });
|
||||
expect(v.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
});
|
||||
|
||||
it("counts the cycle day from the most recent menstrual start", () => {
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-05" });
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(5);
|
||||
expect(v.phase).toBe("FOLLICULAR");
|
||||
expect(v.cycleStartDate).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("builds observed phase arcs that sum to the whole ring", () => {
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "MENSTRUAL"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
day("2026-06-06", "OVULATORY"),
|
||||
day("2026-06-07", "LUTEAL"),
|
||||
day("2026-06-08", "LUTEAL"),
|
||||
];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-05" });
|
||||
expect(v.spans.reduce((s, x) => s + x.fraction, 0)).toBeCloseTo(1, 5);
|
||||
expect(v.spans.map((s) => s.phase).sort()).toEqual([
|
||||
"FOLLICULAR",
|
||||
"LUTEAL",
|
||||
"MENSTRUAL",
|
||||
"OVULATORY",
|
||||
]);
|
||||
expect(v.cycleLength).toBe(8);
|
||||
});
|
||||
|
||||
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
|
||||
// canonical proportions while the day count stays observed.
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-02",
|
||||
profile: {
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
},
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(2);
|
||||
expect(v.spans).toHaveLength(4);
|
||||
expect(v.cycleLength).toBe(28);
|
||||
for (const span of v.spans) {
|
||||
expect(span.fraction).toBeGreaterThan(0);
|
||||
expect(span.fraction).toBeLessThan(0.95);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCycleVerdict — days until the next period", () => {
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
];
|
||||
|
||||
it("counts forward to the predicted start", () => {
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-03",
|
||||
nextPeriodStart: "2026-06-29",
|
||||
});
|
||||
expect(v.daysUntilNext).toBe(26);
|
||||
});
|
||||
|
||||
it("is null when no prediction ran", () => {
|
||||
expect(
|
||||
resolveCycleVerdict({ days, today: "2026-06-03" }).daysUntilNext,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is null once the predicted start is already past", () => {
|
||||
// There is no "until" any more, and inventing a negative number would only
|
||||
// invite a client to render it.
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-03",
|
||||
nextPeriodStart: "2026-06-01",
|
||||
});
|
||||
expect(v.daysUntilNext).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCycleVerdict — the fertile window", () => {
|
||||
it("reports the window it is given and whether today falls in it", () => {
|
||||
const days = [
|
||||
day("2026-06-10", "FOLLICULAR"),
|
||||
day("2026-06-11", "OVULATORY", true),
|
||||
];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-11",
|
||||
fertileWindowStart: "2026-06-08",
|
||||
fertileWindowEnd: "2026-06-13",
|
||||
});
|
||||
expect(v.fertileWindow).toEqual({
|
||||
start: "2026-06-08",
|
||||
end: "2026-06-13",
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays empty when the caller suppressed the window", () => {
|
||||
// The route passes the ALREADY goal- and cold-start-gated values, so a
|
||||
// suppressed window arrives here as nulls and cannot reappear.
|
||||
const days = [day("2026-06-11", "OVULATORY", false)];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-11" });
|
||||
expect(v.fertileWindow).toEqual({
|
||||
start: null,
|
||||
end: null,
|
||||
active: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,3 +24,11 @@ export {
|
||||
clampLuteal,
|
||||
} from "./prediction";
|
||||
export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase";
|
||||
export {
|
||||
resolveCycleVerdict,
|
||||
type CycleVerdict,
|
||||
type CycleVerdictState,
|
||||
type CyclePhaseSpan,
|
||||
type CycleProfileLengths,
|
||||
type VerdictCalendarDay,
|
||||
} from "./verdict";
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The resolved cycle verdict — day-of-cycle, the ring's phase segments, the
|
||||
* overdue judgement and its day count, and the days until the next predicted
|
||||
* period. Computed here, on the server, once.
|
||||
*
|
||||
* This used to live in web client code (`src/components/cycle/wheel-state.ts`)
|
||||
* and every client that wanted the same answer had to reproduce it, including
|
||||
* the fourteen-day grace window that decides when the day count stops being
|
||||
* an observed fact. Two copies of that rule means two answers about one
|
||||
* person's body, and the second copy is how a cycle ring ends up frozen. The
|
||||
* rule now exists once and the answer travels on the wire.
|
||||
*
|
||||
* Pure and deterministic: it reads the calendar grid the same request already
|
||||
* built, plus the profile's typical lengths and the (already goal-gated)
|
||||
* prediction fields. No database, no clock — `today` is resolved by the caller
|
||||
* from the user's own timezone, which is also why a client cannot compute this
|
||||
* correctly on its own.
|
||||
*/
|
||||
import { dayDiff } from "./day-math";
|
||||
import type { CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
/** One arc of the ring: a phase and its share of the represented cycle. */
|
||||
export interface CyclePhaseSpan {
|
||||
phase: CyclePhase;
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the record supports saying about today.
|
||||
*
|
||||
* - `IN_CYCLE` — today sits inside a cycle whose day count is still an
|
||||
* observed fact. `dayOfCycle`, `phase` and `cycleStartDate` are all set.
|
||||
* - `OVERDUE` — the open cycle has run past the profile's typical length by
|
||||
* more than the grace window. `dayOfCycle` and `phase` are null on purpose
|
||||
* (see the ceiling below); `overdueDays` carries how late it is.
|
||||
* - `INSUFFICIENT_DATA` — today carries no phase at all: no cycle has been
|
||||
* logged, or today falls outside every known cycle window.
|
||||
*/
|
||||
export type CycleVerdictState = "IN_CYCLE" | "OVERDUE" | "INSUFFICIENT_DATA";
|
||||
|
||||
export interface CycleVerdict {
|
||||
state: CycleVerdictState;
|
||||
/** 1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA. */
|
||||
dayOfCycle: number | null;
|
||||
/** Days the ring represents (observed run, or the idealized cycle). */
|
||||
cycleLength: number | null;
|
||||
/** Today's phase. Null in OVERDUE and INSUFFICIENT_DATA. */
|
||||
phase: CyclePhase | null;
|
||||
/** The ring's arcs, as fractions summing to ~1. Empty when there is nothing to draw. */
|
||||
spans: CyclePhaseSpan[];
|
||||
/**
|
||||
* First day of the current cycle (`YYYY-MM-DD`), or null when no cycle is
|
||||
* active. Still set while OVERDUE — the last logged period start remains a
|
||||
* fact even once the count stops. Scopes the BBT chart to the same cycle the
|
||||
* ring shows.
|
||||
*/
|
||||
cycleStartDate: string | null;
|
||||
/**
|
||||
* How many days past the profile's typical cycle length the open cycle has
|
||||
* run. Set only in OVERDUE. Measured against the typical length, not against
|
||||
* typical + grace: a person asking how late they are means "later than my
|
||||
* usual cycle", and the grace window is our tolerance, not their calendar.
|
||||
*/
|
||||
overdueDays: number | null;
|
||||
/**
|
||||
* Days from today to the predicted next period start. Null when there is no
|
||||
* prediction (raw-chart mode / prediction disabled) and null once that
|
||||
* predicted start is in the past — at that point there is no "until", and
|
||||
* `state` + `overdueDays` carry what is true instead.
|
||||
*/
|
||||
daysUntilNext: number | null;
|
||||
/** The fertile window as already goal-gated upstream, plus whether today falls in it. */
|
||||
fertileWindow: {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile-derived canonical phase lengths used to draw an idealized ring
|
||||
* for a low-data tracker (see `idealizedSpans`). All optional — every field
|
||||
* falls back to the textbook default when the profile has not set it.
|
||||
*/
|
||||
export interface CycleProfileLengths {
|
||||
/** Typical full cycle length in days (default 28). */
|
||||
typicalCycleLength?: number | null;
|
||||
/** Typical period (menstrual) length in days (default 5). */
|
||||
typicalPeriodLength?: number | null;
|
||||
/** Typical luteal-phase length in days (default 14). */
|
||||
lutealPhaseLength?: number | null;
|
||||
}
|
||||
|
||||
/** The calendar-grid fields the verdict reads. `CalendarDayDTO` satisfies it. */
|
||||
export interface VerdictCalendarDay {
|
||||
date: string;
|
||||
phase: CyclePhase | null;
|
||||
isFertileWindow: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveCycleVerdictInput {
|
||||
/** The calendar grid for the rendered span, in any order. */
|
||||
days: readonly VerdictCalendarDay[];
|
||||
/** Today's `YYYY-MM-DD` in the USER's timezone, not the process's. */
|
||||
today: string;
|
||||
profile?: CycleProfileLengths;
|
||||
/** `prediction.nextPeriodStart`, or null when no prediction ran. */
|
||||
nextPeriodStart?: string | null;
|
||||
/** Already goal- and cold-start-gated fertile window bounds. */
|
||||
fertileWindowStart?: string | null;
|
||||
fertileWindowEnd?: string | null;
|
||||
}
|
||||
|
||||
/** Below this many labelled days in the current cycle run, the ring would
|
||||
* normalise a 1-3 day partial run so a single phase filled the whole circle.
|
||||
* For such low-data trackers we draw the canonical four-phase proportions from
|
||||
* the profile instead, so the dial always reads as a real cycle wheel. A run
|
||||
* of 7+ labelled days already spans more than the menstrual phase, so the
|
||||
* observed-share path takes over from there. */
|
||||
const SPARSE_RUN_THRESHOLD = 7;
|
||||
|
||||
/**
|
||||
* Days PAST the profile's typical cycle length before the count stops.
|
||||
* The engine keeps the open cycle's phase window alive until the predicted
|
||||
* next start, and a confirmed/anchored ovulation signal (BBT trend,
|
||||
* symptothermal double-check, LH surge) or a long estimated length can push
|
||||
* that prediction into the future while the last logged period is months old —
|
||||
* every day of the gap then carries a (population-framed) LUTEAL label and the
|
||||
* day-of-cycle walks up without bound ("day 90"). Beyond typical + grace the
|
||||
* count is no longer an observed fact, so the verdict degrades to the honest
|
||||
* overdue state instead. Two weeks of grace covers ordinary cycle-to-cycle
|
||||
* variation without hiding a real delay.
|
||||
*
|
||||
* This constant is deliberately NOT published on the wire. A client that knows
|
||||
* the threshold will eventually apply it itself, and then there are two
|
||||
* deciders again. Clients read `state` and `overdueDays`.
|
||||
*/
|
||||
const OVERDUE_GRACE_DAYS = 14;
|
||||
|
||||
/** The profile's typical cycle length, floored at a week. */
|
||||
function typicalCycleLength(profile?: CycleProfileLengths): number {
|
||||
return Math.max(Math.round(profile?.typicalCycleLength ?? 28) || 28, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical four-phase span set for a low-data tracker, derived from the
|
||||
* profile's typical lengths (textbook defaults when unset): a 28-day cycle
|
||||
* with a 5-day period, a 2-day ovulatory window, a 14-day luteal phase, and
|
||||
* the follicular phase filling the remainder. Proportions, not day counts, so
|
||||
* the ring renders the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs
|
||||
* instead of one dominant sliver.
|
||||
*/
|
||||
function idealizedSpans(profile?: CycleProfileLengths): {
|
||||
spans: CyclePhaseSpan[];
|
||||
cycleLength: number;
|
||||
} {
|
||||
const cycle = typicalCycleLength(profile);
|
||||
const period = Math.min(
|
||||
Math.max(Math.round(profile?.typicalPeriodLength ?? 5) || 5, 1),
|
||||
cycle - 3,
|
||||
);
|
||||
const luteal = Math.min(
|
||||
Math.max(Math.round(profile?.lutealPhaseLength ?? 14) || 14, 1),
|
||||
cycle - period - 1,
|
||||
);
|
||||
// A short ovulatory window sits at the fertile peak; the follicular phase
|
||||
// fills whatever remains between the period and the ovulatory window.
|
||||
const ovulatory = Math.min(2, Math.max(cycle - period - luteal - 1, 1));
|
||||
const follicular = Math.max(cycle - period - ovulatory - luteal, 1);
|
||||
|
||||
const days: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: period,
|
||||
FOLLICULAR: follicular,
|
||||
OVULATORY: ovulatory,
|
||||
LUTEAL: luteal,
|
||||
};
|
||||
const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL;
|
||||
const spans = PHASE_ORDER.map((phase) => ({
|
||||
phase,
|
||||
fraction: days[phase] / total,
|
||||
}));
|
||||
return { spans, cycleLength: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
|
||||
* the first day of the most recent MENSTRUAL block at/before today with no phase
|
||||
* gap. Assumes `sorted[todayIdx].phase != null` (the caller checks first).
|
||||
*/
|
||||
function findCycleStartIndex(
|
||||
sorted: readonly VerdictCalendarDay[],
|
||||
todayIdx: number,
|
||||
): number {
|
||||
let startIdx = todayIdx;
|
||||
for (let i = todayIdx; i >= 0; i--) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
startIdx = i;
|
||||
if (
|
||||
d.phase === "MENSTRUAL" &&
|
||||
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return startIdx;
|
||||
}
|
||||
|
||||
/** Days from today to the next predicted start, or null once it is past. */
|
||||
function resolveDaysUntilNext(
|
||||
today: string,
|
||||
nextPeriodStart: string | null | undefined,
|
||||
): number | null {
|
||||
if (!nextPeriodStart) return null;
|
||||
const delta = dayDiff(nextPeriodStart, today);
|
||||
return delta >= 0 ? delta : null;
|
||||
}
|
||||
|
||||
function resolveFertileWindow(
|
||||
input: ResolveCycleVerdictInput,
|
||||
todayDay: VerdictCalendarDay | undefined,
|
||||
): CycleVerdict["fertileWindow"] {
|
||||
return {
|
||||
start: input.fertileWindowStart ?? null,
|
||||
end: input.fertileWindowEnd ?? null,
|
||||
active: todayDay?.isFertileWindow ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a client needs to render the cycle ring and its caption.
|
||||
* The only place in the product that decides any of it.
|
||||
*/
|
||||
export function resolveCycleVerdict(
|
||||
input: ResolveCycleVerdictInput,
|
||||
): CycleVerdict {
|
||||
const { days, today, profile } = input;
|
||||
const byDate = new Map(days.map((d) => [d.date, d]));
|
||||
const todayDay = byDate.get(today);
|
||||
const fertileWindow = resolveFertileWindow(input, todayDay);
|
||||
const daysUntilNext = resolveDaysUntilNext(today, input.nextPeriodStart);
|
||||
|
||||
const nothingToSay: CycleVerdict = {
|
||||
state: "INSUFFICIENT_DATA",
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
|
||||
if (!todayDay || todayDay.phase == null) return 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;
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const cycleStartDate = sorted[startIdx].date;
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// Honest ceiling: once the run overshoots the typical length + grace, the
|
||||
// count reflects the engine's open-ended cycle window, not an observed
|
||||
// cycle day. Degrade to the idealized ring with no day / phase claim; the
|
||||
// caption says the period is late and by how much.
|
||||
const typicalLength = typicalCycleLength(profile);
|
||||
if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
state: "OVERDUE",
|
||||
dayOfCycle: null,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: null,
|
||||
spans: ideal.spans,
|
||||
cycleStartDate,
|
||||
overdueDays: dayOfCycle - typicalLength,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
|
||||
// Tally each phase's day-count across the run + the forward predicted run
|
||||
// (so the ring shows the whole cycle, not just elapsed days).
|
||||
const counts: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 0,
|
||||
OVULATORY: 0,
|
||||
LUTEAL: 0,
|
||||
};
|
||||
// Include forward days until the next MENSTRUAL block to bound a full cycle.
|
||||
let end = todayIdx;
|
||||
for (let i = todayIdx + 1; i < sorted.length; i++) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
if (d.phase === "MENSTRUAL") break;
|
||||
end = i;
|
||||
}
|
||||
const fullRun = sorted.slice(startIdx, end + 1);
|
||||
for (const d of fullRun) {
|
||||
if (d.phase) counts[d.phase] += 1;
|
||||
}
|
||||
|
||||
// Low-data tracker: too few labelled days to span a real cycle. The
|
||||
// observed-share math would normalise a 1-3 day partial run so a single
|
||||
// phase filled the whole ring (the "one dominant arc" bug). Fall back to the
|
||||
// canonical four-phase proportions derived from the profile's typical
|
||||
// lengths so the dial reads as a real cycle wheel. The current phase + the
|
||||
// day-of-cycle marker stay driven by the observed labels; only the arc
|
||||
// proportions and the ring's represented length come from the ideal.
|
||||
if (fullRun.length < SPARSE_RUN_THRESHOLD) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: todayDay.phase,
|
||||
spans: ideal.spans,
|
||||
cycleStartDate,
|
||||
overdueDays: null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
|
||||
const total = fullRun.length || 1;
|
||||
const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({
|
||||
phase,
|
||||
fraction: counts[phase] / total,
|
||||
}));
|
||||
|
||||
return {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle,
|
||||
cycleLength: fullRun.length,
|
||||
phase: todayDay.phase,
|
||||
spans,
|
||||
cycleStartDate,
|
||||
overdueDays: null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
@@ -191,6 +191,45 @@ const cycleCalendarDayDto = z.object({
|
||||
cervixOpening: cervixOpeningEnum.nullable(),
|
||||
});
|
||||
|
||||
const cycleVerdictDto = z
|
||||
.object({
|
||||
state: z.enum(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]),
|
||||
dayOfCycle: z.number().int().nullable().meta({
|
||||
description:
|
||||
"1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA — beyond the typical length plus the server's grace window the count is no longer an observed fact.",
|
||||
}),
|
||||
cycleLength: z.number().int().nullable().meta({
|
||||
description:
|
||||
"Days the ring represents: the observed labelled run, or the profile-derived idealized cycle for a low-data tracker.",
|
||||
}),
|
||||
phase: cyclePhaseEnumOpenapi.nullable(),
|
||||
spans: z.array(
|
||||
z.object({ phase: cyclePhaseEnumOpenapi, fraction: z.number() }),
|
||||
),
|
||||
cycleStartDate: z.string().nullable().meta({
|
||||
description:
|
||||
"First day of the current cycle (YYYY-MM-DD). Still set while OVERDUE — the last logged period start remains a fact once the count stops.",
|
||||
}),
|
||||
overdueDays: z.number().int().nullable().meta({
|
||||
description:
|
||||
"How many days past the profile's typical cycle length the open cycle has run. Set only in OVERDUE.",
|
||||
}),
|
||||
daysUntilNext: z.number().int().nullable().meta({
|
||||
description:
|
||||
"Days from today to the predicted next period start. Null when no prediction ran and null once that start is in the past.",
|
||||
}),
|
||||
fertileWindow: z.object({
|
||||
start: z.string().nullable(),
|
||||
end: z.string().nullable(),
|
||||
active: z.boolean(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "CycleVerdictDTO",
|
||||
description:
|
||||
"The resolved cycle verdict for the user's own timezone day. Render these values; do not recompute them. The grace window that decides OVERDUE is deliberately not published — read `state` and `overdueDays`.",
|
||||
});
|
||||
|
||||
const cycleProfileDto = z
|
||||
.object({
|
||||
goal: cycleTrackingGoalEnum,
|
||||
@@ -218,6 +257,10 @@ const cycleCalendarResponse = z.object({
|
||||
cyclesObserved: z.number().int(),
|
||||
}),
|
||||
prediction: cyclePredictionDto.nullable(),
|
||||
// The resolved verdict — required, never optional. An optional verdict is a
|
||||
// plausible default that invites a client to fall back to deriving its own,
|
||||
// which is the defect this field exists to remove.
|
||||
verdict: cycleVerdictDto,
|
||||
// Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
|
||||
// are observed. When set, the `days` grid carries no fertile window, no
|
||||
// predicted-ovulation dot, and no phase band (those would rest on a
|
||||
|
||||
Reference in new issue
Block a user