mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
1fcc5ba96b
commit
84e592b17e
20 files changed
+2003
-96
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user