feat(mood): the closed context vocabulary and its six locales

- one module holds the four sections, their key lists and the rating fields;
  the Zod enums, the capture UI and the analytics all read it, so a value the
  vocabulary does not name cannot be stored
- labels state facts, never judgements, and the contact circles are groups of
  people rather than a score
- a pair guard binds the arrays to all six bundles in both directions, because
  the reverse-coverage matcher sees a `mood.context.` template and stops being
  able to tell a live label from a stale one
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 06:57:36 +02:00
1 parent 7a4ee19c68
commit dbfc0d8145
8 files changed
+1005

No files matched your search

@@ -0,0 +1,144 @@
/**
* Pair guard binding `context-vocabulary.ts` to the six locale bundles.
*
* The generic i18n guards cannot carry this one. `contextValueLabelKey` builds
* its keys from a template with a literal `mood.context.` prefix, so the
* reverse-coverage matcher records the whole subtree as reachable and stops
* being able to tell a live label from a stale one — the exact degradation
* that file's own doc comment warns about, arriving here by construction
* rather than by accident. So the check that matters is written here, against
* the arrays themselves, in both directions:
*
* * every key the vocabulary names has a non-empty label in all six locales;
* * every label under those groups belongs to a key the vocabulary names.
*
* The second direction is the one that catches a value removed from the
* vocabulary whose translations were left behind, and the first is the one
* that catches a value added without them.
*
* Mutation check: drop `"garden"` from `LEISURE_CATEGORY_KEYS` and the orphan
* assertion goes red naming it in all six bundles; delete
* `mood.context.eventType.conflict` from `messages/pl.json` and the coverage
* assertion goes red naming pl.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_SECTION_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
CONTEXT_RATING_FIELDS,
contextSectionLabelKey,
contextValueLabelKey,
} from "@/lib/mood/context-vocabulary";
const MESSAGES = join(__dirname, "../../../../messages");
const LOCALES = readdirSync(MESSAGES)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
function resolve(root: Record<string, unknown>, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
root,
);
}
/** The value groups whose members come straight from a vocabulary array. */
const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
{ group: "workStatus", keys: WORK_STATUS_KEYS },
{ group: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ group: "contactForm", keys: CONTACT_FORM_KEYS },
{ group: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ group: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
{ group: "eventType", keys: EVENT_TYPE_KEYS },
];
describe("mood context vocabulary labels", () => {
it("reads six locales and a non-empty vocabulary", () => {
// Both ends have to be there before anything below proves anything. A
// guard that walks an empty list is a green light nobody earned.
expect(LOCALES.length).toBe(6);
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
expect(total).toBeGreaterThan(30);
expect(CONTEXT_RATING_FIELDS.length).toBeGreaterThan(0);
});
for (const locale of LOCALES) {
it(`${locale} carries a label for every vocabulary value`, () => {
const root = bundle(locale);
const missing: string[] = [];
for (const { group, keys } of VALUE_GROUPS) {
for (const key of keys) {
const label = resolve(root, contextValueLabelKey(group, key));
if (typeof label !== "string" || label.trim() === "") {
missing.push(contextValueLabelKey(group, key));
}
}
}
for (const section of CONTEXT_SECTION_KEYS) {
const label = resolve(root, contextSectionLabelKey(section));
if (typeof label !== "string" || label.trim() === "") {
missing.push(contextSectionLabelKey(section));
}
}
for (const field of CONTEXT_RATING_FIELDS) {
for (const anchor of ["low", "high"] as const) {
const key = `mood.context.rating.${field}.${anchor}`;
const label = resolve(root, key);
if (typeof label !== "string" || label.trim() === "") {
missing.push(key);
}
}
}
expect(
missing,
`messages/${locale}.json is missing ${missing.length} context label(s)`,
).toEqual([]);
});
it(`${locale} carries no label the vocabulary no longer names`, () => {
const root = bundle(locale);
const orphans: string[] = [];
for (const { group, keys } of VALUE_GROUPS) {
const node = resolve(root, `mood.context.${group}`);
expect(
node && typeof node === "object",
`mood.context.${group} is absent from ${locale} — this matcher would prove nothing`,
).toBe(true);
for (const present of Object.keys(node as Record<string, unknown>)) {
if (!keys.includes(present)) {
orphans.push(`mood.context.${group}.${present}`);
}
}
}
expect(
orphans,
`messages/${locale}.json carries ${orphans.length} label(s) for values the vocabulary does not name`,
).toEqual([]);
});
}
});
+207
View File
@@ -0,0 +1,207 @@
/**
* The closed vocabularies a mood entry's day context is written from.
*
* Four sections — work, contacts, leisure, a notable event — each with a small
* fixed key list and a handful of numbers. Every key here becomes three
* things at once: a string stored in somebody's row, six locale labels, and a
* feature name any later analysis names in its output. Renaming one after it
* has been stored means a data migration, six locale edits and a re-fit, so
* the list is closed and lives in exactly one file. The Zod enums derive from
* these arrays, which is what makes "a value the vocabulary does not name
* cannot be stored" a structural property rather than a convention.
*
* What is deliberately NOT here:
*
* * Sleep, activity and vitals. They already have a home, and a mood entry
* links to that home instead of asking a second time
* (`src/lib/mood/linked-context.ts`). A stored copy would drift the moment
* somebody corrected a sleep session.
* * Body symptoms. The illness module owns symptom capture and severity, and
* the mood surface links into it rather than growing a parallel form.
* * A productivity rating and a self-determination rating. Both were on the
* table and both were cut: every field a person can leave empty still
* costs a control, six translations and a model feature, and neither of
* those two earned that.
*
* Labels are neutral by rule. "Worked a regular day" is a fact; "worked too
* much" is a judgement, and a judgement about somebody's day belongs to nobody
* but them. Contact circles are groups of people and are never scored: how
* many people somebody saw is not a good or a bad number, which is why the
* quality of the contact is a separate field from the fact of it.
*/
/** Every numeric self-rating in the context shares the level-A 0-10 scale. */
export const CONTEXT_RATING_MIN = 0;
export const CONTEXT_RATING_MAX = 10;
/**
* Minutes bound for the duration fields. A day holds 1440 of them, and a
* bound is what keeps a mistyped digit from landing as a real answer that
* then drags every average it touches.
*/
export const CONTEXT_MINUTES_MIN = 0;
export const CONTEXT_MINUTES_MAX = 1440;
/** How a notable event landed, from clearly bad to clearly good. */
export const CONTEXT_VALENCE_MIN = -5;
export const CONTEXT_VALENCE_MAX = 5;
/**
* The shape of the working day. `off` covers every kind of not-working day —
* a weekend, leave, sick leave — because the reason belongs to the modules
* that own it, not to a mood entry.
*/
export const WORK_STATUS_KEYS = [
"off",
"partial",
"regular",
"overtime",
] as const;
export type WorkStatus = (typeof WORK_STATUS_KEYS)[number];
/**
* Who the day's contact was with. Multi-select: a day is rarely one circle.
* `professional` is a therapist, a doctor, a counsellor — a contact that is
* neither private nor a colleague, and one people do have on the days they
* most want to look back at.
*/
export const CONTACT_CIRCLE_KEYS = [
"partner",
"child",
"family",
"friends",
"colleagues",
"acquaintances",
"professional",
"other",
] as const;
export type ContactCircle = (typeof CONTACT_CIRCLE_KEYS)[number];
/** How the contact happened. */
export const CONTACT_FORM_KEYS = [
"inPerson",
"phone",
"video",
"written",
] as const;
export type ContactForm = (typeof CONTACT_FORM_KEYS)[number];
/** Roughly how long it lasted, in the coarse bands people actually recall. */
export const CONTACT_EXTENT_KEYS = ["brief", "medium", "extended"] as const;
export type ContactExtent = (typeof CONTACT_EXTENT_KEYS)[number];
/** What the free time was spent on. Multi-select. */
export const LEISURE_CATEGORY_KEYS = [
"film",
"reading",
"gaming",
"music",
"creative",
"outing",
"garden",
"cooking",
"timeWithChild",
"relaxation",
"other",
] as const;
export type LeisureCategory = (typeof LEISURE_CATEGORY_KEYS)[number];
/**
* The kind of thing that happened. One per entry: a day with two notable
* events is a day with two things worth writing in the note, and a list here
* would invite ticking every box rather than naming the one that mattered.
*/
export const EVENT_TYPE_KEYS = [
"goodNews",
"badNews",
"success",
"disappointment",
"conflict",
"appointment",
"familyEvent",
"change",
"unexpected",
] as const;
export type EventType = (typeof EVENT_TYPE_KEYS)[number];
/**
* Every 0-10 self-rating on the context row, in capture order.
*
* Each one has a low and a high anchor in the locale bundles
* (`mood.context.rating.<field>.{low,high}`), because a bare number on a
* ten-point scale means whatever the person reading it decides it means, and
* an anchor is what makes the value they set last week comparable to the one
* they set today. `workLoad` is inverse-oriented — higher is heavier, not
* better — and it is stored exactly as the person set it against the anchors
* they read. A surface that needs "up is better" flips the sign itself, the
* same rule `MoodEntry.stressA2` follows.
*/
export const CONTEXT_RATING_FIELDS = [
"workLoad",
"workSatisfaction",
"contactQuality",
"contactSupport",
"leisureJoy",
"leisureRecovery",
] as const;
export type ContextRatingField = (typeof CONTEXT_RATING_FIELDS)[number];
/** The rating fields where a higher number is a heavier day, not a better one. */
export const CONTEXT_INVERSE_RATING_FIELDS: readonly ContextRatingField[] = [
"workLoad",
];
/** The four sections, in capture order. */
export const CONTEXT_SECTION_KEYS = [
"work",
"contacts",
"leisure",
"events",
] as const;
export type ContextSection = (typeof CONTEXT_SECTION_KEYS)[number];
/**
* i18n key for one vocabulary value.
*
* Resolved from data rather than written at a call site, which is why
* `mood.context` sits in the reverse-coverage guard's dynamic allowlist beside
* `mood.dimension` and `mood.tag`.
*/
export function contextValueLabelKey(
field: string,
value: string,
): `mood.context.${string}.${string}` {
return `mood.context.${field}.${value}`;
}
/** i18n key for a section heading. */
export function contextSectionLabelKey(
section: ContextSection,
): `mood.context.section.${ContextSection}` {
return `mood.context.section.${section}`;
}
/**
* The multi-select lists as they are stored: a JSON array in a string column,
* matching `MoodEntry.tags`.
*
* Encoding lives here rather than at the three call sites that need it,
* because a column read by a route, a backup and an analysis is exactly the
* kind of thing that ends up with three parsers and two of them wrong.
*/
export function encodeKeyList(keys: readonly string[] | null): string | null {
if (!keys || keys.length === 0) return null;
return JSON.stringify([...keys]);
}
/** Decode a stored key list. A malformed value reads as empty, never throws. */
export function decodeKeyList(stored: string | null): string[] {
if (!stored) return [];
try {
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
return parsed.filter((v): v is string => typeof v === "string");
} catch {
return [];
}
}