mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
0474b7161d
commit
93495af788
12 files changed
+696
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user