Merge pull request #735 from MBombeck/feat/mood-level-a

Five dimensions behind the mood check-in
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 04:29:27 +02:00
commit 8045540b7e
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
+11
View File
@@ -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,
+33 -2
View File
@@ -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
+8
View File
@@ -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),
+31 -2
View File
@@ -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
+1
View File
@@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
export { BbtChart } from "@/components/cycle/bbt-chart";
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
@@ -0,0 +1,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"');
+47 -35
View File
@@ -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
+194
View File
@@ -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>
);
}
+36
View File
@@ -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">
+120
View File
@@ -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"');
});
});
+167
View File
@@ -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 };
+48
View File
@@ -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([
"",
"",
"",
"",
"",
]);
});
});
+22
View File
@@ -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";
+15
View File
@@ -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 ?? "",
}));
}
+14
View File
@@ -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);
});
});
+186
View File
@@ -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,
+49
View File
@@ -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,
+138
View File
@@ -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,
};
});
}
+5
View File
@@ -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,
+62
View File
@@ -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();
});
});
+5
View File
@@ -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),
+135
View File
@@ -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);
}
+152
View File
@@ -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);
});
});
+9
View File
@@ -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
+67
View File
@@ -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(