diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d32fa3b1e..c5d57b9ca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 72d0e546e..4090d9ad3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 9b689ad2b..22b529a5b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index f020d3cd4..c9aa4fbdf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 9f90ace3a..739e1af82 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index ebadb9ffe..183042f2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 92f351f3b..ed11af429 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index e4bfa86c2..40b1b6f1c 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -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 }, }); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 26bf834e7..a13c8fac2 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -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 ? ( void calendar.refetch()} /> ) : ( - + <> + {/* 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 ? ( +
+
+ ) : null} + + )} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 25fbf63f7..be791ffe5 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -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 }; } diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 900a3031d..6e2db3080 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -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); + }); + }); }); diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index cc7f33e7b..20c11f2d2 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -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 }; } diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 418424c93..a2663c885 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -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 }) }), });