mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Sweeps every surface that renders a date from before this year without a year suffix — illness episodes/flares/day-logs, lab and custom-metric charts, the mood chart family, medication titration/side-effects/dose- history/next-dose captions (incl. the Dec→Jan rollover), cycle predictions and history, the BBT chart, the intraday-pulse day-nav, the date-range filter chip, and the nutrient/compliance charts. Each now routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts) or the matching date-format.ts helper, which add the year only when it differs from the current one — mirroring the workout list's existing model instead of a bare "19.02." that reads as this year's. Also fixes two sites that bypassed the app's locale entirely: the trusted-devices expiry (bare toLocaleDateString, host locale) and the admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both render through the user's own locale, hour-cycle, and timezone preference.
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.15.0 — the basal-body-temperature (BBT) chart.
|
|
*
|
|
* Plots the user's logged BBT across the CURRENT cycle (the same window the
|
|
* wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots,
|
|
* the symptothermal signal a fertility-aware reader expects. Fertility signs
|
|
* already logged for a day — egg-white/watery mucus, a positive LH test —
|
|
* surface in the tooltip so the temperature shift reads in context. When the
|
|
* goal surfaces a fertile window, the estimated ovulation day is marked with a
|
|
* reference line.
|
|
*
|
|
* Honest data contract: BBT is a LOGGED fact (no estimate), so the curve needs
|
|
* no disclaimer of its own; the estimated-ovulation marker is the prediction
|
|
* panel's, which already carries the fixed non-medical disclaimer beside it.
|
|
* In raw-chart ("Read Your Body") mode the phase tint + ovulation marker are
|
|
* suppressed — only the neutral temperatures are drawn, no interpretation.
|
|
*
|
|
* Recharts (the project's charting lib), Dracula/Alucard tokens, reduced-motion
|
|
* gated via `isAnimationActive`. Needs ≥2 logged readings to draw; below that
|
|
* it surfaces a "log your morning temperature" hint rather than a flat dot.
|
|
*/
|
|
|
|
import { useMemo } from "react";
|
|
import { Thermometer } from "lucide-react";
|
|
import {
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart,
|
|
ReferenceLine,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from "recharts";
|
|
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
import { TileHeader } from "@/components/insights/tile-header";
|
|
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
|
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
|
import { cn } from "@/lib/utils";
|
|
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
|
|
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
|
|
import { currentCycleStartDate } from "./wheel-state";
|
|
|
|
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
|
|
const FALLBACK_WINDOW_DAYS = 35;
|
|
|
|
const LINE_COLOR = "var(--cycle-phase-luteal)";
|
|
|
|
interface BbtPoint {
|
|
t: number;
|
|
temp: number;
|
|
phaseHue: string;
|
|
mucus: CervicalMucus | null;
|
|
ovulationTest: OvulationTest | null;
|
|
}
|
|
|
|
export interface BbtChartProps {
|
|
days: CalendarDay[];
|
|
today: string;
|
|
/** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */
|
|
predictedOvulation: string | null;
|
|
/** Whether the ovulation day was confirmed by a signal layer (solid marker). */
|
|
ovulationConfirmed?: boolean;
|
|
/** Read-Your-Body mode: suppress phase tint + ovulation marker. */
|
|
rawChartMode: boolean;
|
|
}
|
|
|
|
function ymdToMs(d: string): number {
|
|
// Noon UTC so a YYYY-MM-DD never rolls a day across the viewer's tz.
|
|
return Date.parse(`${d}T12:00:00Z`);
|
|
}
|
|
|
|
export function BbtChart({
|
|
days,
|
|
today,
|
|
predictedOvulation,
|
|
ovulationConfirmed = false,
|
|
rawChartMode,
|
|
}: BbtChartProps) {
|
|
const { t } = useTranslations();
|
|
const fmt = useFormatters();
|
|
const animationsEnabled = !prefersReducedMotion();
|
|
|
|
// Scope to the current cycle (wheel window); fall back to a trailing window
|
|
// when no cycle is active so a user still sees their recent temperatures.
|
|
const fromDate = useMemo(() => {
|
|
const start = currentCycleStartDate(days, today);
|
|
if (start) return start;
|
|
const fallback = new Date(ymdToMs(today));
|
|
fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS);
|
|
return fallback.toISOString().slice(0, 10);
|
|
}, [days, today]);
|
|
|
|
const points = useMemo<BbtPoint[]>(() => {
|
|
const fromMs = ymdToMs(fromDate);
|
|
const todayMs = ymdToMs(today);
|
|
return days
|
|
.filter((d) => {
|
|
if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) {
|
|
return false;
|
|
}
|
|
const ms = ymdToMs(d.date);
|
|
return ms >= fromMs && ms <= todayMs;
|
|
})
|
|
.map((d) => ({
|
|
t: ymdToMs(d.date),
|
|
temp: d.basalBodyTempC as number,
|
|
phaseHue:
|
|
rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase],
|
|
mucus: d.cervicalMucus,
|
|
ovulationTest: d.ovulationTest,
|
|
}))
|
|
.sort((a, b) => a.t - b.t);
|
|
}, [days, fromDate, today, rawChartMode]);
|
|
|
|
const ovulationMs = useMemo(() => {
|
|
if (rawChartMode || !predictedOvulation) return null;
|
|
const ms = ymdToMs(predictedOvulation);
|
|
if (points.length < 2) return null;
|
|
const first = points[0].t;
|
|
const last = points[points.length - 1].t;
|
|
return ms >= first && ms <= last ? ms : null;
|
|
}, [predictedOvulation, points, rawChartMode]);
|
|
|
|
const yDomain = useMemo<[number, number]>(() => {
|
|
if (points.length === 0) return [36, 37.5];
|
|
const temps = points.map((p) => p.temp);
|
|
const min = Math.min(...temps);
|
|
const max = Math.max(...temps);
|
|
// Pad ±0.15 °C so the shift is legible and the line never hugs an edge.
|
|
return [
|
|
Math.floor((min - 0.15) * 10) / 10,
|
|
Math.ceil((max + 0.15) * 10) / 10,
|
|
];
|
|
}, [points]);
|
|
|
|
const ticks = useMemo(() => {
|
|
if (points.length < 2) return [];
|
|
const first = points[0].t;
|
|
const last = points[points.length - 1].t;
|
|
const span = last - first;
|
|
if (span <= 0) return [first];
|
|
return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f));
|
|
}, [points]);
|
|
|
|
const hasCurve = points.length >= 2;
|
|
|
|
return (
|
|
<Card data-slot="cycle-bbt-chart">
|
|
<CardHeader>
|
|
<TileHeader icon={Thermometer} title={t("cycle.bbt.title")} />
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!hasCurve ? (
|
|
<div
|
|
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
|
|
data-slot="cycle-bbt-empty"
|
|
>
|
|
<p className="font-medium">{t("cycle.bbt.empty")}</p>
|
|
<p className="text-xs">{t("cycle.bbt.emptyHint")}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p
|
|
className="text-muted-foreground mb-2 text-xs"
|
|
data-slot="cycle-bbt-caption"
|
|
>
|
|
{t("cycle.bbt.caption")}
|
|
</p>
|
|
<div
|
|
className="touch-pan-y"
|
|
style={{ height: "210px" }}
|
|
data-slot="cycle-bbt-area"
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart
|
|
data={points}
|
|
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke="var(--border)"
|
|
opacity={0.5}
|
|
/>
|
|
<XAxis
|
|
dataKey="t"
|
|
type="number"
|
|
scale="time"
|
|
domain={["dataMin", "dataMax"]}
|
|
ticks={ticks}
|
|
tickFormatter={(v) =>
|
|
fmt.dateShortSmart(new Date(v as number))
|
|
}
|
|
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
domain={yDomain}
|
|
width={40}
|
|
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v) => `${fmt.number(v as number)}°`}
|
|
/>
|
|
<Tooltip
|
|
cursor={{
|
|
stroke: "var(--muted-foreground)",
|
|
strokeOpacity: 0.3,
|
|
strokeDasharray: "3 3",
|
|
}}
|
|
content={<BbtTooltip />}
|
|
/>
|
|
{ovulationMs != null ? (
|
|
<ReferenceLine
|
|
x={ovulationMs}
|
|
stroke={OVULATION_HUE}
|
|
// Solid line for a confirmed ovulation (signal-detected),
|
|
// dashed for the calendar estimate (QA M1).
|
|
strokeDasharray={ovulationConfirmed ? undefined : "4 3"}
|
|
strokeWidth={1.5}
|
|
label={{
|
|
value: ovulationConfirmed
|
|
? t("cycle.bbt.ovulationConfirmed")
|
|
: t("cycle.bbt.ovulationEstimate"),
|
|
position: "insideTopRight",
|
|
fontSize: 10,
|
|
// Muted text (not the amber hue) so the tiny label keeps
|
|
// AA contrast on the card; the line carries the colour (L1).
|
|
fill: "var(--muted-foreground)",
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Line
|
|
type="monotone"
|
|
dataKey="temp"
|
|
stroke={LINE_COLOR}
|
|
strokeWidth={2}
|
|
dot={<PhaseDot />}
|
|
activeDot={{ r: 5 }}
|
|
isAnimationActive={animationsEnabled}
|
|
animationDuration={animationsEnabled ? 600 : 0}
|
|
animationEasing="ease-out"
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/** A line dot tinted to its day's cycle phase (neutral in raw-chart mode). */
|
|
function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
|
|
const { cx, cy, payload } = props;
|
|
if (cx == null || cy == null || !payload) return null;
|
|
return (
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={3.5}
|
|
fill={payload.phaseHue}
|
|
stroke="var(--background)"
|
|
strokeWidth={1}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** Tooltip restating the temperature + any fertility signs logged that day. */
|
|
function BbtTooltip(props: {
|
|
active?: boolean;
|
|
payload?: { payload: BbtPoint }[];
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const fmt = useFormatters();
|
|
if (!props.active || !props.payload || props.payload.length === 0)
|
|
return null;
|
|
const p = props.payload[0].payload;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-popover border-border rounded-md border px-2.5 py-2 text-xs shadow-md",
|
|
)}
|
|
>
|
|
<p className="text-muted-foreground">
|
|
{fmt.dateShortSmart(new Date(p.t))}
|
|
</p>
|
|
<p className="text-foreground font-medium tabular-nums">
|
|
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
|
|
</p>
|
|
{p.mucus ? (
|
|
<p className="text-muted-foreground mt-0.5">
|
|
{t("cycle.mucus.label")}: {t(`cycle.mucus.${p.mucus}`)}
|
|
</p>
|
|
) : null}
|
|
{p.ovulationTest && p.ovulationTest !== "NEGATIVE" ? (
|
|
<p className="text-muted-foreground mt-0.5">
|
|
{t("cycle.ovulationTest.label")}:{" "}
|
|
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|