feat(insights): surface mood time-of-day, stability, and weight/BP correlations

Render the new mood analyses on the insights surface:

- a four-bar time-of-day chart (morning / afternoon / evening / night),
  shown only when the server marks the pattern reliable
- a stability tile beside the in-target tile, with the descriptive band
- the time-of-day takeaway in the narrative feed (new Clock icon, daypart
  label resolved from the bundle)

Also wire the mood x weight and mood x systolic-blood-pressure scatter
cards. Their Pearson coefficients and points were already computed and
returned by the endpoint but never rendered; they now sit alongside the
sleep / activity / pulse cards, each gated on the same minimum-pairs
threshold so a thin pairing renders an empty state rather than a noisy r.

New i18n keys land EN-first and propagate across de / es / fr / it / pl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:45:14 +02:00
1 parent 141c083ff6
commit f65181bbf2
12 files changed
+405 -14

No files matched your search

@@ -32,6 +32,13 @@ type Response = {
heatmap: { windowDays: number; cells: [] };
distribution: [];
weekday: [];
timeOfDay: {
buckets: [];
reliable: boolean;
best: null;
worst: null;
};
stability: null;
tags: [];
structuredTags: [];
narratives: Array<{
@@ -43,6 +50,8 @@ type Response = {
sleep: EmptyCorrelation;
steps: EmptyCorrelation;
pulse: EmptyCorrelation;
weight: EmptyCorrelation;
bloodPressureSystolic: EmptyCorrelation;
};
};
@@ -70,6 +79,8 @@ function baseData(inTargetPct: number | null): Response {
heatmap: { windowDays: 30, cells: [] },
distribution: [],
weekday: [],
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
stability: null,
tags: [],
structuredTags: [],
narratives: [
@@ -88,6 +99,8 @@ function baseData(inTargetPct: number | null): Response {
sleep: emptyCorrelation,
steps: emptyCorrelation,
pulse: emptyCorrelation,
weight: emptyCorrelation,
bloodPressureSystolic: emptyCorrelation,
},
};
}
@@ -45,24 +45,30 @@ export interface MoodMetricCorrelationData {
n: number;
}
type MetricKind = "sleep" | "steps" | "pulse";
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
const TITLE_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepTitle",
steps: "insights.mood.correlation.stepsTitle",
pulse: "insights.mood.correlation.pulseTitle",
weight: "insights.mood.correlation.weightTitle",
bloodPressure: "insights.mood.correlation.bloodPressureTitle",
};
const Y_LABEL_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepAxis",
steps: "insights.mood.correlation.stepsAxis",
pulse: "insights.mood.correlation.pulseAxis",
weight: "insights.mood.correlation.weightAxis",
bloodPressure: "insights.mood.correlation.bloodPressureAxis",
};
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
weight: "var(--dracula-green)",
bloodPressure: "var(--dracula-orange)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
@@ -136,16 +142,22 @@ export function MoodCorrelationCards({
sleep,
steps,
pulse,
weight,
bloodPressureSystolic,
}: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: 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} />
<MoodCorrelationCard kind="weight" data={weight} />
<MoodCorrelationCard kind="bloodPressure" data={bloodPressureSystolic} />
</div>
);
}
@@ -33,6 +33,14 @@ import {
type MoodNarrativeItem,
} from "./mood-narrative-feed";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodTimeOfDayChart,
type MoodTimeOfDayPattern,
} from "./mood-time-of-day-chart";
import {
MoodStabilityTile,
type MoodStabilityData,
} from "./mood-stability-tile";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -55,6 +63,8 @@ interface MoodInsightsResponse {
};
distribution: MoodDistributionRow[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
narratives: MoodNarrativeItem[];
@@ -62,6 +72,8 @@ interface MoodInsightsResponse {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
};
}
@@ -122,9 +134,17 @@ export function MoodInsightsSections() {
? data.narratives.filter((item) => item.kind !== "in-target")
: data.narratives;
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
return (
<div className="space-y-4">
<MoodInTargetTile pct={data.summary.inTargetPct} />
{(hasInTargetTile || hasStabilityTile) && (
<div className="grid gap-4 sm:grid-cols-2">
<MoodInTargetTile pct={data.summary.inTargetPct} />
<MoodStabilityTile stability={data.stability} />
</div>
)}
<MoodNarrativeFeed items={narratives} />
@@ -145,6 +165,12 @@ export function MoodInsightsSections() {
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
@@ -168,6 +194,8 @@ export function MoodInsightsSections() {
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</div>
</div>
@@ -4,6 +4,7 @@ import {
ArrowDownRight,
ArrowUpRight,
CalendarDays,
Clock,
Flame,
Tag,
Target,
@@ -42,6 +43,7 @@ const ICONS: Record<string, ComponentType<{ className?: string }>> = {
"tag-drop": ArrowDownRight,
"in-target": Target,
streak: Flame,
"time-of-day": Clock,
};
function pickIcon(item: MoodNarrativeItem): ComponentType<{
@@ -76,6 +78,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
const vars: Record<string, string> = { ...item.vars };
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
if (vars.tagKey) vars.tag = t(vars.tagKey);
if (vars.bucketKey) vars.bucket = t(vars.bucketKey);
return (
<li
key={`${item.kind}-${index}`}
@@ -0,0 +1,66 @@
"use client";
import { Activity } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.9.0 — mood-stability tile.
*
* Surfaces the day-to-day stability score (0..100, higher = steadier)
* computed server-side from the population standard deviation of the
* daily means, plus a descriptive, non-judgemental band. Some variation
* is healthy, so the band reads "steady" / "variable", never
* "good" / "bad". Renders nothing when the score is unavailable (a sparse
* logger below the minimum-days floor) so the page degrades gracefully.
*/
export type MoodStabilityBand =
| "verySteady"
| "steady"
| "variable"
| "veryVariable";
export interface MoodStabilityData {
score: number;
band: MoodStabilityBand;
days: number;
}
const BAND_KEY: Record<MoodStabilityBand, string> = {
verySteady: "insights.mood.stability.bandVerySteady",
steady: "insights.mood.stability.bandSteady",
variable: "insights.mood.stability.bandVariable",
veryVariable: "insights.mood.stability.bandVeryVariable",
};
export function MoodStabilityTile({
stability,
}: {
stability: MoodStabilityData | null;
}) {
const { t } = useTranslations();
if (stability == null) return null;
return (
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
<Activity className="size-5" />
</div>
<div className="min-w-0">
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.stability.value", {
score: String(stability.score),
})}
</p>
<p className="text-muted-foreground text-sm">
{t(BAND_KEY[stability.band])}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,149 @@
"use client";
import {
Bar,
BarChart,
Cell,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.9.0 — average mood per part of day.
*
* Four bars (morning / afternoon / evening / night) over the average
* mood logged in each part of the user's local day. The buckets are
* computed server-side in each entry's own timezone and gated on a
* spread floor (`reliable`); the parent only mounts this chart when the
* pattern is trustworthy, so a once-a-day logger never sees a misleading
* single bar dressed up as a daypart preference. Empty buckets render as
* a muted zero-height bar so the four-column rhythm stays intact.
*/
export type MoodTimeOfDayBucket =
| "morning"
| "afternoon"
| "evening"
| "night";
export interface MoodTimeOfDayRow {
bucket: MoodTimeOfDayBucket;
avgScore: number | null;
count: number;
}
export interface MoodTimeOfDayPattern {
buckets: MoodTimeOfDayRow[];
reliable: boolean;
best: MoodTimeOfDayBucket | null;
worst: MoodTimeOfDayBucket | null;
}
const LABEL_KEYS: Record<MoodTimeOfDayBucket, string> = {
morning: "insights.mood.timeOfDay.morning",
afternoon: "insights.mood.timeOfDay.afternoon",
evening: "insights.mood.timeOfDay.evening",
night: "insights.mood.timeOfDay.night",
};
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 MoodTimeOfDayChart({
pattern,
}: {
pattern: MoodTimeOfDayPattern;
}) {
const { t } = useTranslations();
const data = pattern.buckets.map((row) => ({
bucket: row.bucket,
label: t(LABEL_KEYS[row.bucket]),
value: row.avgScore ?? 0,
avgScore: row.avgScore,
count: row.count,
}));
const bestLabel = pattern.best ? t(LABEL_KEYS[pattern.best]) : null;
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.timeOfDay.title"),
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.bucket}
fill={
row.avgScore == null
? "var(--secondary)"
: colorForScore(row.avgScore)
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{bestLabel && (
<p className="text-muted-foreground text-xs">
{t("insights.mood.timeOfDay.bestCaption", { bucket: bestLabel })}
</p>
)}
</div>
);
}