fix(cycle): gate the calendar grid to a learning state during cold start

The predictions panel suppressed its fertile-window and ovulation rows while
fewer than three cycles were observed, but the month calendar did not — so a
brand-new user who logged a single period saw the grid paint a confident
fertile-window shading, a predicted-ovulation dot, and full phase bands off a
population 28/14 prior at ~0.20 confidence. Calendar-based fertility estimation
is unreliable below several recorded cycles, so the grid was asserting days it
could not know.

Thread the same still-learning gate into the calendar build: while fewer than
three cycles are observed, suppress the fertile window, the ovulation dot, and
the phase band (the predicted-period bar stays, mirroring the panel). Surface a
top-level stillLearning flag on the calendar response so the client renders a
calm "learning your cycle" banner over the grid instead of a bare, falsely
confident calendar. The flag is additive and back-compatible; older clients
ignore it and degrade to the previous render.

Also pin the observed-cycle count to a single source — the engine's
post-exclusion value — so the response never carries two divergent counts.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 14:43:50 +02:00
1 parent 63f398042d
commit f5798e20d8
13 files changed
+159 -19

No files matched your search

+3
View File
@@ -6608,6 +6608,8 @@ components:
anyOf:
- $ref: "#/components/schemas/CyclePredictionDTO"
- type: "null"
stillLearning:
type: boolean
days:
type: array
items:
@@ -6691,6 +6693,7 @@ components:
required:
- profile
- prediction
- stillLearning
- days
- meta
additionalProperties: false
+1
View File
@@ -5824,6 +5824,7 @@
"nextMonth": "Nächster Monat",
"loading": "Kalender wird geladen…",
"empty": "Erfasse Daten, damit sich dein Kalender füllt.",
"learning": "Wir lernen deinen Zyklus – erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.",
"today": "Heute",
"dayAria": "{date}",
"legendSymptom": "Symptome",
+1
View File
@@ -5824,6 +5824,7 @@
"nextMonth": "Next month",
"loading": "Loading your calendar…",
"empty": "Start logging to see your calendar fill in.",
"learning": "Learning your cycle — log at least three full cycles and the fertile window, ovulation and phases fill in. Until then the calendar shows only what you've logged.",
"today": "Today",
"dayAria": "{date}",
"legendSymptom": "Symptoms",
+1
View File
@@ -5824,6 +5824,7 @@
"nextMonth": "Mes siguiente",
"loading": "Cargando tu calendario…",
"empty": "Empieza a registrar para ver tu calendario.",
"learning": "Estamos aprendiendo tu ciclo: registra al menos tres ciclos completos y se completarán la ventana fértil, la ovulación y las fases. Hasta entonces, el calendario solo muestra lo que has registrado.",
"today": "Hoy",
"dayAria": "{date}",
"legendSymptom": "Síntomas",
+1
View File
@@ -5824,6 +5824,7 @@
"nextMonth": "Mois suivant",
"loading": "Chargement de ton calendrier…",
"empty": "Commence à enregistrer pour remplir ton calendrier.",
"learning": "Nous apprenons ton cycle — enregistre au moins trois cycles complets et la fenêtre fertile, l'ovulation et les phases se compléteront. D'ici là, le calendrier n'affiche que ce que tu as enregistré.",
"today": "Aujourd'hui",
"dayAria": "{date}",
"legendSymptom": "Symptômes",
+1
View File
@@ -5824,6 +5824,7 @@
"nextMonth": "Mese successivo",
"loading": "Caricamento del calendario…",
"empty": "Inizia a registrare per riempire il calendario.",
"learning": "Stiamo imparando il tuo ciclo: registra almeno tre cicli completi e si completeranno la finestra fertile, l'ovulazione e le fasi. Fino ad allora il calendario mostra solo ciò che hai registrato.",
"today": "Oggi",
"dayAria": "{date}",
"legendSymptom": "Sintomi",
+1
View File
@@ -5824,6 +5824,7 @@
"nextMonth": "Następny miesiąc",
"loading": "Ładowanie kalendarza…",
"empty": "Zacznij zapisywać, aby wypełnić kalendarz.",
"learning": "Uczymy się Twojego cyklu — zapisz co najmniej trzy pełne cykle, a okno płodne, owulacja i fazy się uzupełnią. Do tego czasu kalendarz pokazuje tylko to, co zapisałaś.",
"today": "Dziś",
"dayAria": "{date}",
"legendSymptom": "Objawy",
+11 -1
View File
@@ -137,7 +137,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
const { prediction, days } = buildCalendar(
const { prediction, stillLearning, days } = buildCalendar(
profile,
cycles,
dayLogs,
@@ -179,11 +179,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
goal: profile.goal,
rawChartMode: profile.rawChartMode,
predictionEnabled: profile.predictionEnabled,
// Single source of truth for the observed-cycle count: the engine's
// post-exclusion `cyclesObserved` (the same number the prediction and the
// `stillLearning` gate key off). The raw `cycles.length` fallback only
// applies when no prediction ran (raw-chart mode / prediction disabled),
// so a consumer never sees two divergent "cycles observed" values.
cyclesObserved: prediction?.cyclesObserved ?? cycles.length,
},
prediction: prediction
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
: null,
// 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
// + back-compatible — older iOS builds ignore the field.
stillLearning,
days,
meta: { generatedAt },
});
+30 -11
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw } from "lucide-react";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -274,16 +274,35 @@ export function CycleView() {
) : calendarError ? (
<TabError onRetry={() => void calendar.refetch()} />
) : (
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ?? null)
: null
}
onSelectDay={openSheet}
/>
<>
{/* Cold start: the grid carries no fertile/ovulation/phase
overlays yet (the server suppresses them while learning),
so name the calm state rather than letting a bare grid
imply a confident forecast. */}
{calendar.data?.stillLearning ? (
<div
data-slot="cycle-calendar-learning"
className="text-muted-foreground bg-muted/40 mb-3 flex items-start gap-2 rounded-lg p-3 text-sm"
>
<Sparkles
className="text-primary mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span>{t("cycle.calendar.learning")}</span>
</div>
) : null}
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ??
null)
: null
}
onSelectDay={openSheet}
/>
</>
)}
</CardContent>
</Card>
+6
View File
@@ -68,6 +68,12 @@ export interface CalendarResponse {
cyclesObserved: number;
};
prediction: CyclePrediction | null;
/**
* Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
* are observed. When set, `days` carries no fertile window, ovulation dot, or
* phase band — render the calm "learning your cycle" state over the grid.
*/
stillLearning: boolean;
days: CalendarDay[];
meta: { generatedAt: string };
}
+68 -5
View File
@@ -96,15 +96,22 @@ describe("buildCalendar", () => {
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
});
it("surfaces the fertile window for the conception goal", () => {
it("surfaces the fertile window for the conception goal once past cold start", () => {
// Four starts → three completed cycles → not still-learning.
const fourCycles = [
cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }),
cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }),
cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }),
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
];
const { days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
cycles,
fourCycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
"2026-03-20",
"2026-05-15",
"2026-04-10",
true,
);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
@@ -123,4 +130,60 @@ describe("buildCalendar", () => {
);
expect(prediction).toBeNull();
});
describe("still-learning gate (cold start)", () => {
// One logged period — zero completed cycles → priors-only forecast.
const oneCycle = [cycle("2026-02-26", { periodEndDate: "2026-03-02" })];
it("flags stillLearning and asserts no fertile/ovulation grid for ≤1 cycle (TTC)", () => {
const { prediction, stillLearning, days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
oneCycle,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
true, // goal allows fertile — only the learning gate must suppress it
);
expect(prediction).not.toBeNull();
expect(prediction!.cyclesObserved).toBeLessThan(3);
expect(prediction!.stillLearning).toBe(true);
expect(stillLearning).toBe(true);
// No confident fertile shading or ovulation dot from a population guess.
expect(days.every((d) => !d.isFertileWindow)).toBe(true);
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
// No asserted phase band either (population-28 frame is not yet earned).
expect(days.every((d) => d.phase === null)).toBe(true);
// The predicted next-period bar still shows (the panel shows it too).
expect(days.some((d) => d.isPredictedPeriod)).toBe(true);
});
it("emits normal fertile/ovulation/phase output once ≥3 cycles are observed", () => {
// Four ~28-day starts → three COMPLETED cycle lengths → cyclesObserved=3.
const fourCycles = [
cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }),
cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }),
cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }),
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
];
const { prediction, stillLearning, days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
fourCycles,
[],
[],
"2026-03-20",
"2026-05-15",
"2026-04-10",
true,
);
expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(3);
expect(prediction!.stillLearning).toBe(false);
expect(stillLearning).toBe(false);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
expect(days.some((d) => d.isPredictedOvulation)).toBe(true);
expect(days.some((d) => d.phase !== null)).toBe(true);
});
});
});
+29 -2
View File
@@ -214,6 +214,15 @@ export function buildPhaseDayMap(
export interface CalendarBuildResult {
prediction: CyclePredictionResult | null;
/**
* True while the engine is "still learning" the user's cycle (< 3 observed
* cycles, mirrors `prediction.stillLearning`). When set, the calendar grid
* does NOT assert a fertile window, an ovulation dot, or a population-framed
* phase band — those are population guesses the app has not yet earned the
* confidence to show as fact. The client renders a calm "learning your cycle"
* state instead. False when no prediction ran (raw-chart mode / disabled).
*/
stillLearning: boolean;
days: CalendarDayDTO[];
}
@@ -226,6 +235,15 @@ export interface CalendarBuildResult {
* (GENERAL_HEALTH / PERIMENOPAUSE) to suppress `isFertileWindow` +
* `isPredictedOvulation` at the grid level (the prediction's own window
* fields are nulled upstream by the engine for those goals).
*
* Cold-start honesty (C1): while the engine reports `stillLearning` (< 3
* observed cycles), the grid suppresses the fertile window, the predicted-
* ovulation dot, AND the phase band — all of which would otherwise be painted
* from a population 28/14 prior at ~0.20 confidence. The predicted-period bar
* is kept (the predictions panel shows it too while learning). This matches
* the `stillLearning` gate the predictions panel already applies, so the
* calendar grid never asserts "these are your fertile days" off a single
* logged cycle.
*/
export function buildCalendar(
profile: CycleProfile,
@@ -254,6 +272,11 @@ export function buildCalendar(
);
}
// Cold-start gate: until ≥3 cycles are observed the engine's fertile/
// ovulation/phase output rests on a population prior, so the calendar must
// present it as a calm "learning" state rather than asserting it as fact.
const stillLearning = prediction?.stillLearning ?? false;
const phaseCycles = buildPhaseCycles(
cycles,
prediction?.nextPeriodStart ?? null,
@@ -285,6 +308,7 @@ export function buildCalendar(
isWithin(date, predictedPeriodStart, predictedPeriodEnd);
const isFertileWindow =
!stillLearning &&
goalAllowsFertile &&
prediction?.fertileWindowStart != null &&
prediction.fertileWindowEnd != null &&
@@ -295,13 +319,16 @@ export function buildCalendar(
);
const isPredictedOvulation =
!stillLearning &&
goalAllowsFertile &&
prediction?.predictedOvulation != null &&
prediction.predictedOvulation === date;
days.push({
date,
phase: phaseAcross(date, phaseCycles),
// No asserted phase band while learning — a population-28 frame off a
// single cycle is not yet earned (Lower: single-cycle phase band).
phase: stillLearning ? null : phaseAcross(date, phaseCycles),
isPredictedPeriod,
isFertileWindow,
isPredictedOvulation,
@@ -315,5 +342,5 @@ export function buildCalendar(
});
}
return { prediction, days };
return { prediction, stillLearning, days };
}
+6
View File
@@ -205,6 +205,12 @@ const cycleCalendarResponse = z.object({
cyclesObserved: z.number().int(),
}),
prediction: cyclePredictionDto.nullable(),
// 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
// population prior) — the client shows a calm "learning your cycle" state.
// Additive + back-compatible.
stillLearning: z.boolean(),
days: z.array(cycleCalendarDayDto),
meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }),
});