feat(insights): enrich the mood page with calendar, distribution, weekday, and correlation views

Lift the mood aggregates the AI status generator already computes —
tag frequency + per-tag average, in-target share, and the
mood × metric Pearson correlations — into a shared
`mood-aggregates.ts` so the prose and the new charts read off one
implementation. `mood-status.ts` now consumes the shared helpers
instead of its own inline copies.

Add a cached `/api/mood/insights` endpoint that returns the
pre-computed shapes (summary, heatmap, distribution, weekday, tags,
correlations) behind a 60-second server cache and a bounded 365-day
read — a cheap read-only path, never a synchronous model call. The
bucket invalidates alongside the mood-analytics cache on a mood write.

Render the shapes on the mood Insights page:

- a calendar heatmap forked from the compliance heatmap, coloured by
  the same mood-score bands as the line chart, with an adaptive
  window (30 days until ~90 days of history, then 90, then a full
  year past 180 days) so the grid is never mostly empty;
- a mood distribution bar chart (share of days per level);
- average mood by weekday with a best/toughest-day callout;
- a tag / trigger breakdown (frequency bars + per-tag average);
- mood vs. sleep / activity / pulse correlation cards reusing the
  existing scatter chart.

Add the supporting i18n keys across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 19:54:26 +02:00
1 parent 1fcc5ba96b
commit 84e592b17e
20 files changed
+2003 -96

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
* v1.8.5 — mood calendar heatmap.
*
* Forked from `compliance-heatmap.tsx`: same SVG cell-grid, Monday-aligned
* columns, month markers, tap-to-pin tooltip, and `stretch` adaptive cell
* sizing. The one swap is `getColor` — instead of an expected/taken
* compliance map, each cell is coloured by its daily-mean mood score band
* (matching the `mood-chart.tsx` VALUE_BANDS: 1–2 red, 2–3 orange, 3–5
* green). One cell per calendar day; days with no entry render in the
* neutral `--secondary` fill.
*/
interface MoodHeatmapCell {
/** YYYY-MM-DD day key. */
date: string;
/** Daily-mean mood score 1..5. */
score: number;
/** Number of entries that fed the mean. */
samples: number;
}
interface MoodHeatmapProps {
/** Per-day cells keyed by YYYY-MM-DD. */
cells: Record<string, MoodHeatmapCell>;
days?: number;
stretch?: boolean;
}
const CELL_SIZE = 18;
const GAP = 3;
const CELL_FLOOR_PX = 14;
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
* (1–2 red, 2–3 orange, 3–5 green) and the green/orange thresholds in
* `mood-aggregates.ts`. No score (no entry that day) → neutral fill.
*/
function getColor(score: number | null): string {
if (score == null) return "var(--secondary)";
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
function formatDateDE(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z");
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
}
export function MoodHeatmap({
cells: cellData,
days = 90,
stretch = false,
}: MoodHeatmapProps) {
const { t } = useTranslations();
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
text: string;
pinned?: boolean;
} | null>(null);
useEffect(() => {
if (!tooltip?.pinned) return;
const handlePointer = (event: PointerEvent) => {
const container = containerRef.current;
if (!container) return;
if (!container.contains(event.target as Node)) {
setTooltip(null);
}
};
document.addEventListener("pointerdown", handlePointer, true);
return () => {
document.removeEventListener("pointerdown", handlePointer, true);
};
}, [tooltip?.pinned]);
const WEEKDAY_LABELS = [
t("charts.weekdays.mon"),
"",
t("charts.weekdays.wed"),
"",
t("charts.weekdays.fri"),
"",
t("charts.weekdays.sun"),
];
useEffect(() => {
if (!stretch) return;
const element = containerRef.current;
if (!element) return;
const updateWidth = () => {
setContainerWidth(element.clientWidth);
};
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [stretch]);
const { cells, weeks, monthMarkers } = useMemo(() => {
const MONTH_LABELS = [
t("charts.months.jan"),
t("charts.months.feb"),
t("charts.months.mar"),
t("charts.months.apr"),
t("charts.months.may"),
t("charts.months.jun"),
t("charts.months.jul"),
t("charts.months.aug"),
t("charts.months.sep"),
t("charts.months.oct"),
t("charts.months.nov"),
t("charts.months.dec"),
];
const now = new Date();
const cellList: Array<{
dateKey: string;
col: number;
row: number;
color: string;
cell: MoodHeatmapCell | null;
}> = [];
const dates: Date[] = [];
for (let d = days - 1; d >= 0; d--) {
const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
dates.push(date);
}
// Monday-align + month markers read off the UTC accessors so the
// grid matches the UTC-anchored dateKey (mirrors compliance-heatmap).
const firstDate = dates[0];
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
const dateKey = date.toISOString().slice(0, 10);
const cell = cellData[dateKey] ?? null;
const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
}
cellList.push({
dateKey,
col: currentCol,
row,
color: getColor(cell?.score ?? null),
cell,
});
col = Math.max(col, currentCol);
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [cellData, days, t]);
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
const cellSize =
stretch && containerWidth > 0
? Math.max(
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
)
: CELL_SIZE;
const step = cellSize + GAP;
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
className={
stretch
? "w-full sm:w-full overflow-x-auto sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{cells.map((cell) => {
const buildText = (): string => {
if (!cell.cell) {
return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
}
const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score));
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
return (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer"
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
});
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
});
}}
/>
);
})}
</svg>
</div>
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
>
{tooltip.text}
</div>
)}
<div
className="text-muted-foreground mt-2 flex flex-wrap items-center gap-3 text-xs"
style={{ marginLeft: stretch ? 0 : labelWidth }}
>
{[
{ color: "var(--dracula-green)", label: t("insights.mood.legendGreat") },
{ color: "var(--dracula-yellow)", label: t("insights.mood.legendGood") },
{ color: "var(--dracula-orange)", label: t("insights.mood.legendOkay") },
{ color: "var(--dracula-red)", label: t("insights.mood.legendLow") },
].map(({ color, label }) => (
<span key={label} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
/>
{label}
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import dynamic from "next/dynamic";
import { Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/analytics/correlations";
/**
* v1.8.5 — mood × metric correlation cards (Apple "Life Factors" parity).
*
* Reuses the shared `<ScatterCorrelationChart>` (same wrapper the
* mother-page correlation cards ride) for each mood × (sleep / steps /
* pulse) pair. The Pearson math comes pre-computed from
* `/api/mood/insights` (single source of truth with the LLM snapshot).
* Below-threshold pairs render an EmptyState rather than a misleading
* near-zero scatter.
*/
const ScatterCorrelationChart = dynamic(
() =>
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
default: mod.ScatterCorrelationChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 aspect-square min-h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none sm:aspect-[3/2] sm:h-auto" />
),
},
);
export interface MoodCorrelationPoint {
x: number;
y: number;
[key: string]: number;
}
export interface MoodMetricCorrelationData {
result: CorrelationResult | null;
points: MoodCorrelationPoint[];
n: number;
}
type MetricKind = "sleep" | "steps" | "pulse";
const TITLE_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepTitle",
steps: "insights.mood.correlation.stepsTitle",
pulse: "insights.mood.correlation.pulseTitle",
};
const Y_LABEL_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepAxis",
steps: "insights.mood.correlation.stepsAxis",
pulse: "insights.mood.correlation.pulseAxis",
};
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
stark: "insights.mood.correlation.strengthStrong",
moderat: "insights.mood.correlation.strengthModerate",
schwach: "insights.mood.correlation.strengthWeak",
keine: "insights.mood.correlation.strengthNone",
};
function MoodCorrelationCard({
kind,
data,
}: {
kind: MetricKind;
data: MoodMetricCorrelationData;
}) {
const { t } = useTranslations();
const hasResult = data.result != null && data.n >= 5;
return (
<Card data-slot="mood-correlation-card" data-kind={kind}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold">
{t(TITLE_KEY[kind])}
</CardTitle>
{hasResult && data.result && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{hasResult && data.result ? (
<>
<ScatterCorrelationChart
data={data.points}
fill={FILL_BY_KIND[kind]}
xAxis={{
dataKey: "x",
name: t("insights.moodSectionTitle"),
domain: [1, 5],
ticks: [1, 2, 3, 4, 5],
}}
yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }}
height={180}
/>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
</p>
</>
) : (
<EmptyState
variant="plain"
size="compact"
icon={<Activity className="size-5" />}
title={t("insights.mood.correlation.emptyTitle")}
description={t("insights.mood.correlation.emptyDescription")}
/>
)}
</CardContent>
</Card>
);
}
export function MoodCorrelationCards({
sleep,
steps,
pulse,
}: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
}) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MoodCorrelationCard kind="sleep" data={sleep} />
<MoodCorrelationCard kind="steps" data={steps} />
<MoodCorrelationCard kind="pulse" data={pulse} />
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import {
Bar,
BarChart,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
* v1.8.5 — mood distribution (share of days per level).
*
* One bar per discrete mood level 1..5, coloured by the same band map
* as the heatmap + line chart. Counts are days (daily means rounded to
* the nearest level) so multi-entry days never over-weight the
* histogram — consistent with the rest of the surface's daily-mean
* convention.
*/
export interface MoodDistributionRow {
score: number;
count: number;
}
const BAR_COLOR_BY_SCORE: Record<number, string> = {
1: "var(--dracula-red)",
2: "var(--dracula-orange)",
3: "var(--dracula-yellow)",
4: "var(--dracula-green)",
5: "var(--dracula-green)",
};
export function MoodDistributionChart({
distribution,
}: {
distribution: MoodDistributionRow[];
}) {
const { t } = useTranslations();
const data = distribution.map((row) => {
const labelKey = moodLabelKeyForScore(row.score);
return {
score: row.score,
count: row.count,
label: labelKey ? t(labelKey) : String(row.score),
};
});
return (
<div className="aspect-[3/2] min-h-[180px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={28}
/>
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(value) =>
[
`${value ?? 0} ${t("insights.mood.distributionDaysUnit")}`,
t("insights.mood.distributionTitle"),
] as [string, string]
}
/>
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.score}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--dracula-purple)"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
MoodDistributionChart,
type MoodDistributionRow,
} from "./mood-distribution-chart";
import {
MoodWeekdayChart,
type MoodWeekdayRow,
} from "./mood-weekday-chart";
import {
MoodTagBreakdown,
type MoodTagRow,
} from "./mood-tag-breakdown";
import {
MoodCorrelationCards,
type MoodMetricCorrelationData,
} from "./mood-correlation-cards";
/**
* v1.8.5 — additional Mood Insights sections.
*
* Reads the pre-computed aggregate bundle from `/api/mood/insights`
* (cheap cached server read, no LLM) and paints the heatmap,
* distribution, weekday pattern, tag breakdown, and mood × metric
* correlation cards. Renders nothing while loading / on error / on an
* empty data set so the page degrades gracefully to the line chart.
*/
interface MoodInsightsResponse {
summary: {
totalEntries: number;
};
heatmap: {
windowDays: number;
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
weekday: MoodWeekdayRow[];
tags: MoodTagRow[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
};
}
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function MoodInsightsSections() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodInsights(),
queryFn: async () => {
const res = await fetch("/api/mood/insights");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as MoodInsightsResponse;
},
enabled: isAuthenticated,
staleTime: 60_000,
});
if (isLoading) {
return <Skeleton className="h-48 w-full rounded-lg" />;
}
if (!data || data.summary.totalEntries === 0) {
return null;
}
const heatmapCells = Object.fromEntries(
data.heatmap.cells.map((cell) => [cell.date, cell]),
);
const hasTags = data.tags.length > 0;
return (
<div className="space-y-4">
<SectionCard title={t("insights.mood.heatmapTitle")}>
<MoodHeatmap
cells={heatmapCells}
days={data.heatmap.windowDays}
stretch
/>
</SectionCard>
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard title={t("insights.mood.distributionTitle")}>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard title={t("insights.mood.weekdayTitle")}>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
<div className="space-y-2">
<h3 className="text-base font-semibold">
{t("insights.mood.correlationsTitle")}
</h3>
<p className="text-muted-foreground text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
/>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — tag / trigger breakdown.
*
* Ranked horizontal bars over tag frequency, with the per-tag average
* mood ("lift") rendered as a coloured score chip. The aggregation comes
* pre-computed from `/api/mood/insights` (shared with the LLM snapshot's
* tag block, so the prose and the chart agree). Works on the flat
* free-text tag array we hold today — the structured-tag taxonomy with
* icons is a separate later piece.
*/
export interface MoodTagRow {
tag: string;
count: number;
avgScore: number;
}
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
const { t } = useTranslations();
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
return (
<ul className="space-y-2" data-slot="mood-tag-breakdown">
{tags.map((row) => (
<li key={row.tag} className="flex items-center gap-2 text-sm">
<span className="text-foreground w-28 shrink-0 truncate" title={row.tag}>
{row.tag}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
}}
/>
</div>
<span className="text-muted-foreground w-6 shrink-0 text-right tabular-nums">
{row.count}
</span>
<span
className="w-10 shrink-0 text-right text-xs font-medium tabular-nums"
style={{ color: colorForScore(row.avgScore) }}
title={t("insights.mood.tagAvgMood")}
>
{row.avgScore.toFixed(1)}
</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useMemo } from "react";
import {
Bar,
BarChart,
Cell,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — average mood per weekday (Monday-aligned).
*
* One bar per weekday over the average daily-mean mood, with the
* best/worst day called out below the chart. Empty weekdays (no data)
* render as a zero-height bar so the seven-column rhythm stays intact.
*/
export interface MoodWeekdayRow {
/** 0 = Monday … 6 = Sunday. */
weekday: number;
avgScore: number | null;
count: number;
}
const WEEKDAY_LABEL_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
"charts.weekdaysFull.wed",
"charts.weekdaysFull.thu",
"charts.weekdaysFull.fri",
"charts.weekdaysFull.sat",
"charts.weekdaysFull.sun",
] as const;
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
const { t } = useTranslations();
const data = weekday.map((row) => ({
weekday: row.weekday,
label: t(WEEKDAY_LABEL_KEYS[row.weekday]),
value: row.avgScore ?? 0,
avgScore: row.avgScore,
count: row.count,
}));
const { best, worst } = useMemo(() => {
const populated = weekday.filter(
(r): r is MoodWeekdayRow & { avgScore: number } => r.avgScore != null,
);
if (populated.length === 0) return { best: null, worst: null };
let bestRow = populated[0];
let worstRow = populated[0];
for (const row of populated) {
if (row.avgScore > bestRow.avgScore) bestRow = row;
if (row.avgScore < worstRow.avgScore) worstRow = row;
}
return { best: bestRow, worst: worstRow };
}, [weekday]);
return (
<div className="space-y-2">
<div className="aspect-[3/2] min-h-[160px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={24}
/>
<ReferenceLine y={3.5} stroke="var(--dracula-green)" strokeDasharray="3 3" />
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(_value, _name, item) => {
const payload = item?.payload as
| { avgScore: number | null; count: number }
| undefined;
if (!payload || payload.avgScore == null) {
return [t("insights.mood.weekdayNoData"), ""];
}
return [
`${payload.avgScore.toFixed(1)} (${payload.count})`,
t("insights.mood.weekdayTitle"),
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.weekday}
fill={
row.avgScore == null
? "var(--secondary)"
: colorForScore(row.avgScore)
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{best && worst && (
<p className="text-muted-foreground text-xs">
{t("insights.mood.weekdayBestWorst", {
best: t(WEEKDAY_LABEL_KEYS[best.weekday]),
worst: t(WEEKDAY_LABEL_KEYS[worst.weekday]),
})}
</p>
)}
</div>
);
}