mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user