feat(mood): trend the five values on the insights page

- one engine: the dimension series is a sibling calculator of the mood
  aggregate hub and rides the same bounded read, so no second summary of
  mood can disagree with the first
- 7 / 30 / 90-day means per dimension, plus the daily series behind them
- a dimension nobody answered is named as not recorded rather than drawn
  flat through the middle of the scale, and a zero counts as an answer
- each line keeps the orientation the person answered in; the legend
  says which way the inverse one runs
- the card rides the existing module-gated aggregate read and query key
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 01:34:59 +02:00
1 parent 97582dedc0
commit b4b39e9088
13 files changed
+649 -6

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ const eslintConfig = defineConfig([
"src/components/cycle/bbt-chart.tsx",
"src/components/insights/derived/delta-sparkline.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
"src/components/insights/mood/mood-dimension-trends.tsx",
"src/components/insights/mood/mood-distribution-chart.tsx",
"src/components/insights/mood/mood-time-of-day-chart.tsx",
"src/components/insights/mood/mood-weekday-chart.tsx",
+9 -1
View File
@@ -3641,7 +3641,15 @@
"statLabel": "Wie das berechnet wurde",
"explainerLabel": "Wie das berechnet wurde"
},
"inTargetTitle": "Im Zielbereich"
"inTargetTitle": "Im Zielbereich",
"dimensions": {
"title": "Fünf Skalen",
"none": "Für diese fünf Skalen ist noch nichts erfasst.",
"window": "{days} Tage",
"noneInWindow": "nichts im Zeitraum",
"higherIsMore": "höher = {anchor}",
"notRecorded": "Nicht erfasst: {list}."
}
},
"derived": {
"coverage": {
+9 -1
View File
@@ -3641,7 +3641,15 @@
"statLabel": "How this was computed",
"explainerLabel": "How this was computed"
},
"inTargetTitle": "In your range"
"inTargetTitle": "In your range",
"dimensions": {
"title": "Five scales",
"none": "Nothing recorded for these five scales yet.",
"window": "{days} days",
"noneInWindow": "nothing in this range",
"higherIsMore": "higher = {anchor}",
"notRecorded": "Not recorded: {list}."
}
},
"derived": {
"coverage": {
+9 -1
View File
@@ -3641,7 +3641,15 @@
"statLabel": "Cómo se calculó esto",
"explainerLabel": "Cómo se calculó esto"
},
"inTargetTitle": "En tu rango"
"inTargetTitle": "En tu rango",
"dimensions": {
"title": "Cinco escalas",
"none": "Todavía no hay nada registrado en estas cinco escalas.",
"window": "{days} días",
"noneInWindow": "nada en este periodo",
"higherIsMore": "más alto = {anchor}",
"notRecorded": "Sin registrar: {list}."
}
},
"derived": {
"coverage": {
+9 -1
View File
@@ -3641,7 +3641,15 @@
"statLabel": "Comment ceci est calculé",
"explainerLabel": "Comment ceci est calculé"
},
"inTargetTitle": "Dans votre plage"
"inTargetTitle": "Dans votre plage",
"dimensions": {
"title": "Cinq échelles",
"none": "Rien n'est encore enregistré pour ces cinq échelles.",
"window": "{days} jours",
"noneInWindow": "rien sur cette période",
"higherIsMore": "plus haut = {anchor}",
"notRecorded": "Non renseigné : {list}."
}
},
"derived": {
"coverage": {
+9 -1
View File
@@ -3641,7 +3641,15 @@
"statLabel": "Come è stato calcolato",
"explainerLabel": "Come è stato calcolato"
},
"inTargetTitle": "Nel tuo intervallo"
"inTargetTitle": "Nel tuo intervallo",
"dimensions": {
"title": "Cinque scale",
"none": "Per queste cinque scale non c'è ancora nulla.",
"window": "{days} giorni",
"noneInWindow": "nulla in questo periodo",
"higherIsMore": "più alto = {anchor}",
"notRecorded": "Non registrato: {list}."
}
},
"derived": {
"coverage": {
+9 -1
View File
@@ -3641,7 +3641,15 @@
"statLabel": "Jak to obliczono",
"explainerLabel": "Jak to obliczono"
},
"inTargetTitle": "W Twoim zakresie"
"inTargetTitle": "W Twoim zakresie",
"dimensions": {
"title": "Pięć skal",
"none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.",
"window": "{days} dni",
"noneInWindow": "brak w tym okresie",
"higherIsMore": "wyżej = {anchor}",
"notRecorded": "Nie zapisano: {list}."
}
},
"derived": {
"coverage": {
+1
View File
@@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
export { BbtChart } from "@/components/cycle/bbt-chart";
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
@@ -0,0 +1,231 @@
"use client";
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { cn } from "@/lib/utils";
/**
* v1.37 — the five level-A dimensions over time.
*
* Each dimension is drawn in the orientation the person answered it in and
* under the label they read, so a high stress line means a stressful stretch
* rather than a good one. Nothing is flipped for the picture's convenience;
* the legend says which way each scale runs.
*
* A dimension nobody has answered is not drawn and is named underneath as not
* recorded. Drawing it flat through the middle would put five answers on the
* chart for a question that was asked once.
*/
export interface MoodDimensionPointData {
date: string;
value: number;
samples: number;
}
export interface MoodDimensionSummaryData {
key: string;
present: boolean;
inverse: boolean;
min: number;
max: number;
count: number;
avg7: number | null;
avg30: number | null;
avg90: number | null;
latest: number | null;
latestDate: string | null;
newestDaysAgo: number | null;
series: MoodDimensionPointData[];
}
const WINDOWS = [7, 30, 90] as const;
type Window = (typeof WINDOWS)[number];
/** One colour per dimension, from the same semantic set the other charts use. */
const COLOR_BY_KEY: Record<string, string> = {
a1: "var(--chart-1)",
a2: "var(--destructive)",
a3: "var(--dracula-orange)",
a4: "var(--info)",
a5: "var(--success)",
};
function meanForWindow(
summary: MoodDimensionSummaryData,
window: Window,
): number | null {
if (window === 7) return summary.avg7;
if (window === 30) return summary.avg30;
return summary.avg90;
}
export function MoodDimensionTrends({
dimensions,
}: {
dimensions: MoodDimensionSummaryData[];
}) {
const { t } = useTranslations();
const [window, setWindow] = useState<Window>(30);
const present = dimensions.filter((d) => d.present);
const missing = dimensions.filter((d) => !d.present);
const chartData = useMemo(() => {
const byDate = new Map<string, Record<string, number | string>>();
for (const summary of present) {
for (const point of summary.series) {
const row = byDate.get(point.date) ?? { date: point.date };
row[summary.key] = point.value;
byDate.set(point.date, row);
}
}
const rows = [...byDate.values()].sort((a, b) =>
String(a.date).localeCompare(String(b.date)),
);
// The series carry the longest window; the shorter ones are a slice of the
// same points rather than a second read.
return rows.slice(Math.max(0, rows.length - window));
}, [present, window]);
const labelFor = (key: string) => {
const dimension = MOOD_DIMENSIONS.find((d) => d.key === key);
return dimension ? t(dimension.labelKey) : key;
};
if (present.length === 0) {
return (
<p className="text-muted-foreground text-sm">
{t("insights.mood.dimensions.none")}
</p>
);
}
return (
<div className="space-y-3" data-slot="mood-dimension-trends">
<div className="flex flex-wrap items-center gap-1.5">
{WINDOWS.map((option) => (
<button
key={option}
type="button"
onClick={() => setWindow(option)}
aria-pressed={window === option}
className={cn(
"min-h-9 rounded-full border px-3 text-xs transition-colors",
window === option
? "border-primary bg-primary/15 text-primary"
: "border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{t("insights.mood.dimensions.window", { days: option })}
</button>
))}
</div>
<div className="h-[clamp(160px,34vh,220px)] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickFormatter={(value: string) => value.slice(5)}
minTickGap={24}
/>
<YAxis
domain={[0, 10]}
ticks={[0, 5, 10]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
/>
<Tooltip
contentStyle={{
background: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
labelStyle={{ color: "var(--muted-foreground)" }}
formatter={(value, name) => [
String(value ?? ""),
labelFor(String(name)),
]}
/>
{present.map((summary) => (
<Line
key={summary.key}
type="monotone"
dataKey={summary.key}
name={summary.key}
stroke={COLOR_BY_KEY[summary.key] ?? "var(--chart-1)"}
strokeWidth={2}
dot={false}
// A gap is a day nobody answered; joining across it would draw
// a value that was never given.
connectNulls={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<ul className="space-y-1.5">
{present.map((summary) => {
const mean = meanForWindow(summary, window);
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key);
return (
<li
key={summary.key}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className="inline-block size-2 shrink-0 rounded-full"
style={{
background: COLOR_BY_KEY[summary.key] ?? "var(--chart-1)",
}}
/>
<span>{labelFor(summary.key)}</span>
{summary.inverse && dimension ? (
<span className="text-muted-foreground text-xs">
{t("insights.mood.dimensions.higherIsMore", {
anchor: t(dimension.highAnchorKey),
})}
</span>
) : null}
</span>
<span className="text-foreground shrink-0 tabular-nums">
{mean === null
? t("insights.mood.dimensions.noneInWindow")
: mean}
</span>
</li>
);
})}
</ul>
{missing.length > 0 ? (
<p className="text-muted-foreground text-xs">
{t("insights.mood.dimensions.notRecorded", {
list: missing.map((d) => labelFor(d.key)).join(", "),
})}
</p>
) : null}
</div>
);
}
@@ -34,12 +34,25 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
// without a layout shift (charts stay Recharts, visually identical). Types
// stay value-free imports so they don't drag the chunk back in.
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
// v1.16.7 established the shared-barrel pattern for the trio; all three
// loaders now resolve the app-wide chart-runtime boundary, so they share
// the ONE recharts chunk with every other chart surface: the cards reveal
// together, and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
@@ -127,6 +140,9 @@ interface MoodInsightsResponse {
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
@@ -258,6 +274,8 @@ export function MoodInsightsSections({
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -334,6 +352,15 @@ export function MoodInsightsSections({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
computeMoodDimensionSeries,
type MoodDimensionRow,
} from "@/lib/insights/mood-dimension-series";
const TODAY = "2026-08-08";
function dayKey(daysAgo: number): string {
const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`));
d.setUTCDate(d.getUTCDate() - daysAgo);
return d.toISOString().slice(0, 10);
}
function row(daysAgo: number, values: Partial<MoodDimensionRow>) {
return {
date: dayKey(daysAgo),
a1: null,
a2: null,
a3: null,
a4: null,
a5: null,
...values,
} as MoodDimensionRow;
}
describe("computeMoodDimensionSeries", () => {
it("reports all five dimensions even when only one was ever answered", () => {
const out = computeMoodDimensionSeries([row(0, { a1: 7 })], TODAY);
expect(out.map((d) => d.key)).toEqual(["a1", "a2", "a3", "a4", "a5"]);
expect(out[0].present).toBe(true);
// The other four are absent, not zero. This is the whole rule.
for (const dimension of out.slice(1)) {
expect(dimension.present).toBe(false);
expect(dimension.count).toBe(0);
expect(dimension.avg7).toBeNull();
expect(dimension.avg30).toBeNull();
expect(dimension.avg90).toBeNull();
expect(dimension.latest).toBeNull();
expect(dimension.newestDaysAgo).toBeNull();
expect(dimension.series).toEqual([]);
}
});
it("treats a dimension answered with zeros as present", () => {
// Zero is a real answer on this scale. A presence check on truthiness
// would report a genuinely calm week as no data at all.
const out = computeMoodDimensionSeries(
[row(0, { a2: 0 }), row(1, { a2: 0 })],
TODAY,
);
const a2 = out.find((d) => d.key === "a2")!;
expect(a2.present).toBe(true);
expect(a2.count).toBe(2);
expect(a2.avg7).toBe(0);
expect(a2.latest).toBe(0);
});
it("averages several entries on the same day into one point", () => {
const out = computeMoodDimensionSeries(
[row(1, { a1: 4 }), row(1, { a1: 8 }), row(0, { a1: 6 })],
TODAY,
);
const a1 = out[0];
expect(a1.series).toEqual([
{ date: dayKey(1), value: 6, samples: 2 },
{ date: dayKey(0), value: 6, samples: 1 },
]);
// The daily mean is the unit every other mood surface reports, so a
// number here can be held against one there.
expect(a1.count).toBe(3);
});
it("windows the means at 7, 30 and 90 days", () => {
const out = computeMoodDimensionSeries(
[
row(1, { a3: 10 }), // inside all three
row(20, { a3: 4 }), // inside 30 and 90
row(60, { a3: 1 }), // inside 90 only
row(200, { a3: 0 }), // outside every window
],
TODAY,
);
const a3 = out.find((d) => d.key === "a3")!;
expect(a3.avg7).toBe(10);
expect(a3.avg30).toBe(7);
expect(a3.avg90).toBe(5);
// The out-of-window row contributes to nothing, not even the count.
expect(a3.count).toBe(3);
expect(a3.series).toHaveLength(3);
});
it("carries the newest value with its age, per dimension", () => {
// The point of a per-dimension age: stress can be a week stale while
// pleasantness was answered this morning.
const out = computeMoodDimensionSeries(
[row(0, { a1: 8 }), row(6, { a2: 9 })],
TODAY,
);
const a1 = out.find((d) => d.key === "a1")!;
const a2 = out.find((d) => d.key === "a2")!;
expect(a1.latest).toBe(8);
expect(a1.latestDate).toBe(dayKey(0));
expect(a1.newestDaysAgo).toBe(0);
expect(a2.latest).toBe(9);
expect(a2.newestDaysAgo).toBe(6);
});
it("carries the orientation rather than flipping the stored value", () => {
const out = computeMoodDimensionSeries([row(0, { a2: 9 })], TODAY);
const a2 = out.find((d) => d.key === "a2")!;
expect(a2.inverse).toBe(true);
// Stored literally: a 9 the user set is a 9 here. Flipping on the way
// through would make the chart disagree with the label they answered.
expect(a2.latest).toBe(9);
expect(out.filter((d) => d.inverse).map((d) => d.key)).toEqual(["a2"]);
});
it("ignores a malformed day key and a future-dated row", () => {
const out = computeMoodDimensionSeries(
[
{ date: "not-a-date", a1: 9, a2: null, a3: null, a4: null, a5: null },
row(-3, { a1: 1 }),
row(2, { a1: 5 }),
],
TODAY,
);
const a1 = out[0];
expect(a1.count).toBe(1);
expect(a1.latest).toBe(5);
});
it("answers five empty summaries for no rows at all", () => {
const out = computeMoodDimensionSeries([], TODAY);
expect(out).toHaveLength(5);
expect(out.every((d) => !d.present)).toBe(true);
});
});
+50
View File
@@ -35,6 +35,16 @@ import {
computeMoodNarratives,
type MoodNarrative,
} from "@/lib/insights/mood-narratives";
import {
computeMoodDimensionSeries,
type MoodDimensionSummary,
} from "@/lib/insights/mood-dimension-series";
export {
computeMoodDimensionSeries,
MOOD_DIMENSION_WINDOWS,
type MoodDimensionPoint,
type MoodDimensionSummary,
} from "@/lib/insights/mood-dimension-series";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
@@ -162,6 +172,16 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
/**
* 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
* nullable because an unanswered dimension is absent rather than average.
*/
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
/**
* v1.14.0 — per-entry RATED factor scores (work / sleep-quality /
* stress …) from the same `mood_entry_tag_links` join, but carrying the
@@ -414,6 +434,13 @@ export interface MoodAggregates {
cells: HeatmapCell[];
};
distribution: DistributionRow[];
/**
* v1.37 — one summary per level-A dimension: 7 / 30 / 90-day means, the
* newest value with its age, and the daily series behind them. A dimension
* nobody answered reports `present: false` and an empty series; the surface
* says so rather than drawing a line through the middle of the scale.
*/
dimensions: MoodDimensionSummary[];
weekday: WeekdayRow[];
/**
* v1.9.0 — average mood per part of day, bucketed in each entry's own
@@ -578,6 +605,17 @@ export function computeMoodAggregates(args: {
cells: computeHeatmapCells(entries, now, windowDays),
},
distribution: computeDistribution(moodDaily),
dimensions: computeMoodDimensionSeries(
entries.map((entry) => ({
date: entry.date,
a1: entry.a1 ?? null,
a2: entry.a2 ?? null,
a3: entry.a3 ?? null,
a4: entry.a4 ?? null,
a5: entry.a5 ?? null,
})),
dayOffsetToBerlinDayKey(now, 0, tz),
),
weekday,
timeOfDay,
stability,
@@ -624,6 +662,13 @@ export async function fetchMoodAggregates(
select: {
date: true,
score: true,
// v1.37 — the five level-A values. Plaintext columns, so the trend is
// a plain read rather than a decrypt-per-row.
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
tags: true,
moodLoggedAt: true,
// v1.9.0 — per-row IANA tz anchors the part-of-day bucketing in
@@ -664,6 +709,11 @@ export async function fetchMoodAggregates(
return rows.reverse().map((row) => ({
date: row.date,
score: row.score,
a1: row.moodA1,
a2: row.stressA2,
a3: row.energyA3,
a4: row.connectionA4,
a5: row.stabilityA5,
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
+146
View File
@@ -0,0 +1,146 @@
/**
* v1.37 — per-dimension mood series over the level-A columns.
*
* A sibling calculator of `mood-aggregates`, following that file's own
* structure: pure functions over already-fetched rows, no database, no i18n.
* The hub reads the rows once and hands them here, which is what keeps a
* second engine from appearing — a parallel `summarize()` of mood that
* disagreed with the dashboard's is the failure this project has already
* paid for once.
*
* The rule the whole module is built around: a dimension nobody answered is
* absent, not zero and not a flat line through the middle. On a 0-10
* self-rating every value in the range is a real answer, so a fabricated one
* is indistinguishable from a given one — and five is the answer somebody
* gives on a genuinely average day.
*/
import { MOOD_DIMENSIONS, type MoodDimensionKey } from "@/lib/mood/dimensions";
import { round } from "@/lib/insights/status-shared";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Windows the trend reports, in days. Concept §10.1. */
export const MOOD_DIMENSION_WINDOWS = [7, 30, 90] as const;
export type MoodDimensionWindow = (typeof MOOD_DIMENSION_WINDOWS)[number];
/** One day's mean for one dimension. */
export interface MoodDimensionPoint {
/** `YYYY-MM-DD`, the row's own local day key. */
date: string;
value: number;
samples: number;
}
export interface MoodDimensionSummary {
key: MoodDimensionKey;
/** True when at least one entry in the window carries a value. */
present: boolean;
/** Higher is worse — the analytics layer flips through this, never inline. */
inverse: boolean;
min: number;
max: number;
/** Number of entries carrying a value, across the whole window. */
count: number;
/** Mean over the trailing 7 / 30 / 90 days; null when that window is empty. */
avg7: number | null;
avg30: number | null;
avg90: number | null;
/** The most recent value, and how old it is in whole local days. */
latest: number | null;
latestDate: string | null;
newestDaysAgo: number | null;
/** Daily means, oldest first, over the longest window. */
series: MoodDimensionPoint[];
}
/** A mood row as this calculator needs it. */
export interface MoodDimensionRow {
date: string;
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
}
function meanOf(values: number[]): number | null {
if (values.length === 0) return null;
const sum = values.reduce((acc, v) => acc + v, 0);
return round(sum / values.length, 1);
}
function dayKeyDaysAgo(dayKey: string, todayKey: string): number | null {
const then = Date.parse(`${dayKey}T00:00:00.000Z`);
const today = Date.parse(`${todayKey}T00:00:00.000Z`);
if (!Number.isFinite(then) || !Number.isFinite(today)) return null;
return Math.round((today - then) / MS_PER_DAY);
}
/**
* Summarise each of the five dimensions over the rows given.
*
* `rows` are the window's entries in any order; `todayKey` is the reader's own
* local day, already resolved by the caller, so nothing here converts a zone.
* Each entry contributes at most one value per dimension, and days with
* several entries are averaged — the same daily-mean shape every other mood
* surface reports, so a number here can be compared with one there.
*/
export function computeMoodDimensionSeries(
rows: MoodDimensionRow[],
todayKey: string,
): MoodDimensionSummary[] {
const longest = Math.max(...MOOD_DIMENSION_WINDOWS);
return MOOD_DIMENSIONS.map((dimension) => {
const byDay = new Map<string, number[]>();
for (const row of rows) {
const value = row[dimension.key];
if (typeof value !== "number" || !Number.isFinite(value)) continue;
const age = dayKeyDaysAgo(row.date, todayKey);
if (age === null || age < 0 || age >= longest) continue;
const bucket = byDay.get(row.date);
if (bucket) bucket.push(value);
else byDay.set(row.date, [value]);
}
const series: MoodDimensionPoint[] = [...byDay.entries()]
.map(([date, values]) => ({
date,
value: meanOf(values) as number,
samples: values.length,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const count = [...byDay.values()].reduce((acc, v) => acc + v.length, 0);
const windowMean = (days: number): number | null => {
const values: number[] = [];
for (const point of series) {
const age = dayKeyDaysAgo(point.date, todayKey);
if (age === null || age >= days) continue;
values.push(point.value);
}
return meanOf(values);
};
const newest = series.at(-1) ?? null;
return {
key: dimension.key,
// Presence is decided by whether anybody answered, never by whether the
// mean happens to be zero.
present: count > 0,
inverse: dimension.inverse,
min: dimension.min,
max: dimension.max,
count,
avg7: windowMean(7),
avg30: windowMean(30),
avg90: windowMean(90),
latest: newest?.value ?? null,
latestDate: newest?.date ?? null,
newestDaysAgo: newest ? dayKeyDaysAgo(newest.date, todayKey) : null,
series,
};
});
}