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.