From b4b39e908808a63f3dc2a7b83f2920b55a80999c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:34:59 +0200 Subject: [PATCH] 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 --- eslint.config.mjs | 1 + messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- src/components/charts/chart-runtime.ts | 1 + .../insights/mood/mood-dimension-trends.tsx | 231 ++++++++++++++++++ .../insights/mood/mood-insights-sections.tsx | 27 ++ .../__tests__/mood-dimension-series.test.ts | 139 +++++++++++ src/lib/insights/mood-aggregates.ts | 50 ++++ src/lib/insights/mood-dimension-series.ts | 146 +++++++++++ 13 files changed, 649 insertions(+), 6 deletions(-) create mode 100644 src/components/insights/mood/mood-dimension-trends.tsx create mode 100644 src/lib/insights/__tests__/mood-dimension-series.test.ts create mode 100644 src/lib/insights/mood-dimension-series.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f0e9130f..36fb7684e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", diff --git a/messages/de.json b/messages/de.json index b22c5e078..69655e2de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 107c00816..412589882 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index fbd062826..5cc45adb6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 632b225ca..246e2aee0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 3a558c56d..6120167d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 872cad733..007254a17 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 6fc198934..788235dc0 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -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"; diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx new file mode 100644 index 000000000..b9aa7af6e --- /dev/null +++ b/src/components/insights/mood/mood-dimension-trends.tsx @@ -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 = { + 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(30); + + const present = dimensions.filter((d) => d.present); + const missing = dimensions.filter((d) => !d.present); + + const chartData = useMemo(() => { + const byDate = new Map>(); + 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 ( +

+ {t("insights.mood.dimensions.none")} +

+ ); + } + + return ( +
+
+ {WINDOWS.map((option) => ( + + ))} +
+ +
+ + + + value.slice(5)} + minTickGap={24} + /> + + [ + String(value ?? ""), + labelFor(String(name)), + ]} + /> + {present.map((summary) => ( + + ))} + + +
+ +
    + {present.map((summary) => { + const mean = meanForWindow(summary, window); + const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key); + return ( +
  • + + + + {mean === null + ? t("insights.mood.dimensions.noneInWindow") + : mean} + +
  • + ); + })} +
+ + {missing.length > 0 ? ( +

+ {t("insights.mood.dimensions.notRecorded", { + list: missing.map((d) => labelFor(d.key)).join(", "), + })} +

+ ) : null} +
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index de4e6c7d4..19c64ac55 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -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: () => ( + + ), + }, +); 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. */} + {/* 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 && ( + + + + )} +
) { + 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); + }); +}); diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index f150a3149..6beb8b343 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -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, diff --git a/src/lib/insights/mood-dimension-series.ts b/src/lib/insights/mood-dimension-series.ts new file mode 100644 index 000000000..801205dd2 --- /dev/null +++ b/src/lib/insights/mood-dimension-series.ts @@ -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(); + 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, + }; + }); +}