Merge work/cycle-verdict

This commit is contained in:
Marc-André Bombeck committed 2026-08-01 11:32:07 +02:00
commit 4f79b9d49a
27 files changed
+1958 -565

No files matched your search

+97
View File
@@ -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:
+50 -11
View File
@@ -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() },
},
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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 -3
View File
@@ -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();
});
});
+16 -8
View File
@@ -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
+16 -27
View File
@@ -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>
+26 -28
View File
@@ -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}
+25
View File
@@ -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
-259
View File
@@ -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,
};
}
+258
View File
@@ -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,
});
});
});
+8
View File
@@ -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";
+354
View File
@@ -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,
};
}
+43
View File
@@ -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