mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(insights): add tag-by-metric crosstab and deprecate the moodLog webhook
Extend the mood-relations surface from mood to a health metric. For each structured mood tag present on enough days, compare a metric's value on tag-present vs tag-absent days: active energy on workout days (same day), sleep length on nights tagged sleep (same day), and next-day recovery after a tag like alcohol or food (D to D+1 lag). The comparison reuses the existing statistics engine wholesale — the Welch two-sample t-test, the per-group day floors, the confidence band, and the Benjamini-Hochberg FDR control already used by the influence and discovery boards — so no new statistics code lands. Only pairs that clear p < 0.05 and the FDR control surface, ranked by adjusted q then absolute delta and capped at eight rows. The result rides on GET /api/mood/insights as a new additive `tagMetricCrosstab` field and renders as a confidence-gated, observational card on the mood insights page, framed as an association in your own data rather than a cause. The endpoint is server-rendered and not in the OpenAPI contract, so no contract change is required. Deprecate the standalone moodLog integration. Mood is now tracked fully inside HealthLog through entries, structured tags, and rated factors, so the external webhook and reverse-sync bridge no longer add anything. The webhook route, the push and pull helpers, and the validation schemas carry an @deprecated note pointing at the native path; the surface stays functional this release and is slated for removal in a future major. The settings card shows a deprecation hint, localised across all six locales.
This commit is contained in:
1 parent
65b00e3a73
commit
cfb97593a3
19 files changed
+971
-1
No files matched your search
@@ -50,6 +50,10 @@ import {
|
||||
MoodBetterDays,
|
||||
type MoodBetterDayFactor,
|
||||
} from "./mood-better-days";
|
||||
import {
|
||||
MoodTagMetricCrosstab,
|
||||
type MoodTagMetricCrosstabRow,
|
||||
} from "./mood-tag-metric-crosstab";
|
||||
import { MoodDiscoveredRelations } from "./mood-discovered-relations";
|
||||
|
||||
/**
|
||||
@@ -84,6 +88,9 @@ interface MoodInsightsResponse {
|
||||
structured: MoodTagInfluenceRow[];
|
||||
};
|
||||
betterDays?: MoodBetterDayFactor[];
|
||||
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
|
||||
// rollout; the live endpoint always populates it.
|
||||
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
@@ -166,6 +173,8 @@ export function MoodInsightsSections() {
|
||||
const hasInfluence = influenceRows.length > 0;
|
||||
const betterDays = data.betterDays ?? [];
|
||||
const hasBetterDays = betterDays.length > 0;
|
||||
const crosstabRows = data.tagMetricCrosstab ?? [];
|
||||
const hasCrosstab = crosstabRows.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -236,6 +245,12 @@ export function MoodInsightsSections() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasCrosstab && (
|
||||
<SectionCard title={t("insights.mood.crosstab.title")}>
|
||||
<MoodTagMetricCrosstab rows={crosstabRows} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("insights.mood.correlationsTitle")}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
|
||||
|
||||
/**
|
||||
* v1.12.0 — tag × health-metric crosstab card.
|
||||
*
|
||||
* Daylio's "Activities & Mood" board, extended from mood to a health
|
||||
* METRIC: for each structured mood tag, a metric's mean on tag-present vs
|
||||
* tag-absent days, the delta, and a confidence chip. The math is
|
||||
* pre-computed in `mood-aggregates.ts` (`computeTagMetricCrosstab`) — the
|
||||
* same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the
|
||||
* relations surface uses; only FDR-surviving rows reach here. Observational
|
||||
* only: the "association, not cause" caption renders once below the list.
|
||||
*/
|
||||
|
||||
export type MoodCrosstabDisplay = "hours" | "kcal" | "score";
|
||||
export type MoodCrosstabMode = "sameDay" | "nextDay";
|
||||
|
||||
export interface MoodTagMetricCrosstabRow {
|
||||
tag: string;
|
||||
labelKey: string;
|
||||
categoryKey: string;
|
||||
icon: string | null;
|
||||
metricKey: string;
|
||||
display: MoodCrosstabDisplay;
|
||||
mode: MoodCrosstabMode;
|
||||
withDays: number;
|
||||
withoutDays: number;
|
||||
withAvg: number;
|
||||
withoutAvg: number;
|
||||
delta: number;
|
||||
pValue: number;
|
||||
qValue: number;
|
||||
confidence: MoodInfluenceConfidence;
|
||||
}
|
||||
|
||||
const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
activeEnergy: "insights.mood.crosstab.metricActiveEnergy",
|
||||
sleepDuration: "insights.mood.crosstab.metricSleepDuration",
|
||||
nextDayRecovery: "insights.mood.crosstab.metricNextDayRecovery",
|
||||
};
|
||||
|
||||
const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
|
||||
hours: "insights.mood.crosstab.unitHours",
|
||||
kcal: "insights.mood.crosstab.unitKcal",
|
||||
score: "insights.mood.crosstab.unitScore",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "insights.mood.influence.confidenceLow",
|
||||
medium: "insights.mood.influence.confidenceMedium",
|
||||
high: "insights.mood.influence.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
|
||||
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
|
||||
};
|
||||
|
||||
/** One decimal for hours/score, whole numbers for kcal. */
|
||||
function fmt(value: number, display: MoodCrosstabDisplay): string {
|
||||
return display === "kcal" ? Math.round(value).toString() : value.toFixed(1);
|
||||
}
|
||||
|
||||
export function MoodTagMetricCrosstab({
|
||||
rows,
|
||||
}: {
|
||||
rows: MoodTagMetricCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="mood-tag-metric-crosstab">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("insights.mood.crosstab.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{rows.map((row) => {
|
||||
const Icon = moodTagIcon(row.icon);
|
||||
const tagLabel = t(row.labelKey);
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
const deltaColor = up
|
||||
? "var(--dracula-green)"
|
||||
: "var(--dracula-red)";
|
||||
return (
|
||||
<li
|
||||
key={`${row.metricKey}:${row.tag}`}
|
||||
className="flex flex-col gap-1.5 py-2"
|
||||
data-slot="mood-crosstab-row"
|
||||
data-metric={row.metricKey}
|
||||
data-direction={up ? "up" : "down"}
|
||||
data-confidence={row.confidence}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${tagLabel} · ${metricLabel}`}
|
||||
>
|
||||
{t("insights.mood.crosstab.pairLabel", {
|
||||
tag: tagLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-sm font-semibold tabular-nums"
|
||||
style={{ color: deltaColor }}
|
||||
>
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
row.mode === "nextDay"
|
||||
? "insights.mood.crosstab.detailNextDay"
|
||||
: "insights.mood.crosstab.detailSameDay",
|
||||
{
|
||||
withAvg: fmt(row.withAvg, row.display),
|
||||
withoutAvg: fmt(row.withoutAvg, row.display),
|
||||
unit,
|
||||
withDays: row.withDays,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-3 text-[11px]">
|
||||
{t("insights.mood.crosstab.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1688,6 +1688,9 @@ function MoodLogCard({
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.moodLogDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 text-[11px] italic">
|
||||
{t("settings.moodLogDeprecated")}
|
||||
</p>
|
||||
|
||||
{/* v1.4.19 A5 — visual divider matches Withings for consistency
|
||||
(the maintainer explicitly called the asymmetry out). */}
|
||||
|
||||
Reference in new issue
Block a user