mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|