mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -4967,6 +4967,18 @@
|
||||
"symptomPatternsEmpty": "Erfasse Symptome über einen Zyklus, um zu sehen, in welcher Phase sie sich häufen.",
|
||||
"symptomMostlyIn": "Meist in {phase} ({count}/{total} Tage)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Was gerade passiert",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Deine Periode ist der Beginn eines neuen Zyklus, während sich die Gebärmutterschleimhaut löst. Energie und Stimmung fühlen sich in diesen ersten Tagen oft niedriger an.",
|
||||
"FOLLICULAR": "Nach der Periode steigt der Östrogenspiegel allmählich, während der Körper eine Eizelle vorbereitet. Viele bemerken in dieser Phase eine gleichmäßigere Energie.",
|
||||
"OVULATORY": "Etwa zur Zyklusmitte wird eine Eizelle freigesetzt und die Fruchtbarkeit erreicht für ein kurzes Fenster ihren Höhepunkt. Diese Phase ist die kürzeste der vier.",
|
||||
"LUTEAL": "Nach dem Eisprung steigt das Progesteron und der Körper kommt zur Ruhe. In den Tagen vor der nächsten Periode bemerken manche prämenstruelle Veränderungen."
|
||||
},
|
||||
"commonHere": "Das bemerkst du hier oft",
|
||||
"trackNudge": "Heute eintragen",
|
||||
"stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen."
|
||||
},
|
||||
"disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.",
|
||||
"title": "Dein Zyklus",
|
||||
"subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.",
|
||||
|
||||
@@ -4967,6 +4967,18 @@
|
||||
"symptomPatternsEmpty": "Log symptoms across a cycle to see which phase they cluster in.",
|
||||
"symptomMostlyIn": "Mostly in {phase} ({count}/{total} days)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "What's happening now",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Your period is the start of a new cycle, as the uterine lining sheds. Energy and mood often feel lower in these first days.",
|
||||
"FOLLICULAR": "After your period, oestrogen gradually rises as the body prepares an egg. Many people notice steadier energy through this stretch.",
|
||||
"OVULATORY": "Around mid-cycle an egg is released and fertility peaks for a short window. This phase is the briefest of the four.",
|
||||
"LUTEAL": "After ovulation, progesterone rises and the body settles in. In the days before your next period some people notice premenstrual changes."
|
||||
},
|
||||
"commonHere": "You often notice this here",
|
||||
"trackNudge": "Log today",
|
||||
"stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days."
|
||||
},
|
||||
"disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.",
|
||||
"title": "Your cycle",
|
||||
"subtitle": "Log your period and symptoms, see what comes next.",
|
||||
|
||||
@@ -4967,6 +4967,18 @@
|
||||
"symptomPatternsEmpty": "Registra síntomas a lo largo de un ciclo para ver en qué fase se concentran.",
|
||||
"symptomMostlyIn": "Sobre todo en {phase} ({count}/{total} días)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Qué está pasando ahora",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Tu menstruación es el inicio de un nuevo ciclo, mientras el revestimiento uterino se desprende. La energía y el ánimo suelen sentirse más bajos en estos primeros días.",
|
||||
"FOLLICULAR": "Tras la menstruación, el estrógeno sube de forma gradual mientras el cuerpo prepara un óvulo. Muchas personas notan una energía más estable durante esta etapa.",
|
||||
"OVULATORY": "Hacia la mitad del ciclo se libera un óvulo y la fertilidad alcanza su punto máximo durante una ventana corta. Esta fase es la más breve de las cuatro.",
|
||||
"LUTEAL": "Después de la ovulación, la progesterona sube y el cuerpo se asienta. En los días previos a la siguiente menstruación, algunas personas notan cambios premenstruales."
|
||||
},
|
||||
"commonHere": "Esto lo notas a menudo aquí",
|
||||
"trackNudge": "Registrar hoy",
|
||||
"stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días."
|
||||
},
|
||||
"disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.",
|
||||
"title": "Tu ciclo",
|
||||
"subtitle": "Registra tu periodo y síntomas, y mira qué viene después.",
|
||||
|
||||
@@ -4967,6 +4967,18 @@
|
||||
"symptomPatternsEmpty": "Notez vos symptômes sur un cycle pour voir dans quelle phase ils se regroupent.",
|
||||
"symptomMostlyIn": "Surtout en {phase} ({count}/{total} jours)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Ce qui se passe maintenant",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Tes règles marquent le début d'un nouveau cycle, lorsque la muqueuse utérine se détache. L'énergie et l'humeur semblent souvent plus basses durant ces premiers jours.",
|
||||
"FOLLICULAR": "Après les règles, l'œstrogène augmente progressivement pendant que le corps prépare un ovule. Beaucoup ressentent une énergie plus stable durant cette période.",
|
||||
"OVULATORY": "Vers le milieu du cycle, un ovule est libéré et la fertilité culmine sur une courte fenêtre. Cette phase est la plus brève des quatre.",
|
||||
"LUTEAL": "Après l'ovulation, la progestérone augmente et le corps se pose. Dans les jours précédant les prochaines règles, certaines personnes remarquent des changements prémenstruels."
|
||||
},
|
||||
"commonHere": "Tu le remarques souvent ici",
|
||||
"trackNudge": "Noter aujourd'hui",
|
||||
"stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées."
|
||||
},
|
||||
"disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.",
|
||||
"title": "Ton cycle",
|
||||
"subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.",
|
||||
|
||||
@@ -4967,6 +4967,18 @@
|
||||
"symptomPatternsEmpty": "Registra i sintomi durante un ciclo per vedere in quale fase si concentrano.",
|
||||
"symptomMostlyIn": "Soprattutto in {phase} ({count}/{total} giorni)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Cosa sta succedendo ora",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Le mestruazioni sono l'inizio di un nuovo ciclo, mentre il rivestimento uterino si sfalda. In questi primi giorni energia e umore spesso si sentono più bassi.",
|
||||
"FOLLICULAR": "Dopo le mestruazioni gli estrogeni salgono gradualmente mentre il corpo prepara un ovulo. Molte persone notano un'energia più stabile in questa fase.",
|
||||
"OVULATORY": "Verso metà ciclo viene rilasciato un ovulo e la fertilità raggiunge il picco per una breve finestra. Questa fase è la più breve delle quattro.",
|
||||
"LUTEAL": "Dopo l'ovulazione il progesterone sale e il corpo si assesta. Nei giorni prima del ciclo successivo alcune persone notano cambiamenti premestruali."
|
||||
},
|
||||
"commonHere": "Questo lo noti spesso qui",
|
||||
"trackNudge": "Registra oggi",
|
||||
"stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate."
|
||||
},
|
||||
"disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.",
|
||||
"title": "Il tuo ciclo",
|
||||
"subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.",
|
||||
|
||||
@@ -4967,6 +4967,18 @@
|
||||
"symptomPatternsEmpty": "Zapisuj objawy przez cały cykl, aby zobaczyć, w której fazie się grupują.",
|
||||
"symptomMostlyIn": "Głównie w fazie {phase} ({count}/{total} dni)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Co się teraz dzieje",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Miesiączka to początek nowego cyklu, gdy złuszcza się błona śluzowa macicy. W tych pierwszych dniach energia i nastrój często są niższe.",
|
||||
"FOLLICULAR": "Po miesiączce poziom estrogenu stopniowo rośnie, gdy organizm przygotowuje komórkę jajową. Wiele osób odczuwa w tej fazie bardziej stabilną energię.",
|
||||
"OVULATORY": "W połowie cyklu uwalniana jest komórka jajowa, a płodność osiąga szczyt przez krótkie okno czasu. Ta faza jest najkrótsza z czterech.",
|
||||
"LUTEAL": "Po owulacji rośnie poziom progesteronu i organizm się wycisza. W dniach przed kolejną miesiączką niektóre osoby zauważają zmiany przedmiesiączkowe."
|
||||
},
|
||||
"commonHere": "To często zauważasz tutaj",
|
||||
"trackNudge": "Zapisz dziś",
|
||||
"stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni."
|
||||
},
|
||||
"disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.",
|
||||
"title": "Twój cykl",
|
||||
"subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.",
|
||||
|
||||
@@ -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