feat(mood): compare the day context against the day's mood, with counts

- one comparison per context value, reusing the Welch test, the day floors and
  the Benjamini-Hochberg family the tag board already runs; a wide sweep
  filtered on a raw p < 0.05 would surface one-in-twenty noise as a finding
- two entries on one day count as one day
- every statement names how many days it compared, on the same surface as the
  claim, and says it shows a connection rather than a cause
- the assistant snapshot carries the same rows with that constraint written
  out beside them
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:40:07 +02:00
1 parent 0474b7161d
commit 93495af788
12 files changed
+696

No files matched your search

+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
@@ -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 (
<div className="space-y-3" data-slot="mood-context-comparison">
<p className="text-muted-foreground text-sm">
{t("insights.mood.contextComparison.description")}
</p>
<ul className="space-y-3">
{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 (
<li
key={`${row.field}:${row.value}`}
className="border-border/60 space-y-1 border-t pt-3 first:border-t-0 first:pt-0"
data-slot="mood-context-comparison-row"
>
<p className="text-foreground text-sm">
{t("insights.mood.contextComparison.statement", {
label,
direction,
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
})}
</p>
{/* The counts ride with the statement, in the same block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{t("insights.mood.contextComparison.counts", {
withDays: String(row.withDays),
withoutDays: String(row.withoutDays),
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-xs">
{t("insights.mood.contextComparison.caveat")}
</p>
</div>
);
}
@@ -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({
</SectionCard>
)}
{hasContextComparison && (
<SectionCard
title={t("insights.mood.contextComparison.title")}
icon={Briefcase}
>
<MoodContextComparison rows={contextRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
@@ -0,0 +1,166 @@
/**
* The context × mood comparison: its floors, its correction, and the two
* things it must never do.
*
* The floors are the point of this file. A board that always has something on
* it is a board that means nothing, and the two ways to get one are to show a
* value seen on three days and to filter a wide sweep on a raw p < 0.05. Both
* are tested at their boundary here, because a floor nobody has watched hold
* is a number in a constant.
*/
import { describe, expect, it } from "vitest";
import {
CONTEXT_MIN_ABSENT_DAYS,
CONTEXT_MIN_PRESENT_DAYS,
computeContextMoodComparison,
type ContextDayRow,
} from "@/lib/insights/mood-context-crosstab";
const EMPTY_CONTEXT = {
workStatus: null,
contactCircles: null,
contactForm: null,
contactExtent: null,
leisureCategories: null,
eventType: null,
};
function day(
index: number,
moodA1: number | null,
context: Partial<ContextDayRow> = {},
): 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([]);
});
});
+122
View File
@@ -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,
+57
View File
@@ -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
+205
View File
@@ -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<string, ContextDayRow>();
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);
}