Files
HealthLog/src/components/cycle/predictions-panel.tsx
T
Marc-André Bombeck 546835a937 fix(cycle): trim duplicate phase card, drop redundant disclaimers, tidy settings
Remove the duplicate phase-education card from the calendar tab, keeping the
single instance under the wheel and surfacing its "you often notice this here"
chip row there. Drop the generic prediction-estimate caption on the predictions
tab for every goal except AVOID_PREGNANCY, where the contraceptive-safety caveat
stays as a deliberate medical note. Stop rendering the associations-not-causes
footer on the insights crosstab. In the cycle settings, align card-header
descriptions with their titles, and remove the standalone cycle export + purge
affordances now that cycle data rides the main export and account-level delete.
Move the web-push enable action into the card header row.
2026-06-06 20:10:15 +02:00

320 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Check, Sparkles } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
/**
* v1.15.0 — the predictions panel.
*
* Shows the next-period RANGE (a window, with a confidence pill), the fertile
* window (already goal-gated server-side — the API nulls it unless TTC, so we
* just render what is present), the "still learning" state for < 3 cycles or
* raw-chart mode, the cycle-history stats, and the fixed non-medical
* disclaimer. Never a single dated next-period claim — always the range.
*/
function formatDate(d: string): string {
// Render at noon UTC so the YYYY-MM-DD never rolls a day across tz.
return new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function confidenceTone(c: number): {
labelKey: string;
variant: "default" | "secondary" | "outline";
} {
if (c >= 0.66)
return { labelKey: "cycle.predictions.confidenceHigh", variant: "default" };
if (c >= 0.33)
return {
labelKey: "cycle.predictions.confidenceMedium",
variant: "secondary",
};
return { labelKey: "cycle.predictions.confidenceLow", variant: "outline" };
}
export interface PredictionsPanelProps {
prediction: CyclePrediction | null;
rawChartMode: boolean;
history: CycleHistoryResponse | undefined;
/** Fallback disclaimer when no prediction carries one. */
fallbackDisclaimer?: string;
/** The active cycle goal — only AVOID_PREGNANCY surfaces the (deliberate)
* contraceptive-safety caveat here; every other goal drops the generic
* "predictions are estimates" line so it does not repeat under every graph. */
goal?: string;
}
export function PredictionsPanel({
prediction,
rawChartMode,
history,
fallbackDisclaimer,
goal,
}: PredictionsPanelProps) {
const { t } = useTranslations();
// Only the AVOID_PREGNANCY goal shows a disclaimer here: it surfaces the
// fertile window, so the stronger "not a contraceptive method" caveat is a
// deliberate medical safety note. For all other goals the generic
// estimate-caveat is suppressed (it already lives once with the data).
const disclaimer =
goal === "AVOID_PREGNANCY"
? (prediction?.disclaimer ?? fallbackDisclaimer ?? "")
: "";
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.predictions.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rawChartMode ? (
<Learning
title={t("cycle.predictions.rawMode")}
body={t("cycle.predictions.rawModeBody")}
/>
) : prediction == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.predictions.none")}
</p>
) : prediction.stillLearning ? (
<>
<Learning
title={t("cycle.predictions.stillLearning")}
body={t("cycle.predictions.stillLearningBody")}
/>
<NextPeriod prediction={prediction} />
</>
) : (
<>
<NextPeriod prediction={prediction} />
{prediction.fertileWindowStart && prediction.fertileWindowEnd ? (
<Row
hue={FERTILE_HUE}
label={t("cycle.predictions.fertileWindow")}
value={`${formatDate(prediction.fertileWindowStart)} – ${formatDate(
prediction.fertileWindowEnd,
)}`}
/>
) : null}
{prediction.predictedOvulation ? (
<Row
hue={OVULATION_HUE}
label={
prediction.ovulationConfirmed
? t("cycle.predictions.ovulationConfirmed")
: t("cycle.predictions.ovulation")
}
value={formatDate(prediction.predictedOvulation)}
/>
) : null}
</>
)}
{disclaimer ? <CycleDisclaimer text={disclaimer} /> : null}
</CardContent>
</Card>
<HistoryCard history={history} />
</div>
);
}
function NextPeriod({ prediction }: { prediction: CyclePrediction }) {
const { t } = useTranslations();
const tone = confidenceTone(prediction.confidence);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<Row
hue={FLOW_HUE}
label={t("cycle.predictions.nextPeriod")}
value={t("cycle.predictions.nextPeriodWindow", {
low: formatDate(prediction.nextPeriodStartLow),
high: formatDate(prediction.nextPeriodStartHigh),
})}
/>
<Badge variant={tone.variant} className="shrink-0">
{t(tone.labelKey)}
</Badge>
</div>
</div>
);
}
function Row({
hue,
label,
value,
}: {
hue: string;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-foreground ml-auto text-sm font-medium tabular-nums">
{value}
</span>
</div>
);
}
function Learning({ title, body }: { title: string; body: string }) {
return (
<div className="bg-muted/40 rounded-md p-3">
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground mt-1 text-sm">{body}</p>
</div>
);
}
/** Inclusive day span between two `YYYY-MM-DD` dates (noon-UTC anchored). */
function daySpan(from: string, to: string): number {
const a = Date.parse(`${from}T12:00:00Z`);
const b = Date.parse(`${to}T12:00:00Z`);
return Math.round((b - a) / 86_400_000) + 1;
}
function HistoryCard({
history,
}: {
history: CycleHistoryResponse | undefined;
}) {
const { t } = useTranslations();
const stats = history?.stats;
// Most-recent observed cycles (the spread the regularity number summarises —
// seeing it builds trust; the data was already on the wire). QA H2.
const observed = (history?.cycles ?? [])
.filter((c) => !c.isPredicted)
.slice(0, 6);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("cycle.history.title")}</CardTitle>
{stats ? (
<CardDescription>
{t(`cycle.history.regularity${stats.regularity}`)}
</CardDescription>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{!stats || stats.avgLengthDays == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
</p>
) : (
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Stat
label={t("cycle.history.avgLength")}
value={t("cycle.history.days", { count: stats.avgLengthDays })}
/>
{stats.lengthVariabilityDays != null ? (
<Stat
label={t("cycle.history.variability")}
value={t("cycle.history.daysPlusMinus", {
count: stats.lengthVariabilityDays,
})}
/>
) : null}
{stats.avgPeriodLengthDays != null ? (
<Stat
label={t("cycle.history.avgPeriodLength")}
value={t("cycle.history.days", {
count: stats.avgPeriodLengthDays,
})}
/>
) : null}
</dl>
)}
{observed.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.history.recentCycles")}
</p>
<ul
className="divide-border divide-y"
data-slot="cycle-history-list"
>
{observed.map((c) => {
const periodLen =
c.periodEndDate != null
? daySpan(c.startDate, c.periodEndDate)
: null;
return (
<li
key={c.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate)}
{" – "}
{c.endDate
? formatDate(c.endDate)
: t("cycle.history.ongoing")}
</span>
<span className="text-muted-foreground flex items-center gap-2 tabular-nums">
{c.lengthDays != null
? t("cycle.history.days", { count: c.lengthDays })
: "—"}
{periodLen != null ? (
<span className="text-xs">
{t("cycle.history.periodDays", { count: periodLen })}
</span>
) : null}
{c.ovulationConfirmed ? (
<Badge variant="secondary" className="gap-1 text-xs">
<Check className="h-3 w-3" aria-hidden="true" />
{t("cycle.history.ovulationChip")}
</Badge>
) : null}
</span>
</li>
);
})}
</ul>
</div>
) : null}
</CardContent>
</Card>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="text-foreground mt-0.5 text-lg font-semibold tabular-nums">
{value}
</dd>
</div>
);
}