diff --git a/messages/de.json b/messages/de.json
index debcaf22c..a96811f57 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3778,6 +3778,15 @@
"noneInWindow": "nichts im Zeitraum",
"higherIsMore": "höher = {anchor}",
"notRecorded": "Nicht erfasst: {list}."
+ },
+ "contextComparison": {
+ "title": "Tageskontext und deine Stimmung",
+ "description": "Wie der Stimmungswert an bisher erfassten Tagen mit diesem Eintrag lag, verglichen mit den übrigen Tagen.",
+ "lower": "niedriger",
+ "higher": "höher",
+ "statement": "An Tagen mit „{label}“ lag die Stimmung im Durchschnitt {direction}: {withAvg} gegenüber {withoutAvg}.",
+ "counts": "{withDays} Tage mit, {withoutDays} Tage ohne",
+ "caveat": "Das zeigt einen Zusammenhang, keine Ursache."
}
},
"derived": {
diff --git a/messages/en.json b/messages/en.json
index 0562c627d..37963e9f7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3778,6 +3778,15 @@
"noneInWindow": "nothing in this range",
"higherIsMore": "higher = {anchor}",
"notRecorded": "Not recorded: {list}."
+ },
+ "contextComparison": {
+ "title": "Day context and your mood",
+ "description": "How the mood value sat on the days recorded so far carrying this entry, compared with the other days.",
+ "lower": "lower",
+ "higher": "higher",
+ "statement": "On days with “{label}” the mood averaged {direction}: {withAvg} against {withoutAvg}.",
+ "counts": "{withDays} days with, {withoutDays} days without",
+ "caveat": "That shows a connection, not a cause."
}
},
"derived": {
diff --git a/messages/es.json b/messages/es.json
index 97276f21a..b3afa6956 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3778,6 +3778,15 @@
"noneInWindow": "nada en este periodo",
"higherIsMore": "más alto = {anchor}",
"notRecorded": "Sin registrar: {list}."
+ },
+ "contextComparison": {
+ "title": "Contexto del día y tu estado de ánimo",
+ "description": "Cómo se situó el valor de ánimo en los días registrados hasta ahora con esta entrada, frente al resto.",
+ "lower": "más bajo",
+ "higher": "más alto",
+ "statement": "En los días con «{label}» el ánimo promedió {direction}: {withAvg} frente a {withoutAvg}.",
+ "counts": "{withDays} días con, {withoutDays} días sin",
+ "caveat": "Esto muestra una relación, no una causa."
}
},
"derived": {
diff --git a/messages/fr.json b/messages/fr.json
index 4e5057a54..b6e92f69c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3778,6 +3778,15 @@
"noneInWindow": "rien sur cette période",
"higherIsMore": "plus haut = {anchor}",
"notRecorded": "Non renseigné : {list}."
+ },
+ "contextComparison": {
+ "title": "Contexte de la journée et ton humeur",
+ "description": "Où se situait la valeur d'humeur les jours enregistrés jusqu'ici avec cette entrée, par rapport aux autres.",
+ "lower": "plus bas",
+ "higher": "plus haut",
+ "statement": "Les jours avec « {label} », l'humeur était en moyenne {direction} : {withAvg} contre {withoutAvg}.",
+ "counts": "{withDays} jours avec, {withoutDays} jours sans",
+ "caveat": "Cela montre un lien, pas une cause."
}
},
"derived": {
diff --git a/messages/it.json b/messages/it.json
index e83bbd6b5..2dbfaeca9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3778,6 +3778,15 @@
"noneInWindow": "nulla in questo periodo",
"higherIsMore": "più alto = {anchor}",
"notRecorded": "Non registrato: {list}."
+ },
+ "contextComparison": {
+ "title": "Contesto della giornata e il tuo umore",
+ "description": "Come si è collocato il valore dell'umore nei giorni finora registrati con questa voce, rispetto agli altri.",
+ "lower": "più basso",
+ "higher": "più alto",
+ "statement": "Nei giorni con «{label}» l'umore è stato in media {direction}: {withAvg} contro {withoutAvg}.",
+ "counts": "{withDays} giorni con, {withoutDays} giorni senza",
+ "caveat": "Questo mostra una relazione, non una causa."
}
},
"derived": {
diff --git a/messages/pl.json b/messages/pl.json
index 60fc2564f..c7a1df5cd 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3778,6 +3778,15 @@
"noneInWindow": "brak w tym okresie",
"higherIsMore": "wyżej = {anchor}",
"notRecorded": "Nie zapisano: {list}."
+ },
+ "contextComparison": {
+ "title": "Kontekst dnia a nastrój",
+ "description": "Jak wypadał nastrój w dotychczas zapisanych dniach z tym wpisem, w porównaniu z pozostałymi.",
+ "lower": "niższy",
+ "higher": "wyższy",
+ "statement": "W dniach z „{label}” nastrój był średnio {direction}: {withAvg} wobec {withoutAvg}.",
+ "counts": "{withDays} dni z, {withoutDays} dni bez",
+ "caveat": "To pokazuje związek, nie przyczynę."
}
},
"derived": {
diff --git a/src/components/insights/mood/mood-context-comparison.tsx b/src/components/insights/mood/mood-context-comparison.tsx
new file mode 100644
index 000000000..3df6ff9ba
--- /dev/null
+++ b/src/components/insights/mood/mood-context-comparison.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+/**
+ * How the day's mood sat on days carrying each context value.
+ *
+ * The copy rule this component exists to hold, stated once so nobody has to
+ * infer it from the strings: **no sentence here names a cause.** Not "family
+ * lowers your mood", not "overtime costs you two points". What the data
+ * supports is "on the days recorded so far with X, the average was lower", and
+ * that is what it says — with the two counts on the same line as the claim,
+ * not behind a tooltip, because a reader who cannot see that a row rests on
+ * six days cannot weigh it.
+ *
+ * The rows arriving here have already cleared the day floors and a
+ * family-wide Benjamini-Hochberg correction. Nothing in this file re-filters
+ * them; a second threshold in the presentation layer would be a second place
+ * to get the statistics wrong.
+ */
+import { useTranslations } from "@/lib/i18n/context";
+import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
+import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
+
+export function MoodContextComparison({
+ rows,
+}: {
+ rows: ContextComparisonRow[];
+}) {
+ const { t } = useTranslations();
+ if (rows.length === 0) return null;
+
+ return (
+
+
+ {t("insights.mood.contextComparison.description")}
+
+
+ {rows.map((row) => {
+ const label = t(contextValueLabelKey(row.field, row.value));
+ const direction =
+ row.delta < 0
+ ? t("insights.mood.contextComparison.lower")
+ : t("insights.mood.contextComparison.higher");
+ return (
+ -
+
+ {t("insights.mood.contextComparison.statement", {
+ label,
+ direction,
+ withAvg: row.withAvg.toFixed(1),
+ withoutAvg: row.withoutAvg.toFixed(1),
+ })}
+
+ {/* The counts ride with the statement, in the same block a
+ screen reader reaches in the same breath. */}
+
+ {t("insights.mood.contextComparison.counts", {
+ withDays: String(row.withDays),
+ withoutDays: String(row.withoutDays),
+ })}
+
+
+ );
+ })}
+
+
+ {t("insights.mood.contextComparison.caveat")}
+
+
+ );
+}
diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx
index 19c64ac55..cd6f4228b 100644
--- a/src/components/insights/mood/mood-insights-sections.tsx
+++ b/src/components/insights/mood/mood-insights-sections.tsx
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import type { ComponentType } from "react";
import {
BarChart3,
+ Briefcase,
CalendarDays,
CalendarRange,
Clock,
@@ -89,6 +90,8 @@ const MoodTimeOfDayChart = dynamic(
),
},
);
+import { MoodContextComparison } from "./mood-context-comparison";
+import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown";
import {
MoodCorrelationCards,
@@ -158,6 +161,8 @@ interface MoodInsightsResponse {
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
+ /** v1.38 — context value against the day's mood, counts included. */
+ contextComparison?: ContextComparisonRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
@@ -272,6 +277,10 @@ export function MoodInsightsSections({
const hasBetterDays = betterDays.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
+ // Defensive against a stale server-cache payload minted before this shape
+ // landed, the same way the influence rows above are.
+ const contextRows = data.contextComparison ?? [];
+ const hasContextComparison = contextRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
@@ -409,6 +418,15 @@ export function MoodInsightsSections({
)}
+ {hasContextComparison && (
+
+
+
+ )}
+
{hasFactorCrosstab && (
= {},
+): ContextDayRow {
+ return {
+ day: `2026-03-${String(index + 1).padStart(2, "0")}`,
+ moodA1,
+ ...EMPTY_CONTEXT,
+ ...context,
+ };
+}
+
+/** `present` days carrying `workStatus: overtime` at `lowMood`, the rest high. */
+function overtimeFixture(present: number, absent: number): ContextDayRow[] {
+ const rows: ContextDayRow[] = [];
+ for (let i = 0; i < present; i++) {
+ rows.push(day(i, i % 2 === 0 ? 2 : 3, { workStatus: "overtime" }));
+ }
+ for (let i = 0; i < absent; i++) {
+ rows.push(day(present + i, i % 2 === 0 ? 8 : 7, { workStatus: "regular" }));
+ }
+ return rows;
+}
+
+describe("context × mood comparison", () => {
+ it("reports the two counts and the two means it compared", () => {
+ const rows = computeContextMoodComparison(overtimeFixture(10, 12));
+ const overtime = rows.find((r) => r.value === "overtime");
+ expect(overtime, "the overtime comparison did not survive").toBeDefined();
+ expect(overtime!.field).toBe("workStatus");
+ expect(overtime!.withDays).toBe(10);
+ expect(overtime!.withoutDays).toBe(12);
+ expect(overtime!.withAvg).toBe(2.5);
+ expect(overtime!.withoutAvg).toBe(7.5);
+ expect(overtime!.delta).toBe(-5);
+ });
+
+ it("holds the present-day floor at its boundary", () => {
+ // One below the floor: nothing, however clean the separation looks.
+ const below = computeContextMoodComparison(
+ overtimeFixture(CONTEXT_MIN_PRESENT_DAYS - 1, 12),
+ );
+ expect(below.some((r) => r.value === "overtime")).toBe(false);
+
+ // Exactly at it: the row is allowed to exist.
+ const at = computeContextMoodComparison(
+ overtimeFixture(CONTEXT_MIN_PRESENT_DAYS, 12),
+ );
+ expect(at.some((r) => r.value === "overtime")).toBe(true);
+ });
+
+ it("holds the absent-day floor at its boundary", () => {
+ const below = computeContextMoodComparison(
+ overtimeFixture(10, CONTEXT_MIN_ABSENT_DAYS - 1),
+ );
+ expect(below.some((r) => r.value === "overtime")).toBe(false);
+
+ const at = computeContextMoodComparison(
+ overtimeFixture(10, CONTEXT_MIN_ABSENT_DAYS),
+ );
+ expect(at.some((r) => r.value === "overtime")).toBe(true);
+ });
+
+ it("folds a day logged twice into one observation", () => {
+ // Two entries on one day is not two days. Without the fold, somebody who
+ // logs three times on their worst day drags every comparison that day
+ // belongs to.
+ const rows = overtimeFixture(10, 12);
+ const doubled = [...rows, { ...rows[0], moodA1: 0 }];
+ const once = computeContextMoodComparison(rows).find(
+ (r) => r.value === "overtime",
+ );
+ const twice = computeContextMoodComparison(doubled).find(
+ (r) => r.value === "overtime",
+ );
+ expect(twice!.withDays).toBe(once!.withDays);
+ expect(twice!.withAvg).toBe(once!.withAvg);
+ });
+
+ it("skips days with no mood value rather than treating them as zero", () => {
+ const rows = overtimeFixture(10, 12);
+ rows.push(day(50, null, { workStatus: "overtime" }));
+ const overtime = computeContextMoodComparison(rows).find(
+ (r) => r.value === "overtime",
+ );
+ expect(overtime!.withDays).toBe(10);
+ });
+
+ it("reads a multi-select value out of its stored JSON list", () => {
+ const rows: ContextDayRow[] = [];
+ for (let i = 0; i < 10; i++) {
+ rows.push(
+ day(i, i % 2 === 0 ? 3 : 4, {
+ contactCircles: JSON.stringify(["partner", "family"]),
+ }),
+ );
+ }
+ for (let i = 0; i < 12; i++) {
+ rows.push(
+ day(10 + i, i % 2 === 0 ? 8 : 7, {
+ contactCircles: JSON.stringify(["friends"]),
+ }),
+ );
+ }
+ const circles = computeContextMoodComparison(rows);
+ expect(circles.find((r) => r.value === "partner")?.withDays).toBe(10);
+ expect(circles.find((r) => r.value === "friends")?.withDays).toBe(12);
+ });
+
+ it("drops noise a raw p < 0.05 would have surfaced", () => {
+ // Twenty-odd values tested against a mood series with no structure in it.
+ // Some pair will look significant at 0.05 sooner or later; the point of
+ // the family-wide correction is that it does not reach the board.
+ const values = [
+ ...Array.from({ length: 40 }, (_, i) =>
+ day(i, (i * 7) % 11, {
+ contactCircles: JSON.stringify([
+ ["partner", "child", "family", "friends"][i % 4],
+ ]),
+ leisureCategories: JSON.stringify([
+ ["film", "reading", "gaming", "music"][i % 4],
+ ]),
+ eventType: ["goodNews", "badNews", "success", "conflict"][i % 4],
+ }),
+ ),
+ ];
+ const rows = computeContextMoodComparison(values);
+ for (const row of rows) {
+ expect(row.qValue).toBeLessThanOrEqual(0.1);
+ }
+ });
+
+ it("answers nothing at all on an empty history", () => {
+ expect(computeContextMoodComparison([])).toEqual([]);
+ expect(computeContextMoodComparison([day(0, null)])).toEqual([]);
+ });
+});
diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts
index 9fc67670f..129fef2dc 100644
--- a/src/lib/insights/features.ts
+++ b/src/lib/insights/features.ts
@@ -16,6 +16,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
+import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -200,6 +201,105 @@ async function buildMoodDimensionFeatures(
return blocks.length > 0 ? blocks : undefined;
}
+/**
+ * The day-context comparisons, as the model may state them.
+ *
+ * Every row has already cleared the day floors and a family-wide
+ * Benjamini-Hochberg correction, so nothing here is a raw p < 0.05 finding.
+ * The counts ride with each row and the block carries one sentence saying
+ * what the rows are and are not — a model follows a stated constraint far
+ * more reliably than it infers one from a field name, and the constraint here
+ * is the difference between an observation and a claim about somebody's life.
+ *
+ * Bounded: the same 90-day window the dimensions use, and the comparison's own
+ * cap on how many rows survive.
+ */
+async function buildMoodContextBlock(userId: string): Promise<{
+ context?: {
+ note: string;
+ comparisons: Array<{
+ field: string;
+ value: string;
+ withDays: number;
+ withoutDays: number;
+ withAvg: number;
+ withoutAvg: number;
+ }>;
+ };
+}> {
+ const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
+ const rows = await prisma.moodEntry.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ moodLoggedAt: { gte: since },
+ context: { isNot: null },
+ },
+ orderBy: { moodLoggedAt: "desc" },
+ take: 2000,
+ select: {
+ date: true,
+ moodA1: true,
+ context: {
+ select: {
+ workStatus: true,
+ contactCircles: true,
+ contactForm: true,
+ contactExtent: true,
+ leisureCategories: true,
+ eventType: true,
+ },
+ },
+ },
+ });
+ if (rows.length === 0) return {};
+
+ // Days WITHOUT a context belong on the "without" side of every comparison,
+ // so the second read takes the whole window rather than only the rows above.
+ const allDays = await prisma.moodEntry.findMany({
+ where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
+ orderBy: { moodLoggedAt: "desc" },
+ take: 2000,
+ select: { date: true, moodA1: true },
+ });
+ const contextByDay = new Map(rows.map((row) => [row.date, row.context]));
+
+ const comparisons = computeContextMoodComparison(
+ allDays.map((row) => {
+ const context = contextByDay.get(row.date) ?? null;
+ return {
+ day: row.date,
+ moodA1: row.moodA1,
+ workStatus: context?.workStatus ?? null,
+ contactCircles: context?.contactCircles ?? null,
+ contactForm: context?.contactForm ?? null,
+ contactExtent: context?.contactExtent ?? null,
+ leisureCategories: context?.leisureCategories ?? null,
+ eventType: context?.eventType ?? null,
+ };
+ }),
+ );
+ if (comparisons.length === 0) return {};
+
+ return {
+ context: {
+ note:
+ "Each row compares the average mood value on days that carried a " +
+ "context entry against the days that did not, over the last 90 days. " +
+ "These are associations and nothing more: state them with their day " +
+ "counts, and never say that the context caused the mood.",
+ comparisons: comparisons.map((row) => ({
+ field: row.field,
+ value: row.value,
+ withDays: row.withDays,
+ withoutDays: row.withoutDays,
+ withAvg: row.withAvg,
+ withoutAvg: row.withoutAvg,
+ })),
+ },
+ };
+}
+
/**
* The dimensions block and the note that says how to read it beside the
* block-level `asOf`. Absent entirely when nobody has answered a dimension,
@@ -346,6 +446,27 @@ export interface AggregatedFeatures {
* Present only alongside `dimensions`.
*/
dimensionsAsOfNote?: string;
+ /**
+ * v1.38 — how the average mood value sat on days carrying each day-context
+ * entry against the days that did not, over the last 90 days.
+ *
+ * Present only when at least one comparison cleared the day floors and the
+ * family-wide correction. The `note` beside the rows states what they are:
+ * a model that is told in a sentence that these are associations states
+ * them as associations, and one that is left to infer it from a field name
+ * does not.
+ */
+ context?: {
+ note: string;
+ comparisons: Array<{
+ field: string;
+ value: string;
+ withDays: number;
+ withoutDays: number;
+ withAvg: number;
+ withoutAvg: number;
+ }>;
+ };
};
sleep?: {
avg7: number | null;
@@ -1241,6 +1362,7 @@ export async function extractFeatures(
trend30,
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
+ ...(await buildMoodContextBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts
index 2db2d22ab..985b998ea 100644
--- a/src/lib/insights/mood-aggregates.ts
+++ b/src/lib/insights/mood-aggregates.ts
@@ -47,6 +47,10 @@ export {
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
+import {
+ computeContextMoodComparison,
+ type ContextComparisonRow,
+} from "@/lib/insights/mood-context-crosstab";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
computeBetterDays,
@@ -171,6 +175,20 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
+ /**
+ * v1.38 — the day's context, when the entry carries one. Only the
+ * categorical fields: the comparison below tests membership of a value, and
+ * the numeric ratings are a different question this milestone does not ask.
+ * Optional so every fixture written before contexts existed keeps its shape.
+ */
+ context?: {
+ workStatus: string | null;
+ contactCircles: string | null;
+ contactForm: string | null;
+ contactExtent: string | null;
+ leisureCategories: string | null;
+ eventType: string | null;
+ } | null;
/**
* v1.37 — the five level-A values, each 0-10 and each nullable. Optional so
* every fixture written before they existed keeps its narrow shape, and
@@ -453,6 +471,13 @@ export interface MoodAggregates {
*/
stability: MoodStability | null;
tags: TagSummaryRow[];
+ /**
+ * v1.38 — how the day's pleasantness sat on days carrying each context
+ * value against the days that did not. A comparison and never a cause: the
+ * copy that renders it says so, and every row carries the two counts it was
+ * computed from so a reader can weigh it themselves.
+ */
+ contextComparison: ContextComparisonRow[];
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
structuredTags: StructuredTagRow[];
/**
@@ -582,6 +607,23 @@ export function computeMoodAggregates(args: {
userPriorityJson,
});
+ // v1.38 — every context value against the day's pleasantness, one BH-FDR
+ // family across the whole sweep. Entries with no context contribute to the
+ // "without" side of each comparison, which is what makes the counts mean
+ // what the copy says they mean.
+ const contextComparison = computeContextMoodComparison(
+ entries.map((entry) => ({
+ day: entry.date,
+ moodA1: entry.a1 ?? null,
+ workStatus: entry.context?.workStatus ?? null,
+ contactCircles: entry.context?.contactCircles ?? null,
+ contactForm: entry.context?.contactForm ?? null,
+ contactExtent: entry.context?.contactExtent ?? null,
+ leisureCategories: entry.context?.leisureCategories ?? null,
+ eventType: entry.context?.eventType ?? null,
+ })),
+ );
+
// Distinct day keys the user logged on — drives the streak takeaway.
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
@@ -624,6 +666,7 @@ export function computeMoodAggregates(args: {
betterDays,
tagMetricCrosstab,
factorCrosstab,
+ contextComparison,
narratives: computeMoodNarratives({
daily: moodDaily,
weekday,
@@ -677,6 +720,19 @@ export async function fetchMoodAggregates(
// the breakdown can fold structured tags next to the flat ones.
// v1.14.0 — also pull the per-link `rating` + the factor's
// kind/scale/inverse so RATED factors feed the factor crosstab.
+ // v1.38 — the categorical context fields. The numeric ratings are not
+ // read here: this surface compares membership of a value against the
+ // day's pleasantness, and a rating is a different question.
+ context: {
+ select: {
+ workStatus: true,
+ contactCircles: true,
+ contactForm: true,
+ contactExtent: true,
+ leisureCategories: true,
+ eventType: true,
+ },
+ },
tagLinks: {
select: {
rating: true,
@@ -716,6 +772,7 @@ export async function fetchMoodAggregates(
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
+ context: row.context,
// BINARY links carry the structured-tag breakdown; RATED links
// (a non-null `rating`) carry the factor score. One join, two axes.
structuredTags: row.tagLinks
diff --git a/src/lib/insights/mood-context-crosstab.ts b/src/lib/insights/mood-context-crosstab.ts
new file mode 100644
index 000000000..d237b40ab
--- /dev/null
+++ b/src/lib/insights/mood-context-crosstab.ts
@@ -0,0 +1,205 @@
+/**
+ * Day context against the day's mood, with the count on every statement.
+ *
+ * The question this answers is "on the days you recorded a conflict, how did
+ * the mood value sit against the days you did not" — and the answer is a
+ * comparison, never a cause. Nothing in this file, and nothing rendered from
+ * it, may say that a context value made a day worse. It did not, and the data
+ * cannot know whether it did.
+ *
+ * The statistics are borrowed rather than rebuilt. The Welch t-test, the
+ * Benjamini-Hochberg step-up, the per-side day floors and the FDR threshold
+ * all come from the machinery the tag crosstab already runs. That matters more
+ * here than it sounds: a context sweep tests a few dozen values at once, and a
+ * raw p < 0.05 over that many comparisons surfaces one-in-twenty noise as a
+ * finding. The BH correction is the difference between a board that means
+ * something and a board that always has something on it.
+ *
+ * The floors are constants with names because Phase 3's model will import
+ * them rather than declare a second set that drifts from these.
+ */
+import {
+ CROSSTAB_FDR_Q,
+ CROSSTAB_MIN_ABSENT_DAYS,
+ CROSSTAB_MIN_PRESENT_DAYS,
+} from "@/lib/insights/mood-crosstab";
+import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
+import { welchTTest } from "@/lib/insights/correlations";
+import {
+ CONTACT_CIRCLE_KEYS,
+ CONTACT_EXTENT_KEYS,
+ CONTACT_FORM_KEYS,
+ EVENT_TYPE_KEYS,
+ LEISURE_CATEGORY_KEYS,
+ WORK_STATUS_KEYS,
+ decodeKeyList,
+} from "@/lib/mood/context-vocabulary";
+
+/**
+ * The smallest number of days a context value may be seen on and still be
+ * shown at all.
+ *
+ * Five is the floor the rest of the mood surfaces already use, and it is a
+ * floor rather than a target: the concept that shaped this feature asks for
+ * ten, and a reader looking at a row backed by five days should be able to see
+ * that number and discount it accordingly. Which is why the count is on the
+ * statement and not in a tooltip.
+ */
+export const CONTEXT_MIN_PRESENT_DAYS = CROSSTAB_MIN_PRESENT_DAYS;
+export const CONTEXT_MIN_ABSENT_DAYS = CROSSTAB_MIN_ABSENT_DAYS;
+/** Most rows the board shows, so it stays readable rather than exhaustive. */
+export const CONTEXT_MAX_ROWS = 8;
+
+/** One row of a stored context, as the comparison reads it. */
+export interface ContextDayRow {
+ /** The entry's local day. One row per day; a second entry on a day is folded. */
+ day: string;
+ /** The day's pleasantness value, 0-10. Days without one are skipped. */
+ moodA1: number | null;
+ workStatus: string | null;
+ contactCircles: string | null;
+ contactForm: string | null;
+ contactExtent: string | null;
+ leisureCategories: string | null;
+ eventType: string | null;
+}
+
+export interface ContextComparisonRow {
+ /** Which context field this row is about, e.g. `workStatus`. */
+ field: string;
+ /** The value inside that field, e.g. `overtime`. */
+ value: string;
+ /** Days the value was recorded and the day had a mood value. */
+ withDays: number;
+ /** Days it was not, and the day had a mood value. */
+ withoutDays: number;
+ /** Mean pleasantness on days carrying the value. */
+ withAvg: number;
+ /** Mean pleasantness on the other days. */
+ withoutAvg: number;
+ /** `withAvg - withoutAvg`. Signed, and never described as an effect. */
+ delta: number;
+ pValue: number;
+ qValue: number;
+}
+
+/** The single-valued fields, and the vocabulary each draws from. */
+const SINGLE_FIELDS: Array<{
+ field: keyof ContextDayRow & string;
+ keys: readonly string[];
+}> = [
+ { field: "workStatus", keys: WORK_STATUS_KEYS },
+ { field: "contactForm", keys: CONTACT_FORM_KEYS },
+ { field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
+ { field: "eventType", keys: EVENT_TYPE_KEYS },
+];
+
+/** The multi-select fields, stored as JSON arrays in a string column. */
+const MULTI_FIELDS: Array<{
+ field: keyof ContextDayRow & string;
+ keys: readonly string[];
+}> = [
+ { field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
+ { field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
+];
+
+function round(value: number, digits: number): number {
+ const factor = 10 ** digits;
+ return Math.round(value * factor) / factor;
+}
+
+function mean(values: number[]): number {
+ return values.reduce((sum, v) => sum + v, 0) / values.length;
+}
+
+/**
+ * Compare every context value against the day's pleasantness.
+ *
+ * Pure over already-fetched rows, with the database read left to the caller —
+ * the shape `mood-aggregates.ts` uses, so the whole comparison is testable
+ * without a container.
+ */
+export function computeContextMoodComparison(
+ rows: readonly ContextDayRow[],
+): ContextComparisonRow[] {
+ // One observation per day. Two entries on one day would otherwise weight
+ // that day twice, and a day somebody logged three times is not three days.
+ const byDay = new Map();
+ for (const row of rows) {
+ if (row.moodA1 === null) continue;
+ if (!byDay.has(row.day)) byDay.set(row.day, row);
+ }
+ if (byDay.size === 0) return [];
+ const days = [...byDay.values()];
+
+ const candidates: ContextComparisonRow[] = [];
+
+ const test = (
+ field: string,
+ value: string,
+ carries: (row: ContextDayRow) => boolean,
+ ) => {
+ const withVals: number[] = [];
+ const withoutVals: number[] = [];
+ for (const row of days) {
+ // `moodA1` is non-null by construction above; the guard keeps the
+ // narrowing local rather than asserting it.
+ if (row.moodA1 === null) continue;
+ if (carries(row)) withVals.push(row.moodA1);
+ else withoutVals.push(row.moodA1);
+ }
+ if (
+ withVals.length < CONTEXT_MIN_PRESENT_DAYS ||
+ withoutVals.length < CONTEXT_MIN_ABSENT_DAYS
+ ) {
+ return;
+ }
+ const withAvg = mean(withVals);
+ const withoutAvg = mean(withoutVals);
+ const delta = withAvg - withoutAvg;
+ if (delta === 0) return;
+ const welch = welchTTest(withVals, withoutVals);
+ candidates.push({
+ field,
+ value,
+ withDays: withVals.length,
+ withoutDays: withoutVals.length,
+ withAvg: round(withAvg, 2),
+ withoutAvg: round(withoutAvg, 2),
+ delta: round(delta, 2),
+ pValue: welch.status === "ok" ? welch.pValue : 1,
+ qValue: 1,
+ });
+ };
+
+ for (const { field, keys } of SINGLE_FIELDS) {
+ for (const value of keys) {
+ test(field, value, (row) => row[field] === value);
+ }
+ }
+ for (const { field, keys } of MULTI_FIELDS) {
+ for (const value of keys) {
+ test(field, value, (row) =>
+ decodeKeyList(row[field] as string | null).includes(value),
+ );
+ }
+ }
+
+ if (candidates.length === 0) return [];
+
+ // One family across every field × value pair tested. Correcting per field
+ // would be six small families instead of one honest one, and the whole
+ // reason to correct is that the sweep is wide.
+ const qValues = benjaminiHochberg(candidates.map((c) => c.pValue));
+
+ return candidates
+ .map((c, i) => ({ ...c, qValue: round(qValues[i], 3) }))
+ .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.value.localeCompare(b.value),
+ )
+ .slice(0, CONTEXT_MAX_ROWS);
+}