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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 00:36:09 +02:00
1 parent 45cae42774
commit 9a390aacea
5 files changed
+335

No files matched your search

+4
View File
@@ -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: "<high-entropy string>"` as a token.
'''mood\.dimension\.''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
+87
View File
@@ -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();
});
});
+138
View File
@@ -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<string, MoodDimension>(
MOOD_DIMENSIONS.map((d) => [d.key, d]),
);
/** The dimension carrying this wire key, or `undefined` for anything else. */
export function getMoodDimension(key: string): MoodDimension | undefined {
return BY_KEY.get(key);
}
/**
* 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;
}
@@ -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);
});
});
+37
View File
@@ -46,6 +46,43 @@ export function getScoreForMood(mood: string): number {
return MOOD_SCORE_MAP[mood] ?? 3;
}
/**
* The five-point mood label mapped onto the level-A pleasantness scale (A1).
*
* The quick check-in asks one question and offers five faces, and it stays a
* full-value capture path: its answer produces a real A1 rather than a
* degraded one. This is the single place those five numbers exist. A second
* copy anywhere in the tree — including inside a migration — is a drift
* waiting to happen, which is why the backfill's SQL is read back and compared
* against this map by a test rather than trusted to stay in step.
*
* The endpoints are 1 and 9, not 0 and 10. "LAUSIG" means as bad as this
* instrument can express, not as bad as a person can feel; pinning it to 0
* would make every mapped historical entry look more extreme than every future
* hand-set one and would leave the true extremes unreachable. 5 is the
* concept's neutral midpoint and is where "OKAY" belongs.
*/
const MOOD_A1_MAP: Record<string, number> = {
SUPER_GUT: 9,
GUT: 7,
OKAY: 5,
SCHLECHT: 3,
LAUSIG: 1,
};
/**
* A1 for a five-point mood label. An unrecognised label answers the neutral
* midpoint, matching what `getScoreForMood` answers for the same input.
*/
export function getA1ForMood(mood: string): number {
return MOOD_A1_MAP[mood] ?? 5;
}
/** Read-only view of the A1 map, for the guards that pin the backfill to it. */
export function moodA1Map(): Readonly<Record<string, number>> {
return MOOD_A1_MAP;
}
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
// Additive alongside the flat free-text `tags`: an entry can carry both.
// Bounded so a single create can't fan out an unbounded link set.