mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge pull request #735 from MBombeck/feat/mood-level-a
Five dimensions behind the mood check-in
This commit is contained in:
47 files changed
+3667
-74
No files matched your search
@@ -53,6 +53,12 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
"mood.tag",
|
||||
"mood.tagCategory",
|
||||
// `mood.dimension`: the five level-A dimensions are a table
|
||||
// (`src/lib/mood/dimensions.ts`) whose rows carry their own label and anchor
|
||||
// keys, so every leaf is resolved from a value rather than written as a
|
||||
// literal. The table is the reason the keys cannot drift apart from the
|
||||
// columns they describe.
|
||||
"mood.dimension",
|
||||
// The whole namespace is a runtime lookup table: the client resolves an
|
||||
// error envelope's `meta.errorCode` through it (`localizedApiError`), so no
|
||||
// leaf ever appears as a literal at a call site. Adding a key here is only
|
||||
|
||||
@@ -774,6 +774,19 @@ const handler = apiHandler(
|
||||
date: entry.date,
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
// The five level-A values, carried the same way `syncVersion`
|
||||
// is: absent in the file means the file predates the columns,
|
||||
// and an upsert whose update arm stated them would wipe live
|
||||
// answers off every matched row when an older backup is
|
||||
// restored over a going concern. A file that has them writes
|
||||
// them, including an explicit null; a file that does not says
|
||||
// nothing. Never derived — filling one in here would put a
|
||||
// number the person never gave into a restored row.
|
||||
...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }),
|
||||
...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }),
|
||||
...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }),
|
||||
...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }),
|
||||
...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }),
|
||||
tags: entry.tags ?? null,
|
||||
// The note comes back the way every other dual-column note
|
||||
// does: ciphertext verbatim when the file carries it, and a
|
||||
|
||||
@@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
@@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// v1.37 — pleasantness derived from the row's own label, not
|
||||
// from the file's `score`. This is the one writer whose score
|
||||
// comes out of the file rather than out of the label, so the
|
||||
// two can disagree; the column that records a self-assessment
|
||||
// has to agree with the label the user actually picked.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
@@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
@@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// See the upsert arm above: derived from the label, because
|
||||
// an imported file's `score` is its own field.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -57,7 +58,11 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
|
||||
return apiSuccess({
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(shapeMoodNote(entry)),
|
||||
tags: parseTags(entry.tags),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -120,7 +125,32 @@ export const PUT = apiHandler(
|
||||
if (data.mood !== undefined) {
|
||||
updateData.mood = data.mood;
|
||||
updateData.score = getScoreForMood(data.mood);
|
||||
// v1.37 — pleasantness follows the label the same way `score` does, so
|
||||
// an entry corrected from "okay" to "bad" cannot keep yesterday's A1.
|
||||
// Only when the label actually moved, though: the edit dialog sends the
|
||||
// whole entry back on every save, and re-deriving on an unchanged label
|
||||
// would overwrite a value the person set by hand every time they
|
||||
// corrected the timestamp. An explicit `a1` below overrides either way.
|
||||
if (data.mood !== existing.mood) {
|
||||
updateData.moodA1 = deriveA1(data.mood);
|
||||
}
|
||||
}
|
||||
// v1.37 — level-A values are per-field on this path: omitted keeps the
|
||||
// stored value, an explicit number replaces it, an explicit null clears
|
||||
// it. Built one at a time from the parsed body, never spread.
|
||||
//
|
||||
// Pleasantness is the exception, and it is the same exception the create
|
||||
// path makes: an entry always carries a five-point label, so it always
|
||||
// implies a pleasantness value, and a null falls back to the derivation
|
||||
// rather than emptying the column. One rule on both routes — the capture
|
||||
// surfaces therefore offer no clear control for it.
|
||||
if (data.a1 !== undefined) {
|
||||
updateData.moodA1 = data.a1 ?? deriveA1(data.mood ?? existing.mood);
|
||||
}
|
||||
if (data.a2 !== undefined) updateData.stressA2 = data.a2;
|
||||
if (data.a3 !== undefined) updateData.energyA3 = data.a3;
|
||||
if (data.a4 !== undefined) updateData.connectionA4 = data.a4;
|
||||
if (data.a5 !== undefined) updateData.stabilityA5 = data.a5;
|
||||
if (data.moodLoggedAt !== undefined) {
|
||||
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
|
||||
// displayTimezone. Also refresh the `tz` column so the row's
|
||||
@@ -290,7 +320,8 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
...entry,
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(entry),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the update response renders the tag set without a
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const date = moodDateKey(entry.moodLoggedAt, tz);
|
||||
const score = getScoreForMood(entry.mood);
|
||||
// v1.37 — pleasantness derived from the same label the score comes from.
|
||||
// The batch request shape is unchanged: this path carries no level-A
|
||||
// input, so A2 to A5 are left alone rather than written as nulls, and a
|
||||
// row that already carries hand-set values keeps them across a re-import.
|
||||
const moodA1 = deriveA1(entry.mood);
|
||||
|
||||
try {
|
||||
// v1.12.1 — when the entry carries a source-stable `externalId`,
|
||||
@@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
@@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -125,7 +126,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(e),
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
@@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) {
|
||||
source,
|
||||
externalId,
|
||||
} = parsed.data;
|
||||
// v1.37 — the five level-A values, built field by field from the parsed
|
||||
// body (never spread) and resolved once for every branch below. A1 falls
|
||||
// back to the derivation from `mood`; the other four are whatever the user
|
||||
// set, or nothing.
|
||||
const levelA = levelAForWrite(mood, {
|
||||
a1: parsed.data.a1,
|
||||
a2: parsed.data.a2,
|
||||
a3: parsed.data.a3,
|
||||
a4: parsed.data.a4,
|
||||
a5: parsed.data.a5,
|
||||
});
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
@@ -265,6 +281,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -272,11 +289,19 @@ async function postMoodEntry(request: NextRequest) {
|
||||
externalId,
|
||||
moodLoggedAt,
|
||||
},
|
||||
// A re-post restates pleasantness, because the label it carries
|
||||
// always implies one and a stale A1 beside a changed mood would
|
||||
// contradict the entry's own face. It does NOT restate the other
|
||||
// four: `levelA` carries them only when this request did, so a
|
||||
// client re-posting without sliders — the phone, whose build has
|
||||
// none — leaves the stress and energy somebody answered on the
|
||||
// web exactly where they were. An explicit null clears one.
|
||||
update: {
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -290,6 +315,9 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// A fresh row: an omitted dimension lands as NULL, which is
|
||||
// the same thing the upsert's arms mean by omitting it.
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -374,7 +402,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
return apiSuccess(
|
||||
{
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(entry),
|
||||
// v1.37 — level-A values under their wire keys (see the list read).
|
||||
...shapeLevelA(shapeMoodNote(entry)),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the create response renders the tag set without a
|
||||
|
||||
@@ -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,232 @@
|
||||
"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,
|
||||
MOOD_DIMENSION_WINDOWS,
|
||||
type MoodDimensionWindow,
|
||||
} 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[];
|
||||
}
|
||||
|
||||
/** 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: MoodDimensionWindow,
|
||||
): 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<MoodDimensionWindow>(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">
|
||||
{MOOD_DIMENSION_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")}
|
||||
|
||||
@@ -85,7 +85,10 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
|
||||
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
|
||||
expect(formSrc).toContain('tagsInput.trim() !== ""');
|
||||
expect(formSrc).toContain("tagKeys.length > 0");
|
||||
expect(formSrc).toContain("ratedFactors.length > 0");
|
||||
// v1.37 — a level-A value the user set is typed input too. The rated
|
||||
// factors this line used to name are no longer captured here; see the
|
||||
// level-A describe below.
|
||||
expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)");
|
||||
expect(formSrc).toContain('note.trim() !== ""');
|
||||
// The `isDirty` expression itself must not read `moodLoggedAt`.
|
||||
const isDirtyExpr = formSrc.slice(
|
||||
@@ -115,31 +118,51 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodForm — rated factors (v1.12.0)", () => {
|
||||
it("lifts a ratedFactors set and threads it to the tag picker", () => {
|
||||
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
|
||||
expect(formSrc).toMatch(
|
||||
/function rateFactor\(key: string, rating: number \| null\)/,
|
||||
);
|
||||
// The picker receives both the binary toggle and the factor rate
|
||||
// callbacks so it can render BINARY chips and RATED controls.
|
||||
expect(formSrc).toMatch(
|
||||
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
|
||||
describe("MoodForm — level A replaces the factor ratings (v1.37)", () => {
|
||||
it("no longer offers a factor rating on a new entry", () => {
|
||||
// A 1-5 score for "work stress" and a 0-10 stress self-state are two
|
||||
// answers to one question. The create sheet asks the second one only.
|
||||
// Nothing was deleted: the catalogue rows, the stored ratings and the
|
||||
// edit dialog below all still work, which the next describe pins.
|
||||
expect(formSrc).not.toContain("onRateFactor=");
|
||||
expect(formSrc).not.toContain('mode="rated"');
|
||||
expect(formSrc).not.toContain("ratedFactors");
|
||||
// The binary structured-tag contract stays byte-identical.
|
||||
expect(formSrc).toContain('mode="binary"');
|
||||
expect(formSrc).toContain(
|
||||
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
|
||||
expect(formSrc).toContain(
|
||||
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
|
||||
it("offers the five level-A sliders and sends only the answered ones", () => {
|
||||
expect(formSrc).toContain("<MoodLevelASection");
|
||||
expect(formSrc).toContain("...levelACreatePayload(levelA)");
|
||||
// Picking a face seeds pleasantness; moving that slider ends the seeding.
|
||||
expect(formSrc).toMatch(
|
||||
/function pickMood\(value: string\) \{[\s\S]*seedA1FromMood\(prev, value, a1Touched\)/,
|
||||
);
|
||||
// The binary structured-tag contract stays byte-identical.
|
||||
expect(formSrc).toContain(
|
||||
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
|
||||
expect(formSrc).toContain("onA1Touched={() => setA1Touched(true)}");
|
||||
// Nothing here defaults a value: the state starts empty and stays empty.
|
||||
expect(formSrc).toContain("useState<LevelAState>(EMPTY_LEVEL_A)");
|
||||
});
|
||||
|
||||
it("offers no clear control for pleasantness, matching the write path", () => {
|
||||
// An entry always carries a label, so it always implies a pleasantness
|
||||
// value and both write routes derive one from a null. A clear button here
|
||||
// would be a control the server undoes on the way to the database.
|
||||
const fieldsSrc = readFileSync(
|
||||
resolve(__dirname, "../mood-level-a-fields.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(fieldsSrc).toMatch(
|
||||
/onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — rated factor editing", () => {
|
||||
// Unchanged by v1.37 on purpose: the create sheet stopped asking, and an
|
||||
// entry that already carries a rating must still be correctable.
|
||||
it("hydrates, renders, and submits the entry's current ratedFactors", () => {
|
||||
expect(listSrc).toContain("ratedFactors: RatedFactor[];");
|
||||
expect(listSrc).toContain(
|
||||
@@ -156,6 +179,18 @@ describe("MoodList — rated factor editing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — level A on the edit path (v1.37)", () => {
|
||||
it("seeds the five values from the entry and sends them back, nulls included", () => {
|
||||
// What the capture sheet writes, this dialog has to show. A value visible
|
||||
// in a trend and nowhere else is a value the person cannot correct.
|
||||
expect(listSrc).toContain("levelA: levelAFromEntry(entry)");
|
||||
expect(listSrc).toContain("setEditLevelA(seed.levelA)");
|
||||
expect(listSrc).toContain("<MoodLevelASection");
|
||||
// Nulls travel: this path replaces, so a cleared value has to be stated.
|
||||
expect(listSrc).toContain("...levelAUpdatePayload(levelA)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — delete failure toast (v1.11.5)", () => {
|
||||
it("surfaces a toast on a rejected delete instead of failing silently", () => {
|
||||
expect(listSrc).toContain('import { toast } from "sonner"');
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,7 +44,14 @@ import {
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelACreatePayload,
|
||||
seedA1FromMood,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
@@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
|
||||
count: MOOD_LEVELS.length,
|
||||
selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood),
|
||||
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
|
||||
onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value),
|
||||
});
|
||||
// v1.37 — the five level-A values. Pleasantness follows the picked face
|
||||
// until the user moves it themselves; the other four start and stay empty
|
||||
// unless somebody answers them.
|
||||
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [a1Touched, setA1Touched] = useState(false);
|
||||
const [levelAOpen, setLevelAOpen] = useState(false);
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
|
||||
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
|
||||
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// v1.8.5 (C1) — first-class free-text note. The model + API already
|
||||
// accepted `note`; the web form was the only surface that couldn't
|
||||
// write it.
|
||||
@@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood !== "" ||
|
||||
tagsInput.trim() !== "" ||
|
||||
tagKeys.length > 0 ||
|
||||
ratedFactors.length > 0 ||
|
||||
// A level-A value the user set counts as typed input: Reset must ask
|
||||
// before it discards an answer that took five slider moves.
|
||||
Object.values(levelA).some((v) => v !== null) ||
|
||||
note.trim() !== "";
|
||||
|
||||
// v1.37 — picking a face suggests a pleasantness value; the suggestion
|
||||
// stands until the user moves that slider, after which the two are
|
||||
// independent. Moving the slider never changes which face is lit.
|
||||
function pickMood(value: string) {
|
||||
setMood(value);
|
||||
setLevelA((prev) => seedA1FromMood(prev, value, a1Touched));
|
||||
}
|
||||
|
||||
function toggleTagKey(key: string) {
|
||||
setTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.12.0 — set or clear a rated factor's score. `rating === null`
|
||||
// removes the factor from the set (re-tapping the active step clears).
|
||||
function rateFactor(key: string, rating: number | null) {
|
||||
setRatedFactors((prev) => {
|
||||
const rest = prev.filter((f) => f.key !== key);
|
||||
return rating === null ? rest : [...rest, { key, rating }];
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
@@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setLevelA(EMPTY_LEVEL_A);
|
||||
setA1Touched(false);
|
||||
setLevelAOpen(false);
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setRatedFactors([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setError(null);
|
||||
@@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
|
||||
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : undefined,
|
||||
// v1.37 — only the values somebody answered. An untouched slider
|
||||
// sends nothing, and the server writes NULL rather than a middle.
|
||||
...levelACreatePayload(levelA),
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
@@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
aria-label={t(level.labelKey)}
|
||||
data-slot="mood-face"
|
||||
data-mood={level.value}
|
||||
onClick={() => setMood(level.value)}
|
||||
onClick={() => pickMood(level.value)}
|
||||
{...getMoodRadioProps(index)}
|
||||
// v1.12 — selected-state unified with the tag tiles and
|
||||
// factor steps: `border-primary bg-primary/15 text-primary`
|
||||
@@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
|
||||
exactly as the existing segmented control. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionFactors")}
|
||||
icon={<SlidersHorizontal />}
|
||||
summary={<SheetSectionCount count={ratedFactors.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
mode="rated"
|
||||
/>
|
||||
</SheetSection>
|
||||
{/* v1.37 — the five level-A values, closed by default. The rated
|
||||
factors that used to sit here are no longer offered on a new
|
||||
entry: a 1-5 score for "work stress" and a 0-10 stress
|
||||
self-state are different questions, and asking both invites
|
||||
two answers to one thing. Existing ratings keep working
|
||||
everywhere they already do, including the edit dialog, and no
|
||||
catalogue row was removed. */}
|
||||
<MoodLevelASection
|
||||
value={levelA}
|
||||
onChange={setLevelA}
|
||||
onA1Touched={() => setA1Touched(true)}
|
||||
open={levelAOpen}
|
||||
onOpenChange={setLevelAOpen}
|
||||
/>
|
||||
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
and the note field. The badge counts whichever of these carry
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The five level-A sliders, and the state rules around them.
|
||||
*
|
||||
* One component for both mood surfaces — the create sheet and the list's edit
|
||||
* dialog — because what one writes the other has to be able to show. A value a
|
||||
* user set on Monday and cannot see on Tuesday is worse than one they were
|
||||
* never offered.
|
||||
*
|
||||
* The section is closed by default and nothing in it is required. The five
|
||||
* faces above it remain a complete entry on their own: one tap still saves.
|
||||
*/
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { SliderField } from "@/components/ui/slider";
|
||||
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
import { getA1ForMood } from "@/lib/validations/mood";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** The five values as the form holds them. `null` means unanswered. */
|
||||
export interface LevelAState {
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
export const EMPTY_LEVEL_A: LevelAState = {
|
||||
a1: null,
|
||||
a2: null,
|
||||
a3: null,
|
||||
a4: null,
|
||||
a5: null,
|
||||
};
|
||||
|
||||
/** How many of the five carry an answer — the collapsed section's badge. */
|
||||
export function levelAAnsweredCount(state: LevelAState): number {
|
||||
return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five values as the API takes them, with the unanswered ones left out.
|
||||
*
|
||||
* Omitted rather than sent as null on the create path: absent means "nothing
|
||||
* to say", and the server writes NULL for it. Nothing here ever sends a
|
||||
* default, which is the whole reason the columns are nullable.
|
||||
*/
|
||||
export function levelACreatePayload(
|
||||
state: LevelAState,
|
||||
): Partial<Record<keyof LevelAState, number>> {
|
||||
const out: Partial<Record<keyof LevelAState, number>> = {};
|
||||
for (const dimension of MOOD_DIMENSIONS) {
|
||||
const value = state[dimension.key];
|
||||
if (value !== null) out[dimension.key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five values as the edit path takes them — nulls included.
|
||||
*
|
||||
* The edit path replaces rather than adds, so a value the user cleared has to
|
||||
* travel as an explicit null. Sending nothing would leave the old answer in
|
||||
* place and the dialog would lie about what it just saved.
|
||||
*/
|
||||
export function levelAUpdatePayload(state: LevelAState): LevelAState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the state from an entry being edited.
|
||||
*
|
||||
* The reader is the API's own wire shape, so a row that has never carried
|
||||
* these values arrives as `undefined` and lands as `null` — unanswered, which
|
||||
* is what it is.
|
||||
*/
|
||||
export function levelAFromEntry(entry: {
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
}): LevelAState {
|
||||
return {
|
||||
a1: entry.a1 ?? null,
|
||||
a2: entry.a2 ?? null,
|
||||
a3: entry.a3 ?? null,
|
||||
a4: entry.a4 ?? null,
|
||||
a5: entry.a5 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed pleasantness from a freshly picked face — but only while the user
|
||||
* has not moved that slider themselves.
|
||||
*
|
||||
* This is the one interaction in the sheet that will confuse a later reader,
|
||||
* so it is written down here rather than inferred from two call sites: picking
|
||||
* a face suggests a pleasantness value, and moving the slider takes the
|
||||
* suggestion over. After that the faces and the slider are independent, and in
|
||||
* particular moving the slider never changes which face is lit — the two are
|
||||
* different questions at different resolutions, not two views of one answer.
|
||||
*/
|
||||
export function seedA1FromMood(
|
||||
state: LevelAState,
|
||||
mood: string,
|
||||
a1Touched: boolean,
|
||||
): LevelAState {
|
||||
if (a1Touched) return state;
|
||||
return { ...state, a1: mood ? getA1ForMood(mood) : null };
|
||||
}
|
||||
|
||||
interface MoodLevelASectionProps {
|
||||
value: LevelAState;
|
||||
onChange: (next: LevelAState) => void;
|
||||
/** Called when the user moves pleasantness themselves, which ends the re-seeding. */
|
||||
onA1Touched?: () => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MoodLevelASection({
|
||||
value,
|
||||
onChange,
|
||||
onA1Touched,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MoodLevelASectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<SheetSection
|
||||
title={t("mood.sectionLevelA")}
|
||||
icon={<Gauge />}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
summary={<SheetSectionCount count={levelAAnsweredCount(value)} />}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-level-a">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mood.sectionLevelAHelp")}
|
||||
</p>
|
||||
{MOOD_DIMENSIONS.map((dimension) => {
|
||||
const current = value[dimension.key];
|
||||
const low = t(dimension.lowAnchorKey);
|
||||
const high = t(dimension.highAnchorKey);
|
||||
return (
|
||||
<SliderField
|
||||
key={dimension.key}
|
||||
label={t(dimension.labelKey)}
|
||||
value={current}
|
||||
min={dimension.min}
|
||||
max={dimension.max}
|
||||
step={1}
|
||||
lowAnchor={low}
|
||||
highAnchor={high}
|
||||
emptyLabel={t("mood.dimensionNotAnswered")}
|
||||
valueText={
|
||||
current === null
|
||||
? undefined
|
||||
: t("mood.dimensionValueText", {
|
||||
value: current,
|
||||
max: dimension.max,
|
||||
low,
|
||||
high,
|
||||
})
|
||||
}
|
||||
clearLabel={t("mood.dimensionClear")}
|
||||
// Pleasantness has no clear control, and that is the same rule
|
||||
// the server keeps: an entry always carries a five-point label,
|
||||
// so it always implies a pleasantness value, and there is no
|
||||
// state in which the field is honestly unanswered. Offering a
|
||||
// control that the write path would immediately undo would be a
|
||||
// button that quietly does nothing. The other four are genuinely
|
||||
// optional and keep theirs.
|
||||
onClear={
|
||||
dimension.key === "a1"
|
||||
? undefined
|
||||
: () => onChange({ ...value, [dimension.key]: null })
|
||||
}
|
||||
onValueChange={(next) => {
|
||||
if (dimension.key === "a1") onA1Touched?.();
|
||||
onChange({ ...value, [dimension.key]: next });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,13 @@ import {
|
||||
} from "@/components/data-list";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelAFromEntry,
|
||||
levelAUpdatePayload,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
|
||||
@@ -104,6 +111,12 @@ interface MoodEntry {
|
||||
// v1.8.5 — structured-tag keys + free-text note.
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
// v1.37 — the five level-A values, absent on entries written before them.
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
@@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const [editTagsInput, setEditTagsInput] = useState("");
|
||||
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
|
||||
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
|
||||
// so what the capture sheet wrote is visible and changeable here.
|
||||
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editSeed, setEditSeed] = useState<{
|
||||
@@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagsInput: string;
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
note: string;
|
||||
moodLoggedAt: string;
|
||||
}>({
|
||||
@@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagsInput: "",
|
||||
tagKeys: [],
|
||||
ratedFactors: [],
|
||||
levelA: EMPTY_LEVEL_A,
|
||||
note: "",
|
||||
moodLoggedAt: "",
|
||||
});
|
||||
@@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags,
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
levelA,
|
||||
note,
|
||||
moodLoggedAt,
|
||||
}: {
|
||||
@@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags: string[] | null;
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
@@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags,
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
// Nulls included: this path replaces rather than adds, so a value the
|
||||
// user cleared has to travel as an explicit null or the old answer
|
||||
// survives an edit that visibly removed it.
|
||||
...levelAUpdatePayload(levelA),
|
||||
note,
|
||||
moodLoggedAt,
|
||||
});
|
||||
@@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...editRatedFactors].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
});
|
||||
@@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...editRatedFactors].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
},
|
||||
@@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: levelAFromEntry(entry),
|
||||
note: entry.note ?? "",
|
||||
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
|
||||
};
|
||||
@@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setEditTagsInput(seed.tagsInput);
|
||||
setEditTagKeys(seed.tagKeys);
|
||||
setEditRatedFactors(seed.ratedFactors);
|
||||
setEditLevelA(seed.levelA);
|
||||
setEditNote(seed.note);
|
||||
setEditMoodLoggedAt(seed.moodLoggedAt);
|
||||
setEditSeed(seed);
|
||||
@@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
tagKeys: editTagKeys,
|
||||
ratedFactors: editRatedFactors,
|
||||
levelA: editLevelA,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
moodLoggedAt: measuredDate.toISOString(),
|
||||
});
|
||||
@@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.37 — the five level-A values, seeded from the entry.
|
||||
Pleasantness is not re-seeded from the face here: this dialog
|
||||
edits an entry that already has an answer, and replacing it
|
||||
with the derived one would quietly discard what the person
|
||||
set. */}
|
||||
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `<Slider>` / `<SliderField>` — the shape and the unanswered state.
|
||||
*
|
||||
* The suite renders server-side (`@testing-library/react` is not a dependency
|
||||
* here), so nothing below drags a thumb or presses an arrow key; the keyboard
|
||||
* behaviour is Radix's own contract and the manual pass covers it. What is
|
||||
* proven here is what the component decides: that an unanswered field renders
|
||||
* no number and announces itself as unanswered rather than as the value its
|
||||
* parked thumb sits on, that a zero is rendered as an answer, and that the
|
||||
* anchors reach the markup as muted legend text.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { Slider, SliderField } from "../slider";
|
||||
|
||||
function field(props: Partial<React.ComponentProps<typeof SliderField>> = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<SliderField
|
||||
label="Stress"
|
||||
value={7}
|
||||
onValueChange={() => {}}
|
||||
emptyLabel="nicht angegeben"
|
||||
valueText="7 von 10"
|
||||
lowAnchor="vollkommen entspannt"
|
||||
highAnchor="extrem belastet"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<Slider>", () => {
|
||||
it("renders the Radix parts under their slots", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Slider value={[4]} min={0} max={10} step={1} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="slider"');
|
||||
expect(html).toContain('data-slot="slider-track"');
|
||||
expect(html).toContain('data-slot="slider-range"');
|
||||
expect(html).toContain('data-slot="slider-thumb"');
|
||||
});
|
||||
|
||||
it("announces the caller's text instead of the bare number", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Slider
|
||||
value={[6]}
|
||||
min={0}
|
||||
max={10}
|
||||
valueText="6 von 10: sehr unangenehm bis sehr angenehm"
|
||||
thumbLabel="Stimmung"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'aria-valuetext="6 von 10: sehr unangenehm bis sehr angenehm"',
|
||||
);
|
||||
expect(html).toContain('aria-label="Stimmung"');
|
||||
// The thumb is a slider to assistive tech, with the bounds published.
|
||||
// `aria-valuenow` is deliberately not asserted: Radix sets it after mount,
|
||||
// so it is absent from server markup and a test demanding it here would be
|
||||
// measuring the renderer rather than the component.
|
||||
expect(html).toContain('role="slider"');
|
||||
expect(html).toContain('aria-valuemin="0"');
|
||||
expect(html).toContain('aria-valuemax="10"');
|
||||
expect(html).toContain('aria-orientation="horizontal"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SliderField>", () => {
|
||||
it("renders the label, the value and both anchors", () => {
|
||||
const html = field();
|
||||
expect(html).toContain("Stress");
|
||||
expect(html).toContain("vollkommen entspannt");
|
||||
expect(html).toContain("extrem belastet");
|
||||
expect(html).toContain('data-slot="slider-field-value"');
|
||||
expect(html).toContain(">7<");
|
||||
});
|
||||
|
||||
it("puts the answer in content colour and the anchors in muted", () => {
|
||||
const html = field();
|
||||
expect(html).toContain("tabular-nums");
|
||||
expect(html).toContain("text-foreground");
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).toContain("text-xs");
|
||||
});
|
||||
|
||||
it("renders an unanswered field as unanswered, not as its parked value", () => {
|
||||
const html = field({ value: null, valueText: "should not be announced" });
|
||||
expect(html).toContain('data-unset="true"');
|
||||
expect(html).toContain("nicht angegeben");
|
||||
// The thumb sits at the low end so the track reads empty, but nothing
|
||||
// claims that low end as the person's answer.
|
||||
expect(html).toContain('aria-valuetext="nicht angegeben"');
|
||||
expect(html).not.toContain("should not be announced");
|
||||
});
|
||||
|
||||
it("renders a zero as an answer", () => {
|
||||
// Zero is a real point on this scale. A field that treated it as absence
|
||||
// would show the empty label and lose an answer somebody gave.
|
||||
const html = field({ value: 0, valueText: "0 von 10" });
|
||||
expect(html).not.toContain('data-unset="true"');
|
||||
expect(html).not.toContain("nicht angegeben");
|
||||
expect(html).toContain('aria-valuetext="0 von 10"');
|
||||
});
|
||||
|
||||
it("offers to clear an answer only when there is one to clear", () => {
|
||||
const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" });
|
||||
expect(set).toContain('data-slot="slider-field-clear"');
|
||||
expect(set).toContain("zurücksetzen");
|
||||
|
||||
const unanswered = field({
|
||||
value: null,
|
||||
onClear: () => {},
|
||||
clearLabel: "zurücksetzen",
|
||||
});
|
||||
expect(unanswered).not.toContain('data-slot="slider-field-clear"');
|
||||
|
||||
const noHandler = field();
|
||||
expect(noHandler).not.toContain('data-slot="slider-field-clear"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Numeric range entry.
|
||||
*
|
||||
* The alternative this replaces is eleven discrete buttons, which is what the
|
||||
* rated-factor picker does for a 1-5 scale. That pattern does not survive the
|
||||
* jump to 0-10: eleven 32 px targets with 4 px gaps come to roughly 392 px
|
||||
* before the label, and the narrow phone this app is mostly used on is 390 px
|
||||
* wide. A slider answers the same question in a fixed width at any scale, and
|
||||
* arrow keys move it a step at a time, which eleven buttons never did.
|
||||
*
|
||||
* `Slider` is the bare control. `SliderField` is the labelled row around it —
|
||||
* name on the left, value on the right, the two scale anchors underneath — and
|
||||
* it is what a capture surface should reach for, because the anchors are what
|
||||
* make a 7 mean anything.
|
||||
*/
|
||||
function Slider({
|
||||
className,
|
||||
valueText,
|
||||
thumbLabel,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root> & {
|
||||
/**
|
||||
* What a screen reader announces instead of the bare number. A slider that
|
||||
* says "6" tells somebody who cannot see the anchors nothing at all; the
|
||||
* caller passes the number together with what the ends of the scale mean.
|
||||
*/
|
||||
valueText?: string;
|
||||
/** Accessible name for the thumb when the field label is not adjacent. */
|
||||
thumbLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
|
||||
// The 44 px hit target WCAG 2.5.5 asks for, without a 44 px track:
|
||||
// the padding is vertical only, so the visible rail stays 6 px.
|
||||
"py-[19px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="bg-primary absolute h-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
aria-label={thumbLabel}
|
||||
aria-valuetext={valueText}
|
||||
className="border-primary bg-background focus-visible:ring-ring/50 block size-4 rounded-full border-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled slider with its scale anchors.
|
||||
*
|
||||
* `value` is nullable and the null is load-bearing: a question nobody answered
|
||||
* is not a question answered in the middle. An unset field renders no number
|
||||
* and fills no track, and only an interaction commits a value. `onClear`, when
|
||||
* given, renders the control that takes an answer back — without it a user who
|
||||
* nudged a slider by accident can never return the field to empty.
|
||||
*/
|
||||
function SliderField({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
onClear,
|
||||
clearLabel,
|
||||
emptyLabel,
|
||||
valueText,
|
||||
lowAnchor,
|
||||
highAnchor,
|
||||
min = 0,
|
||||
max = 10,
|
||||
step = 1,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
onValueChange: (value: number) => void;
|
||||
onClear?: () => void;
|
||||
clearLabel?: string;
|
||||
/** Rendered in place of the number while the field is unanswered. */
|
||||
emptyLabel: string;
|
||||
/** Announced by a screen reader; carries the anchors, not only the number. */
|
||||
valueText?: string;
|
||||
lowAnchor: string;
|
||||
highAnchor: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
className?: string;
|
||||
} & Omit<
|
||||
React.ComponentProps<typeof SliderPrimitive.Root>,
|
||||
"value" | "onValueChange" | "min" | "max" | "step" | "className"
|
||||
>) {
|
||||
const unset = value === null;
|
||||
return (
|
||||
<div
|
||||
data-slot="slider-field"
|
||||
data-unset={unset ? "true" : undefined}
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
{/* Content colour for the label and the value, muted for the anchors
|
||||
below: the anchors are the legend, the value is the answer. */}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
data-slot="slider-field-value"
|
||||
className={cn(
|
||||
"text-sm tabular-nums",
|
||||
unset ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{unset ? emptyLabel : value}
|
||||
</span>
|
||||
{!unset && onClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
data-slot="slider-field-clear"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
// An unanswered field parks the thumb at the low end with an empty
|
||||
// track, so it reads as "nothing here" rather than as the value the
|
||||
// low end happens to carry. The announced text says the same.
|
||||
value={[unset ? min : value]}
|
||||
onValueChange={(next) => onValueChange(next[0])}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
thumbLabel={label}
|
||||
valueText={unset ? emptyLabel : valueText}
|
||||
{...props}
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between gap-2 text-xs">
|
||||
<span>{lowAnchor}</span>
|
||||
<span className="text-right">{highAnchor}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider, SliderField };
|
||||
@@ -400,3 +400,51 @@ describe("formatMoodEntriesForExport with userTz", () => {
|
||||
expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMoodEntriesForExport level-A columns", () => {
|
||||
const base = {
|
||||
date: "2026-05-15",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: null,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: new Date("2026-05-15T18:30:00Z"),
|
||||
};
|
||||
|
||||
it("appends the five values without moving an existing column", () => {
|
||||
const csv = toCSV(
|
||||
formatMoodEntriesForExport([
|
||||
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
|
||||
]),
|
||||
);
|
||||
const [header, row] = csv.split("\n");
|
||||
// The pre-existing columns keep their order and their positions. Somebody's
|
||||
// spreadsheet has a formula pointed at column D.
|
||||
expect(header).toBe(
|
||||
"date,mood,score,tags,note,source,loggedAt,a1,a2,a3,a4,a5",
|
||||
);
|
||||
expect(row.split(",").slice(0, 3)).toEqual(["2026-05-15", "OKAY", "3"]);
|
||||
});
|
||||
|
||||
it("writes an empty cell for an unanswered value and a zero for a zero", () => {
|
||||
const csv = toCSV(
|
||||
formatMoodEntriesForExport([
|
||||
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
|
||||
]),
|
||||
);
|
||||
const cells = csv.split("\n")[1].split(",");
|
||||
// a1, a2, a3, a4, a5 — the last two were never answered.
|
||||
expect(cells.slice(-5)).toEqual(["4", "9", "0", "", ""]);
|
||||
});
|
||||
|
||||
it("leaves every level-A cell empty for an entry written before the columns", () => {
|
||||
const csv = toCSV(formatMoodEntriesForExport([base]));
|
||||
expect(csv.split("\n")[1].split(",").slice(-5)).toEqual([
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,25 @@
|
||||
/**
|
||||
* The WELLBEING pillar reads validated instruments, and only those.
|
||||
*
|
||||
* PHQ-9, GAD-7 and WHO-5 over a 90-day window. Every threshold below cites
|
||||
* the paper it comes from, which is the whole reason this pillar can be shown
|
||||
* to a doctor.
|
||||
*
|
||||
* Daily mood does not feed it, and that is a decision rather than an
|
||||
* oversight. Since v1.37 a mood entry carries a 0-10 self-rated pleasantness
|
||||
* on most rows, including a mapped value on historical ones, so the number is
|
||||
* available and folding it in would be a small change. It stays out: a
|
||||
* self-rated slider moved once a day and a scored clinical questionnaire are
|
||||
* different kinds of evidence, and averaging them into one figure that cites
|
||||
* Kroenke, Spitzer and the WHO would weaken exactly the line that makes the
|
||||
* figure worth anything. There is also no good answer to "why did my score
|
||||
* drop because I logged a bad afternoon".
|
||||
*
|
||||
* Daily mood keeps its own surfaces — the trend page, the assistant, the
|
||||
* briefing — where its resolution is the point. Bringing it in here would be
|
||||
* a score decision, taken on the score line, with its own coverage rule and
|
||||
* its own line in the explainer; it is not something to add on the way past.
|
||||
*/
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
|
||||
import type { PillarValue, WellbeingPillarInput } from "./types";
|
||||
|
||||
@@ -336,6 +336,12 @@ export function formatMoodEntriesForExport(
|
||||
note?: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: Date;
|
||||
/** The five level-A values, absent on entries written before they existed. */
|
||||
moodA1?: number | null;
|
||||
stressA2?: number | null;
|
||||
energyA3?: number | null;
|
||||
connectionA4?: number | null;
|
||||
stabilityA5?: number | null;
|
||||
}>,
|
||||
userTz?: string,
|
||||
): ExportableRecord[] {
|
||||
@@ -347,5 +353,14 @@ export function formatMoodEntriesForExport(
|
||||
note: e.note ?? "",
|
||||
source: e.source,
|
||||
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
|
||||
// Appended after the existing columns rather than slotted in beside
|
||||
// `score`, because this file lands in somebody's spreadsheet and a column
|
||||
// that moves breaks every formula pointed at it. An unanswered value is an
|
||||
// empty cell, not a zero: zero is a real answer on this scale.
|
||||
a1: e.moodA1 ?? "",
|
||||
a2: e.stressA2 ?? "",
|
||||
a3: e.energyA3 ?? "",
|
||||
a4: e.connectionA4 ?? "",
|
||||
a5: e.stabilityA5 ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -117,6 +117,11 @@ const MOOD_ENTRY_BACKUP_SELECT = {
|
||||
date: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
tags: true,
|
||||
note: true,
|
||||
noteEncrypted: true,
|
||||
@@ -480,6 +485,15 @@ export async function buildFullBackupPayload(
|
||||
date: moodEntry.date,
|
||||
mood: moodEntry.mood,
|
||||
score: moodEntry.score,
|
||||
// The five level-A values, under the keys the write path takes. Both
|
||||
// arms carry them: they are the entry's own answers, not a storage
|
||||
// detail, and a portable export that dropped them would hand back a
|
||||
// file describing the day less well than the row it came from.
|
||||
a1: moodEntry.moodA1,
|
||||
a2: moodEntry.stressA2,
|
||||
a3: moodEntry.energyA3,
|
||||
a4: moodEntry.connectionA4,
|
||||
a5: moodEntry.stabilityA5,
|
||||
tags: moodEntry.tags,
|
||||
source: moodEntry.source,
|
||||
loggedAt: moodEntry.moodLoggedAt.toISOString(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* v1.37 — per-dimension staleness on the level-A blocks.
|
||||
*
|
||||
* The failure this exists to prevent has already happened once on the vitals
|
||||
* surfaces: a value was handed to the narrator with no notion of when it was
|
||||
* taken, and last week's reading came back as this morning's. The five
|
||||
* dimensions make that failure easier, not harder, because their ages differ
|
||||
* — somebody taps a face every morning and opens the detail sliders once a
|
||||
* week, so pleasantness is fresh while stress is six days old.
|
||||
*
|
||||
* The rule under test: every dimension carries its own age, and `current`
|
||||
* answers "may this back a present-tense sentence" through the one shared
|
||||
* freshness rule rather than through a comparison written here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
|
||||
import {
|
||||
TODAY_CLAIM_MAX_AGE_DAYS,
|
||||
isCurrentForTodayClaim,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** The shape `buildMoodDimensionFeatures` derives, without a database. */
|
||||
function blocksFor(
|
||||
rows: Array<{ daysAgo: number; a1?: number; a2?: number }>,
|
||||
): Array<{
|
||||
key: string;
|
||||
latest: number | null;
|
||||
newestDaysAgo: number | null;
|
||||
current: boolean;
|
||||
}> {
|
||||
const summaries = computeMoodDimensionSeries(
|
||||
rows.map((r) => ({
|
||||
date: dayKey(r.daysAgo),
|
||||
a1: r.a1 ?? null,
|
||||
a2: r.a2 ?? null,
|
||||
a3: null,
|
||||
a4: null,
|
||||
a5: null,
|
||||
})),
|
||||
TODAY,
|
||||
);
|
||||
return summaries
|
||||
.filter((s) => s.present)
|
||||
.map((s) => ({
|
||||
key: s.key,
|
||||
latest: s.latest,
|
||||
newestDaysAgo: s.newestDaysAgo,
|
||||
current: isCurrentForTodayClaim(s.newestDaysAgo),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("level-A staleness", () => {
|
||||
it("calls this morning's pleasantness current and a five-day-old stress stale", () => {
|
||||
// The fixture the whole file is about: one entry today with a mood, one
|
||||
// five days ago that also answered stress.
|
||||
const blocks = blocksFor([
|
||||
{ daysAgo: 0, a1: 8 },
|
||||
{ daysAgo: 5, a1: 4, a2: 9 },
|
||||
]);
|
||||
|
||||
const a1 = blocks.find((b) => b.key === "a1")!;
|
||||
const a2 = blocks.find((b) => b.key === "a2")!;
|
||||
|
||||
expect(a1.newestDaysAgo).toBe(0);
|
||||
expect(a1.current).toBe(true);
|
||||
|
||||
expect(a2.latest).toBe(9);
|
||||
expect(a2.newestDaysAgo).toBe(5);
|
||||
// The claim under test: nothing may say "your stress is high today" from
|
||||
// this row. It is history and is stated with its date.
|
||||
expect(a2.current).toBe(false);
|
||||
});
|
||||
|
||||
it("never carries a latest without an age beside it", () => {
|
||||
// The structural half of the fix. A `latest` alone is exactly what
|
||||
// produced the wrong sentence, so the shape is asserted, not the value.
|
||||
for (const block of blocksFor([{ daysAgo: 2, a1: 5, a2: 5 }])) {
|
||||
expect(block.latest).not.toBeNull();
|
||||
expect(block.newestDaysAgo).not.toBeNull();
|
||||
expect(typeof block.current).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unknown age as stale rather than as fresh", () => {
|
||||
// A missing age is not a fresh one. This is the shared rule's own
|
||||
// behaviour and is asserted here so a local reimplementation that
|
||||
// defaulted to `true` would be caught.
|
||||
expect(isCurrentForTodayClaim(null)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("puts the boundary exactly where the shared rule puts it", () => {
|
||||
// Yesterday still backs a present claim; the day before does not. Read
|
||||
// off the exported constant rather than the literal 1, so a change to the
|
||||
// window moves this test with it instead of stranding it.
|
||||
const fresh = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS, a1: 6 }]);
|
||||
expect(fresh[0].current).toBe(true);
|
||||
|
||||
const stale = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS + 1, a1: 6 }]);
|
||||
expect(stale[0].current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
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 { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -64,6 +69,158 @@ interface DataCoverage {
|
||||
newestDaysAgo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One level-A dimension as the model reads it.
|
||||
*
|
||||
* `latest` never travels without `newestDaysAgo` and `current` beside it. A
|
||||
* value with no age is exactly what produced a reading from last week stated
|
||||
* as this morning's, so the age is part of the shape rather than something a
|
||||
* caller is trusted to look up.
|
||||
*/
|
||||
export interface MoodDimensionFeature {
|
||||
key: string;
|
||||
/** The wording behind the numbers, so a 7 is not read as 7 out of 5. */
|
||||
scale: string;
|
||||
/** True when a higher number means a worse day — stress, and only stress. */
|
||||
inverse: boolean;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
latest: number | null;
|
||||
/** The local day the newest value belongs to. */
|
||||
latestDate: string | null;
|
||||
/** Whole days between that day and today. */
|
||||
newestDaysAgo: number | null;
|
||||
/**
|
||||
* Whether that value may back a present-tense claim. False for an absent
|
||||
* age too: a missing age is not a fresh one.
|
||||
*/
|
||||
current: boolean;
|
||||
/**
|
||||
* Present only when `current` is false, and says in words what the flag
|
||||
* says in a boolean. The model reads prose more reliably than it reads a
|
||||
* field name, and a stale dimension narrated as today's is the one failure
|
||||
* this block exists to prevent.
|
||||
*/
|
||||
staleNotice?: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level-A blocks for the model, read once over a bounded window.
|
||||
*
|
||||
* The rollup tier caches the mean of `score` and nothing else, so this is its
|
||||
* own read rather than a widened rollup: a cached tier for four columns that
|
||||
* may all be NULL would cost a migration to answer a question the live read
|
||||
* answers in one indexed range scan. If a surface measurably slows, the tier
|
||||
* grows then, as a read-swap with a fallback on a coverage miss — never as a
|
||||
* second engine running beside this one.
|
||||
*
|
||||
* Only dimensions somebody answered are returned. An empty block would invite
|
||||
* a sentence about a question that was never asked.
|
||||
*
|
||||
* A STALE dimension is still returned, carrying its age and an instruction,
|
||||
* rather than filtered out the way the health-status surfaces drop a reading
|
||||
* too old to describe. That is a deliberate difference and worth the sentence:
|
||||
* those surfaces write one line about one metric, so a value they cannot
|
||||
* narrate has nothing left to contribute and dropping it is the whole fix.
|
||||
* This block is context for an open conversation, where "your stress was 9 on
|
||||
* the third, and you have not answered it since" is a useful and true thing to
|
||||
* say — and where silence is worse than history, because the reader then has
|
||||
* no way to tell an unanswered dimension from one nobody has looked at in a
|
||||
* week. The safety therefore rides on `current` plus `staleNotice` rather than
|
||||
* on absence, and both are asserted at the shipped composition in
|
||||
* `tests/integration/mood-dimension-features.test.ts`.
|
||||
*/
|
||||
async function buildMoodDimensionFeatures(
|
||||
userId: string,
|
||||
): Promise<MoodDimensionFeature[] | undefined> {
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const { t } = getServerTranslator("en");
|
||||
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 2000,
|
||||
select: {
|
||||
date: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
// The same calculator the trend page uses, so the number in a Coach
|
||||
// sentence and the number on the chart cannot disagree.
|
||||
const summaries = computeMoodDimensionSeries(
|
||||
rows.map((row) => ({
|
||||
date: row.date,
|
||||
a1: row.moodA1,
|
||||
a2: row.stressA2,
|
||||
a3: row.energyA3,
|
||||
a4: row.connectionA4,
|
||||
a5: row.stabilityA5,
|
||||
})),
|
||||
userDayKey(new Date(), tz),
|
||||
);
|
||||
|
||||
const blocks = summaries
|
||||
.filter((summary) => summary.present)
|
||||
.map((summary) => {
|
||||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key)!;
|
||||
const current = isCurrentForTodayClaim(summary.newestDaysAgo);
|
||||
return {
|
||||
key: summary.key,
|
||||
// The anchors are resolved through the bundle rather than repeated
|
||||
// here: the model must read the same words the person read when they
|
||||
// moved the slider, and a second copy of that wording would drift
|
||||
// from the one on screen the first time either was edited. English,
|
||||
// like the legacy `scale` line beside it.
|
||||
scale: `${t(dimension.labelKey)}, 0-10: 0 = ${t(dimension.lowAnchorKey)}, 10 = ${t(dimension.highAnchorKey)}`,
|
||||
inverse: summary.inverse,
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
latest: summary.latest,
|
||||
latestDate: summary.latestDate,
|
||||
newestDaysAgo: summary.newestDaysAgo,
|
||||
current,
|
||||
...(current
|
||||
? {}
|
||||
: {
|
||||
staleNotice: summary.latestDate
|
||||
? `Last answered on ${summary.latestDate}. State it as history with that date; do not describe it as today.`
|
||||
: "No dated value. Do not describe this dimension as current.",
|
||||
}),
|
||||
count: summary.count,
|
||||
};
|
||||
});
|
||||
|
||||
return blocks.length > 0 ? blocks : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* so an account that only ever taps a face carries neither key.
|
||||
*/
|
||||
async function buildMoodDimensionBlock(userId: string): Promise<{
|
||||
dimensions?: MoodDimensionFeature[];
|
||||
dimensionsAsOfNote?: string;
|
||||
}> {
|
||||
const dimensions = await buildMoodDimensionFeatures(userId);
|
||||
if (!dimensions) return {};
|
||||
return {
|
||||
dimensions,
|
||||
dimensionsAsOfNote:
|
||||
"The block-level asOf describes the newest mood ENTRY on the 1-5 axis. " +
|
||||
"For the five dimensions below, each carries its own newestDaysAgo and " +
|
||||
"current, and those are the ones that govern what may be said about " +
|
||||
"them today; they are often older than the entry itself.",
|
||||
};
|
||||
}
|
||||
|
||||
export interface AggregatedFeatures {
|
||||
weight?: {
|
||||
latest: number;
|
||||
@@ -161,6 +318,34 @@ export interface AggregatedFeatures {
|
||||
trend30: "improving" | "declining" | "stable" | null;
|
||||
totalEntries: number;
|
||||
coverage: DataCoverage;
|
||||
/**
|
||||
* v1.37 — the five level-A self-state values, one block each, and only for
|
||||
* the dimensions somebody actually answered. `scale` above describes the
|
||||
* legacy 1-5 axis and is handed to the model verbatim, so it is left
|
||||
* exactly as it was; these carry their own scale line beside it.
|
||||
*
|
||||
* Every block states its own age, because the ages differ: stress can be
|
||||
* six days old while pleasantness was answered this morning, and a model
|
||||
* given one age for the pair narrates the stale one as today's.
|
||||
*/
|
||||
dimensions?: MoodDimensionFeature[];
|
||||
/**
|
||||
* Says which age governs which claim, because two of them ride in the
|
||||
* same block and they disagree by design.
|
||||
*
|
||||
* `annotateSnapshotFreshness` stamps every metric block with an `asOf`
|
||||
* built from `coverage.newestDaysAgo`, which for mood is the age of the
|
||||
* newest ENTRY — the 1-5 axis. Somebody who taps a face each morning and
|
||||
* opens the sliders once a week has `asOf.daysAgo` of 0 and a stress
|
||||
* reading six days old, so a reader taking the block-level stamp as
|
||||
* covering the dimensions would state last week's answer as today's.
|
||||
* That is the exact sentence this milestone exists to prevent, and it is
|
||||
* cheaper to name the precedence than to leave two true numbers looking
|
||||
* like a contradiction.
|
||||
*
|
||||
* Present only alongside `dimensions`.
|
||||
*/
|
||||
dimensionsAsOfNote?: string;
|
||||
};
|
||||
sleep?: {
|
||||
avg7: number | null;
|
||||
@@ -1055,6 +1240,7 @@ export async function extractFeatures(
|
||||
latest: moodLatestScore,
|
||||
trend30,
|
||||
totalEntries: moodTotalEntries,
|
||||
...(await buildMoodDimensionBlock(userId)),
|
||||
coverage: {
|
||||
count: moodTotalEntries,
|
||||
spanDays,
|
||||
|
||||
@@ -35,6 +35,15 @@ import {
|
||||
computeMoodNarratives,
|
||||
type MoodNarrative,
|
||||
} from "@/lib/insights/mood-narratives";
|
||||
import {
|
||||
computeMoodDimensionSeries,
|
||||
type MoodDimensionSummary,
|
||||
} from "@/lib/insights/mood-dimension-series";
|
||||
export {
|
||||
computeMoodDimensionSeries,
|
||||
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 +171,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 +433,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 +604,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 +661,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 +708,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,138 @@
|
||||
/**
|
||||
* 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,
|
||||
MOOD_DIMENSION_WINDOWS,
|
||||
type MoodDimensionKey,
|
||||
} from "@/lib/mood/dimensions";
|
||||
import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = dayKeyAgeInDays(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 = dayKeyAgeInDays(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 ? dayKeyAgeInDays(newest.date, todayKey) : null,
|
||||
series,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
||||
import { getScoreForMood } from "@/lib/validations/mood";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMessage,
|
||||
@@ -648,6 +649,10 @@ export async function logMcpMood(input: {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// v1.37 — pleasantness derived from the same label the score comes
|
||||
// from. The tool still takes a 1-5 score; that is its input and it is
|
||||
// unchanged.
|
||||
moodA1: deriveA1(mood),
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note),
|
||||
source: MCP_SOURCE,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MOOD_DIMENSIONS,
|
||||
MOOD_DIMENSION_MAX,
|
||||
MOOD_DIMENSION_MIN,
|
||||
getMoodDimension,
|
||||
} from "@/lib/mood/dimensions";
|
||||
|
||||
describe("MOOD_DIMENSIONS", () => {
|
||||
it("carries the five level-A dimensions in capture order", () => {
|
||||
expect(MOOD_DIMENSIONS.map((d) => d.key)).toEqual([
|
||||
"a1",
|
||||
"a2",
|
||||
"a3",
|
||||
"a4",
|
||||
"a5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds each dimension to its own column", () => {
|
||||
expect(MOOD_DIMENSIONS.map((d) => d.column)).toEqual([
|
||||
"moodA1",
|
||||
"stressA2",
|
||||
"energyA3",
|
||||
"connectionA4",
|
||||
"stabilityA5",
|
||||
]);
|
||||
const columns = new Set(MOOD_DIMENSIONS.map((d) => d.column));
|
||||
expect(columns.size).toBe(MOOD_DIMENSIONS.length);
|
||||
});
|
||||
|
||||
it("runs 0..10 on every dimension", () => {
|
||||
for (const d of MOOD_DIMENSIONS) {
|
||||
expect(d.min).toBe(MOOD_DIMENSION_MIN);
|
||||
expect(d.max).toBe(MOOD_DIMENSION_MAX);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks exactly one dimension inverse, and it is stress", () => {
|
||||
const inverse = MOOD_DIMENSIONS.filter((d) => d.inverse);
|
||||
expect(inverse.map((d) => d.key)).toEqual(["a2"]);
|
||||
});
|
||||
|
||||
it("gives every dimension a distinct label and two distinct anchors", () => {
|
||||
const keys: string[] = [];
|
||||
for (const d of MOOD_DIMENSIONS) {
|
||||
expect(d.labelKey).toBe(`mood.dimension.${d.key}.label`);
|
||||
expect(d.lowAnchorKey).toBe(`mood.dimension.${d.key}.low`);
|
||||
expect(d.highAnchorKey).toBe(`mood.dimension.${d.key}.high`);
|
||||
keys.push(d.labelKey, d.lowAnchorKey, d.highAnchorKey);
|
||||
}
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("resolves a dimension by wire key and refuses anything else", () => {
|
||||
expect(getMoodDimension("a3")?.column).toBe("energyA3");
|
||||
expect(getMoodDimension("a6")).toBeUndefined();
|
||||
expect(getMoodDimension("moodA1")).toBeUndefined();
|
||||
expect(getMoodDimension("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
||||
import { getScoreForMood } from "@/lib/validations/mood";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
@@ -79,6 +80,10 @@ export async function logTelegramMood(input: {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// v1.37 — pleasantness derived from the same label the score comes
|
||||
// from, so a mood tapped in the chat carries the value a mood typed
|
||||
// into the web form would. Nothing here can answer the other four.
|
||||
moodA1: deriveA1(mood),
|
||||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(input.note ?? null),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The five level-A self-state dimensions of a mood entry.
|
||||
*
|
||||
* A mood entry has carried one 1–5 value (`MoodEntry.score`) since the first
|
||||
* release. That value keeps its meaning and its scale — eleven surfaces read
|
||||
* it on the assumption that it runs 1..5 — and the five dimensions below ride
|
||||
* beside it as their own 0–10 columns. The two coexist by design: `score` is
|
||||
* "how was the day, in one word", the dimensions are "how was the day, in the
|
||||
* terms the day was actually made of".
|
||||
*
|
||||
* Each dimension is stored literally, exactly as the user set it against the
|
||||
* anchors they read. A2 (stress) is inverse-oriented — a higher value means a
|
||||
* worse day — and it is stored that way rather than flipped at write time. An
|
||||
* analytics surface that needs "up is better" flips the sign through
|
||||
* `inverse` on this table; nothing flips inline. The same rule already governs
|
||||
* rated mood factors (`MoodTag.inverse`), and it exists because a stored value
|
||||
* that disagrees with the label the user saw is unrecoverable afterwards.
|
||||
*
|
||||
* This module is pure: it holds i18n keys and column names, and imports
|
||||
* neither Prisma nor the message bundles. The German wording behind every key
|
||||
* lives in `messages/de.json` and its five siblings.
|
||||
*/
|
||||
|
||||
/** Wire/DTO key of a level-A dimension. */
|
||||
export type MoodDimensionKey = "a1" | "a2" | "a3" | "a4" | "a5";
|
||||
|
||||
/** `MoodEntry` column a level-A dimension is stored in. */
|
||||
export type MoodDimensionColumn =
|
||||
"moodA1" | "stressA2" | "energyA3" | "connectionA4" | "stabilityA5";
|
||||
|
||||
export interface MoodDimension {
|
||||
/** Wire key (`a1`…`a5`) accepted by the create schema and echoed on reads. */
|
||||
key: MoodDimensionKey;
|
||||
/** Prisma field on `MoodEntry` this dimension is stored in. */
|
||||
column: MoodDimensionColumn;
|
||||
/** i18n key of the dimension's own name. */
|
||||
labelKey: string;
|
||||
/** i18n key of the wording at the bottom of the scale. */
|
||||
lowAnchorKey: string;
|
||||
/** i18n key of the wording at the top of the scale. */
|
||||
highAnchorKey: string;
|
||||
/**
|
||||
* A higher stored value means a worse day. Exactly one dimension carries
|
||||
* this today (A2, stress), and the unit test pins that count, so a second
|
||||
* one cannot be flipped quietly.
|
||||
*/
|
||||
inverse: boolean;
|
||||
min: 0;
|
||||
max: 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions in capture order — the order the sliders render in and the
|
||||
* order every per-dimension surface iterates. Concept order, not alphabetical:
|
||||
* pleasantness first because it is the one the quick check-in already answers.
|
||||
*/
|
||||
export const MOOD_DIMENSIONS: readonly MoodDimension[] = [
|
||||
{
|
||||
key: "a1",
|
||||
column: "moodA1",
|
||||
labelKey: "mood.dimension.a1.label",
|
||||
lowAnchorKey: "mood.dimension.a1.low",
|
||||
highAnchorKey: "mood.dimension.a1.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a2",
|
||||
column: "stressA2",
|
||||
labelKey: "mood.dimension.a2.label",
|
||||
lowAnchorKey: "mood.dimension.a2.low",
|
||||
highAnchorKey: "mood.dimension.a2.high",
|
||||
inverse: true,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a3",
|
||||
column: "energyA3",
|
||||
labelKey: "mood.dimension.a3.label",
|
||||
lowAnchorKey: "mood.dimension.a3.low",
|
||||
highAnchorKey: "mood.dimension.a3.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a4",
|
||||
column: "connectionA4",
|
||||
labelKey: "mood.dimension.a4.label",
|
||||
lowAnchorKey: "mood.dimension.a4.low",
|
||||
highAnchorKey: "mood.dimension.a4.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a5",
|
||||
column: "stabilityA5",
|
||||
labelKey: "mood.dimension.a5.label",
|
||||
lowAnchorKey: "mood.dimension.a5.low",
|
||||
highAnchorKey: "mood.dimension.a5.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Windows the per-dimension trend reports, in days. Concept §10.1.
|
||||
*
|
||||
* Here rather than beside the calculator that averages over them, because the
|
||||
* chart offering the choice needs the same three numbers and cannot import
|
||||
* that module: it reaches Prisma two hops down, and a client component that
|
||||
* pulled it in would drag the database client into the browser bundle. This
|
||||
* module is pure by construction, which is what makes it the one place both
|
||||
* ends can agree on.
|
||||
*/
|
||||
export const MOOD_DIMENSION_WINDOWS = [7, 30, 90] as const;
|
||||
export type MoodDimensionWindow = (typeof MOOD_DIMENSION_WINDOWS)[number];
|
||||
|
||||
/** Lowest value any dimension accepts. */
|
||||
export const MOOD_DIMENSION_MIN = 0;
|
||||
/** Highest value any dimension accepts. */
|
||||
export const MOOD_DIMENSION_MAX = 10;
|
||||
|
||||
const BY_KEY = new Map<string, MoodDimension>(
|
||||
MOOD_DIMENSIONS.map((d) => [d.key, d]),
|
||||
);
|
||||
|
||||
/** The dimension carrying this wire key, or `undefined` for anything else. */
|
||||
export function getMoodDimension(key: string): MoodDimension | undefined {
|
||||
return BY_KEY.get(key);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The one place a mood write resolves its five level-A values.
|
||||
*
|
||||
* Five writers reach `MoodEntry`, and every one of them sends exactly what it
|
||||
* sent before this existed: a five-point label and nothing else. The server
|
||||
* derives pleasantness (A1) from that label so a quick check-in is a full
|
||||
* entry rather than a degraded one, and so a Telegram row, an imported row and
|
||||
* a row typed into the web form all carry the same value for the same day.
|
||||
*
|
||||
* The other four are never derived. There is nothing in a five-point label to
|
||||
* derive stress, energy, connectedness or stability from, and a fabricated
|
||||
* value would read as an answer nobody gave — it would poison the trend and
|
||||
* any later model trained on these columns. They stay NULL until somebody
|
||||
* moves the slider.
|
||||
*/
|
||||
import { getA1ForMood } from "@/lib/validations/mood";
|
||||
|
||||
/** Level-A values as a client may send them, all optional. */
|
||||
export interface LevelAInput {
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-A values as they are written to `MoodEntry`.
|
||||
*
|
||||
* A1 is always stated, because it is always derivable from the label. The
|
||||
* other four are present only when the request carried them: an absent key
|
||||
* means the caller said nothing about that dimension, and on an upsert's
|
||||
* `update:` arm saying nothing has to leave the stored answer alone.
|
||||
*/
|
||||
export interface LevelAColumns {
|
||||
moodA1: number;
|
||||
stressA2?: number | null;
|
||||
energyA3?: number | null;
|
||||
connectionA4?: number | null;
|
||||
stabilityA5?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five columns for a write of `mood`, with any explicit client value
|
||||
* winning over the derivation.
|
||||
*
|
||||
* A client that sends only a label gets the derived A1 and says nothing at all
|
||||
* about the other four. A client that sends `a1` gets its own number — that is
|
||||
* what makes the detail sliders a real capture surface rather than a
|
||||
* suggestion the server overrides on the way to the database.
|
||||
*
|
||||
* **A2 to A5 are omitted when the request did not carry them, and that is the
|
||||
* point.** The callers are upserts, so this same object feeds an `update:`
|
||||
* arm: a phone re-posting an entry it holds — the label, the timestamp, the
|
||||
* tags, and no sliders, because its own build has none — must not blank the
|
||||
* stress and energy somebody answered on the web. Absence in a request means
|
||||
* "nothing to say", never "set this to nothing". Clearing an answer is what an
|
||||
* explicit `null` is for, and it travels here as a stated null.
|
||||
*
|
||||
* The asymmetry with A1 is deliberate rather than an oversight. A1 can always
|
||||
* be derived from the label the request already carries, so there is no state
|
||||
* in which restating it loses information; the other four have no such source,
|
||||
* and a value invented for them would be indistinguishable from one somebody
|
||||
* gave — which would poison the trend and anything later trained on it.
|
||||
*
|
||||
* On the `create:` arm an omitted column lands as NULL anyway, so one object
|
||||
* serves both arms. Callers whose write semantics are per-field rather than
|
||||
* whole-row (the edit route) build their own object and use `deriveA1`.
|
||||
*/
|
||||
export function levelAForWrite(
|
||||
mood: string,
|
||||
explicit?: LevelAInput,
|
||||
): LevelAColumns {
|
||||
// Field by field, never a spread of the parsed body.
|
||||
return {
|
||||
// An explicit null falls back to the derivation rather than clearing: an
|
||||
// entry always carries a pleasantness value, because the five-point label
|
||||
// it was saved with always implies one.
|
||||
moodA1: explicit?.a1 ?? getA1ForMood(mood),
|
||||
...(explicit?.a2 === undefined ? {} : { stressA2: explicit.a2 }),
|
||||
...(explicit?.a3 === undefined ? {} : { energyA3: explicit.a3 }),
|
||||
...(explicit?.a4 === undefined ? {} : { connectionA4: explicit.a4 }),
|
||||
...(explicit?.a5 === undefined ? {} : { stabilityA5: explicit.a5 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */
|
||||
export function deriveA1(mood: string): number {
|
||||
return getA1ForMood(mood);
|
||||
}
|
||||
|
||||
/** The five columns as a client reads them back. */
|
||||
export interface LevelAWire {
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
/** The five columns as they sit on a `MoodEntry` row. */
|
||||
export interface LevelARow {
|
||||
moodA1: number | null;
|
||||
stressA2: number | null;
|
||||
energyA3: number | null;
|
||||
connectionA4: number | null;
|
||||
stabilityA5: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a stored row's five columns into the wire keys the client sends.
|
||||
*
|
||||
* A read that answered `moodA1` while the write took `a1` would leave every
|
||||
* form mapping the pair by hand, and one of them would eventually map it
|
||||
* wrong. The names match in both directions.
|
||||
*/
|
||||
export function levelAForWire(row: LevelARow): LevelAWire {
|
||||
return {
|
||||
a1: row.moodA1,
|
||||
a2: row.stressA2,
|
||||
a3: row.energyA3,
|
||||
a4: row.connectionA4,
|
||||
a5: row.stabilityA5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A row shaped for a response: the five columns replaced by their wire keys,
|
||||
* not joined by them.
|
||||
*
|
||||
* The mood routes spread the whole row into their responses, so simply adding
|
||||
* the wire keys left every entry carrying the same five values twice under two
|
||||
* spellings. Two names for one number is a decision nobody made, and the one a
|
||||
* client picks is the one it gets stuck with. This strips the column spelling
|
||||
* and keeps the wire one, which is the spelling the write path accepts.
|
||||
*/
|
||||
export function shapeLevelA<T extends LevelARow>(
|
||||
row: T,
|
||||
): Omit<T, keyof LevelARow> & LevelAWire {
|
||||
const { moodA1, stressA2, energyA3, connectionA4, stabilityA5, ...rest } =
|
||||
row;
|
||||
return {
|
||||
...rest,
|
||||
...levelAForWire({
|
||||
moodA1,
|
||||
stressA2,
|
||||
energyA3,
|
||||
connectionA4,
|
||||
stabilityA5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getA1ForMood,
|
||||
getScoreForMood,
|
||||
moodA1Map,
|
||||
} from "@/lib/validations/mood";
|
||||
|
||||
/**
|
||||
* The five-point label → A1 map is the single derivation point every writer
|
||||
* shares, so every anchor is asserted by value rather than by shape. A test
|
||||
* that only checked monotonicity would pass a map that had drifted a point.
|
||||
*/
|
||||
describe("getA1ForMood", () => {
|
||||
it("maps every five-point label onto its level-A anchor", () => {
|
||||
expect(getA1ForMood("LAUSIG")).toBe(1);
|
||||
expect(getA1ForMood("SCHLECHT")).toBe(3);
|
||||
expect(getA1ForMood("OKAY")).toBe(5);
|
||||
expect(getA1ForMood("GUT")).toBe(7);
|
||||
expect(getA1ForMood("SUPER_GUT")).toBe(9);
|
||||
});
|
||||
|
||||
it("keeps the endpoints inside the scale rather than on it", () => {
|
||||
// 0 and 10 stay reachable only by a value the user set by hand: a mapped
|
||||
// historical entry must not read as more extreme than every future one.
|
||||
expect(getA1ForMood("LAUSIG")).toBeGreaterThan(0);
|
||||
expect(getA1ForMood("SUPER_GUT")).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("answers the same neutral midpoint an unknown label gets from the score map", () => {
|
||||
// getScoreForMood answers 3 on a 1..5 axis for an unknown label — the
|
||||
// midpoint. A1's answer is the midpoint of its own axis, and the two are
|
||||
// asserted against each other so a change to one is loud in the other.
|
||||
expect(getScoreForMood("NOT_A_MOOD")).toBe(3);
|
||||
expect(getA1ForMood("NOT_A_MOOD")).toBe(5);
|
||||
expect(getA1ForMood("")).toBe(5);
|
||||
});
|
||||
|
||||
it("is ordered and evenly spaced across the five labels", () => {
|
||||
const ordered = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"].map(
|
||||
getA1ForMood,
|
||||
);
|
||||
expect(ordered).toEqual([1, 3, 5, 7, 9]);
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
expect(ordered[i] - ordered[i - 1]).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes exactly the five labels and nothing else", () => {
|
||||
expect(Object.keys(moodA1Map()).sort()).toEqual([
|
||||
"GUT",
|
||||
"LAUSIG",
|
||||
"OKAY",
|
||||
"SCHLECHT",
|
||||
"SUPER_GUT",
|
||||
]);
|
||||
});
|
||||
|
||||
it("agrees with the score map on ordering", () => {
|
||||
// Both axes must rank the five labels identically; a disagreement would
|
||||
// mean a row whose score and A1 tell opposite stories.
|
||||
const labels = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"];
|
||||
const byScore = [...labels].sort(
|
||||
(a, b) => getScoreForMood(a) - getScoreForMood(b),
|
||||
);
|
||||
const byA1 = [...labels].sort((a, b) => getA1ForMood(a) - getA1ForMood(b));
|
||||
expect(byA1).toEqual(byScore);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,15 @@ const moodEntrySchema = z
|
||||
date: z.string().min(1),
|
||||
mood: z.string().min(1),
|
||||
score: z.number().int().min(0).max(10),
|
||||
// The five level-A values. Optional and nullable: a file written before
|
||||
// they existed carries none, and an entry whose sliders were never touched
|
||||
// carries nulls. Absent restores as NULL, never as a defaulted midpoint —
|
||||
// a restore that invents an answer is worse than one that admits none.
|
||||
a1: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a2: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a3: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a4: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a5: z.number().int().min(0).max(10).nullable().optional(),
|
||||
tags: moodEntryTagsSchema,
|
||||
// The free text, in both storage shapes. A portable export carries the
|
||||
// decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus
|
||||
|
||||
@@ -46,6 +46,43 @@ export function getScoreForMood(mood: string): number {
|
||||
return MOOD_SCORE_MAP[mood] ?? 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five-point mood label mapped onto the level-A pleasantness scale (A1).
|
||||
*
|
||||
* The quick check-in asks one question and offers five faces, and it stays a
|
||||
* full-value capture path: its answer produces a real A1 rather than a
|
||||
* degraded one. This is the single place those five numbers exist. A second
|
||||
* copy anywhere in the tree — including inside a migration — is a drift
|
||||
* waiting to happen, which is why the backfill's SQL is read back and compared
|
||||
* against this map by a test rather than trusted to stay in step.
|
||||
*
|
||||
* The endpoints are 1 and 9, not 0 and 10. "LAUSIG" means as bad as this
|
||||
* instrument can express, not as bad as a person can feel; pinning it to 0
|
||||
* would make every mapped historical entry look more extreme than every future
|
||||
* hand-set one and would leave the true extremes unreachable. 5 is the
|
||||
* concept's neutral midpoint and is where "OKAY" belongs.
|
||||
*/
|
||||
const MOOD_A1_MAP: Record<string, number> = {
|
||||
SUPER_GUT: 9,
|
||||
GUT: 7,
|
||||
OKAY: 5,
|
||||
SCHLECHT: 3,
|
||||
LAUSIG: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* A1 for a five-point mood label. An unrecognised label answers the neutral
|
||||
* midpoint, matching what `getScoreForMood` answers for the same input.
|
||||
*/
|
||||
export function getA1ForMood(mood: string): number {
|
||||
return MOOD_A1_MAP[mood] ?? 5;
|
||||
}
|
||||
|
||||
/** Read-only view of the A1 map, for the guards that pin the backfill to it. */
|
||||
export function moodA1Map(): Readonly<Record<string, number>> {
|
||||
return MOOD_A1_MAP;
|
||||
}
|
||||
|
||||
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
|
||||
// Additive alongside the flat free-text `tags`: an entry can carry both.
|
||||
// Bounded so a single create can't fan out an unbounded link set.
|
||||
@@ -69,6 +106,14 @@ const ratedFactor = z.object({
|
||||
});
|
||||
const ratedFactors = z.array(ratedFactor).max(30);
|
||||
|
||||
// v1.37 — the five level-A self-state values, each 0-10. Optional on every
|
||||
// path: a client that sends only a five-point label still writes a complete
|
||||
// entry, because the server derives A1 from the label. A2 to A5 are only ever
|
||||
// what the user set, so an omitted one stays absent rather than defaulting to
|
||||
// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS`
|
||||
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
|
||||
const levelADimension = z.number().int().min(0).max(10);
|
||||
|
||||
export const createMoodEntrySchema = z
|
||||
.object({
|
||||
mood: moodLevelEnum,
|
||||
@@ -83,6 +128,19 @@ export const createMoodEntrySchema = z
|
||||
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
|
||||
// rejected (422) / dropped server-side per the catalog.
|
||||
ratedFactors: ratedFactors.optional(),
|
||||
// v1.37 — the five level-A values the detail sliders capture. Three
|
||||
// states, and they mean three different things, because this create can
|
||||
// land on the `update:` arm of an `externalId` upsert:
|
||||
// absent — nothing to say; a stored answer is left alone
|
||||
// number — the answer, stored literally
|
||||
// null — take the answer back
|
||||
// `a1` is the exception: absent or null, the server derives it from
|
||||
// `mood`, so a one-tap check-in is a full entry and never a degraded one.
|
||||
a1: levelADimension.nullable().optional(),
|
||||
a2: levelADimension.nullable().optional(),
|
||||
a3: levelADimension.nullable().optional(),
|
||||
a4: levelADimension.nullable().optional(),
|
||||
a5: levelADimension.nullable().optional(),
|
||||
// v1.4.30 H-5 — first-class free-text note. Replaces the
|
||||
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
|
||||
// Coach evidence shelf renders cleanly without truncating chips.
|
||||
@@ -118,6 +176,15 @@ export const updateMoodEntrySchema = z.object({
|
||||
// v1.12.0 — full replacement of the rated-factor set when present.
|
||||
// `null` clears every rated link; omit to leave them untouched.
|
||||
ratedFactors: ratedFactors.nullable().optional(),
|
||||
// v1.37 — level-A values on the edit path. Omitted leaves the stored value
|
||||
// alone; an explicit `null` clears it, which is how a user takes back an
|
||||
// answer they did not mean to give. Editing the five-point label without
|
||||
// sending `a1` re-derives A1, the same way it re-derives `score`.
|
||||
a1: levelADimension.nullable().optional(),
|
||||
a2: levelADimension.nullable().optional(),
|
||||
a3: levelADimension.nullable().optional(),
|
||||
a4: levelADimension.nullable().optional(),
|
||||
a5: levelADimension.nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
// v1.17 W1b — same plausibility bound on the edit path.
|
||||
moodLoggedAt: validateEntryInstant(
|
||||
|
||||
Reference in new issue
Block a user