feat(cycle): per-phase what's-happening education card

This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:45:15 +02:00
1 parent 93d47ddd05
commit 81ec98fc97
10 files changed
+462 -1

No files matched your search

@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { PhaseEducationCard } from "../phase-education-card";
import { I18nProvider } from "@/lib/i18n/context";
import type { SymptomPhaseRow } from "../use-cycle";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const lutealCramps: SymptomPhaseRow = {
symptomKey: "cramps",
counts: { MENSTRUAL: 1, FOLLICULAR: 0, OVULATORY: 0, LUTEAL: 6 },
total: 7,
topPhase: "LUTEAL",
topShare: 6 / 7,
};
const follicularFatigue: SymptomPhaseRow = {
symptomKey: "fatigue",
counts: { MENSTRUAL: 0, FOLLICULAR: 5, OVULATORY: 0, LUTEAL: 1 },
total: 6,
topPhase: "FOLLICULAR",
topShare: 5 / 6,
};
const okGate = {
predictionEnabled: true,
rawChartMode: false,
cyclesObserved: 4,
onLogToday: () => {},
};
describe("<PhaseEducationCard>", () => {
it("renders phase name + descriptive line + the log nudge when the gate passes", () => {
const html = render(
<PhaseEducationCard
phase="LUTEAL"
symptomPatterns={[lutealCramps, follicularFatigue]}
{...okGate}
/>,
);
expect(html).toContain("Luteal");
expect(html).toContain("progesterone"); // curated whatsHappening line
expect(html).toContain("Log today");
expect(html).toContain('data-phase="LUTEAL"');
});
it("shows only the user's OWN symptoms that cluster in the active phase", () => {
const html = render(
<PhaseEducationCard
phase="LUTEAL"
symptomPatterns={[lutealCramps, follicularFatigue]}
{...okGate}
/>,
);
// cramps clusters in luteal → shown; fatigue clusters in follicular → not.
expect(html).toContain("Cramps");
expect(html).not.toContain("Fatigue");
});
it("falls back to the still-learning line when fewer than three cycles", () => {
const html = render(
<PhaseEducationCard
phase="LUTEAL"
symptomPatterns={[lutealCramps]}
predictionEnabled
rawChartMode={false}
cyclesObserved={2}
onLogToday={() => {}}
/>,
);
expect(html).toContain("Still learning your cycle");
expect(html).not.toContain("progesterone");
expect(html).not.toContain("Cramps");
});
it("falls back to still-learning in raw-chart mode / prediction disabled", () => {
const raw = render(
<PhaseEducationCard
phase="LUTEAL"
symptomPatterns={[lutealCramps]}
predictionEnabled
rawChartMode
cyclesObserved={9}
onLogToday={() => {}}
/>,
);
expect(raw).toContain("Still learning your cycle");
const off = render(
<PhaseEducationCard
phase="LUTEAL"
symptomPatterns={[lutealCramps]}
predictionEnabled={false}
rawChartMode={false}
cyclesObserved={9}
onLogToday={() => {}}
/>,
);
expect(off).toContain("Still learning your cycle");
});
it("omits the chip row in the compact variant", () => {
const html = render(
<PhaseEducationCard
compact
phase="LUTEAL"
symptomPatterns={[lutealCramps]}
{...okGate}
/>,
);
expect(html).toContain("progesterone");
expect(html).not.toContain("cycle-phase-education-chips");
// The compact variant still carries the log nudge.
expect(html).toContain("Log today");
});
});
+34 -1
View File
@@ -13,6 +13,7 @@ import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { LogDaySheet } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
@@ -96,6 +97,10 @@ export function CycleView() {
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
// key for the no-prediction calendar tab (QA H-1).
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
// raw-chart mode, and at least three cycles are observed.
const calProfile = calendar.data?.profile;
const disclaimerText =
calendar.data?.prediction?.disclaimer ??
t(
@@ -123,6 +128,8 @@ export function CycleView() {
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel + compact phase card — grouped so they stick together. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
<div
data-slot="cycle-wheel-tile"
@@ -133,7 +140,7 @@ export function CycleView() {
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6 lg:sticky lg:top-6",
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
play && "wellness-tile-rise",
)}
>
@@ -182,6 +189,22 @@ export function CycleView() {
) : null}
</div>
{/* Compact phase-education card beneath the wheel — chip row omitted
here (compact); the full card lives on the calendar tab. */}
{!loading && !calendarError ? (
<PhaseEducationCard
compact
animate={play}
phase={wheel.phase}
symptomPatterns={insights.data?.symptomPatterns ?? []}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
onLogToday={() => openSheet(today)}
/>
) : null}
</div>
<Tabs defaultValue="calendar">
<TabsList className="w-full">
<TabsTrigger value="calendar" className="flex-1">
@@ -199,6 +222,16 @@ export function CycleView() {
</TabsList>
<TabsContent value="calendar" className="mt-4 space-y-4">
{!loading && !calendarError ? (
<PhaseEducationCard
phase={wheel.phase}
symptomPatterns={insights.data?.symptomPatterns ?? []}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
onLogToday={() => openSheet(today)}
/>
) : null}
<Card>
<CardContent className="py-4">
{loading ? (
@@ -0,0 +1,213 @@
"use client";
import { useMemo } from "react";
import { Sparkles, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { PHASE_WHATS_HAPPENING_KEY } from "@/lib/cycle/phase-copy";
import { PHASE_HUE } from "./phase-tokens";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import type { CyclePhase } from "./types";
import type { SymptomPhaseRow } from "./use-cycle";
/**
* v1.15.1 — the per-phase "what's happening now" education card.
*
* Anchored to the wheel: a hue-tinted `wellness-tile` surface keyed to the
* ACTIVE phase (`--tile-hue`), so it reads as part of the ring, not a generic
* tip card. Three zones:
* 1. phase name + hue dot,
* 2. a short, factual, descriptive-only "what's happening" line (curated i18n,
* never clinical advice / a medical claim),
* 3. a "you often notice this here" chip row sourced from the user's OWN
* symptom-by-phase history (`symptomPatterns`), filtered to symptoms that
* cluster in THIS phase — never a generic population claim — plus a
* "log today" nudge into the existing day-log sheet.
*
* Honesty gate (Clue precedent): when prediction is disabled / raw-chart mode
* or fewer than three observed cycles, the predictive framing is suppressed and
* the card shows a calm "still learning" line instead of phase copy + chips.
*
* Motion: NO competing animation. The card rides the wheel's once-per-session
* reveal — `animate` drives the shared `wellness-tile-rise` keyframe (a single
* calm entrance), which the global `prefers-reduced-motion` block already
* zeroes. No bespoke transitions.
*/
const MIN_CYCLES_FOR_PREDICTION = 3;
export interface PhaseEducationCardProps {
/** The active phase from the calendar read, or null when none is resolved. */
phase: CyclePhase | null;
/** The user's own symptom-by-phase rows (from `useCycleInsights`). */
symptomPatterns: SymptomPhaseRow[];
/** Honesty-gate inputs from the calendar profile. */
predictionEnabled: boolean;
rawChartMode: boolean;
cyclesObserved: number;
/** Open the day-log sheet for today (the "log today" nudge). */
onLogToday: () => void;
/** Ride the wheel's once-per-session reveal — no independent animation. */
animate?: boolean;
/** Compact variant beneath the wheel (tighter padding, no chip row). */
compact?: boolean;
className?: string;
}
export function PhaseEducationCard({
phase,
symptomPatterns,
predictionEnabled,
rawChartMode,
cyclesObserved,
onLogToday,
animate = false,
compact = false,
className,
}: PhaseEducationCardProps) {
const { t } = useTranslations();
// Honesty gate: with prediction off / raw-chart mode / too few cycles, the
// phase label is not trustworthy — suppress the framing and stay calm.
const stillLearning =
!phase ||
!predictionEnabled ||
rawChartMode ||
cyclesObserved < MIN_CYCLES_FOR_PREDICTION;
// Flatten the catalog once: symptom key → { labelKey, icon }. Reuses the same
// catalog the symptom-patterns card draws from so the chip labels + icons
// match exactly.
const byKey = useMemo(() => {
const map = new Map<
string,
{
labelKey: string;
icon: (typeof CYCLE_SYMPTOM_CATALOG)[number]["symptoms"][number]["icon"];
}
>();
for (const cat of CYCLE_SYMPTOM_CATALOG) {
for (const s of cat.symptoms) {
map.set(s.key, { labelKey: s.labelKey, icon: s.icon });
}
}
return map;
}, []);
// The "common here" chips are the user's OWN symptoms that cluster in the
// ACTIVE phase (topPhase === phase). Most-concentrated first; capped so the
// row stays scannable. Empty when no pattern points at this phase yet.
const chips = useMemo(() => {
if (stillLearning || !phase) return [];
return symptomPatterns
.filter((row) => row.topPhase === phase)
.sort((a, b) => b.topShare - a.topShare || b.total - a.total)
.slice(0, 5);
}, [symptomPatterns, phase, stillLearning]);
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
const phaseName = phase ? t(`cycle.phase.${phase}`) : null;
return (
<section
data-slot="cycle-phase-education"
data-revealed={animate ? "true" : undefined}
data-phase={phase ?? "none"}
aria-label={t("cycle.phaseEducation.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className={cn(
"wellness-tile rounded-xl",
compact ? "px-4 py-4" : "px-5 py-5",
animate && "wellness-tile-rise",
className,
)}
>
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.phaseEducation.title")}
</span>
</div>
{stillLearning ? (
<p
data-slot="cycle-phase-education-learning"
className="text-muted-foreground mt-2 text-sm"
>
{t("cycle.phaseEducation.stillLearning")}
</p>
) : (
<>
<h3 className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold">
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
{phaseName}
</h3>
{/* Zone 2 — the curated, descriptive-only "what's happening" line. */}
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
</p>
{/* Zone 3 — the user's OWN clustered symptoms for this phase. The
compact variant under the wheel omits the chip row to stay light;
the calendar-tab variant shows it. */}
{!compact && chips.length > 0 ? (
<div className="mt-4">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.phaseEducation.commonHere")}
</p>
<ul
data-slot="cycle-phase-education-chips"
className="mt-2 flex flex-wrap gap-1.5"
>
{chips.map((row) => {
const meta = byKey.get(row.symptomKey);
const Icon = meta?.icon;
const name = meta ? t(meta.labelKey) : row.symptomKey;
return (
<li
key={row.symptomKey}
className="border-foreground/10 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
>
{Icon ? (
<Icon
className="h-3 w-3 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
) : null}
{name}
</li>
);
})}
</ul>
</div>
) : null}
{/* The "log today" nudge — opens the existing day-log sheet. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={onLogToday}
className="bg-background/55 mt-4 gap-1.5"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{t("cycle.phaseEducation.trackNudge")}
</Button>
</>
)}
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* v1.15.1 — per-phase education copy → i18n leaf mapping.
*
* The `PhaseEducationCard` renders a short, factual "what's happening" line for
* the active cycle phase. The copy is curated, descriptive-only (no clinical
* advice, no medical claims) and lives entirely under `cycle.phaseEducation.*`
* in the message bundles — this module only maps a phase to its leaf key so the
* i18n-call-site-coverage guard sees the literal `t()` arguments at the call
* site and the copy stays translatable across all six locales.
*/
import type { CyclePhase } from "@/lib/cycle/types";
/**
* The phase → i18n leaf for the "what's happening now" descriptive line. The
* keys are spelled out as string literals (not interpolated) so the
* call-site-coverage walker resolves each one against `messages/en.json`.
*/
export const PHASE_WHATS_HAPPENING_KEY: Record<CyclePhase, string> = {
MENSTRUAL: "cycle.phaseEducation.whatsHappening.MENSTRUAL",
FOLLICULAR: "cycle.phaseEducation.whatsHappening.FOLLICULAR",
OVULATORY: "cycle.phaseEducation.whatsHappening.OVULATORY",
LUTEAL: "cycle.phaseEducation.whatsHappening.LUTEAL",
};