feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:39:11 +02:00
1 parent b91328d67e
commit 79f7eb199b
35 files changed
+2454 -50

No files matched your search

@@ -28,7 +28,6 @@ import {
} from "./use-dashboard-derived";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
@@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
// and feed it to both the wellness strip and this grid. Imported above.
/**
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
* them: which way it should move depends on the person's own target (#1006,
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
* it stays neutral rather than assuming a fall is good.
*/
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
]);
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
function vitalSentiment(type: string): TrendDirectionSentiment {
if (UP_BAD_VITALS.has(type)) return "up-bad";
if (UP_GOOD_VITALS.has(type)) return "up-good";
return "neutral";
}
interface DashboardProps {
/**
* The shared overview derived batch (owned by the page so the wellness
@@ -306,7 +285,10 @@ function BaselineTile({
icon={Icon}
series={displaySeries}
framing={framing}
directionSentiment={vitalSentiment(type)}
// v1.43 — resolved on the server against the person's own target
// or profile default; an older server sends none and the tile
// stays neutral rather than assuming a polarity.
directionSentiment={v.direction?.direction ?? "neutral"}
provenance={
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
// "typical range = median ± MAD" caption repeated across every vital
@@ -30,6 +30,7 @@ import {
Gauge,
Grid3x3,
Link2,
Repeat,
Tag,
Tags,
TrendingUp,
@@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile";
import { MoodTagInfluence } from "./mood-tag-influence";
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
import { MoodPatterns } from "./mood-patterns";
export function MoodInsightsBreakdowns({
data,
@@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const patterns = data.patterns ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
return (
@@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.43 — the patterns the server found and gated; the card stays
away when none passed. */}
{patterns.length > 0 && (
<SectionCard title={t("insights.mood.patterns.title")} icon={Repeat}>
<MoodPatterns patterns={patterns} />
</SectionCard>
)}
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
@@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days";
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
import type { MoodPatternRow } from "./mood-patterns";
export interface MoodInsightsResponse {
summary: {
@@ -65,6 +66,11 @@ export interface MoodInsightsResponse {
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
/**
* v1.43 — the gated patterns the server found over the requested window.
* Optional to tolerate a payload from before it existed.
*/
patterns?: MoodPatternRow[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
@@ -0,0 +1,111 @@
"use client";
/**
* v1.43 (ios #116 item 6) — the mood patterns the server found.
*
* Nothing here decides whether a pattern exists: every row is one the server
* published after its gate (eight days a side, Welch, false discovery
* control), computed on the mood day. This component only phrases each one.
* Rows render in the order delivered; a kind this build does not know is
* skipped rather than guessed at.
*/
import { useFormatters, useTranslations } from "@/lib/i18n/context";
export interface MoodPatternRow {
id: string;
kind: string;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
tagLabel?: string;
};
sampleSize: { subject: number; comparison: number };
}
const WEEKDAY_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
"charts.weekdaysFull.wed",
"charts.weekdaysFull.thu",
"charts.weekdaysFull.fri",
"charts.weekdaysFull.sat",
"charts.weekdaysFull.sun",
] as const;
/** The sentence key per kind and direction; `none` cannot pass the gate. */
const SENTENCE_KEYS: Record<string, { higher: string; lower: string | null }> =
{
previous_day: {
higher: "insights.mood.patterns.previousDayHigher",
lower: "insights.mood.patterns.previousDayLower",
},
weekend: {
higher: "insights.mood.patterns.weekendHigher",
lower: "insights.mood.patterns.weekendLower",
},
best_weekday: {
higher: "insights.mood.patterns.bestWeekday",
lower: null,
},
notes: {
higher: "insights.mood.patterns.notesHigher",
lower: "insights.mood.patterns.notesLower",
},
delayed_tag: {
higher: "insights.mood.patterns.delayedTagHigher",
lower: "insights.mood.patterns.delayedTagLower",
},
multiple_entries: {
higher: "insights.mood.patterns.multipleEntriesHigher",
lower: "insights.mood.patterns.multipleEntriesLower",
},
};
export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
const rows = patterns.flatMap((pattern) => {
const keys = SENTENCE_KEYS[pattern.kind];
if (!keys || pattern.direction === "none") return [];
const key = pattern.direction === "higher" ? keys.higher : keys.lower;
if (!key) return [];
const weekdayKey =
pattern.values.weekday !== undefined
? WEEKDAY_KEYS[pattern.values.weekday]
: undefined;
return [
{
id: pattern.id,
sentence: t(key, {
subject: fmt.number(pattern.values.subject, 1),
comparison: fmt.number(pattern.values.comparison, 1),
weekday: weekdayKey ? t(weekdayKey) : "",
tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "",
}),
basedOn: t("insights.mood.patterns.basedOn", {
subject: pattern.sampleSize.subject,
comparison: pattern.sampleSize.comparison,
}),
},
];
});
if (rows.length === 0) return null;
return (
<ul className="space-y-3" data-slot="mood-patterns">
{rows.map((row) => (
<li key={row.id} data-pattern-id={row.id} className="space-y-1">
<p className="text-sm">{row.sentence}</p>
<p className="text-muted-foreground text-xs">{row.basedOn}</p>
</li>
))}
</ul>
);
}