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:
Marc-André Bombeck committed 2026-06-05 00:33:15 +02:00
1 parent 65b00e3a73
commit cfb97593a3
19 files changed
+971 -1

No files matched your search

@@ -12,6 +12,13 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
export const dynamic = "force-dynamic";
/**
* @deprecated The standalone moodLog webhook is superseded by native mood
* entries plus structured tags and rated factors — mood is now tracked
* fully inside HealthLog, so the external moodLog bridge no longer adds
* anything. Kept functional for existing integrations; slated for removal
* in a future major release. Do not build new callers against it.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "moodlog.webhook" } });
@@ -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). */}
@@ -15,6 +15,10 @@ import {
computeTagSummary,
computeTagInfluence,
computeBetterDays,
computeTagMetricCrosstab,
CROSSTAB_MIN_PRESENT_DAYS,
CROSSTAB_MIN_ABSENT_DAYS,
CROSSTAB_MAX_ROWS,
influenceConfidence,
computeTimeOfDayAverages,
computeWeekdayAverages,
@@ -824,3 +828,160 @@ describe("computeBetterDays", () => {
expect(board.length).toBe(BETTER_DAYS_MAX_FACTORS);
});
});
// ── v1.12.0 — computeTagMetricCrosstab (tag × HK metric) ─────────────
describe("computeTagMetricCrosstab", () => {
const WORKOUT: StructuredTagRef = {
key: "worked_out",
categoryKey: "health",
labelKey: "mood.tag.workedOut",
icon: "Dumbbell",
};
function structuredEntry(
offset: number,
score: number,
structuredTags: StructuredTagRef[],
): MoodAggregateEntry {
return { ...entry(offset, score, null), structuredTags };
}
/** A measurement at midday on the day `offset` days before NOW. */
function meas(offset: number, type: string, value: number): CrossMetricMeasurement {
return { type, value, measuredAt: new Date(NOW.getTime() - offset * dayMs) };
}
it("compares a metric on tag-present vs tag-absent days (same-day) and surfaces a significant delta", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
// 12 days WITH the workout tag, high active energy (~600 ± a little).
for (let i = 0; i < 12; i++) {
entries.push(structuredEntry(i, 4, [WORKOUT]));
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 600 + (i % 2 === 0 ? 20 : -20)));
}
// 12 days WITHOUT the tag, low active energy (~350).
for (let i = 12; i < 24; i++) {
entries.push(structuredEntry(i, 3, []));
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350 + (i % 2 === 0 ? 20 : -20)));
}
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
const row = rows.find((r) => r.tag === "worked_out" && r.metricKey === "activeEnergy");
expect(row).toBeDefined();
expect(row!.display).toBe("kcal");
expect(row!.mode).toBe("sameDay");
expect(row!.withDays).toBe(12);
expect(row!.withoutDays).toBe(12);
expect(row!.withAvg).toBeCloseTo(600, 0);
expect(row!.withoutAvg).toBeCloseTo(350, 0);
expect(row!.delta).toBeCloseTo(250, 0);
// strong clean separation on enough days → high confidence + tight q.
expect(row!.confidence).toBe("high");
expect(row!.qValue).toBeLessThanOrEqual(0.1);
});
it("converts SLEEP_DURATION minutes to display hours", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
const SLEEP: StructuredTagRef = {
key: "slept_well",
categoryKey: "health",
labelKey: "mood.tag.sleptWell",
icon: "Moon",
};
// present: 480 min = 8 h; absent: 360 min = 6 h.
for (let i = 0; i < 12; i++) {
entries.push(structuredEntry(i, 4, [SLEEP]));
measurements.push(meas(i, "SLEEP_DURATION", 480 + (i % 2 === 0 ? 6 : -6)));
}
for (let i = 12; i < 24; i++) {
entries.push(structuredEntry(i, 3, []));
measurements.push(meas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6)));
}
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
const row = rows.find((r) => r.metricKey === "sleepDuration");
expect(row).toBeDefined();
expect(row!.display).toBe("hours");
expect(row!.withAvg).toBeCloseTo(8, 1);
expect(row!.withoutAvg).toBeCloseTo(6, 1);
expect(row!.delta).toBeCloseTo(2, 1);
});
it("pairs a tag against the NEXT day's recovery (D → D+1 lag)", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
const ALCOHOL: StructuredTagRef = {
key: "alcohol",
categoryKey: "consumption",
labelKey: "mood.tag.alcohol",
icon: "Wine",
};
// Tag on even offsets (present), recovery measured the day AFTER (offset-1).
// present days: tag at offset i, recovery low (40) at offset i-1.
// absent days: no tag at offset i, recovery high (80) at offset i-1.
// Build 24 consecutive days; tag the older half so the +1 day always exists.
for (let i = 1; i <= 12; i++) {
entries.push(structuredEntry(i, 3, [ALCOHOL]));
measurements.push(meas(i - 1, "RECOVERY_SCORE", 40 + (i % 2 === 0 ? 3 : -3)));
}
for (let i = 13; i <= 24; i++) {
entries.push(structuredEntry(i, 4, []));
measurements.push(meas(i - 1, "RECOVERY_SCORE", 80 + (i % 2 === 0 ? 3 : -3)));
}
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
const row = rows.find((r) => r.metricKey === "nextDayRecovery");
expect(row).toBeDefined();
expect(row!.mode).toBe("nextDay");
expect(row!.display).toBe("score");
// present-day next-day recovery ~40, absent ~80 → negative delta.
expect(row!.withAvg).toBeCloseTo(40, 0);
expect(row!.withoutAvg).toBeCloseTo(80, 0);
expect(row!.delta).toBeLessThan(0);
});
it("drops a tag below the per-side day floors", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
// only 3 present days < CROSSTAB_MIN_PRESENT_DAYS
for (let i = 0; i < 3; i++) {
entries.push(structuredEntry(i, 4, [WORKOUT]));
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 600));
}
for (let i = 3; i < 15; i++) {
entries.push(structuredEntry(i, 3, []));
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350));
}
expect(CROSSTAB_MIN_PRESENT_DAYS).toBeGreaterThan(3);
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
expect(rows.find((r) => r.tag === "worked_out")).toBeUndefined();
});
it("excludes flat free-text tags (structured only)", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
for (let i = 0; i < 12; i++) {
entries.push(entry(i, 4, ["flat_workout"]));
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 600));
}
for (let i = 12; i < 24; i++) {
entries.push(entry(i, 3, []));
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350));
}
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
expect(rows).toEqual([]);
});
it("returns an empty array when no metric data is present", () => {
const entries: MoodAggregateEntry[] = [];
for (let i = 0; i < 24; i++) {
entries.push(structuredEntry(i, 4, i < 12 ? [WORKOUT] : []));
}
const rows = computeTagMetricCrosstab({ entries, measurements: [], now: NOW });
expect(rows).toEqual([]);
});
it("caps the surfaced rows at CROSSTAB_MAX_ROWS", () => {
expect(CROSSTAB_MAX_ROWS).toBeGreaterThan(0);
expect(CROSSTAB_MIN_ABSENT_DAYS).toBeGreaterThan(0);
});
});
+295 -1
View File
@@ -31,6 +31,9 @@ import {
type MoodNarrative,
} from "@/lib/insights/mood-narratives";
import { welchTTest } from "@/lib/insights/correlations";
import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery";
import { toBerlinYmd } from "@/lib/tz/resolver";
import type { MeasurementType } from "@/generated/prisma/enums";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -688,6 +691,275 @@ export function computeBetterDays(
.slice(0, BETTER_DAYS_MAX_FACTORS);
}
// --- Tag × health-metric crosstab (v1.12.0) ------------------------------
/**
* v1.12.0 — per-tag × health-metric crosstab.
*
* Daylio's "Activities & Mood" board answers "how does my mood differ on
* days I tagged X?". This extends the same with-vs-without comparison to a
* health METRIC: for each structured mood tag present on enough days, the
* metric's mean on tag-present days vs tag-absent days, the delta, the
* Welch two-sided p, and a confidence band — reusing the EXACT statistics
* engine the F1 tag-influence board already runs (`welchTTest` +
* `influenceConfidence` + the per-group day floors). FDR-corrected across
* every tested (tag × metric) pair via the shared `benjaminiHochberg`
* step-up so the surface stays honest as the matrix grows.
*
* Pairing modes:
* - "sameDay" — the metric on the SAME day the tag was logged. Used for
* activity (a workout/active tag × ACTIVE_ENERGY_BURNED) and sleep (a
* sleep tag × SLEEP_DURATION).
* - "nextDay" — the metric on the day AFTER the tag (D → D+1 lag join, the
* same lag the correlation-discovery engine uses). Used for an
* alcohol/food tag × next-day recovery/readiness (RECOVERY_SCORE),
* where the plausible direction is "tonight's choice → tomorrow's
* recovery".
*
* Observational only — the UI renders the standing "association, not
* cause" caption once. The metric value is shown in its display unit
* (SLEEP_DURATION minutes → hours; energy kcal; recovery score 0..100).
*/
/** Default Benjamini-Hochberg target FDR for the crosstab family. */
export const CROSSTAB_FDR_Q = 0.1;
/**
* Per-side day floors for the crosstab — reuse the influence floors so a
* tag needs the same defensible present/absent support before a metric
* delta surfaces.
*/
export const CROSSTAB_MIN_PRESENT_DAYS = INFLUENCE_MIN_PRESENT_DAYS;
export const CROSSTAB_MIN_ABSENT_DAYS = INFLUENCE_MIN_ABSENT_DAYS;
/** Max rows surfaced across the whole crosstab so the surface stays scannable. */
export const CROSSTAB_MAX_ROWS = 8;
type CrosstabMode = "sameDay" | "nextDay";
/**
* The metric channels the crosstab pairs each tag against. The `display`
* key drives unit formatting on the client; `mode` fixes the pairing lag.
* Single-sourced so the fetch filter (`CROSSTAB_METRIC_TYPES`) and the
* compute can never drift.
*/
export const CROSSTAB_METRICS: Record<
string,
{ type: MeasurementType; mode: CrosstabMode; display: "hours" | "kcal" | "score" }
> = {
// A workout / active tag × same-day active energy.
activeEnergy: { type: "ACTIVE_ENERGY_BURNED", mode: "sameDay", display: "kcal" },
// A sleep tag × same-night sleep duration (stored minutes → hours on UI).
sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" },
// An alcohol / food tag × next-day recovery/readiness (D → D+1 lag).
nextDayRecovery: { type: "RECOVERY_SCORE", mode: "nextDay", display: "score" },
} as const;
export type CrosstabMetricKey = keyof typeof CROSSTAB_METRICS;
/** Distinct measurement types the crosstab reads — single-sourced. */
export const CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
new Set(Object.values(CROSSTAB_METRICS).map((m) => m.type)),
);
export interface TagMetricCrosstabRow {
/** Stable structured-tag key. */
tag: string;
/** i18n label key for the tag (structured tags only — never flat). */
labelKey: string;
/** Parent category key, for grouping/icon. */
categoryKey: string;
/** Lucide icon name, or null. */
icon: string | null;
/** Which metric channel this row compares against. */
metricKey: CrosstabMetricKey;
/** Display unit hint for the client formatter. */
display: "hours" | "kcal" | "score";
/** Pairing mode used (echoed so the UI can caption "next-day"). */
mode: CrosstabMode;
/** Days the tag was present that had a paired metric value. */
withDays: number;
/** Days the tag was absent that had a paired metric value. */
withoutDays: number;
/** Mean metric on tag-present days (display unit). */
withAvg: number;
/** Mean metric on tag-absent days (display unit). */
withoutAvg: number;
/** withAvg − withoutAvg (display unit). */
delta: number;
/** Welch two-sided p-value for the difference of means. */
pValue: number;
/** Benjamini-Hochberg adjusted q-value across the tested family. */
qValue: number;
/** Discrete confidence band (p + min per-group day count). */
confidence: InfluenceConfidence;
}
/**
* Build a Berlin-day-keyed metric map with the right aggregation. Energy
* and step totals are SUMMED per day (HealthKit `stats:` rows are already
* one daily-total row, so the sum is the total either way); sleep duration
* is SUMMED across per-stage rows to get the night total; everything else
* (a once-daily score) is MEANED. Returns minutes/kcal/raw — the display
* conversion happens at row-build time.
*/
function metricDayMap(
measurements: CrossMetricMeasurement[],
type: string,
): Map<string, number> {
const SUM_TYPES = new Set([
"ACTIVE_ENERGY_BURNED",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
]);
const summed = SUM_TYPES.has(type);
const byDay = new Map<string, { sum: number; count: number }>();
for (const m of measurements) {
if (m.type !== type) continue;
const { year, month, day } = toBerlinYmd(m.measuredAt);
const key = `${year}-${month}-${day}`;
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += m.value;
cur.count += 1;
byDay.set(key, cur);
}
const out = new Map<string, number>();
for (const [key, agg] of byDay) {
out.set(key, summed ? agg.sum : agg.sum / agg.count);
}
return out;
}
/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */
function shiftDayKey(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
}
/** Convert a raw metric value to the row's display unit. */
function toDisplayUnit(value: number, display: "hours" | "kcal" | "score"): number {
return display === "hours" ? value / 60 : value;
}
interface CrosstabCandidate {
row: Omit<TagMetricCrosstabRow, "qValue">;
}
/**
* Compute the tag × metric crosstab. Pure over already-fetched rows.
*
* For every structured tag and every configured metric channel, collapse
* the window to one observation per tz-anchored day (the tag's daily
* membership) joined to the metric's per-day value (same-day or D+1). The
* tag-present and tag-absent metric samples feed `welchTTest`; rows that
* clear both day floors are tested, FDR-corrected as one family, and the
* survivors (p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`) surface ranked by q then
* |delta|.
*
* Structured tags only — flat free-text tags are excluded because the
* crosstab needs a stable, localized label and a curated-catalog tag is
* the right granularity for a metric comparison.
*/
export function computeTagMetricCrosstab(args: {
entries: MoodAggregateEntry[];
measurements: CrossMetricMeasurement[];
now: Date;
windowDays?: number;
}): TagMetricCrosstabRow[] {
const { entries, measurements, now } = args;
const windowDays = args.windowDays ?? 365;
const dayMap = collapseToTaggedDays(entries, now, windowDays);
if (dayMap.size === 0) return [];
// The set of structured tags seen in the window, with their meta.
const structuredMeta = new Map<string, StructuredTagRef>();
for (const day of dayMap.values()) {
for (const [key, ref] of day.structuredTags) {
if (!structuredMeta.has(key)) structuredMeta.set(key, ref);
}
}
if (structuredMeta.size === 0) return [];
const candidates: CrosstabCandidate[] = [];
for (const [metricKey, cfg] of Object.entries(CROSSTAB_METRICS) as Array<
[CrosstabMetricKey, (typeof CROSSTAB_METRICS)[CrosstabMetricKey]]
>) {
const metricByDay = metricDayMap(measurements, cfg.type);
if (metricByDay.size === 0) continue;
for (const [tagKey, ref] of structuredMeta) {
const withVals: number[] = [];
const withoutVals: number[] = [];
for (const [dayKey, day] of dayMap) {
const metricKeyDay =
cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey;
const metricValue = metricByDay.get(metricKeyDay);
if (metricValue == null || !Number.isFinite(metricValue)) continue;
const display = toDisplayUnit(metricValue, cfg.display);
if (day.structuredTags.has(tagKey)) withVals.push(display);
else withoutVals.push(display);
}
if (
withVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
withoutVals.length < CROSSTAB_MIN_ABSENT_DAYS
) {
continue;
}
const welch = welchTTest(withVals, withoutVals);
const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length;
const withoutAvg =
withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length;
const delta = withAvg - withoutAvg;
if (delta === 0) continue;
const pValue = welch.status === "ok" ? welch.pValue : 1;
const minGroupDays = Math.min(withVals.length, withoutVals.length);
candidates.push({
row: {
tag: tagKey,
labelKey: ref.labelKey,
categoryKey: ref.categoryKey,
icon: ref.icon,
metricKey,
display: cfg.display,
mode: cfg.mode,
withDays: withVals.length,
withoutDays: withoutVals.length,
withAvg: round(withAvg, 2),
withoutAvg: round(withoutAvg, 2),
delta: round(delta, 2),
pValue,
confidence: influenceConfidence(pValue, minGroupDays),
},
});
}
}
if (candidates.length === 0) return [];
// FDR-correct across the whole tested family (every tag × metric pair) so
// the multiple-comparison surface stays honest as the matrix grows. Reuses
// the same Benjamini-Hochberg step-up the correlation-discovery engine runs.
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
return candidates
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
.sort(
(a, b) =>
a.qValue - b.qValue ||
Math.abs(b.delta) - Math.abs(a.delta) ||
a.tag.localeCompare(b.tag),
)
.slice(0, CROSSTAB_MAX_ROWS);
}
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
/**
@@ -1124,6 +1396,13 @@ export interface MoodAggregates {
* into one effect-size-ranked, confidence-gated, observational list.
*/
betterDays: BetterDayFactor[];
/**
* v1.12.0 — per-tag × health-metric crosstab: for each structured tag,
* a health metric's mean on tag-present vs tag-absent days (same-day or
* D→D+1 lag), Welch-tested + FDR-corrected. Observational; the card
* renders the standing "association, not cause" caption.
*/
tagMetricCrosstab: TagMetricCrosstabRow[];
/**
* v1.8.6 — ranked, threshold-gated narrative takeaways. The "read
* this first" layer above the charts; the same array feeds the LLM
@@ -1197,6 +1476,11 @@ export function computeMoodAggregates(args: {
) as MoodAggregates["correlations"];
const betterDays = computeBetterDays(tagInfluence, correlations);
const tagMetricCrosstab = computeTagMetricCrosstab({
entries,
measurements,
now,
});
// Distinct day keys the user logged on — drives the streak takeaway.
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
@@ -1227,6 +1511,7 @@ export function computeMoodAggregates(args: {
structuredTags,
tagInfluence,
betterDays,
tagMetricCrosstab,
narratives: computeMoodNarratives({
daily: moodDaily,
weekday,
@@ -1308,7 +1593,16 @@ export async function fetchMoodAggregates(
measuredAt: { gte: windowCutoff },
// v1.8.5 — single-sourced from CORRELATION_METRICS so the fetched
// channels and the computed correlations never drift apart.
type: { in: Object.values(CORRELATION_METRICS) },
// v1.12.0 — union the crosstab metric channels so the tag × metric
// board has its measurements without a second query.
type: {
in: Array.from(
new Set<MeasurementType>([
...(Object.values(CORRELATION_METRICS) as MeasurementType[]),
...CROSSTAB_METRIC_TYPES,
]),
),
},
},
orderBy: { measuredAt: "desc" },
take: 5000,
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.12.0 — moodLog is deprecated (superseded by native mood entries +
* structured tags + rated factors), but kept FUNCTIONAL this release. This
* test pins the `@deprecated` JSDoc marker on the surface so a future edit
* that touches these files can't silently drop the deprecation notice
* before the planned removal.
*/
const ROOT = join(__dirname, "..", "..", "..", "..");
const DEPRECATED_FILES = [
"src/app/api/integrations/moodlog/webhook/route.ts",
"src/lib/moodlog/push.ts",
"src/lib/moodlog/sync.ts",
"src/lib/validations/moodlog.ts",
];
describe("moodLog deprecation markers", () => {
for (const rel of DEPRECATED_FILES) {
it(`marks ${rel} with @deprecated`, () => {
const src = readFileSync(join(ROOT, rel), "utf8");
expect(src).toContain("@deprecated");
// The marker must mention the superseding native path so the note is
// actionable, not a bare tag.
expect(src.toLowerCase()).toContain("superseded");
});
}
});
+6
View File
@@ -53,6 +53,12 @@ export interface MoodEntryForPush {
source: string;
}
/**
* @deprecated The moodLog integration is superseded by native mood entries
* plus structured tags and rated factors. Mood lives fully inside HealthLog,
* so the reverse-sync bridge no longer adds value. Kept functional for
* existing setups; slated for removal in a future major release.
*/
export async function pushMoodEntriesToMoodLog(
userId: string,
entries: ReadonlyArray<MoodEntryForPush>,
+6
View File
@@ -13,6 +13,12 @@ import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
/**
* Sync mood entries from a user's moodLog instance.
* Fetches from the health-log export endpoint and upserts into local DB.
*
* @deprecated The moodLog integration is superseded by native mood entries
* plus structured tags and rated factors. Mood is tracked fully inside
* HealthLog now, so the external pull bridge no longer adds value. Kept
* functional for existing setups; slated for removal in a future major
* release.
*/
export async function syncMoodLogEntries(
userId: string,
+8
View File
@@ -1,6 +1,14 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* @deprecated The standalone moodLog integration is superseded by native
* mood entries plus structured tags and rated factors — mood is tracked
* fully inside HealthLog now. These schemas remain only to keep the
* existing webhook + sync paths functional; the surface is slated for
* removal in a future major release. The native mood schemas
* (`createMoodEntrySchema` etc.) below are the supported path.
*/
export const moodLogCredentialsSchema = z.object({
url: z
.string()