From 9a390aaceadd49d3eca1065d758689c16133a571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:36:09 +0200 Subject: [PATCH 01/19] feat(mood): add the level-A dimension table and the A1 derivation map - MOOD_DIMENSIONS carries the five self-state dimensions in capture order, each with its column, its label and anchor keys, its orientation and its 0-10 bounds - getA1ForMood maps the five-point label onto the pleasantness scale once, so the quick check-in stays a full-value capture path - only stress is inverse-oriented, and a test pins that count - the secret scan reads a dotted message key on a *Key field as a token; allowlist the dimension keys the way the existing message keys are --- .gitleaks.toml | 4 + src/lib/mood/__tests__/dimensions.test.ts | 87 +++++++++++ src/lib/mood/dimensions.ts | 138 ++++++++++++++++++ .../validations/__tests__/mood-a1-map.test.ts | 69 +++++++++ src/lib/validations/mood.ts | 37 +++++ 5 files changed, 335 insertions(+) create mode 100644 src/lib/mood/__tests__/dimensions.test.ts create mode 100644 src/lib/mood/dimensions.ts create mode 100644 src/lib/validations/__tests__/mood-a1-map.test.ts diff --git a/.gitleaks.toml b/.gitleaks.toml index 1b98529ce..b5d9433d0 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -28,6 +28,10 @@ regexes = [ # Coach cadence-suggestion label keys are dotted message keys, not secrets; # one trips the generic-api-key entropy heuristic. '''coach\.reminderSuggestion\.cadence''', + # Level-A mood dimension label and anchor keys are dotted message keys, not + # secrets; they sit on fields named `labelKey` / `lowAnchorKey`, and the + # generic-api-key rule reads any `*Key: ""` as a token. + '''mood\.dimension\.''', ] # Specific commit fingerprints from prior false-positive findings (kept for reproducibility). diff --git a/src/lib/mood/__tests__/dimensions.test.ts b/src/lib/mood/__tests__/dimensions.test.ts new file mode 100644 index 000000000..5782f1900 --- /dev/null +++ b/src/lib/mood/__tests__/dimensions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + MOOD_DIMENSIONS, + MOOD_DIMENSION_MAX, + MOOD_DIMENSION_MIN, + getMoodDimension, + orientForComparison, +} 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(); + }); +}); + +describe("orientForComparison", () => { + const a1 = MOOD_DIMENSIONS[0]; + const a2 = MOOD_DIMENSIONS[1]; + + it("leaves an upright dimension alone", () => { + expect(orientForComparison(a1, 0)).toBe(0); + expect(orientForComparison(a1, 7)).toBe(7); + expect(orientForComparison(a1, 10)).toBe(10); + }); + + it("flips the inverse dimension around the scale", () => { + expect(orientForComparison(a2, 0)).toBe(10); + expect(orientForComparison(a2, 3)).toBe(7); + expect(orientForComparison(a2, 10)).toBe(0); + }); + + it("answers null for an absent value rather than a neutral one", () => { + expect(orientForComparison(a1, null)).toBeNull(); + expect(orientForComparison(a1, undefined)).toBeNull(); + expect(orientForComparison(a2, null)).toBeNull(); + expect(orientForComparison(a2, Number.NaN)).toBeNull(); + }); +}); diff --git a/src/lib/mood/dimensions.ts b/src/lib/mood/dimensions.ts new file mode 100644 index 000000000..31f6b6ba9 --- /dev/null +++ b/src/lib/mood/dimensions.ts @@ -0,0 +1,138 @@ +/** + * 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; + +/** 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( + 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); +} + +/** + * Turn a stored value into one where "up" reads as "better". + * + * The only legal way to normalise an inverse dimension. Returns `null` for an + * absent value, because an unanswered dimension is not a neutral one — the + * whole point of leaving A2..A5 nullable is that absence stays readable as + * absence. + */ +export function orientForComparison( + dimension: MoodDimension, + value: number | null | undefined, +): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return dimension.inverse ? dimension.max - value : value; +} diff --git a/src/lib/validations/__tests__/mood-a1-map.test.ts b/src/lib/validations/__tests__/mood-a1-map.test.ts new file mode 100644 index 000000000..86aae60c3 --- /dev/null +++ b/src/lib/validations/__tests__/mood-a1-map.test.ts @@ -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); + }); +}); diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index 117e98a17..c5d88c745 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -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 = { + 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> { + 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. From ad4f5c04f0e5c4b21f41c22902b900b96fe9768a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:37:41 +0200 Subject: [PATCH 02/19] feat(mood): add the five level-A columns to the mood entry - migration 0310 adds mood_a1, stress_a2, energy_a3, connection_a4 and stability_a5, all nullable, no table rewrite - the schema comment states the four things a later reader needs: score keeps its 1-5 axis, the columns are queryable plaintext on purpose, stress is stored literally, and a read delegate sees them by design - the backup guard is red until the carriers land alongside --- .../migration.sql | 21 +++++++++++ prisma/schema.prisma | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 prisma/migrations/0310_mood_level_a_dimensions/migration.sql diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql new file mode 100644 index 000000000..173bc03f2 --- /dev/null +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -0,0 +1,21 @@ +-- v1.37 — the five level-A self-state values on a mood entry. +-- +-- A mood entry has carried one 1-5 value since the first release. That value +-- keeps its scale and its meaning; these five ride beside it, each 0-10 with +-- its own anchors: pleasantness, stress, energy, connectedness and stability. +-- +-- All five are nullable, which is what makes this an additive change rather +-- than a table rewrite, and nullable is also the honest shape: only the first +-- one can be derived from what a row already carries, and the other four stay +-- empty until somebody answers them. A default would fill a hundred thousand +-- historical rows with a number nobody chose. +-- +-- No CHECK constraint on the range. Bounds are enforced server-side, the same +-- way `mood_entry_tag_links.rating` documents: a future change to the scale +-- must not strand rows written under the old one. +ALTER TABLE "mood_entries" + ADD COLUMN "mood_a1" INTEGER, + ADD COLUMN "stress_a2" INTEGER, + ADD COLUMN "energy_a3" INTEGER, + ADD COLUMN "connection_a4" INTEGER, + ADD COLUMN "stability_a5" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa1aeaa28..169a853d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4016,6 +4016,41 @@ model MoodEntry { date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 + /// v1.37 — the five level-A self-state values, each 0-10 with its own + /// anchors. `MOOD_DIMENSIONS` (`src/lib/mood/dimensions.ts`) is the one + /// table binding a column to its label, its anchors and its orientation. + /// + /// `score` above stays the 1-5 legacy axis and is not re-scaled: eleven + /// surfaces derive numbers from it on that assumption, three of them into + /// prose an assistant then states as fact. The two axes coexist by design. + /// `moodA1` is derived from the five-point label on every write, so a quick + /// check-in is a full entry rather than a degraded one; A2 to A5 are only + /// ever set by hand, and stay NULL when nobody answered them. Absence reads + /// as absence — a fabricated middle value would poison both the trend and + /// any later model trained on these columns. + /// + /// Queryable plaintext, deliberately — the same trade `CycleDayLog` + /// documents at its `basalBodyTempC`. Encrypting them would remove the + /// trend read, the factor crosstab and every aggregate at once, and the + /// values are self-ratings on a fixed scale rather than prose. The free-text + /// note beside them stays encrypted, because that is where the prose is. + /// + /// `stressA2` is stored literally, exactly as the user set it against the + /// anchors they read, so a higher value means more stress and a worse day. + /// A surface that needs "up is better" flips the sign through the `inverse` + /// flag on the dimension table, never inline. The same rule already governs + /// `MoodTag.inverse`, and it exists because a stored value that disagrees + /// with the label the user saw cannot be recovered afterwards. + /// + /// A delegate holding a READ grant sees these columns, because a grant is + /// whole-record by design and carries no per-domain scope. That is + /// deliberate, not an oversight: withholding part of a record from a + /// delegate would be a new capability, not a field select. + moodA1 Int? @map("mood_a1") // 0-10, pleasantness + stressA2 Int? @map("stress_a2") // 0-10, inverse-oriented + energyA3 Int? @map("energy_a3") // 0-10 + connectionA4 Int? @map("connection_a4") // 0-10 + stabilityA5 Int? @map("stability_a5") // 0-10 tags String? // JSON array of tag keys /// v1.4.30 — first-class free-text note. Replaces the /// `tags: ["note:"]` workaround the iOS app shipped through From a1c78eceef0afb9aeefb764b3aa20c20811f6cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:49:15 +0200 Subject: [PATCH 03/19] feat(mood): derive pleasantness on every write path - one helper resolves the level-A values for a write; the five writers call it instead of carrying their own copy of the derivation - the create schema takes a1 to a5, and a value the user set wins over the derived one; the batch request shape is untouched - both arms of both upserts restate the values, so a re-post cannot leave a stale reading beside a mood that has since changed - the JSON import derives from the row's own label rather than the file's score, which is the one place the two can disagree - correcting an entry's label re-derives pleasantness the way it re-derives the score; an explicit null takes an answer back - a round trip through all five writers against real Postgres asserts the rows agree and that nothing invents the other four values --- src/app/api/import/route.ts | 11 + src/app/api/mood-entries/[id]/route.ts | 22 +- src/app/api/mood-entries/bulk/route.ts | 8 + src/app/api/mood-entries/route.ts | 37 ++ src/lib/mcp/writes.ts | 5 + src/lib/mood/create-from-telegram.ts | 5 + src/lib/mood/level-a.ts | 100 ++++++ src/lib/validations/mood.ts | 25 ++ tests/integration/mood-writers-agree.test.ts | 343 +++++++++++++++++++ 9 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 src/lib/mood/level-a.ts create mode 100644 tests/integration/mood-writers-agree.test.ts diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index f1c32149f..e81f51a54 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index b6f1c4412..f6b01ba4d 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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, levelAForWire } 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,12 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) }); + return apiSuccess({ + ...shapeMoodNote(entry), + // v1.37 — level-A values under the keys the write path takes. + ...levelAForWire(entry), + tags: parseTags(entry.tags), + }); }, ); @@ -120,7 +126,19 @@ 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. + // An explicit `a1` in the same request overrides it below. + 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. + if (data.a1 !== undefined) updateData.moodA1 = data.a1; + 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 @@ -291,6 +309,8 @@ export const PUT = apiHandler( return apiSuccess({ ...entry, + // v1.37 — level-A values under the keys the write path takes. + ...levelAForWire(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 diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index a8f3bd3d2..938be2a60 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { 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 { 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 { // the same row. mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e92baa7d1..b03b3bb04 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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 { levelAForWire, levelAForWrite } from "@/lib/mood/level-a"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -126,6 +127,10 @@ 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. + ...levelAForWire(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,11 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -272,11 +293,20 @@ async function postMoodEntry(request: NextRequest) { externalId, moodLoggedAt, }, + // A re-post replaces the row, so the level-A values are restated + // here too. Omitting them would leave a stress reading from an + // earlier post sitting beside a mood the user has since changed + // — the same staleness the `score` restatement already avoids. update: { date, tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -290,6 +320,11 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -375,6 +410,8 @@ async function postMoodEntry(request: NextRequest) { { // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. ...shapeMoodNote(entry), + // v1.37 — level-A values under their wire keys (see the list read). + ...levelAForWire(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 diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 83183d2db..a25903f19 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -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, diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index 1e1c9371d..3b5c4d3eb 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -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), diff --git a/src/lib/mood/level-a.ts b/src/lib/mood/level-a.ts new file mode 100644 index 000000000..15505eb75 --- /dev/null +++ b/src/lib/mood/level-a.ts @@ -0,0 +1,100 @@ +/** + * 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`. */ +export interface LevelAColumns { + moodA1: number | null; + 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 four NULLs. 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. + * + * Every field is stated explicitly, including the NULLs, because the callers + * are upserts: on the `update:` arm an omitted field keeps a value from an + * earlier post, and a stale stress reading sitting beside a mood the user just + * changed is exactly the mismatch these columns exist to avoid. Callers whose + * write semantics are per-field rather than whole-row (the PATCH route) build + * their own object and use `deriveA1` directly. + */ +export function levelAForWrite( + mood: string, + explicit?: LevelAInput, +): LevelAColumns { + return { + moodA1: explicit?.a1 ?? getA1ForMood(mood), + stressA2: explicit?.a2 ?? null, + energyA3: explicit?.a3 ?? null, + connectionA4: explicit?.a4 ?? null, + stabilityA5: explicit?.a5 ?? null, + }; +} + +/** 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; +} + +/** + * 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: { + moodA1: number | null; + stressA2: number | null; + energyA3: number | null; + connectionA4: number | null; + stabilityA5: number | null; +}): LevelAWire { + return { + a1: row.moodA1, + a2: row.stressA2, + a3: row.energyA3, + a4: row.connectionA4, + a5: row.stabilityA5, + }; +} diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index c5d88c745..6c58ac819 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -106,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, @@ -120,6 +128,14 @@ 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. Absent + // means the user did not answer; `a1` absent means the server derives it + // from `mood`, which keeps the one-tap check-in a full entry. + a1: levelADimension.optional(), + a2: levelADimension.optional(), + a3: levelADimension.optional(), + a4: levelADimension.optional(), + a5: levelADimension.optional(), // v1.4.30 H-5 — first-class free-text note. Replaces the // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. @@ -155,6 +171,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( diff --git a/tests/integration/mood-writers-agree.test.ts b/tests/integration/mood-writers-agree.test.ts new file mode 100644 index 000000000..feb797bd3 --- /dev/null +++ b/tests/integration/mood-writers-agree.test.ts @@ -0,0 +1,343 @@ +/** + * v1.37 — every mood writer resolves the same pleasantness value. + * + * Five paths write a `MoodEntry`, and only one of them is the web form. If + * the derivation lived at the call site, a mood tapped in a chat and the same + * mood typed into the form would drift apart the first time one of the five + * was edited without the others. This test posts the identical mood through + * all five and asserts the rows agree, against real Postgres and the real + * handlers: + * + * 1. POST /api/mood-entries (web + iOS single) + * 2. POST /api/mood-entries/bulk (iOS backfill) + * 3. logTelegramMood (the bot) + * 4. POST /api/import (JSON restore) + * 5. logMcpMood (the assistant tool) + * + * It also pins the three rules the columns exist for: a re-post refreshes the + * value rather than leaving a stale one beside a fresh mood, an explicit + * client value beats the derivation, and nothing ever invents A2 to A5. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-mood-writers-agree"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function createUserSession() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "mood-writers-agree", + email: "mood-writers-agree@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: USER, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function jsonRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("mood writers agree on the derived level-A value", () => { + it("writes the same moodA1 through all five writers for the same mood", async () => { + await createUserSession(); + + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + const { POST: postImport } = await import("@/app/api/import/route"); + const { logTelegramMood } = await import("@/lib/mood/create-from-telegram"); + const { logMcpMood } = await import("@/lib/mcp/writes"); + + // 1 — single create. + const single = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "writer-single", + }), + ); + expect(single.status).toBe(201); + + // 2 — bulk backfill. The request shape carries no level-A field; the + // server derives it, which is the whole point of this phase being + // invisible on the wire. + const bulk = await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + externalId: "writer-bulk", + }, + ], + }), + ); + expect(bulk.status).toBe(200); + + // 3 — the bot. Its input is the 1-5 score; it resolves the label itself. + const telegram = await logTelegramMood({ + userId: USER, + score: 4, + tz: "Europe/Berlin", + externalId: "telegram:mood:1:2:4", + }); + expect(telegram.created).toBe(true); + + // 4 — the JSON import. Deliberately carries a `score` that disagrees with + // its own label: this is the one writer whose score comes out of the file + // rather than out of the label, and the self-assessment column has to + // follow the label the user picked, not the number the file asserts. + const imported = await postImport( + jsonRequest("/api/import", { + moodEntries: [ + { + date: "2026-05-16", + mood: "GUT", + score: 2, + loggedAt: "2026-05-16T10:00:00.000Z", + externalId: "writer-import", + }, + ], + }), + ); + expect(imported.status).toBe(200); + + // 5 — the assistant tool. + const mcp = await logMcpMood({ + userId: USER, + score: 4, + tz: "Europe/Berlin", + idempotencyKey: "writer-mcp-1", + }); + expect(mcp.status).toBe("written"); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + orderBy: { source: "asc" }, + select: { + source: true, + mood: true, + score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + }, + }); + + // Five writers, five rows — and one of them is the bulk path, which must + // not have been folded into the single path's row. + expect(rows).toHaveLength(5); + expect(rows.map((r) => r.source).sort()).toEqual([ + "IMPORT", + "MANUAL", + "MANUAL", + "MCP", + "TELEGRAM", + ]); + + for (const row of rows) { + expect(row.mood).toBe("GUT"); + // GUT sits at 7 on the level-A scale. Asserted by value on every row, + // not by comparing the rows to each other: five rows agreeing on a + // wrong number would pass a same-value assertion. + expect(row.moodA1).toBe(7); + // Nothing derives the other four, on any path. + expect(row.stressA2).toBeNull(); + expect(row.energyA3).toBeNull(); + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + } + + // The imported row keeps the file's own score while its level-A value + // follows the label — the disagreement the file carried is not copied + // into the new column. + const importedRow = rows.find((r) => r.source === "IMPORT"); + expect(importedRow?.score).toBe(2); + expect(importedRow?.moodA1).toBe(7); + }); + + it("refreshes moodA1 when an externalId is re-posted with a different mood", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "repost-single", + }), + ); + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:10:00.000Z", + externalId: "repost-single", + }), + ); + + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T11:00:00.000Z", + externalId: "repost-bulk", + }, + ], + }), + ); + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T11:10:00.000Z", + externalId: "repost-bulk", + }, + ], + }), + ); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + orderBy: { externalId: "asc" }, + select: { externalId: true, mood: true, score: true, moodA1: true }, + }); + + // One row per externalId — the re-post updated in place, as before. + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.mood).toBe("SUPER_GUT"); + expect(row.score).toBe(5); + // The update arm of both upserts restates the value. Without it the row + // would carry LAUSIG's 1 beside SUPER_GUT's 5. + expect(row.moodA1).toBe(9); + } + }); + + it("lets an explicit client value win over the derivation, and stores the other four literally", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + + const res = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T12:00:00.000Z", + // A day that felt middling overall but was genuinely stressful: the + // whole reason five values beat one. + a1: 4, + a2: 9, + a3: 2, + }), + ); + expect(res.status).toBe(201); + const json = (await res.json()) as { + data: { a1: number; a2: number; a3: number; a4: null; a5: null }; + }; + // The response echoes the values under the keys the request used. + expect(json.data.a1).toBe(4); + expect(json.data.a2).toBe(9); + expect(json.data.a5).toBeNull(); + + const row = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER }, + }); + // Not 5 — OKAY's derived value lost to the number the user set. + expect(row.moodA1).toBe(4); + // Stored literally, higher meaning more stress. A value flipped on the + // way in could never be recovered. + expect(row.stressA2).toBe(9); + expect(row.energyA3).toBe(2); + // Untouched sliders write nothing at all. + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + // The five-point axis is untouched by any of this. + expect(row.score).toBe(3); + }); + + it("re-derives A1 when an entry's mood is corrected, and clears a value on an explicit null", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + + const created = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SCHLECHT", + moodLoggedAt: "2026-05-16T13:00:00.000Z", + a2: 8, + }), + ); + const createdJson = (await created.json()) as { data: { id: string } }; + const id = createdJson.data.id; + + const corrected = await PUT( + new NextRequest(`http://localhost/api/mood-entries/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mood: "GUT", a2: null }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(corrected.status).toBe(200); + + const row = await getPrismaClient().moodEntry.findUniqueOrThrow({ + where: { id }, + }); + expect(row.mood).toBe("GUT"); + expect(row.score).toBe(4); + // The label moved, so pleasantness moved with it — the same way `score` + // does. A stale 3 here would contradict the entry's own label. + expect(row.moodA1).toBe(7); + // An explicit null takes back an answer. + expect(row.stressA2).toBeNull(); + }); +}); From 643d8a5a00b5f407db246093b63918ad1ea911f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:03:05 +0200 Subject: [PATCH 04/19] feat(mood): map existing entries onto the pleasantness scale - the migration fills mood_a1 from the five-point label for every row that has none, and leaves the other four columns empty because there is nothing in a label to derive them from - guarded on the column being empty, so re-applying the migration is a no-op rather than an overwrite of an answer somebody gave since - the test reads the CASE arms back out of the .sql and compares them to the runtime map in both directions, so neither can drift alone - rows are asserted by value after the statement runs, and again after a second run, including one hand-set value that must survive it --- .../migration.sql | 28 +++ tests/integration/mood-a1-backfill.test.ts | 229 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/integration/mood-a1-backfill.test.ts diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql index 173bc03f2..e07fec8ee 100644 --- a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -19,3 +19,31 @@ ALTER TABLE "mood_entries" ADD COLUMN "energy_a3" INTEGER, ADD COLUMN "connection_a4" INTEGER, ADD COLUMN "stability_a5" INTEGER; + +-- Map every entry written before these columns existed onto the pleasantness +-- scale. The five-point label is the only thing a historical row can answer +-- from, so it is the only column filled: stress, energy, connectedness and +-- stability stay NULL, because there is nothing to derive them from and an +-- invented number would be indistinguishable from one somebody gave. +-- +-- The anchors are 1/3/5/7/9, matching `MOOD_A1_MAP` in +-- `src/lib/validations/mood.ts` — the runtime map and these arms are compared +-- to each other by a test, so the two cannot drift apart. Not 0 and 10 at the +-- ends: the five-point endpoints mean as bad or as good as that instrument +-- can express, not as bad or as good as a person can feel, and pinning them +-- to the extremes would make every mapped row read as more extreme than every +-- hand-set one. +-- +-- `WHERE mood_a1 IS NULL` makes a re-run a no-op instead of an overwrite. A +-- migration re-applied against a database that has since taken real answers +-- must not replace them with the mapped value. +UPDATE mood_entries + SET mood_a1 = CASE mood + WHEN 'LAUSIG' THEN 1 + WHEN 'SCHLECHT' THEN 3 + WHEN 'OKAY' THEN 5 + WHEN 'GUT' THEN 7 + WHEN 'SUPER_GUT' THEN 9 + END + WHERE mood_a1 IS NULL + AND mood IN ('LAUSIG', 'SCHLECHT', 'OKAY', 'GUT', 'SUPER_GUT'); diff --git a/tests/integration/mood-a1-backfill.test.ts b/tests/integration/mood-a1-backfill.test.ts new file mode 100644 index 000000000..a98efdac3 --- /dev/null +++ b/tests/integration/mood-a1-backfill.test.ts @@ -0,0 +1,229 @@ +/** + * v1.37 — the level-A backfill, read out of the migration and executed. + * + * The statement that maps a hundred thousand historical rows onto the + * pleasantness scale carries its own copy of five numbers, and the runtime map + * carries the other. Two copies of the same five numbers is a drift waiting to + * happen, so this test reads the arms back out of the `.sql` file and compares + * them to `MOOD_A1_MAP` arm by arm — a migration edited without the map, or a + * map edited without the migration, fails here. + * + * The rest of it executes the statement against real Postgres on rows inserted + * the way legacy rows actually look (`mood_a1 IS NULL`), and asserts the rows, + * not the fact that the statement ran. It runs a second time and asserts + * nothing moved: a migration re-applied against a database that has since + * taken real answers must leave them alone. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { moodA1Map } from "@/lib/validations/mood"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION_PATH = path.join( + process.cwd(), + "prisma", + "migrations", + "0310_mood_level_a_dimensions", + "migration.sql", +); + +const USER = "user-mood-a1-backfill"; + +function migrationSql(): string { + return readFileSync(MIGRATION_PATH, "utf8"); +} + +/** The `UPDATE mood_entries … ;` statement, comments stripped. */ +function backfillStatement(sql: string): string { + const withoutComments = sql + .split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n"); + const match = withoutComments.match(/UPDATE\s+mood_entries[\s\S]*?;/i); + if (!match) { + throw new Error( + `No UPDATE statement found in ${MIGRATION_PATH}. The backfill is the point of this test; an absent statement is a failure, not a skip.`, + ); + } + return match[0]; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("mood a1 backfill — the statement agrees with the runtime map", () => { + it("carries one CASE arm per label, with the value the map answers", () => { + const statement = backfillStatement(migrationSql()); + const arms = [ + ...statement.matchAll(/WHEN\s+'([A-Z_]+)'\s+THEN\s+(\d+)/gi), + ].map((m) => [m[1], Number(m[2])] as const); + + // An empty match set means the matcher stopped matching, not that the + // migration is correct. A guard that cannot fail is worse than none. + expect(arms.length).toBeGreaterThan(0); + + const map = moodA1Map(); + expect(arms.length).toBe(Object.keys(map).length); + for (const [label, value] of arms) { + expect( + map[label], + `CASE arm '${label}' has no entry in MOOD_A1_MAP`, + ).toBe(value); + } + // And the other direction: a label in the map with no arm would leave + // those rows NULL after the backfill claimed to have covered them. + for (const label of Object.keys(map)) { + expect( + arms.map(([l]) => l), + `MOOD_A1_MAP entry '${label}' has no CASE arm in the migration`, + ).toContain(label); + } + }); + + it("is guarded on the column being empty, so a re-run cannot overwrite an answer", () => { + const statement = backfillStatement(migrationSql()); + expect(statement.replace(/\s+/g, " ")).toMatch( + /WHERE\s+mood_a1\s+IS\s+NULL/i, + ); + }); +}); + +describe("mood a1 backfill — executed against real rows", () => { + async function seedLegacyRows() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: USER, + username: "mood-a1-backfill", + email: "mood-a1-backfill@example.test", + timezone: "Europe/Berlin", + }, + }); + + // Inserted through raw SQL so the columns look exactly like a row written + // before they existed: every level-A column NULL. Going through Prisma + // would let a default or a hook fill one in and the test would be + // proving the seed rather than the migration. + const rows: Array<[string, string, number, string | null]> = [ + ["legacy-lausig", "LAUSIG", 1, null], + ["legacy-schlecht", "SCHLECHT", 2, null], + ["legacy-okay", "OKAY", 3, null], + ["legacy-gut", "GUT", 4, null], + ["legacy-super", "SUPER_GUT", 5, null], + // A tombstoned row is still the user's history and still restorable. + ["legacy-deleted", "GUT", 4, "2026-05-01T00:00:00.000Z"], + // A label nothing recognises: no arm matches, so it stays NULL rather + // than being handed the midpoint. + ["legacy-unknown", "NOT_A_MOOD", 3, null], + ]; + + for (const [index, [id, mood, score, deletedAt]] of rows.entries()) { + // An explicit instant per row, one hour apart. `NOW()` would be the + // transaction's own clock, and two inserts landing on the same tick + // collide on the `(user_id, date, mood_logged_at)` unique — a seed that + // fails on a fast machine and passes on a slow one. + const loggedAt = `2026-05-16T0${index}:00:00.000Z`; + await prisma.$executeRawUnsafe( + `INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, created_at, updated_at, sync_version, deleted_at) + VALUES ($1, $2, '2026-05-16', $3, $4, 'MANUAL', $5::timestamptz, NOW(), NOW(), NOW(), 0, $6::timestamptz)`, + id, + USER, + mood, + score, + loggedAt, + deletedAt, + ); + } + + const before = await prisma.$queryRawUnsafe>( + `SELECT COUNT(*)::bigint AS count FROM mood_entries WHERE mood_a1 IS NULL`, + ); + expect(Number(before[0].count)).toBe(rows.length); + return rows.length; + } + + it("maps every legacy row onto its anchor and leaves the other four empty", async () => { + const prisma = getPrismaClient(); + const seeded = await seedLegacyRows(); + + const changed = await prisma.$executeRawUnsafe( + backfillStatement(migrationSql()), + ); + // Every row but the unknown label — the statement's IN list excludes it. + expect(changed).toBe(seeded - 1); + + const after = await prisma.moodEntry.findMany({ + where: { userId: USER }, + orderBy: { id: "asc" }, + select: { + id: true, + mood: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + }, + }); + expect(after).toHaveLength(seeded); + + const byId = new Map(after.map((r) => [r.id, r])); + expect(byId.get("legacy-lausig")?.moodA1).toBe(1); + expect(byId.get("legacy-schlecht")?.moodA1).toBe(3); + expect(byId.get("legacy-okay")?.moodA1).toBe(5); + expect(byId.get("legacy-gut")?.moodA1).toBe(7); + expect(byId.get("legacy-super")?.moodA1).toBe(9); + // A soft-deleted row is backfilled too: it is still in the backup and + // still comes back from a restore. + expect(byId.get("legacy-deleted")?.moodA1).toBe(7); + // An unrecognised label gets nothing rather than a fabricated midpoint. + expect(byId.get("legacy-unknown")?.moodA1).toBeNull(); + + // Nothing derived the other four, on any row. + for (const row of after) { + expect(row.stressA2).toBeNull(); + expect(row.energyA3).toBeNull(); + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + } + }); + + it("changes nothing on a second run, including a value set since the first", async () => { + const prisma = getPrismaClient(); + await seedLegacyRows(); + const statement = backfillStatement(migrationSql()); + + await prisma.$executeRawUnsafe(statement); + + // Somebody answers the slider on one of the backfilled rows, contradicting + // the mapped value. A re-run must not take that answer away. + await prisma.moodEntry.update({ + where: { id: "legacy-okay" }, + data: { moodA1: 8 }, + }); + + const beforeSecond = await prisma.moodEntry.findMany({ + where: { userId: USER }, + orderBy: { id: "asc" }, + select: { id: true, moodA1: true, updatedAt: true }, + }); + + const changedOnSecondRun = await prisma.$executeRawUnsafe(statement); + expect(changedOnSecondRun).toBe(0); + + const afterSecond = await prisma.moodEntry.findMany({ + where: { userId: USER }, + orderBy: { id: "asc" }, + select: { id: true, moodA1: true, updatedAt: true }, + }); + expect(afterSecond).toEqual(beforeSecond); + expect( + afterSecond.find((r) => r.id === "legacy-okay")?.moodA1, + "the hand-set answer survived the re-run", + ).toBe(8); + }); +}); From 9e44c8754ec4c177257c5afdaa71c33c125066bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:08:27 +0200 Subject: [PATCH 05/19] feat(mood): carry the five level-A values through backup, restore and CSV - the backup select and the payload carry all five, on both the portable and the disaster-recovery arm - the restore writes what the file holds and nothing else: a file from before these columns restores them empty rather than deriving them - the CSV appends them after the existing columns so nothing shifts under a spreadsheet, and an unanswered value is an empty cell rather than a zero, which is a real answer on this scale - the round trip asserts each of the five by value, including a zero --- .../api/admin/backups/[id]/restore/route.ts | 9 ++++ src/lib/__tests__/export.test.ts | 48 +++++++++++++++++++ src/lib/export.ts | 15 ++++++ src/lib/export/full-backup-payload.ts | 14 ++++++ src/lib/validations/backup.ts | 9 ++++ .../mood-backup-round-trip.test.ts | 31 ++++++++++++ 6 files changed, 126 insertions(+) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 8ede74cac..a6d393133 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -774,6 +774,15 @@ const handler = apiHandler( date: entry.date, mood: entry.mood, score: entry.score, + // The five level-A values. `?? null` rather than a derivation: + // a file that carries none is a file from before they existed, + // and filling them in here would put a number the person never + // gave into a restored row and call it their answer. + moodA1: entry.a1 ?? null, + stressA2: entry.a2 ?? null, + energyA3: entry.a3 ?? null, + connectionA4: entry.a4 ?? null, + stabilityA5: entry.a5 ?? null, 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 diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index c4a2a7ef1..8fcb32467 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -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([ + "", + "", + "", + "", + "", + ]); + }); +}); diff --git a/src/lib/export.ts b/src/lib/export.ts index 5ed09e5a5..0dd083bab 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -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 ?? "", })); } diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9f4506de5..0d3375c99 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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(), diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3f1a981e1..374847ed1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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 diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 42c43e088..677b40f6d 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -186,6 +186,15 @@ describe("mood backup round trip", () => { date: "2026-07-19", mood: "OKAY", score: 3, + // All five level-A values, and deliberately not the ones the label + // would derive: a restore that re-derived them instead of carrying + // them would look right on this row and be wrong on every row where + // the person disagreed with their own five-point pick. + moodA1: 4, + stressA2: 9, + energyA3: 2, + connectionA4: 6, + stabilityA5: 0, tags: JSON.stringify(["free-text-tag"]), note: null, noteEncrypted: encryptNote(NOTE), @@ -268,6 +277,14 @@ describe("mood backup round trip", () => { expect(restored.updatedAt).toEqual(UPDATED_AT); expect(restored.date).toBe("2026-07-19"); expect(restored.score).toBe(3); + // Each of the five asserted by value. Zero is included on purpose: it is a + // real answer on this scale, and a carrier that treated it as absence + // would round-trip it as NULL and pass a truthiness check. + expect(restored.moodA1).toBe(4); + expect(restored.stressA2).toBe(9); + expect(restored.energyA3).toBe(2); + expect(restored.connectionA4).toBe(6); + expect(restored.stabilityA5).toBe(0); expect(restored.tags).toBe(JSON.stringify(["free-text-tag"])); expect(restored.deletedAt).toBeNull(); @@ -276,6 +293,11 @@ describe("mood backup round trip", () => { }); expect(tombstone.deletedAt).toEqual(DELETED_AT); expect(tombstone.syncVersion).toBe(5); + // The tombstone was written without level-A values and comes back without + // them. A restore filling these in would put an answer nobody gave into a + // row the person had already deleted. + expect(tombstone.moodA1).toBeNull(); + expect(tombstone.stressA2).toBeNull(); const links = await prisma.moodEntryTagLink.findMany({ where: { moodEntryId: "mood-rt-entry" }, @@ -338,6 +360,7 @@ describe("mood backup round trip", () => { expect(parsed.moodEntries[0].structuredTags).toEqual([]); expect(parsed.moodEntries[0].tz).toBeUndefined(); expect(parsed.moodEntries[0].syncVersion).toBeUndefined(); + expect(parsed.moodEntries[0].a1).toBeUndefined(); const response = await restoreFromPayload( prisma, @@ -360,6 +383,14 @@ describe("mood backup round trip", () => { expect(restored.score).toBe(4); expect(restored.externalId).toBe("moodlog-legacy"); expect(restored.deletedAt).toBeNull(); + // A file from before these columns carries no answer for them, and the + // restore does not invent one. `GUT` would derive to 7; a restore that + // derived instead of carrying would write a number this file never held. + expect(restored.moodA1).toBeNull(); + expect(restored.stressA2).toBeNull(); + expect(restored.energyA3).toBeNull(); + expect(restored.connectionA4).toBeNull(); + expect(restored.stabilityA5).toBeNull(); const links = await prisma.moodEntryTagLink.findMany({ where: { moodEntryId: "mood-rt-legacy" }, From 7eaede257e685a2ab02668be1319604b6ae90ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:12:52 +0200 Subject: [PATCH 06/19] feat(ui): add a slider primitive for bounded numeric entry - Slider wraps the Radix control the way Switch and Progress already do; no new dependency - SliderField adds the label, the value and the two scale anchors, which are what make a 7 mean anything - a null value renders as unanswered rather than as the value the parked thumb sits on, and zero renders as the answer it is - the announced text carries the scale, not only the number --- src/components/ui/__tests__/slider.test.tsx | 120 ++++++++++++++ src/components/ui/slider.tsx | 167 ++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/components/ui/__tests__/slider.test.tsx create mode 100644 src/components/ui/slider.tsx diff --git a/src/components/ui/__tests__/slider.test.tsx b/src/components/ui/__tests__/slider.test.tsx new file mode 100644 index 000000000..1908fc897 --- /dev/null +++ b/src/components/ui/__tests__/slider.test.tsx @@ -0,0 +1,120 @@ +/** + * `` / `` — 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> = {}) { + return renderToStaticMarkup( + {}} + emptyLabel="nicht angegeben" + valueText="7 von 10" + lowAnchor="vollkommen entspannt" + highAnchor="extrem belastet" + {...props} + />, + ); +} + +describe("", () => { + it("renders the Radix parts under their slots", () => { + const html = renderToStaticMarkup( + , + ); + 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( + , + ); + 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("", () => { + 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"'); + }); +}); diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 000000000..53ed01642 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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 & { + /** + * 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 ( + + + + + + + ); +} + +/** + * 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, + "value" | "onValueChange" | "min" | "max" | "step" | "className" +>) { + const unset = value === null; + return ( +
+
+ {/* Content colour for the label and the value, muted for the anchors + below: the anchors are the legend, the value is the answer. */} + {label} + + + {unset ? emptyLabel : value} + + {!unset && onClear ? ( + + ) : null} + +
+ onValueChange(next[0])} + min={min} + max={max} + step={step} + thumbLabel={label} + valueText={unset ? emptyLabel : valueText} + {...props} + /> +
+ {lowAnchor} + {highAnchor} +
+
+ ); +} + +export { Slider, SliderField }; From 97582dedc0774aae1cbe116a455229f71b572e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:24:04 +0200 Subject: [PATCH 07/19] feat(mood): capture the five values without lengthening the quick check-in - the five faces are untouched: pick one, save, done in a tap - a closed section below them opens onto five sliders; picking a face suggests a pleasantness value and moving that slider takes the suggestion over, while the face selection stays where it is - an untouched slider writes nothing, and clearing one takes the answer back rather than leaving a middle value behind - the list's edit dialog shows and changes the same five, so nothing is written on one screen and invisible on the other - correcting an entry no longer re-derives pleasantness when the label did not move, which used to overwrite a hand-set value on any edit - factor ratings are no longer offered on a new entry and nothing was deleted: existing ratings still render, edit and feed the crosstab - six locales, and the dimension table's keys are declared dynamic --- messages/de.json | 35 +++- messages/en.json | 35 +++- messages/es.json | 35 +++- messages/fr.json | 35 +++- messages/it.json | 35 +++- messages/pl.json | 35 +++- prisma/schema.prisma | 10 + src/__tests__/i18n-reverse-coverage.test.ts | 6 + src/app/api/mood-entries/[id]/route.ts | 9 +- .../mood/__tests__/mood-qol-fixes.test.tsx | 58 ++++-- src/components/mood/mood-form.tsx | 82 ++++---- src/components/mood/mood-level-a-fields.tsx | 186 ++++++++++++++++++ src/components/mood/mood-list.tsx | 36 ++++ 13 files changed, 530 insertions(+), 67 deletions(-) create mode 100644 src/components/mood/mood-level-a-fields.tsx diff --git a/messages/de.json b/messages/de.json index 455bad74f..b22c5e078 100644 --- a/messages/de.json +++ b/messages/de.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Sonstiges" }, "sectionMoreTags": "Weitere Tags", - "sectionFactors": "Faktoren", "sectionNoteDetails": "Notiz & Details", "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", "quickTagsExpand": "Weitere", - "loadError": "Stimmungseinträge konnten nicht geladen werden." + "loadError": "Stimmungseinträge konnten nicht geladen werden.", + "sectionLevelA": "Fünf Skalen", + "sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.", + "dimensionNotAnswered": "nicht angegeben", + "dimensionClear": "leeren", + "dimensionValueText": "{value} von {max}: {low} bis {high}", + "dimension": { + "a1": { + "label": "Stimmung", + "low": "sehr unangenehm", + "high": "sehr angenehm" + }, + "a2": { + "label": "Stress", + "low": "vollkommen entspannt", + "high": "extrem belastet" + }, + "a3": { + "label": "Energie", + "low": "erschöpft", + "high": "energiegeladen" + }, + "a4": { + "label": "Verbundenheit", + "low": "isoliert", + "high": "verstanden und unterstützt" + }, + "a5": { + "label": "Stabilität", + "low": "vollständig überfordert", + "high": "sehr handlungsfähig" + } + } }, "medications": { "title": "Medikamente", diff --git a/messages/en.json b/messages/en.json index 2041766c0..107c00816 100644 --- a/messages/en.json +++ b/messages/en.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Other" }, "sectionMoreTags": "More tags", - "sectionFactors": "Factors", "sectionNoteDetails": "Note & details", "quickTagsHelp": "Your frequent tags — one tap", "quickTagsExpand": "More", - "loadError": "Could not load your mood entries." + "loadError": "Could not load your mood entries.", + "sectionLevelA": "Five scales", + "sectionLevelAHelp": "Optional. Anything you leave alone stays empty.", + "dimensionNotAnswered": "not recorded", + "dimensionClear": "clear", + "dimensionValueText": "{value} of {max}: {low} to {high}", + "dimension": { + "a1": { + "label": "Mood", + "low": "very unpleasant", + "high": "very pleasant" + }, + "a2": { + "label": "Stress", + "low": "completely relaxed", + "high": "extremely strained" + }, + "a3": { + "label": "Energy", + "low": "drained", + "high": "full of energy" + }, + "a4": { + "label": "Connectedness", + "low": "isolated", + "high": "understood and supported" + }, + "a5": { + "label": "Stability", + "low": "completely overwhelmed", + "high": "fully able to act" + } + } }, "medications": { "title": "Medications", diff --git a/messages/es.json b/messages/es.json index bafa72e6e..fbd062826 100644 --- a/messages/es.json +++ b/messages/es.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Otros" }, "sectionMoreTags": "Más etiquetas", - "sectionFactors": "Factores", "sectionNoteDetails": "Nota y detalles", "quickTagsHelp": "Tus etiquetas frecuentes: un toque", "quickTagsExpand": "Más", - "loadError": "No se pudieron cargar tus registros de estado de ánimo." + "loadError": "No se pudieron cargar tus registros de estado de ánimo.", + "sectionLevelA": "Cinco escalas", + "sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.", + "dimensionNotAnswered": "sin indicar", + "dimensionClear": "borrar", + "dimensionValueText": "{value} de {max}: {low} a {high}", + "dimension": { + "a1": { + "label": "Estado de ánimo", + "low": "muy desagradable", + "high": "muy agradable" + }, + "a2": { + "label": "Estrés", + "low": "completamente relajado", + "high": "extremadamente tenso" + }, + "a3": { + "label": "Energía", + "low": "agotado", + "high": "lleno de energía" + }, + "a4": { + "label": "Conexión", + "low": "aislado", + "high": "comprendido y apoyado" + }, + "a5": { + "label": "Estabilidad", + "low": "completamente desbordado", + "high": "con plena capacidad de acción" + } + } }, "medications": { "title": "Medicamentos", diff --git a/messages/fr.json b/messages/fr.json index 5324f563a..632b225ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Autres" }, "sectionMoreTags": "Plus de tags", - "sectionFactors": "Facteurs", "sectionNoteDetails": "Note et détails", "quickTagsHelp": "Vos tags fréquents — en un geste", "quickTagsExpand": "Plus", - "loadError": "Impossible de charger vos entrées d'humeur." + "loadError": "Impossible de charger vos entrées d'humeur.", + "sectionLevelA": "Cinq échelles", + "sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.", + "dimensionNotAnswered": "non renseigné", + "dimensionClear": "effacer", + "dimensionValueText": "{value} sur {max} : {low} à {high}", + "dimension": { + "a1": { + "label": "Humeur", + "low": "très désagréable", + "high": "très agréable" + }, + "a2": { + "label": "Stress", + "low": "parfaitement détendu", + "high": "extrêmement tendu" + }, + "a3": { + "label": "Énergie", + "low": "épuisé", + "high": "plein d'énergie" + }, + "a4": { + "label": "Lien social", + "low": "isolé", + "high": "compris et soutenu" + }, + "a5": { + "label": "Stabilité", + "low": "complètement dépassé", + "high": "pleinement capable d'agir" + } + } }, "medications": { "title": "Médicaments", diff --git a/messages/it.json b/messages/it.json index 38af4fc16..3a558c56d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Altro" }, "sectionMoreTags": "Altri tag", - "sectionFactors": "Fattori", "sectionNoteDetails": "Nota e dettagli", "quickTagsHelp": "I tuoi tag frequenti: un tocco", "quickTagsExpand": "Altro", - "loadError": "Impossibile caricare le voci dell'umore." + "loadError": "Impossibile caricare le voci dell'umore.", + "sectionLevelA": "Cinque scale", + "sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.", + "dimensionNotAnswered": "non indicato", + "dimensionClear": "cancella", + "dimensionValueText": "{value} su {max}: da {low} a {high}", + "dimension": { + "a1": { + "label": "Umore", + "low": "molto spiacevole", + "high": "molto piacevole" + }, + "a2": { + "label": "Stress", + "low": "completamente rilassato", + "high": "estremamente sotto pressione" + }, + "a3": { + "label": "Energia", + "low": "esausto", + "high": "pieno di energia" + }, + "a4": { + "label": "Connessione", + "low": "isolato", + "high": "capito e sostenuto" + }, + "a5": { + "label": "Stabilità", + "low": "completamente sopraffatto", + "high": "pienamente capace di agire" + } + } }, "medications": { "title": "Farmaci", diff --git a/messages/pl.json b/messages/pl.json index 702ad81c5..872cad733 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Inne" }, "sectionMoreTags": "Więcej tagów", - "sectionFactors": "Czynniki", "sectionNoteDetails": "Notatka i szczegóły", "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", "quickTagsExpand": "Więcej", - "loadError": "Nie udało się załadować wpisów nastroju." + "loadError": "Nie udało się załadować wpisów nastroju.", + "sectionLevelA": "Pięć skal", + "sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.", + "dimensionNotAnswered": "nie podano", + "dimensionClear": "wyczyść", + "dimensionValueText": "{value} z {max}: od {low} do {high}", + "dimension": { + "a1": { + "label": "Nastrój", + "low": "bardzo nieprzyjemnie", + "high": "bardzo przyjemnie" + }, + "a2": { + "label": "Stres", + "low": "całkowicie odprężony", + "high": "skrajnie obciążony" + }, + "a3": { + "label": "Energia", + "low": "wyczerpany", + "high": "pełen energii" + }, + "a4": { + "label": "Więź", + "low": "odizolowany", + "high": "rozumiany i wspierany" + }, + "a5": { + "label": "Stabilność", + "low": "całkowicie przytłoczony", + "high": "w pełni zdolny do działania" + } + } }, "medications": { "title": "Leki", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 169a853d9..c8aef7520 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4213,6 +4213,16 @@ model MoodTag { /// mood *factor* the user scores per entry (e.g. work, social, sleep /// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one /// taxonomy + one ingestion path for both flavours. + /// + /// v1.37 — `RATED` is frozen, not removed. The capture sheet stopped + /// offering factor ratings when the level-A columns arrived on `MoodEntry`: + /// a 1-5 score for "work stress" and a 0-10 stress self-state are different + /// questions, and asking both on one entry invites two answers to one + /// thing. Nothing was deleted — no tag row, no link, no rating — so every + /// existing score keeps rendering, keeps editing and keeps feeding the + /// factor crosstab. Re-enabling the offer on the create sheet is a decision + /// about what to ask, not a bug fix; do not restore it because it looks + /// like an omission. kind String @default("BINARY") @map("kind") // "BINARY" | "RATED" /// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale /// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags. diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 1feac2a38..cb5a0d316 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -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 diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f6b01ba4d..9e7ea9565 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -128,8 +128,13 @@ export const PUT = apiHandler( 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. - // An explicit `a1` in the same request overrides it below. - updateData.moodA1 = deriveA1(data.mood); + // 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 diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 8670b5575..6073a01e6 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -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,38 @@ 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( - / { - expect(formSrc).toContain( - "ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,", - ); +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("offers the five level-A sliders and sends only the answered ones", () => { + expect(formSrc).toContain(" setA1Touched(true)}"); + // Nothing here defaults a value: the state starts empty and stays empty. + expect(formSrc).toContain("useState(EMPTY_LEVEL_A)"); + }); }); 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 +166,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(" { it("surfaces a toast on a rejected delete instead of failing silently", () => { expect(listSrc).toContain('import { toast } from "sonner"'); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce89a80e8..7c779cf26 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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(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([]); - // 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([]); // 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) { /> - {/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept - exactly as the existing segmented control. */} - } - summary={} - > - - + {/* 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. */} + 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 diff --git a/src/components/mood/mood-level-a-fields.tsx b/src/components/mood/mood-level-a-fields.tsx new file mode 100644 index 000000000..8b1f15cda --- /dev/null +++ b/src/components/mood/mood-level-a-fields.tsx @@ -0,0 +1,186 @@ +"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> { + const out: Partial> = {}; + 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 ( + } + open={open} + onOpenChange={onOpenChange} + summary={} + > +
+

+ {t("mood.sectionLevelAHelp")} +

+ {MOOD_DIMENSIONS.map((dimension) => { + const current = value[dimension.key]; + const low = t(dimension.lowAnchorKey); + const high = t(dimension.highAnchorKey); + return ( + { + if (dimension.key === "a1") onA1Touched?.(); + onChange({ ...value, [dimension.key]: null }); + }} + onValueChange={(next) => { + if (dimension.key === "a1") onA1Touched?.(); + onChange({ ...value, [dimension.key]: next }); + }} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 7cd920d1c..94b5d7cc9 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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([]); const [editRatedFactors, setEditRatedFactors] = useState([]); + // 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(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 = {}) { /> + {/* 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. */} + + {/* v1.8.5 (C1) — free-text note. */}