mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
45cae42774
commit
9a390aacea
5 files changed
+335
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user