diff --git a/messages/de.json b/messages/de.json index 329c13522..83bf2a774 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 76cc7a265..f8f791de8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 9dadf056b..d42236d4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 553da5fe7..bca12109c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 792bb59bf..0fa6d76a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 0c8475bde..bf977d2aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/lib/mood/__tests__/context-vocabulary.test.ts b/src/lib/mood/__tests__/context-vocabulary.test.ts new file mode 100644 index 000000000..b5dfad007 --- /dev/null +++ b/src/lib/mood/__tests__/context-vocabulary.test.ts @@ -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 { + return JSON.parse( + readFileSync(join(MESSAGES, `${locale}.json`), "utf8"), + ) as Record; +} + +function resolve(root: Record, key: string): unknown { + return key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[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)) { + 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([]); + }); + } +}); diff --git a/src/lib/mood/context-vocabulary.ts b/src/lib/mood/context-vocabulary.ts new file mode 100644 index 000000000..ff961813f --- /dev/null +++ b/src/lib/mood/context-vocabulary.ts @@ -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..{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 []; + } +}