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

+109
View File
@@ -922,6 +922,115 @@
"low": "vollständig überfordert",
"high": "sehr handlungsfähig"
}
},
"context": {
"section": {
"work": "Arbeit",
"contacts": "Kontakte",
"leisure": "Freizeit",
"events": "Ereignis"
},
"field": {
"workStatus": "Arbeitstag",
"workMinutes": "Arbeitszeit",
"overtimeMinutes": "Überstunden",
"workLoad": "Arbeitsbelastung",
"workSatisfaction": "Zufriedenheit mit der Arbeit",
"contactCircles": "Mit wem Kontakt bestand",
"contactForm": "Art des Kontakts",
"contactExtent": "Dauer",
"contactQuality": "Qualität des Kontakts",
"contactSupport": "Empfundene Unterstützung",
"leisureCategories": "Womit die Freizeit verbracht wurde",
"leisureMinutes": "Zeit für dich",
"leisureJoy": "Freude",
"leisureRecovery": "Erholung",
"eventType": "Was passiert ist",
"eventValence": "Wie es angekommen ist",
"eventAt": "Wann",
"notes": "Notiz"
},
"workStatus": {
"off": "Nicht gearbeitet",
"partial": "Teilweise gearbeitet",
"regular": "Regulär gearbeitet",
"overtime": "Länger als üblich"
},
"contactCircles": {
"partner": "Partnerschaft",
"child": "Kind",
"family": "Familie",
"friends": "Freundeskreis",
"colleagues": "Kollegium",
"acquaintances": "Bekannte",
"professional": "Professionelle Begleitung",
"other": "Sonstige"
},
"contactForm": {
"inPerson": "Persönlich",
"phone": "Telefon",
"video": "Videoanruf",
"written": "Schriftlich"
},
"contactExtent": {
"brief": "Kurz",
"medium": "Mittel",
"extended": "Ausgedehnt"
},
"leisureCategories": {
"film": "Film oder Serie",
"reading": "Lesen",
"gaming": "Spielen",
"music": "Musik",
"creative": "Kreatives",
"outing": "Unterwegs",
"garden": "Garten",
"cooking": "Kochen",
"timeWithChild": "Zeit mit dem Kind",
"relaxation": "Ausruhen",
"other": "Sonstiges"
},
"eventType": {
"goodNews": "Gute Nachricht",
"badNews": "Schlechte Nachricht",
"success": "Etwas ist gelungen",
"disappointment": "Eine Enttäuschung",
"conflict": "Ein Konflikt",
"appointment": "Ein Termin",
"familyEvent": "Ein Familienereignis",
"change": "Eine Veränderung",
"unexpected": "Etwas Unerwartetes"
},
"rating": {
"workLoad": {
"low": "kein Druck",
"high": "unter starkem Druck"
},
"workSatisfaction": {
"low": "gar nicht zufrieden",
"high": "sehr zufrieden"
},
"contactQuality": {
"low": "hat nicht gutgetan",
"high": "hat sehr gutgetan"
},
"contactSupport": {
"low": "nicht unterstützt",
"high": "gut unterstützt"
},
"leisureJoy": {
"low": "keine Freude",
"high": "viel Freude"
},
"leisureRecovery": {
"low": "nicht erholsam",
"high": "sehr erholsam"
},
"eventValence": {
"low": "deutlich schlecht",
"high": "deutlich gut"
}
}
}
},
"medications": {
+109
View File
@@ -922,6 +922,115 @@
"low": "completely overwhelmed",
"high": "fully able to act"
}
},
"context": {
"section": {
"work": "Work",
"contacts": "Contact",
"leisure": "Free time",
"events": "Event"
},
"field": {
"workStatus": "Working day",
"workMinutes": "Time worked",
"overtimeMinutes": "Overtime",
"workLoad": "Workload",
"workSatisfaction": "Satisfaction with the work",
"contactCircles": "Who the contact was with",
"contactForm": "Form of contact",
"contactExtent": "How long it lasted",
"contactQuality": "Quality of the contact",
"contactSupport": "Support felt",
"leisureCategories": "What the free time went into",
"leisureMinutes": "Time for yourself",
"leisureJoy": "Enjoyment",
"leisureRecovery": "Recovery",
"eventType": "What happened",
"eventValence": "How it landed",
"eventAt": "When",
"notes": "Note"
},
"workStatus": {
"off": "Not working",
"partial": "Part of the day",
"regular": "A regular day",
"overtime": "Longer than usual"
},
"contactCircles": {
"partner": "Partner",
"child": "Child",
"family": "Family",
"friends": "Friends",
"colleagues": "Colleagues",
"acquaintances": "Acquaintances",
"professional": "Professional support",
"other": "Others"
},
"contactForm": {
"inPerson": "In person",
"phone": "Phone",
"video": "Video call",
"written": "Written"
},
"contactExtent": {
"brief": "Brief",
"medium": "Medium",
"extended": "Extended"
},
"leisureCategories": {
"film": "Film or series",
"reading": "Reading",
"gaming": "Gaming",
"music": "Music",
"creative": "Something creative",
"outing": "Out and about",
"garden": "Garden",
"cooking": "Cooking",
"timeWithChild": "Time with the child",
"relaxation": "Rest",
"other": "Something else"
},
"eventType": {
"goodNews": "Good news",
"badNews": "Bad news",
"success": "Something worked out",
"disappointment": "A disappointment",
"conflict": "A conflict",
"appointment": "An appointment",
"familyEvent": "A family occasion",
"change": "A change",
"unexpected": "Something unexpected"
},
"rating": {
"workLoad": {
"low": "no pressure",
"high": "under heavy pressure"
},
"workSatisfaction": {
"low": "not satisfied at all",
"high": "very satisfied"
},
"contactQuality": {
"low": "did not do me good",
"high": "did me a lot of good"
},
"contactSupport": {
"low": "not supported",
"high": "well supported"
},
"leisureJoy": {
"low": "no enjoyment",
"high": "a lot of enjoyment"
},
"leisureRecovery": {
"low": "not restorative",
"high": "very restorative"
},
"eventValence": {
"low": "clearly bad",
"high": "clearly good"
}
}
}
},
"medications": {
+109
View File
@@ -922,6 +922,115 @@
"low": "completamente desbordado",
"high": "con plena capacidad de acción"
}
},
"context": {
"section": {
"work": "Trabajo",
"contacts": "Contactos",
"leisure": "Tiempo libre",
"events": "Acontecimiento"
},
"field": {
"workStatus": "Jornada laboral",
"workMinutes": "Tiempo trabajado",
"overtimeMinutes": "Horas extra",
"workLoad": "Carga de trabajo",
"workSatisfaction": "Satisfacción con el trabajo",
"contactCircles": "Con quién hubo contacto",
"contactForm": "Forma de contacto",
"contactExtent": "Duración",
"contactQuality": "Calidad del contacto",
"contactSupport": "Apoyo percibido",
"leisureCategories": "En qué se empleó el tiempo libre",
"leisureMinutes": "Tiempo para ti",
"leisureJoy": "Disfrute",
"leisureRecovery": "Recuperación",
"eventType": "Qué ocurrió",
"eventValence": "Cómo sentó",
"eventAt": "Cuándo",
"notes": "Nota"
},
"workStatus": {
"off": "Sin trabajar",
"partial": "Parte del día",
"regular": "Jornada normal",
"overtime": "Más de lo habitual"
},
"contactCircles": {
"partner": "Pareja",
"child": "Hijo o hija",
"family": "Familia",
"friends": "Amistades",
"colleagues": "Colegas",
"acquaintances": "Conocidos",
"professional": "Apoyo profesional",
"other": "Otras personas"
},
"contactForm": {
"inPerson": "En persona",
"phone": "Teléfono",
"video": "Videollamada",
"written": "Por escrito"
},
"contactExtent": {
"brief": "Breve",
"medium": "Medio",
"extended": "Prolongado"
},
"leisureCategories": {
"film": "Cine o series",
"reading": "Lectura",
"gaming": "Videojuegos",
"music": "Música",
"creative": "Algo creativo",
"outing": "Salida",
"garden": "Jardín",
"cooking": "Cocinar",
"timeWithChild": "Tiempo con el hijo o la hija",
"relaxation": "Descanso",
"other": "Otra cosa"
},
"eventType": {
"goodNews": "Buenas noticias",
"badNews": "Malas noticias",
"success": "Algo salió bien",
"disappointment": "Una decepción",
"conflict": "Un conflicto",
"appointment": "Una cita",
"familyEvent": "Un acontecimiento familiar",
"change": "Un cambio",
"unexpected": "Algo inesperado"
},
"rating": {
"workLoad": {
"low": "sin presión",
"high": "bajo mucha presión"
},
"workSatisfaction": {
"low": "nada satisfecho",
"high": "muy satisfecho"
},
"contactQuality": {
"low": "no sentó bien",
"high": "sentó muy bien"
},
"contactSupport": {
"low": "sin apoyo",
"high": "muy apoyado"
},
"leisureJoy": {
"low": "sin disfrute",
"high": "mucho disfrute"
},
"leisureRecovery": {
"low": "nada reparador",
"high": "muy reparador"
},
"eventValence": {
"low": "claramente malo",
"high": "claramente bueno"
}
}
}
},
"medications": {
+109
View File
@@ -922,6 +922,115 @@
"low": "complètement dépassé",
"high": "pleinement capable d'agir"
}
},
"context": {
"section": {
"work": "Travail",
"contacts": "Contacts",
"leisure": "Temps libre",
"events": "Événement"
},
"field": {
"workStatus": "Journée de travail",
"workMinutes": "Temps travaillé",
"overtimeMinutes": "Heures supplémentaires",
"workLoad": "Charge de travail",
"workSatisfaction": "Satisfaction au travail",
"contactCircles": "Avec qui le contact a eu lieu",
"contactForm": "Forme du contact",
"contactExtent": "Durée",
"contactQuality": "Qualité du contact",
"contactSupport": "Soutien ressenti",
"leisureCategories": "À quoi le temps libre a servi",
"leisureMinutes": "Temps pour soi",
"leisureJoy": "Plaisir",
"leisureRecovery": "Récupération",
"eventType": "Ce qui s'est passé",
"eventValence": "Comment cela a été vécu",
"eventAt": "Quand",
"notes": "Note"
},
"workStatus": {
"off": "Pas de travail",
"partial": "Une partie de la journée",
"regular": "Journée normale",
"overtime": "Plus longtemps que d'habitude"
},
"contactCircles": {
"partner": "Partenaire",
"child": "Enfant",
"family": "Famille",
"friends": "Amis",
"colleagues": "Collègues",
"acquaintances": "Connaissances",
"professional": "Accompagnement professionnel",
"other": "Autres"
},
"contactForm": {
"inPerson": "En personne",
"phone": "Téléphone",
"video": "Appel vidéo",
"written": "Par écrit"
},
"contactExtent": {
"brief": "Bref",
"medium": "Moyen",
"extended": "Prolongé"
},
"leisureCategories": {
"film": "Film ou série",
"reading": "Lecture",
"gaming": "Jeux vidéo",
"music": "Musique",
"creative": "Activité créative",
"outing": "Sortie",
"garden": "Jardin",
"cooking": "Cuisine",
"timeWithChild": "Temps avec l'enfant",
"relaxation": "Repos",
"other": "Autre chose"
},
"eventType": {
"goodNews": "Bonne nouvelle",
"badNews": "Mauvaise nouvelle",
"success": "Quelque chose a réussi",
"disappointment": "Une déception",
"conflict": "Un conflit",
"appointment": "Un rendez-vous",
"familyEvent": "Un événement familial",
"change": "Un changement",
"unexpected": "Quelque chose d'inattendu"
},
"rating": {
"workLoad": {
"low": "aucune pression",
"high": "sous forte pression"
},
"workSatisfaction": {
"low": "pas du tout satisfait",
"high": "très satisfait"
},
"contactQuality": {
"low": "ne m'a pas fait de bien",
"high": "m'a fait beaucoup de bien"
},
"contactSupport": {
"low": "pas soutenu",
"high": "bien soutenu"
},
"leisureJoy": {
"low": "aucun plaisir",
"high": "beaucoup de plaisir"
},
"leisureRecovery": {
"low": "pas reposant",
"high": "très reposant"
},
"eventValence": {
"low": "nettement négatif",
"high": "nettement positif"
}
}
}
},
"medications": {
+109
View File
@@ -922,6 +922,115 @@
"low": "completamente sopraffatto",
"high": "pienamente capace di agire"
}
},
"context": {
"section": {
"work": "Lavoro",
"contacts": "Contatti",
"leisure": "Tempo libero",
"events": "Evento"
},
"field": {
"workStatus": "Giornata lavorativa",
"workMinutes": "Tempo lavorato",
"overtimeMinutes": "Straordinari",
"workLoad": "Carico di lavoro",
"workSatisfaction": "Soddisfazione per il lavoro",
"contactCircles": "Con chi c'è stato contatto",
"contactForm": "Forma del contatto",
"contactExtent": "Durata",
"contactQuality": "Qualità del contatto",
"contactSupport": "Sostegno percepito",
"leisureCategories": "Come è stato speso il tempo libero",
"leisureMinutes": "Tempo per sé",
"leisureJoy": "Piacere",
"leisureRecovery": "Recupero",
"eventType": "Che cosa è successo",
"eventValence": "Come è stato vissuto",
"eventAt": "Quando",
"notes": "Nota"
},
"workStatus": {
"off": "Nessun lavoro",
"partial": "Parte della giornata",
"regular": "Giornata regolare",
"overtime": "Più del solito"
},
"contactCircles": {
"partner": "Partner",
"child": "Figlio o figlia",
"family": "Famiglia",
"friends": "Amici",
"colleagues": "Colleghi",
"acquaintances": "Conoscenti",
"professional": "Supporto professionale",
"other": "Altre persone"
},
"contactForm": {
"inPerson": "Di persona",
"phone": "Telefono",
"video": "Videochiamata",
"written": "Per iscritto"
},
"contactExtent": {
"brief": "Breve",
"medium": "Medio",
"extended": "Prolungato"
},
"leisureCategories": {
"film": "Film o serie",
"reading": "Lettura",
"gaming": "Videogiochi",
"music": "Musica",
"creative": "Attività creativa",
"outing": "Uscita",
"garden": "Giardino",
"cooking": "Cucinare",
"timeWithChild": "Tempo con il figlio o la figlia",
"relaxation": "Riposo",
"other": "Altro"
},
"eventType": {
"goodNews": "Buona notizia",
"badNews": "Cattiva notizia",
"success": "Qualcosa è riuscito",
"disappointment": "Una delusione",
"conflict": "Un conflitto",
"appointment": "Un appuntamento",
"familyEvent": "Un evento familiare",
"change": "Un cambiamento",
"unexpected": "Qualcosa di inaspettato"
},
"rating": {
"workLoad": {
"low": "nessuna pressione",
"high": "sotto forte pressione"
},
"workSatisfaction": {
"low": "per niente soddisfatto",
"high": "molto soddisfatto"
},
"contactQuality": {
"low": "non ha fatto bene",
"high": "ha fatto molto bene"
},
"contactSupport": {
"low": "senza sostegno",
"high": "ben sostenuto"
},
"leisureJoy": {
"low": "nessun piacere",
"high": "molto piacere"
},
"leisureRecovery": {
"low": "per niente rigenerante",
"high": "molto rigenerante"
},
"eventValence": {
"low": "chiaramente negativo",
"high": "chiaramente positivo"
}
}
}
},
"medications": {
+109
View File
@@ -922,6 +922,115 @@
"low": "całkowicie przytłoczony",
"high": "w pełni zdolny do działania"
}
},
"context": {
"section": {
"work": "Praca",
"contacts": "Kontakty",
"leisure": "Czas wolny",
"events": "Wydarzenie"
},
"field": {
"workStatus": "Dzień pracy",
"workMinutes": "Czas pracy",
"overtimeMinutes": "Nadgodziny",
"workLoad": "Obciążenie pracą",
"workSatisfaction": "Zadowolenie z pracy",
"contactCircles": "Z kim był kontakt",
"contactForm": "Forma kontaktu",
"contactExtent": "Czas trwania",
"contactQuality": "Jakość kontaktu",
"contactSupport": "Odczuwane wsparcie",
"leisureCategories": "Na co poszedł czas wolny",
"leisureMinutes": "Czas dla siebie",
"leisureJoy": "Radość",
"leisureRecovery": "Regeneracja",
"eventType": "Co się wydarzyło",
"eventValence": "Jak zostało odebrane",
"eventAt": "Kiedy",
"notes": "Notatka"
},
"workStatus": {
"off": "Bez pracy",
"partial": "Część dnia",
"regular": "Zwykły dzień",
"overtime": "Dłużej niż zwykle"
},
"contactCircles": {
"partner": "Partner",
"child": "Dziecko",
"family": "Rodzina",
"friends": "Przyjaciele",
"colleagues": "Współpracownicy",
"acquaintances": "Znajomi",
"professional": "Wsparcie profesjonalne",
"other": "Inne osoby"
},
"contactForm": {
"inPerson": "Osobiście",
"phone": "Telefon",
"video": "Rozmowa wideo",
"written": "Pisemnie"
},
"contactExtent": {
"brief": "Krótki",
"medium": "Średni",
"extended": "Długi"
},
"leisureCategories": {
"film": "Film lub serial",
"reading": "Czytanie",
"gaming": "Gry",
"music": "Muzyka",
"creative": "Coś twórczego",
"outing": "Wyjście",
"garden": "Ogród",
"cooking": "Gotowanie",
"timeWithChild": "Czas z dzieckiem",
"relaxation": "Odpoczynek",
"other": "Coś innego"
},
"eventType": {
"goodNews": "Dobra wiadomość",
"badNews": "Zła wiadomość",
"success": "Coś się udało",
"disappointment": "Rozczarowanie",
"conflict": "Konflikt",
"appointment": "Termin",
"familyEvent": "Wydarzenie rodzinne",
"change": "Zmiana",
"unexpected": "Coś nieoczekiwanego"
},
"rating": {
"workLoad": {
"low": "bez presji",
"high": "pod dużą presją"
},
"workSatisfaction": {
"low": "wcale niezadowolony",
"high": "bardzo zadowolony"
},
"contactQuality": {
"low": "nie zrobił mi dobrze",
"high": "zrobił mi bardzo dobrze"
},
"contactSupport": {
"low": "bez wsparcia",
"high": "dobrze wspierany"
},
"leisureJoy": {
"low": "bez radości",
"high": "dużo radości"
},
"leisureRecovery": {
"low": "wcale nieregenerujący",
"high": "bardzo regenerujący"
},
"eventValence": {
"low": "wyraźnie zły",
"high": "wyraźnie dobry"
}
}
}
},
"medications": {
@@ -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 [];
}
}