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")} +

+ +

+ {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); +}