mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(cycle): per-phase what's-happening education card
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
Reference in new issue
Block a user