mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
97582dedc0
commit
b4b39e9088
13 files changed
+649
-6
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user