mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user