mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
63f398042d
commit
f5798e20d8
13 files changed
+159
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }) }),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user