Files
HealthLog/src/components/charts/mood-chart.tsx
T
Marc-André Bombeck 8bcb3e6301 fix: keep the two-step on chart points on touch
A point and a calendar cell are small targets; one tap that opened the
day opened days by accident. On a touch screen the first tap shows the
value again and the tooltip's calm 44 px row opens the day, on every
day-linked chart and calendar alike. A date in a list still opens in one
tap; a click on a fine pointer still opens directly. The tooltip keeps
not gliding into place.
2026-10-08 16:04:54 +02:00

1234 lines
45 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 { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceArea,
ReferenceLine,
} from "recharts";
import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
import { TileHeader } from "@/components/insights/tile-header";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
pickBucket,
type ChartBucketType,
} from "@/lib/charts/bucket-time-series";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartDataTable } from "./chart-data-table";
import { chartPointDayKey } from "@/components/day/chart-day";
import {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartErrorState } from "./chart-error-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
import type {
ChartOverlayKey,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
import { apiGet } from "@/lib/api/api-fetch";
// --- Types ---
interface MoodAnalyticsData {
entries: Array<{ date: string; score: number; samples: number }>;
summary: DataSummary;
}
interface ChartDataPoint {
date: string;
timestamp: number;
pointIndex: number;
score: number;
ma?: number;
trend?: number;
/** v1.4.16 B8 — prior-period overlay value. */
scoreCompare?: number;
}
interface MoodChartProps {
title?: string;
/**
* v1.4.16 B5c — compact mode for embedding inside the
* RecommendationCard. Hides the range tabs / toggle row and shrinks
* padding. Tooltip stays intact.
*/
mini?: boolean;
/**
* v1.4.16 B5c — pin the chart to a specific rationale data
* window regardless of any parent UI state.
*/
windowOverride?: "last7days" | "last30days" | "last90days" | "allTime";
/**
* v1.4.16 B8 — when set to "lastMonth" / "lastYear", overlay a
* dimmed prior-period mood line beneath the current series. Same
* shift mechanic the BP/weight/pulse chart uses; the mood score is
* a single metric so only one comparison line is drawn.
*/
compareBaseline?: ComparisonBaseline;
/**
* v1.4.18 — per-chart overlay-prefs key. When supplied, the chart
* mounts the overlay-controls dropdown and reads its three toggle
* states from the persisted user prefs. Omit (default) when the
* chart is being rendered ad-hoc on /insights or any other read-
* only surface — the cog stays hidden and overlays are clean.
*/
chartKey?: ChartOverlayKey;
/**
* v1.16.0 — fires once the mood analytics query has settled (initial
* load finished). The dashboard's shared reveal gate listens here so
* every chart cell swaps from skeleton to content in one frame —
* pre-fix the cheap `/api/mood/analytics` read made this chart paint
* visibly before its measurement siblings. Optional and repeat-safe.
*/
onDataReady?: () => void;
/**
* v1.42 — the chart is a door to its days, exactly as `HealthChart`'s
* `dayLinks`: a click on a point (the tooltip's button on touch) opens the
* day, the open day carries a dashed line, a row of dots marks the days
* with entries, and the data table under the chart links each date. Daily
* points only, never in `mini` mode.
*/
dayLinks?: boolean;
}
// --- Constants ---
// The range tabs select a CALENDAR-DAY window ending now, matching
// `health-chart.tsx` (see the note there). Until v1.37.29 this chart
// sliced the last N LOGGED DAYS out of the whole history instead — the
// same "7" tab meant seven calendar days on a weight chart and seven
// logged days (possibly weeks apart) on the mood chart one card over.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
// MOOD_LABELS built dynamically via t() in the component
// v1.12.6 — mood-level bands ride the same Dracula good→bad ramp the mood
// heatmap and distribution chart already use (red → orange → yellow →
// green), so the three mood surfaces read as one palette instead of the
// chart's former three-band red/orange/green that skipped the yellow rung.
// Tokens, not raw hex, so a theme change flows through one source.
const VALUE_BANDS = [
{ min: 1, max: 2, color: "var(--dracula-red)", opacity: 0.16 },
{ min: 2, max: 3, color: "var(--dracula-orange)", opacity: 0.18 },
{ min: 3, max: 3.5, color: "var(--dracula-yellow)", opacity: 0.18 },
{ min: 3.5, max: 5, color: "var(--dracula-green)", opacity: 0.2 },
] as const;
// The mood line + overlays use the same hues the rest of the Insights
// charts do — purple primary (matching HealthChart), pink moving average,
// cyan trend — as tokens rather than the former hand-picked hex.
const COLOR_MAIN = "var(--dracula-purple)";
const COLOR_MA = "var(--dracula-pink)";
const COLOR_TREND = "var(--dracula-cyan)";
// --- Helpers ---
function dayKeyToTimestamp(dayKey: string): number {
const [year, month, day] = dayKey.split("-").map(Number);
return Date.UTC(year, month - 1, day, 12, 0, 0);
}
function movingAverageByPoints(
data: Array<{ date: Date; value: number }>,
windowSize: number,
): Array<{ date: Date; value: number }> {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
return sorted.map((point, index) => {
const start = Math.max(0, index - windowSize + 1);
const window = sorted.slice(start, index + 1);
const avg =
window.reduce((sum, item) => sum + item.value, 0) / window.length;
return {
date: point.date,
value: Math.round(avg * 100) / 100,
};
});
}
function buildTrendLine(data: Array<{ date: Date; value: number }>): {
points: Array<{ date: Date; value: number }>;
slopePerDay: number;
} | null {
if (data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const startTime = sorted[0].date.getTime();
const points = sorted.map((p) => ({
x: (p.date.getTime() - startTime) / 86400000,
y: p.value,
}));
const n = points.length;
const sumX = points.reduce((s, p) => s + p.x, 0);
const sumY = points.reduce((s, p) => s + p.y, 0);
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
return {
points: sorted.map((p) => ({
date: p.date,
value:
Math.round(
(intercept + slope * ((p.date.getTime() - startTime) / 86400000)) *
100,
) / 100,
})),
slopePerDay: slope,
};
}
// --- Aggregation helpers (exported for unit tests) ---
interface MoodEntryDay {
date: string; // "YYYY-MM-DD" Berlin
score: number;
}
/**
* Pick the time bucket for a window of mood entries based on the same
* thresholds the dashboard charts use. Pure: no DOM/i18n side-effects.
*
* - ≤ 90 days → "day" (raw daily averages)
* - 91-730 → "week" (ISO weekly mean)
* - > 730 → "month" (Berlin calendar monthly mean)
*/
export function pickMoodBucket(entries: MoodEntryDay[]): ChartBucketType {
if (entries.length < 2) return "day";
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
const firstTs = dayKeyToTimestamp(sorted[0].date);
const lastTs = dayKeyToTimestamp(sorted[sorted.length - 1].date);
const rangeDays = Math.round((lastTs - firstTs) / (24 * 60 * 60 * 1000));
return pickBucket(rangeDays);
}
/**
* Aggregate daily mood entries into the bucket chosen by `pickMoodBucket`.
* Returns `{ timestamp, score }` rows so the chart can render them
* without re-formatting. Days/weeks/months without an observation are
* skipped, never emitted as 0.
*
* Pure & deterministic so the unit test can pin the exact aggregated
* mean per bucket.
*/
export function aggregateMoodEntries(
entries: MoodEntryDay[],
/**
* The calendar the week / month boundaries are drawn in. Required rather
* than defaulted for the same reason `bucketTimeSeries` requires it: a
* default would leave a forgotten caller quietly bucketing someone else's
* evening into the wrong month.
*/
timeZone: string,
): {
bucket: ChartBucketType;
points: Array<{ timestamp: number; score: number }>;
} {
if (entries.length === 0) return { bucket: "day", points: [] };
const bucket = pickMoodBucket(entries);
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
if (bucket === "day") {
return {
bucket,
points: sorted.map((entry) => ({
timestamp: dayKeyToTimestamp(entry.date),
score: entry.score,
})),
};
}
const out = bucketTimeSeries(
sorted.map((entry) => ({
timestamp: dayKeyToTimestamp(entry.date),
values: { score: entry.score },
})),
{ bucket, timeZone },
);
return {
bucket,
points: out.points.map((point) => ({
timestamp: point.timestamp,
score: point.values.score,
})),
};
}
// --- Component ---
const MINI_RANGE_POINTS: Record<
NonNullable<MoodChartProps["windowOverride"]>,
number
> = {
last7days: 7,
last30days: 30,
last90days: 90,
allTime: 0,
};
export function MoodChart({
title,
mini = false,
windowOverride,
compareBaseline = "none",
chartKey,
onDataReady,
dayLinks = false,
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a server day key,
// or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so
// labels render UTC-pinned via `makeBucketLabelFormatters` — the
// encoded day paints verbatim in every profile zone (a profile-tz
// formatter shifted week/month bucket labels for zones west of Berlin).
// Issue #922 — the UTC pin decides WHICH day the label names; the
// profile's date order decides how it is spelled. Both travel.
const dateFormat = useDateFormatPreference();
const tzFmt = useMemo(
() => makeBucketLabelFormatters(locale, dateFormat),
[locale, dateFormat],
);
// The bucket BOUNDARIES follow the profile, even though the bucket LABELS
// stay UTC-pinned (see above): a week that starts on the wrong day puts a
// reading in the wrong bar, which is a different mistake from a label that
// reads a few hours off.
const profileTimeZone = readStoredTimezone() || DEFAULT_TIMEZONE;
const initialRangePoints = windowOverride
? MINI_RANGE_POINTS[windowOverride]
: 30;
const [rangePoints, setRangePoints] = useState(initialRangePoints);
// v1.4.18 — three overlay toggles persisted per chart. Mini-mode
// renders without controls and stays at the clean-line default.
const overlayPrefs = useChartOverlayPrefs(chartKey);
const showMA = !mini && overlayPrefs.prefs.showTrendIndicator;
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
const showBands = !mini && overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline =
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
// v1.30.1 M2 — hydrate the range tab from the persisted prefs once
// they arrive; see health-chart.tsx for the full rationale (the
// dashboard-layout query resolves after mount, and `rangePoints`
// stays local state because several memos depend on it by identity).
// Adjusted during render, not an effect — React's documented "adjust
// state during render" pattern (a second state flag rather than a
// ref, since the lint rule bans ref reads during render).
const [rangeHydrated, setRangeHydrated] = useState(false);
if (
!mini &&
!windowOverride &&
chartKey &&
!rangeHydrated &&
typeof overlayPrefs.prefs.rangePoints === "number"
) {
setRangeHydrated(true);
setRangePoints(overlayPrefs.prefs.rangePoints);
}
// v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth();
const displayTitle = title ?? t("charts.mood");
// v1.4.40 W-RSC — align the chart's queryKey with the dashboard's
// `useQuery({ queryKey: queryKeys.moodAnalytics() })` call against the
// same `/api/mood/analytics` endpoint. Pre-fix the chart fetched its
// own copy under `["mood-chart-data"]` (audit C1) — TanStack treated
// the two keys as separate cache slots and the cold-mount dashboard
// fired the request twice (once at `app/page.tsx:291`, once here on
// the chart's first render). The dedup eliminates a ~22 KB round-trip
// and frees one pg pool slot per cold mount.
//
// `staleTime: 60_000` matches the dashboard's `DASHBOARD_QUERY_OPTS`
// so a route swing back to the dashboard within a minute keeps the
// cache fresh; the chart's own subscription becomes the cache writer
// for any insights / mood sub-page that mounts the chart without the
// dashboard parent having fetched first.
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async (): Promise<MoodAnalyticsData> => {
return apiGet<MoodAnalyticsData>("/api/mood/analytics");
},
enabled: isAuthenticated,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
// v1.16.0 — report the settled query to the dashboard's shared reveal
// gate (see `onDataReady` prop doc). Must run BEFORE the
// empty-entries early return below so a no-data mount still unblocks
// the row.
//
// v1.20.1 — fire once via a ref instead of keying on the unstable
// `onDataReady` prop. See the matching note in `health-chart.tsx`: the
// dashboard hands every chart a fresh `() => markChartReady(id)` closure
// each render, so depending on it re-ran this notify on every commit and
// the per-commit passive effect kept the Radix-Popper tile anchors
// re-committing until React's update-depth guard tripped (#185).
const onDataReadyRef = useRef(onDataReady);
useEffect(() => {
onDataReadyRef.current = onDataReady;
}, [onDataReady]);
const dataReadyFiredRef = useRef(false);
useEffect(() => {
if (
!shouldFireDataReady({
isLoading,
alreadyFired: dataReadyFiredRef.current,
})
)
return;
dataReadyFiredRef.current = true;
onDataReadyRef.current?.();
}, [isLoading]);
// v1.4.43 W2-CHART-GATE — raw mood-entry count across every day in
// the window. `entries[].samples` carries the per-day raw count
// straight from the analytics endpoint; summing it produces the
// total entries the user logged, independent of how many calendar
// days they hit. Used by the empty-state gate to distinguish "few
// entries" from "few days".
const rawCount = useMemo<number>(() => {
if (!data?.entries?.length) return 0;
return data.entries.reduce(
(acc, entry) =>
acc + (Number.isFinite(entry.samples) ? entry.samples : 0),
0,
);
}, [data]);
const chartData = useMemo((): ChartDataPoint[] | undefined => {
if (!data?.entries?.length) return undefined;
const allPoints: ChartDataPoint[] = data.entries
.map((entry) => ({
date: tzFmt.dateShortSmart(new Date(dayKeyToTimestamp(entry.date))),
timestamp: dayKeyToTimestamp(entry.date),
pointIndex: 0,
score: entry.score,
}))
.sort((a, b) => a.timestamp - b.timestamp);
// v1.37.29 — a calendar-day window, not "the last N logged days":
// the tabs promise days, and the measurement charts window by days.
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? allPoints.filter((point) => point.timestamp >= cutoff)
: allPoints;
// v1.4.15 Fix 3: parity with health-chart's auto-bucketing. Other
// dashboard charts (BP, weight, pulse) auto-aggregate to weekly /
// monthly when the visible range is long enough that drawing every
// daily point would clutter the chart; the mood chart used to show
// raw points only, which made multi-month windows unreadable.
// Using the same `pickBucket()` thresholds the rest of the
// dashboard uses (≤90d → day, 91-730d → week, >730d → month) keeps
// the bucketing chip consistent across metrics.
const rangeDays =
sliced.length < 2
? 0
: Math.round(
(sliced[sliced.length - 1].timestamp - sliced[0].timestamp) /
(24 * 60 * 60 * 1000),
);
const bucketType = pickBucket(rangeDays);
const visibleData: ChartDataPoint[] =
bucketType === "day"
? sliced
: bucketTimeSeries(
sliced.map((p) => ({
timestamp: p.timestamp,
values: { score: p.score },
})),
{ bucket: bucketType, timeZone: profileTimeZone },
).points.map((point) => ({
date: tzFmt.dateShortSmart(new Date(point.timestamp)),
timestamp: point.timestamp,
pointIndex: 0,
score: point.values.score,
}));
const enriched: ChartDataPoint[] = visibleData.map((d, index) => ({
...d,
pointIndex: index,
}));
if (showMA) {
const scoreData = enriched.map((d) => ({
date: new Date(d.timestamp),
value: d.score,
}));
if (scoreData.length >= 2) {
const ma = movingAverageByPoints(scoreData, 7);
for (const point of ma) {
const pointTimestamp = point.date.getTime();
const existing = enriched.find((d) => d.timestamp === pointTimestamp);
if (existing) {
existing.ma = point.value;
}
}
}
}
if (showTrend) {
const scoreData = enriched.map((d) => ({
date: new Date(d.timestamp),
value: d.score,
}));
if (scoreData.length >= 2) {
const trend = buildTrendLine(scoreData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
const existing = enriched.find(
(d) => d.timestamp === pointTimestamp,
);
if (existing) {
existing.trend = point.value;
}
}
}
}
}
return enriched;
}, [data, rangePoints, showMA, showTrend, tzFmt, profileTimeZone]);
/**
* v1.4.16 B8 — comparison overlay merged into chartData.
*
* Same mechanic as HealthChart: shift the full mood-entries history
* forward by 30 / 365 days, key by the existing `date` formatter, and
* stamp the prior-period score onto each visible point as
* `scoreCompare`. The Recharts <Line dataKey="scoreCompare" />
* below renders the dimmed dashed overlay.
*/
const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => {
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.entries?.length) return chartData;
const shifted = shiftDailySeriesForward(
data.entries.map((entry) => ({
timestamp: dayKeyToTimestamp(entry.date),
score: entry.score,
})),
effectiveCompareBaseline,
);
const shiftedByDay = new Map<string, number>();
for (const row of shifted) {
const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp));
if (typeof row.score === "number" && Number.isFinite(row.score)) {
shiftedByDay.set(dayKey, row.score);
}
}
return chartData.map((point) => {
const compareValue = shiftedByDay.get(point.date);
if (typeof compareValue === "number" && Number.isFinite(compareValue)) {
return { ...point, scoreCompare: compareValue };
}
return point;
});
}, [chartData, effectiveCompareBaseline, data, tzFmt]);
const hasComparisonData = useMemo(() => {
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
typeof point.scoreCompare === "number"
? Number.isFinite(point.scoreCompare)
: false,
);
}, [chartDataWithCompare, effectiveCompareBaseline]);
// Mirror the activeBucket calculation from health-chart so the chip
// in the header reflects the *same* aggregation chartData used.
// Computed independently of chartData so an empty/loading dataset
// doesn't crash the chip render.
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.entries?.length) return "day";
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sorted = [...data.entries].sort((a, b) =>
a.date.localeCompare(b.date),
);
// v1.37.29 — the same calendar-day window `chartData` applies, so the
// chip describes the points actually painted.
const sliced =
rangePoints > 0
? sorted.filter((entry) => dayKeyToTimestamp(entry.date) >= cutoff)
: sorted;
if (sliced.length < 2) return "day";
const firstTs = dayKeyToTimestamp(sliced[0].date);
const lastTs = dayKeyToTimestamp(sliced[sliced.length - 1].date);
const rangeDays = Math.round((lastTs - firstTs) / (24 * 60 * 60 * 1000));
return pickBucket(rangeDays);
}, [data, rangePoints]);
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
// v1.4.27 B6 / BL-P6-11 — the chart axis and the mood-list cards
// share `MOOD_LABEL_KEYS` so a polishing-pass copy update lands in
// both surfaces. The shared resolver maps a numeric score back to
// the canonical key set under `mood.level*`.
const moodLabels: Record<number, string> = {
1: t(moodLabelKeyForScore(1) ?? "mood.levelLausig"),
2: t(moodLabelKeyForScore(2) ?? "mood.levelSchlecht"),
3: t(moodLabelKeyForScore(3) ?? "mood.levelOkay"),
4: t(moodLabelKeyForScore(4) ?? "mood.levelGut"),
5: t(moodLabelKeyForScore(5) ?? "mood.levelSuperGut"),
};
// v1.4.18 — emoji glyph map removed. the maintainer explicitly rejected
// smileys in the mood chart; the line now uses plain Recharts dots
// and the y-axis already labels each integer (very low / low / okay
// / good / great) so the chart is fully scannable without a glyph.
const formatMoodTick = (value: number): string => {
return moodLabels[value] ?? String(value);
};
const formatTooltipValue = (value: number): string => {
const rounded = Math.round(value * 10) / 10;
const label = moodLabels[Math.round(value)];
return label ? `${rounded} (${label})` : String(rounded);
};
// v1.4.16 B1a — personal mood baseline (median over visible window).
const personalBaseline = useMemo<number | null>(() => {
if (!chartData?.length || chartData.length < 5) return null;
const sorted = chartData.map((d) => d.score).sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}, [chartData]);
// v1.42 — the chart as a door to its days (`dayLinks`), through the same
// hook every day-linked chart uses.
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
const dayLinkSource = chartDataWithCompare ?? chartData;
const chartDays = useChartDayLinks({
enabled: dayLinksActive,
days: dayLinksActive
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
: [],
focusFor: (index) => {
const point = dayLinkSource?.[index];
return point
? {
label: displayTitle,
value: formatTooltipValue(point.score),
types: ["MOOD"],
}
: null;
},
});
const animationsEnabled = !prefersReducedMotion();
// v1.16.8 — only an EMPTY success hides the card (the dashboard gates
// the mount on `hasMood` anyway). A failed query must keep the card
// mounted so the error state + retry affordance below can paint;
// pre-fix the `return null` made an outage erase the widget entirely.
if (!isLoading && !isError && !data?.entries?.length) return null;
// v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title +
// chart body render inside the exact same flat shell `<HealthChart
// mini>` uses (`bg-card border-border rounded-md border p-2` with a
// plain `<div>` title), instead of the `<Card>/<CardHeader>/
// <CardTitle>` chrome that stacked extra vertical space above and
// below the "Stimmung" heading. Aligning the STRUCTURE — not just
// the padding values — makes the three trend cards share one title
// baseline and one height. Full (non-mini) mode keeps the Card path.
// Accessibility — a concise spoken summary of the visible mood line so a
// screen reader announces the score range, the latest value and the
// direction rather than an unlabelled graphic.
const moodAriaLabel = (() => {
const metric = displayTitle;
const scores = (chartData ?? [])
.map((d) => d.score)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (scores.length === 0) {
return t("charts.a11y.noData", { metric });
}
const fmtScore = (v: number) => String(Math.round(v * 10) / 10);
const minVal = Math.min(...scores);
const maxVal = Math.max(...scores);
const latestVal = scores[scores.length - 1];
const firstVal = scores[0];
const epsilon = 0.1;
const delta = latestVal - firstVal;
const trend =
delta > epsilon
? t("charts.a11y.trendUp")
: delta < -epsilon
? t("charts.a11y.trendDown")
: t("charts.a11y.trendFlat");
return t("charts.a11y.summary", {
metric,
min: fmtScore(minVal),
max: fmtScore(maxVal),
latest: fmtScore(latestVal),
trend,
});
})();
const chartBody = isLoading ? (
// v1.16.0 — height-matched skeleton band instead of the former
// `h-48` spinner box: the loading state occupies the same box the
// painted chart will (mini 140 px / full 200-220 px), so the card
// never jumps when the data lands.
<Skeleton
className={
mini
? "h-[var(--chart-height,140px)] w-full"
: "h-[var(--chart-height,200px)] w-full md:h-[var(--chart-height-md,220px)]"
}
/>
) : isError ? (
// v1.16.8 — a failed query paints as an ERROR with a retry
// affordance instead of unmounting the card (see the gate above).
<ChartErrorState
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={displayTitle}
onAction={() => void refetch()}
height={CHART_HEIGHT_PX}
/>
) : !chartData?.length ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
) : chartData.length < 3 ? (
// v1.4.16 B1a — sparse-data placeholder consistent with the
// BP/weight/pulse charts. Height tracks the chart strip's
// shared CHART_HEIGHT_PX so the empty state preserves the
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
// other dashboard chart card).
//
// v1.4.43 W2-CHART-GATE — split the copy on the raw mood-entry
// count. A user with 50 mood entries across 2 calendar days
// sees `chartData.length = 2` (daily-aggregated) but the raw
// count (sum of `samples`) is 50. Surface "need more days"
// in that scenario so the call-to-action matches reality.
rawCount >= 3 ? (
<ChartEmptyState
title={t("charts.needMoreDistinctDaysTitle")}
description={t("charts.needMoreDistinctDaysDescription")}
height={CHART_HEIGHT_PX}
/>
) : (
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
height={CHART_HEIGHT_PX}
/>
)
) : (
<div
// v1.12.7 — the mood line plots a 1–5 score band; the former
// 240/280 px full-mode height left a tall dead strip below the
// line on the mood subpage. Tighten the default to 200/220 px so
// the chart fits its content without the empty gap. A per-mount
// `--chart-height` override (trends-row mini, etc.) still wins, so
// only the unoverridden full-mode mount shrinks.
className={`${
mini
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y ${chartDays.plotClassName}`}
{...chartDays.plotProps}
// Same stable hook as the measurement chart: rendered by the data
// branch only, so the browser suite can gate on it instead of on
// Recharts' own class names.
data-slot="chart-plot"
role="img"
aria-label={moodAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
accessibilityLayer
onClick={chartDays.onChartClick}
>
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
five mood-score ticks ([1,2,3,4,5]), which Recharts
syncs with CartesianGrid by default. That left the
mini variant painting five horizontal lines while
the BP / Weight / Pulse minis painted six (their
auto-generated YAxis ticks land on six bands in
typical health ranges). The explicit coordinates
generator below produces six evenly-spaced lines
so the trends row reads as a single rhythm. */}
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
horizontalCoordinatesGenerator={({ offset }) => {
// `offset` carries the chart's plot-area metrics.
// Six evenly-spaced y-coordinates inside the plot
// area (top + 4 intermediate + bottom) match the
// density of the BP/Weight/Pulse minis.
const top = offset.top ?? 0;
const height = offset.height ?? 0;
if (height <= 0) return [];
const lines = 6;
return Array.from({ length: lines }, (_, i) =>
Math.round(top + (height * i) / (lines - 1)),
);
}}
/>
{showBands &&
VALUE_BANDS.map((band) => (
<ReferenceArea
key={`band-${band.min}-${band.max}`}
y1={band.min}
y2={band.max}
fill={band.color}
fillOpacity={band.opacity}
strokeOpacity={0}
ifOverflow="discard"
/>
))}
{/* v1.4.18 — personal-baseline gated behind the
Trend toggle (maintainer: "only when a trend is being
displayed"). Default OFF. */}
{showTrend && personalBaseline != null && (
<ReferenceLine
y={personalBaseline}
stroke={COLOR_MAIN}
strokeDasharray="2 4"
strokeOpacity={0.4}
strokeWidth={1}
ifOverflow="discard"
label={{
value: t("charts.personalBaseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
opacity: 0.7,
}}
/>
)}
<XAxis
type="number"
dataKey="pointIndex"
domain={[0, maxPointIndex]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
tzFmt.date(
new Date(chartData[Math.round(value)]?.timestamp ?? Date.now()),
)
}
// v1.4.29 — Recharts ignores `interval` on numeric
// axes; pass explicit tick positions instead.
ticks={computeTickPositions(chartData ?? [], viewportWidth)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={65}
tickMargin={10}
tickFormatter={formatMoodTick}
/>
{chartDays.openIndex !== undefined ? (
<ReferenceLine
key="open-day"
x={chartDays.openIndex}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
const {
active,
payload,
label: rechartsLabel,
} = props as unknown as {
active?: boolean;
payload?: Array<{
name?: string;
value?: number;
color?: string;
dataKey?: string;
payload?: ChartDataPoint;
}>;
label?: number;
};
if (!active || !payload?.length) return null;
const ts =
payload[0]?.payload?.timestamp ??
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
const hoverPoint = payload[0]?.payload as
ChartDataPoint | undefined;
for (const item of payload) {
if (typeof item.value !== "number") continue;
const dataKey = String(item.dataKey ?? "");
// Skip auxiliary lines (`ma`, `trend`,
// `scoreCompare`). Comparison value rendered as
// delta on the primary row + own row below.
if (
dataKey === "ma" ||
dataKey === "trend" ||
dataKey === "scoreCompare"
)
continue;
let delta: string | undefined;
const compareValue =
effectiveCompareBaseline !== "none" && hoverPoint
? hoverPoint.scoreCompare
: undefined;
if (
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
const diff = item.value - compareValue;
if (Math.abs(diff) < 0.05) {
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
delta = t(
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
}
} else if (personalBaseline != null) {
const diff = item.value - personalBaseline;
if (Math.abs(diff) < 0.05) {
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
delta = t("charts.deltaVsBaseline").replace(
"{delta}",
formatted,
);
}
}
rows.push({
name: t("charts.moodScore"),
value: formatTooltipValue(item.value),
color: item.color ?? COLOR_MAIN,
delta,
});
if (
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
rows.push({
name: `${t("charts.moodScore")} · ${t(
"comparison.tooltipPrior",
)}`,
value: formatTooltipValue(compareValue),
color: item.color ?? COLOR_MAIN,
});
}
}
if (rows.length === 0) return null;
return (
<RichChartTooltip
active
label={dateLabel}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
)}
/>
);
}}
/>
<Line
type="monotone"
dataKey="score"
name="score"
stroke={COLOR_MAIN}
strokeWidth={2}
dot={{ r: 3, fill: COLOR_MAIN }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
{showMA && (
<Line
type="monotone"
dataKey="ma"
name="ma"
stroke={COLOR_MA}
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
connectNulls
/>
)}
{showTrend && (
<Line
type="linear"
dataKey="trend"
name="trend"
stroke={COLOR_TREND}
strokeWidth={1}
strokeDasharray="8 4"
dot={false}
connectNulls
/>
)}
{/* v1.4.16 B8 — comparison overlay (mood). Same
dimmed dashed treatment the BP/weight/pulse chart
uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior
data via hasComparisonData. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<Line
type="monotone"
dataKey="scoreCompare"
name="scoreCompare"
stroke={COLOR_MAIN}
strokeWidth={1.25}
strokeDasharray="4 3"
strokeOpacity={0.45}
dot={false}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
legendType="none"
data-slot="chart-compare-line-mood"
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);
// v1.13.1 — mini mode: flat shell that exactly mirrors `<HealthChart
// mini>` (bg-card border rounded-md p-2 + plain `<div>` title), so the
// three trend cards share one title baseline and one height.
if (mini) {
return (
<div
data-slot="chart-mini"
className="bg-card border-border rounded-md border p-2"
>
<div className="text-muted-foreground text-2xs mb-1 font-medium tracking-wider uppercase">
{displayTitle}
</div>
{chartBody}
</div>
);
}
return (
<Card>
<CardHeader>
{/* v1.4.19 A2 — mobile-first header: stack title row above
controls row on small viewports so the bucket / comparison
chips never push the range tabs into a 2nd line. ≥sm goes
back to side-by-side. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<TileHeader title={displayTitle} />
{activeBucket !== "day" && (
<TagChip className="hidden tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
</TagChip>
)}
{/* v1.4.16 B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 text-2xs hidden rounded-md border border-current/30 px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
</div>
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.days);
// v1.30.1 M2 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.days,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
{t(r.labelKey)}
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. Only painted when the chart is bound to
a persistent chartKey; ad-hoc usages (/insights mood
preview, recommendation card) stay clean. */}
{chartKey ? (
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
</div>
</CardHeader>
<CardContent>
{chartBody}
{!isLoading && !isError && chartData && chartData.length >= 3 ? (
<>
<ChartDayFooter
links={chartDays}
points={chartData}
// The plot's margin (8) + the y axis (65) + the x axis padding
// (10) on the left, margin + padding on the right.
insetLeft={8 + 65 + 10}
insetRight={8 + 10}
/>
{dayLinksActive ? (
<ChartDataTable
points={chartData.map((point) => ({
date: point.date,
timestamp: point.timestamp,
score: point.score,
}))}
columns={[{ key: "score", label: t("charts.moodScore") }]}
formatValue={formatTooltipValue}
formatDate={tzFmt.date}
bucket="day"
metricLabel={displayTitle}
dayLinks
/>
) : null}
</>
) : null}
</CardContent>
</Card>
);
}