From 7a4ee19c6826ef719197177ef27c8315c03fbaba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 06:51:42 +0200 Subject: [PATCH 01/22] feat(mood): add the MoodContext sub-row and its encrypted note - migration 0311 creates mood_contexts, keyed by the entry, cascading from both the entry and the owner - work, contacts, leisure and one notable event; every field nullable, and an entry with nothing filled in gets no row at all - notes_encrypted registered in ENCRYPTED_COLUMNS and in the rotation script, so a key rotation cannot strand it --- .../0311_mood_context/migration.sql | 84 +++++++++++++++ prisma/schema.prisma | 101 ++++++++++++++++++ scripts/rotate-encryption-key.ts | 11 ++ src/lib/crypto/encrypted-columns.ts | 4 + 4 files changed, 200 insertions(+) create mode 100644 prisma/migrations/0311_mood_context/migration.sql diff --git a/prisma/migrations/0311_mood_context/migration.sql b/prisma/migrations/0311_mood_context/migration.sql new file mode 100644 index 000000000..2930d6233 --- /dev/null +++ b/prisma/migrations/0311_mood_context/migration.sql @@ -0,0 +1,84 @@ +-- v1.38 — the day's context around a mood entry. +-- +-- Work, contacts, leisure and a notable event, as an optional sub-row rather +-- than as twenty more columns on `mood_entries`. Level A went the other way in +-- migration 0310, and for the opposite reasons: those five values are on +-- nearly every row and every mood reader wants them, while this is sparse by +-- design (only the sections somebody opened produce anything at all) and only +-- the context surfaces read it. +-- +-- An entry with nothing filled in gets NO row here. A table of all-NULL rows +-- would record "asked and answered nothing" where the truth is "never asked", +-- and the two are different facts. +-- +-- Nothing in this table duplicates another module. Sleep, activity and vitals +-- are resolved at read time from the modules that own them, and body symptoms +-- stay in the illness tables. A stored copy would be a second home for the +-- same fact and would drift the first time somebody corrected the original. +-- +-- The two multi-select lists are JSON arrays in TEXT columns, matching the +-- `mood_entries.tags` convention. Not queryable, read whole — acceptable +-- because every reader wants an entry's whole context anyway. +-- +-- No CHECK constraints on the numeric ranges. Bounds are enforced server-side, +-- the same way `mood_entry_tag_links.rating` documents: a later change to a +-- scale must not strand rows written under the old one. + +CREATE TABLE "mood_contexts" ( + "mood_entry_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + + -- Work. `work_status` is one of off / partial / regular / overtime. + "work_status" TEXT, + "work_minutes" INTEGER, + "overtime_minutes" INTEGER, + "work_load" INTEGER, + "work_satisfaction" INTEGER, + + -- Contacts. `contact_circles` is a JSON array of circle keys; the count is + -- never scored, which is why the experienced quality is its own column. + "contact_circles" TEXT, + "contact_form" TEXT, + "contact_extent" TEXT, + "contact_quality" INTEGER, + "contact_support" INTEGER, + + -- Leisure. `leisure_categories` is a JSON array of category keys. + "leisure_categories" TEXT, + "leisure_minutes" INTEGER, + "leisure_joy" INTEGER, + "leisure_recovery" INTEGER, + + -- One notable event, if there was one. + "event_type" TEXT, + "event_valence" INTEGER, + "event_at" TIMESTAMPTZ(3), + + -- One AES-256-GCM note for the whole context, not one per section: four + -- ciphertext columns would be four rotation entries and four ways to + -- forget one. Registered in ENCRYPTED_COLUMNS and in the rotation script. + "notes_encrypted" BYTEA, + + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mood_contexts_pkey" PRIMARY KEY ("mood_entry_id") +); + +-- Deleting the entry deletes its context. The context has no meaning without +-- the entry it describes, and a row pointing at a deleted entry would be +-- readable by nobody and deletable by nobody. The user cascade matches every +-- other user-scoped table here. +ALTER TABLE "mood_contexts" + ADD CONSTRAINT "mood_contexts_mood_entry_id_fkey" + FOREIGN KEY ("mood_entry_id") REFERENCES "mood_entries"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "mood_contexts" + ADD CONSTRAINT "mood_contexts_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- The user-scoped sweep: the context analytics read a window of an account's +-- contexts, and the account-deletion path clears them by owner. +CREATE INDEX "mood_contexts_user_id_idx" ON "mood_contexts"("user_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index affc5e70c..92cb5c569 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -773,6 +773,7 @@ model User { googleHealthConnection GoogleHealthConnection? googleHealthOAuthStates GoogleHealthOAuthState[] moodEntries MoodEntry[] + moodContexts MoodContext[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") @@ -4124,6 +4125,12 @@ model MoodEntry { // `tags` untouched. tagLinks MoodEntryTagLink[] + /// v1.38 — the day's context, when somebody chose to add one. A separate + /// optional row rather than columns here: it is genuinely sparse, it is + /// twenty fields wide, and no reader outside the context surfaces wants it + /// inline. + context MoodContext? + @@unique([userId, date, moodLoggedAt]) // v1.12.1 — NULL-distinct external dedup key (migration 0122). Importers // that carry a source-stable id upsert on this; native/MANUAL entries @@ -4155,6 +4162,100 @@ model MoodEntry { @@map("mood_entries") } +/// v1.38 — the shape of the day around a mood entry: work, contacts, leisure, +/// and a notable event if there was one. +/// +/// A 1:1 optional sub-row keyed by the entry it belongs to. Every field is +/// nullable and an entry with nothing filled in has no row at all — an empty +/// sub-object is not a row, because a table full of all-NULL rows would say +/// "asked and answered nothing" where the truth is "never asked". +/// +/// **Nothing here is captured twice.** Sleep, activity and vitals belong to +/// the modules that own them and are resolved at read time +/// (`src/lib/mood/linked-context.ts`), never copied onto this row: a copy +/// would go stale the moment somebody corrected a sleep session, and then two +/// surfaces would disagree about the same night. Body symptoms and their +/// severity belong to the illness module (`IllnessDayLog`, +/// `IllnessSymptomLink`), which this row links to and does not duplicate. +/// +/// The multi-select lists ride as JSON arrays in string columns, matching +/// `MoodEntry.tags`. That column shape is not queryable and is read whole — +/// acceptable here because every reader wants the entry's whole context +/// anyway, and the alternative is two join tables for what is a checkbox row. +/// A future filter over "days that included the partner" would need a real +/// column or a join, and would be a schema change, not a query change. +/// +/// The numeric ranges are stated in the trailing comments and enforced in Zod, +/// not as CHECK constraints — the same trade `MoodEntryTagLink.rating` +/// documents: a later change to a scale must not strand rows written under the +/// old one. +/// +/// A delegate holding a READ grant sees this row, including the decrypted +/// note, because a grant is whole-record by design and carries no per-domain +/// scope. That is deliberate and is stated here so nobody discovers it later: +/// withholding part of a record from a delegate would be a new capability, not +/// a field select. +model MoodContext { + /// The entry this context belongs to, and the primary key. One context per + /// entry, enforced by the key rather than by application code. + moodEntryId String @id @map("mood_entry_id") + /// Carried so the context can be queried and deleted per user without a + /// join through the entry. Every read still pins it. + userId String @map("user_id") + + // ── Work ──────────────────────────────────────────────────── + /// `off` | `partial` | `regular` | `overtime` — `WORK_STATUS_KEYS`. + workStatus String? @map("work_status") + workMinutes Int? @map("work_minutes") // 0-1440 + overtimeMinutes Int? @map("overtime_minutes") // 0-1440 + workLoad Int? @map("work_load") // 0-10, inverse-oriented (higher is heavier) + workSatisfaction Int? @map("work_satisfaction") // 0-10 + + // ── Contacts ──────────────────────────────────────────────── + /// JSON array of `CONTACT_CIRCLE_KEYS`. Never scored: how many people + /// somebody saw is not a good or a bad number, which is why the experienced + /// quality below is its own field. + contactCircles String? @map("contact_circles") + /// `inPerson` | `phone` | `video` | `written` — `CONTACT_FORM_KEYS`. + contactForm String? @map("contact_form") + /// `brief` | `medium` | `extended` — `CONTACT_EXTENT_KEYS`. + contactExtent String? @map("contact_extent") + contactQuality Int? @map("contact_quality") // 0-10 + contactSupport Int? @map("contact_support") // 0-10 + + // ── Leisure ───────────────────────────────────────────────── + /// JSON array of `LEISURE_CATEGORY_KEYS`. + leisureCategories String? @map("leisure_categories") + leisureMinutes Int? @map("leisure_minutes") // 0-1440 + leisureJoy Int? @map("leisure_joy") // 0-10 + leisureRecovery Int? @map("leisure_recovery") // 0-10 + + // ── A notable event ───────────────────────────────────────── + /// One of `EVENT_TYPE_KEYS`. One per entry: a day with two notable events + /// has two things worth writing in the note, and a multi-select would invite + /// ticking every box instead of naming the one that mattered. + eventType String? @map("event_type") + eventValence Int? @map("event_valence") // -5 to +5 + /// When it happened, if the person knew. Absent is common and means absent. + eventAt DateTime? @map("event_at") + + /// One free-text note for the whole context, AES-256-GCM at rest (Bytes, the + /// shared codec every other note column uses). One column rather than one + /// per section: four ciphertext columns would be four rotation entries and + /// four ways to forget one. Registered in `ENCRYPTED_COLUMNS` and in + /// `scripts/rotate-encryption-key.ts`. + notesEncrypted Bytes? @map("notes_encrypted") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("mood_contexts") +} + // ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ──────────────────────── // // Structured, icon-bearing tag catalog modelled on the standalone mood diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index f764c55df..7c4c2ddd1 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -479,6 +479,17 @@ async function main() { prisma.measurement, ), ); + // v1.38 — the day-context note on a mood entry. Its own table, so its own + // walk; a context row exists only where somebody added one, and a row with + // no note carries NULL here and is skipped like every other nullable Bytes + // column. + results.push( + await rotateBytesColumn( + "MoodContext", + "notesEncrypted", + prisma.moodContext, + ), + ); // ───── v1.25 medication free-text notes (Bytes columns) ───── // "notesEncrypted" (MedicationSideEffect + MedicationInventoryItem) + diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 3e3cc1241..de88ef822 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -204,6 +204,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // IllnessDayLog / LabResult note columns above; Bytes via the shared codec. { model: "MoodEntry", field: "noteEncrypted", kind: "bytes" }, { model: "Measurement", field: "notesEncrypted", kind: "bytes" }, + // v1.38 — the one free-text note on a mood entry's day context. Same codec + // as the entry note beside it; one column for the whole context rather than + // one per section, so there is one rotation entry rather than four. + { model: "MoodContext", field: "notesEncrypted", kind: "bytes" }, // ───── v1.25 medication free-text notes (Bytes columns) ───── // Side-effect log note, dose-change titration note, and inventory-item From dbfc0d8145a5194f1da6d4a808ad1ae54786d010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 06:57:36 +0200 Subject: [PATCH 02/22] 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 --- messages/de.json | 109 +++++++++ messages/en.json | 109 +++++++++ messages/es.json | 109 +++++++++ messages/fr.json | 109 +++++++++ messages/it.json | 109 +++++++++ messages/pl.json | 109 +++++++++ .../mood/__tests__/context-vocabulary.test.ts | 144 ++++++++++++ src/lib/mood/context-vocabulary.ts | 207 ++++++++++++++++++ 8 files changed, 1005 insertions(+) create mode 100644 src/lib/mood/__tests__/context-vocabulary.test.ts create mode 100644 src/lib/mood/context-vocabulary.ts 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 []; + } +} From a25ec211cd997e5349ce1b60965f381bafee1c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:07:03 +0200 Subject: [PATCH 03/22] feat(mood): capture and persist a mood entry's day context - moodContextSchema derives every enum from the vocabulary, so a value it does not name answers 422 through returnAllZodIssues rather than being dropped - the context is written in the same transaction as the entry and its links; a failure rolls the entry back rather than leaving a client retrying a 5xx into a duplicate - an entry with nothing filled in stores no row, and a context that says nothing removes the one that was there - the create, list, detail and edit reads answer the context with its lists decoded and its note decrypted; the ciphertext never leaves the server --- .../mood-entries/[id]/__tests__/route.test.ts | 17 + src/app/api/mood-entries/[id]/route.ts | 41 +- .../api/mood-entries/__tests__/route.test.ts | 10 + src/app/api/mood-entries/route.ts | 252 +++++++------ src/lib/mood/context.ts | 218 +++++++++++ src/lib/validations/mood.ts | 96 +++++ tests/integration/mood-context.test.ts | 357 ++++++++++++++++++ 7 files changed, 880 insertions(+), 111 deletions(-) create mode 100644 src/lib/mood/context.ts create mode 100644 tests/integration/mood-context.test.ts diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index ec47b786a..3570b2b13 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -11,6 +11,16 @@ const txClient = vi.hoisted(() => ({ moodEntryTagLink: { findMany: vi.fn(), }, + // v1.38 — the day context is replaced inside the same transaction. Answering + // null from `findUnique` keeps this fake honest: what a context write + // actually stores is proven against real Postgres in + // `tests/integration/mood-context.test.ts`, not by a stub that agrees with + // whatever it is asked. + moodContext: { + deleteMany: vi.fn(), + upsert: vi.fn(), + findUnique: vi.fn(), + }, })); vi.mock("@/lib/db", () => ({ @@ -114,6 +124,8 @@ beforeEach(() => { ); txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never); txClient.moodEntryTagLink.findMany.mockResolvedValue([]); + txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 }); + txClient.moodContext.findUnique.mockResolvedValue(null); vi.mocked(replaceTagLinks).mockResolvedValue(undefined); vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined); // v1.7.0 sync — PUT now looks the row up via `findFirst` with a @@ -296,6 +308,11 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { moodEntryTagLink: { findMany: vi.fn(), }, + moodContext: { + deleteMany: vi.fn(), + upsert: vi.fn(), + findUnique: vi.fn(), + }, }; vi.mocked(replaceTagLinks).mockImplementationOnce(async () => { draft.binary = ["calm"]; diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index c3f09b24c..f6535df55 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -16,6 +16,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; +import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -47,6 +48,9 @@ export const GET = apiHandler( // (not `findUnique`) because `deletedAt` is not part of a unique index. const entry = await prisma.moodEntry.findFirst({ where: { id, deletedAt: null }, + // v1.38 — the day context rides with the entry rather than behind a + // second request: the edit surface needs both or neither. + include: { context: true }, }); if (!entry || entry.userId !== user.id) { @@ -58,10 +62,13 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); + const { context, ...row } = entry; return apiSuccess({ // v1.37 — level-A values under the keys the write path takes. - ...shapeLevelA(shapeMoodNote(entry)), - tags: parseTags(entry.tags), + ...shapeLevelA(shapeMoodNote(row)), + tags: parseTags(row.tags), + // v1.38 — lists decoded, note decrypted, ciphertext dropped. + context: context ? contextForWire(context) : null, }); }, ); @@ -201,6 +208,20 @@ export const PUT = apiHandler( ); } + // v1.38 — the day context replaces whole when the request carried + // one, and is left alone when it did not, matching the two link sets + // above. Inside the same transaction: a context write that fails rolls + // the edit and its syncVersion increment back with it. + const contextOutcome = await persistMoodContext( + tx, + id, + user.id, + data.context, + ); + const storedContext = await tx.moodContext.findUnique({ + where: { moodEntryId: id }, + }); + // Return the same split shape as list/create: binary keys are // independent from rated-factor keys and their per-entry scores. const links = await tx.moodEntryTagLink.findMany({ @@ -213,6 +234,8 @@ export const PUT = apiHandler( return { entry: updated, + contextOutcome, + persistedContext: storedContext, persistedTagKeys: links .filter((link) => link.moodTag.kind !== "RATED") .map((link) => link.moodTag.key), @@ -252,8 +275,13 @@ export const PUT = apiHandler( } throw error; } - const { entry, persistedTagKeys, persistedRatedFactors } = - transactionOutcome.result; + const { + entry, + persistedTagKeys, + persistedRatedFactors, + persistedContext, + contextOutcome, + } = transactionOutcome.result; await auditLog("moodEntry.update", { userId: user.id, @@ -278,6 +306,7 @@ export const PUT = apiHandler( ...(data.tags !== undefined ? ["tags"] : []), ...(data.tagKeys !== undefined ? ["tagKeys"] : []), ...(data.ratedFactors !== undefined ? ["ratedFactors"] : []), + ...(data.context !== undefined ? ["context"] : []), ], }), }, @@ -285,7 +314,7 @@ export const PUT = apiHandler( annotate({ action: { name: "mood-entries.update" }, - meta: { moodEntryId: id }, + meta: { moodEntryId: id, mood_context: contextOutcome }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -328,6 +357,8 @@ export const PUT = apiHandler( // refetch (shape-matches the list GET). tagKeys: persistedTagKeys, ratedFactors: persistedRatedFactors, + // v1.38 — the stored context after the edit, or null when there is none. + context: persistedContext ? contextForWire(persistedContext) : null, }); }, ); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index f0cfc624f..383671464 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -19,6 +19,16 @@ const txClient = { moodEntryTagLink: { findMany: vi.fn().mockResolvedValue([]), }, + // v1.38 — the day context is written inside the same transaction, and the + // route reads it back for the response. A fake that answered a row here + // regardless of what was written would prove nothing, so `findUnique` + // answers null and the context-carrying claims live in the integration + // suite (`tests/integration/mood-context.test.ts`) against real Postgres. + moodContext: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + upsert: vi.fn(), + findUnique: vi.fn().mockResolvedValue(null), + }, }; vi.mock("@/lib/db", () => ({ diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index cc3ef97c8..350095e60 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -25,6 +25,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; +import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -114,6 +115,10 @@ export const GET = apiHandler(async (request: NextRequest) => { moodTag: { select: { key: true, kind: true } }, }, }, + // v1.38 — the day context, so the edit dialog can pre-populate the + // sections without a second round trip per row. Sparse by design: most + // rows carry none and the join costs nothing on those. + context: true, }, }), prisma.moodEntry.count({ where }), @@ -124,7 +129,7 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { total, limit, offset }, }); - const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ + const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({ // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. // v1.37 — the five level-A values under the keys the create path takes, // so the edit form reads back what it wrote instead of mapping column @@ -145,6 +150,9 @@ export const GET = apiHandler(async (request: NextRequest) => { key: link.moodTag.key, rating: link.rating as number, })), + // v1.38 — the day context, or null. The ciphertext never leaves the + // server: `contextForWire` reads the note and drops the column. + context: context ? contextForWire(context) : null, })); return apiSuccess({ @@ -207,6 +215,7 @@ async function postMoodEntry(request: NextRequest) { tagKeys, ratedFactors, note, + context, moodLoggedAt, source, externalId, @@ -257,118 +266,141 @@ async function postMoodEntry(request: NextRequest) { // tag-link failure must roll the entry back too — otherwise a client // retry on the 5xx mints a duplicate entry. The tx client is threaded // through the helper so both writes commit (or abort) together. - const { entry, persistedTagKeys, persistedRatedFactors } = - await prisma.$transaction(async (tx) => { - // v1.12.1 — when the client supplies a source-stable `externalId`, - // upsert on the NULL-distinct `(userId, source, externalId)` key so - // a re-post with the same id updates the existing row in place - // (idempotent re-import) instead of minting a duplicate or 409-ing. - // Without an `externalId`, fall back to the legacy first-write - // `create` exactly as before — a same-tuple re-post then trips the - // `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below. - const created = externalId - ? await tx.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId: user.id, - source: resolvedSource, - externalId, - }, - }, - create: { + const { + entry, + persistedTagKeys, + persistedRatedFactors, + persistedContext, + contextOutcome, + } = await prisma.$transaction(async (tx) => { + // v1.12.1 — when the client supplies a source-stable `externalId`, + // upsert on the NULL-distinct `(userId, source, externalId)` key so + // a re-post with the same id updates the existing row in place + // (idempotent re-import) instead of minting a duplicate or 409-ing. + // Without an `externalId`, fall back to the legacy first-write + // `create` exactly as before — a same-tuple re-post then trips the + // `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below. + const created = externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { userId: user.id, - date, - tz, - mood, - score, - ...levelA, - tags: tags ? JSON.stringify(tags) : null, - note: null, - noteEncrypted: encryptNote(note ?? null), source: resolvedSource, externalId, - moodLoggedAt, }, - // A re-post restates pleasantness, because the label it carries - // always implies one and a stale A1 beside a changed mood would - // contradict the entry's own face. It does NOT restate the other - // four: `levelA` carries them only when this request did, so a - // client re-posting without sliders — the phone, whose build has - // none — leaves the stress and energy somebody answered on the - // web exactly where they were. An explicit null clears one. - update: { - date, - tz, - mood, - score, - ...levelA, - tags: tags ? JSON.stringify(tags) : null, - note: null, - noteEncrypted: encryptNote(note ?? null), - moodLoggedAt, - }, - }) - : await tx.moodEntry.create({ - data: { - userId: user.id, - date, - tz, - mood, - score, - // A fresh row: an omitted dimension lands as NULL, which is - // the same thing the upsert's arms mean by omitting it. - ...levelA, - tags: tags ? JSON.stringify(tags) : null, - note: null, - noteEncrypted: encryptNote(note ?? null), - source: resolvedSource, - moodLoggedAt, - }, - }); + }, + create: { + userId: user.id, + date, + tz, + mood, + score, + ...levelA, + tags: tags ? JSON.stringify(tags) : null, + note: null, + noteEncrypted: encryptNote(note ?? null), + source: resolvedSource, + externalId, + moodLoggedAt, + }, + // A re-post restates pleasantness, because the label it carries + // always implies one and a stale A1 beside a changed mood would + // contradict the entry's own face. It does NOT restate the other + // four: `levelA` carries them only when this request did, so a + // client re-posting without sliders — the phone, whose build has + // none — leaves the stress and energy somebody answered on the + // web exactly where they were. An explicit null clears one. + update: { + date, + tz, + mood, + score, + ...levelA, + tags: tags ? JSON.stringify(tags) : null, + note: null, + noteEncrypted: encryptNote(note ?? null), + moodLoggedAt, + }, + }) + : await tx.moodEntry.create({ + data: { + userId: user.id, + date, + tz, + mood, + score, + // A fresh row: an omitted dimension lands as NULL, which is + // the same thing the upsert's arms mean by omitting it. + ...levelA, + tags: tags ? JSON.stringify(tags) : null, + note: null, + noteEncrypted: encryptNote(note ?? null), + source: resolvedSource, + moodLoggedAt, + }, + }); - if ( - (tagKeys && tagKeys.length > 0) || - (ratedFactors && ratedFactors.length > 0) - ) { - // Unknown / non-RATED keys are dropped inside the helper (the - // catalog is the source of truth). An out-of-scale rating - // throws `RatedFactorOutOfRangeError`, rolling the tx back. - await createTagLinks( - created.id, - user.id, - tagKeys ?? [], - tx, - ratedFactors ?? [], - ); - } + if ( + (tagKeys && tagKeys.length > 0) || + (ratedFactors && ratedFactors.length > 0) + ) { + // Unknown / non-RATED keys are dropped inside the helper (the + // catalog is the source of truth). An out-of-scale rating + // throws `RatedFactorOutOfRangeError`, rolling the tx back. + await createTagLinks( + created.id, + user.id, + tagKeys ?? [], + tx, + ratedFactors ?? [], + ); + } - // v1.8.5 / v1.12.0 — read the persisted links back so the create - // response mirrors the list GET shape exactly: binary keys and - // rated factors split by `kind` (unknown keys already filtered). - const links = await tx.moodEntryTagLink.findMany({ - where: { moodEntryId: created.id }, - select: { - rating: true, - moodTag: { select: { key: true, kind: true } }, - }, - }); - - return { - entry: created, - persistedTagKeys: links - .filter((link) => link.moodTag.kind !== "RATED") - .map((link) => link.moodTag.key), - persistedRatedFactors: links - .filter( - (link) => link.moodTag.kind === "RATED" && link.rating !== null, - ) - .map((link) => ({ - key: link.moodTag.key, - rating: link.rating as number, - })), - }; + // v1.38 — the day context, in the SAME transaction as the entry and + // its links, for the same reason they are: it is user-intended + // content, not a cache, so a context write that fails has to roll the + // entry back rather than leave a client retrying a 5xx into a + // duplicate entry. An absent `context` key leaves a stored one alone; + // a context that says nothing removes the row. + const contextOutcome = await persistMoodContext( + tx, + created.id, + user.id, + context, + ); + const storedContext = await tx.moodContext.findUnique({ + where: { moodEntryId: created.id }, }); + // v1.8.5 / v1.12.0 — read the persisted links back so the create + // response mirrors the list GET shape exactly: binary keys and + // rated factors split by `kind` (unknown keys already filtered). + const links = await tx.moodEntryTagLink.findMany({ + where: { moodEntryId: created.id }, + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, + }); + + return { + entry: created, + contextOutcome, + persistedContext: storedContext, + persistedTagKeys: links + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + persistedRatedFactors: links + .filter( + (link) => link.moodTag.kind === "RATED" && link.rating !== null, + ) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), + }; + }); + await auditLog("moodEntry.create", { userId: user.id, ipAddress: getClientIp(request), @@ -377,7 +409,11 @@ async function postMoodEntry(request: NextRequest) { annotate({ action: { name: "mood-entries.create" }, - meta: { moodEntryId: entry.id, mood }, + // `mood_context` says written / removed / untouched rather than a + // boolean, because "the request said nothing" and "the request cleared + // it" are different acts and a dashboard that folded them together + // could not tell an abandoned section from a deliberate one. + meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -411,6 +447,10 @@ async function postMoodEntry(request: NextRequest) { tagKeys: persistedTagKeys, // v1.12.0 — rated factors with their per-entry score. ratedFactors: persistedRatedFactors, + // v1.38 — the stored context, lists decoded and note decrypted. Null + // when the entry carries none, which is a different answer from an + // object full of nulls and reads as one. + context: persistedContext ? contextForWire(persistedContext) : null, }, 201, ); diff --git a/src/lib/mood/context.ts b/src/lib/mood/context.ts new file mode 100644 index 000000000..a976428f3 --- /dev/null +++ b/src/lib/mood/context.ts @@ -0,0 +1,218 @@ +/** + * The one place a mood entry's day context crosses between the wire and the + * `mood_contexts` row. + * + * Three call sites need this — the create route, the edit route and the bulk + * route — plus the backup builder and the restore. Left to themselves they + * would each build their own `data` object, and one of them would eventually + * forget a column: exactly how the level-A values shipped twice under two + * spellings before `shapeLevelA` existed. So the mapping lives here, once, in + * both directions. + * + * Everything is built field by field from the parsed body. Nothing is spread + * from a request (`CLAUDE.md`, no mass assignment), and the two multi-select + * lists go through the vocabulary module's encoder rather than a local + * `JSON.stringify`. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; +import { decodeKeyList, encodeKeyList } from "@/lib/mood/context-vocabulary"; + +/** Either the plain client or a transaction client, as the tag-link helper takes. */ +type MoodContextDb = PrismaClient | Prisma.TransactionClient; + +/** + * The context as a client sends it. Every field optional; a `null` is an + * explicit "take this back", an absent key says nothing at all. + */ +export interface MoodContextInput { + workStatus?: string | null; + workMinutes?: number | null; + overtimeMinutes?: number | null; + workLoad?: number | null; + workSatisfaction?: number | null; + contactCircles?: string[] | null; + contactForm?: string | null; + contactExtent?: string | null; + contactQuality?: number | null; + contactSupport?: number | null; + leisureCategories?: string[] | null; + leisureMinutes?: number | null; + leisureJoy?: number | null; + leisureRecovery?: number | null; + eventType?: string | null; + eventValence?: number | null; + eventAt?: Date | null; + note?: string | null; +} + +/** The context as it sits on a row, before the note is decrypted. */ +export interface MoodContextRow { + workStatus: string | null; + workMinutes: number | null; + overtimeMinutes: number | null; + workLoad: number | null; + workSatisfaction: number | null; + contactCircles: string | null; + contactForm: string | null; + contactExtent: string | null; + contactQuality: number | null; + contactSupport: number | null; + leisureCategories: string | null; + leisureMinutes: number | null; + leisureJoy: number | null; + leisureRecovery: number | null; + eventType: string | null; + eventValence: number | null; + eventAt: Date | null; + notesEncrypted: Uint8Array | null; +} + +/** The context as a client reads it back. Lists decoded, note decrypted. */ +export interface MoodContextWire { + workStatus: string | null; + workMinutes: number | null; + overtimeMinutes: number | null; + workLoad: number | null; + workSatisfaction: number | null; + contactCircles: string[]; + contactForm: string | null; + contactExtent: string | null; + contactQuality: number | null; + contactSupport: number | null; + leisureCategories: string[]; + leisureMinutes: number | null; + leisureJoy: number | null; + leisureRecovery: number | null; + eventType: string | null; + eventValence: number | null; + eventAt: string | null; + note: string | null; +} + +/** + * Does this sub-object actually say anything? + * + * An entry whose context sections were all left closed must store **no row**. + * A row of nothing but NULLs would record "asked and answered nothing" where + * the truth is "never asked", and every count over the table would then be a + * count of empty forms. An empty list counts as nothing, because a client that + * renders a multi-select and sends back `[]` has said the same thing as one + * that sent nothing at all. + */ +export function isEmptyContextInput(input: MoodContextInput): boolean { + const values: Array = [ + input.workStatus, + input.workMinutes, + input.overtimeMinutes, + input.workLoad, + input.workSatisfaction, + input.contactForm, + input.contactExtent, + input.contactQuality, + input.contactSupport, + input.leisureMinutes, + input.leisureJoy, + input.leisureRecovery, + input.eventType, + input.eventValence, + input.eventAt, + ]; + if (values.some((v) => v !== undefined && v !== null)) return false; + if (input.contactCircles && input.contactCircles.length > 0) return false; + if (input.leisureCategories && input.leisureCategories.length > 0) + return false; + if (input.note !== undefined && input.note !== null && input.note !== "") { + return false; + } + return true; +} + +/** + * The write payload for a context row, whole. + * + * Whole rather than per-field, and that is the contract: a context write + * REPLACES the stored context. The capture surface always sends the sections + * it is showing, so a field the payload omits is a field the person cleared, + * and a preserve-when-absent rule here would make an emptied section + * impossible to save. The entry's own columns work the other way round + * (`levelAForWrite`) because those writers are upserts a phone re-posts + * blind; this object is only ever built from a payload that carried a + * `context` key at all. + */ +export function contextRowData(input: MoodContextInput) { + return { + workStatus: input.workStatus ?? null, + workMinutes: input.workMinutes ?? null, + overtimeMinutes: input.overtimeMinutes ?? null, + workLoad: input.workLoad ?? null, + workSatisfaction: input.workSatisfaction ?? null, + contactCircles: encodeKeyList(input.contactCircles ?? null), + contactForm: input.contactForm ?? null, + contactExtent: input.contactExtent ?? null, + contactQuality: input.contactQuality ?? null, + contactSupport: input.contactSupport ?? null, + leisureCategories: encodeKeyList(input.leisureCategories ?? null), + leisureMinutes: input.leisureMinutes ?? null, + leisureJoy: input.leisureJoy ?? null, + leisureRecovery: input.leisureRecovery ?? null, + eventType: input.eventType ?? null, + eventValence: input.eventValence ?? null, + eventAt: input.eventAt ?? null, + notesEncrypted: encryptNote(input.note ?? null), + }; +} + +/** A stored context row as the client reads it. */ +export function contextForWire(row: MoodContextRow): MoodContextWire { + return { + workStatus: row.workStatus, + workMinutes: row.workMinutes, + overtimeMinutes: row.overtimeMinutes, + workLoad: row.workLoad, + workSatisfaction: row.workSatisfaction, + contactCircles: decodeKeyList(row.contactCircles), + contactForm: row.contactForm, + contactExtent: row.contactExtent, + contactQuality: row.contactQuality, + contactSupport: row.contactSupport, + leisureCategories: decodeKeyList(row.leisureCategories), + leisureMinutes: row.leisureMinutes, + leisureJoy: row.leisureJoy, + leisureRecovery: row.leisureRecovery, + eventType: row.eventType, + eventValence: row.eventValence, + eventAt: row.eventAt ? row.eventAt.toISOString() : null, + // Fail-closed like every other note read: a present ciphertext under an + // unknown key id throws rather than answering an empty note. + note: readNote(row.notesEncrypted, null), + }; +} + +/** + * Write, replace or remove the context of one entry, inside the caller's + * transaction. + * + * `undefined` means the request said nothing about the context and the stored + * one is left alone. A context that says nothing removes the row, which is the + * same rule the create path applies: no row rather than a row of NULLs. + */ +export async function persistMoodContext( + tx: MoodContextDb, + moodEntryId: string, + userId: string, + input: MoodContextInput | null | undefined, +): Promise<"written" | "removed" | "untouched"> { + if (input === undefined) return "untouched"; + if (input === null || isEmptyContextInput(input)) { + await tx.moodContext.deleteMany({ where: { moodEntryId } }); + return "removed"; + } + const data = contextRowData(input); + await tx.moodContext.upsert({ + where: { moodEntryId }, + create: { moodEntryId, userId, ...data }, + update: data, + }); + return "written"; +} diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index 37ef68a6f..e31fd37c1 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -9,6 +9,20 @@ import { z } from "zod/v4"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; import { assertStableExternalId } from "@/lib/validations/external-id"; +import { + CONTACT_CIRCLE_KEYS, + CONTACT_EXTENT_KEYS, + CONTACT_FORM_KEYS, + CONTEXT_MINUTES_MAX, + CONTEXT_MINUTES_MIN, + CONTEXT_RATING_MAX, + CONTEXT_RATING_MIN, + CONTEXT_VALENCE_MAX, + CONTEXT_VALENCE_MIN, + EVENT_TYPE_KEYS, + LEISURE_CATEGORY_KEYS, + WORK_STATUS_KEYS, +} from "@/lib/mood/context-vocabulary"; // --- CRUD schemas for mood entries --- @@ -114,6 +128,77 @@ const ratedFactors = z.array(ratedFactor).max(30); // (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean. const levelADimension = z.number().int().min(0).max(10); +// v1.38 — the day's context. Every enum below derives from +// `context-vocabulary.ts`, which is what makes "a value the vocabulary does +// not name cannot be stored" structural rather than a convention: adding a key +// to the stored set means adding it to the list the labels and the analytics +// read too, in one edit. +const contextRating = z + .number() + .int() + .min(CONTEXT_RATING_MIN) + .max(CONTEXT_RATING_MAX); +const contextMinutes = z + .number() + .int() + .min(CONTEXT_MINUTES_MIN) + .max(CONTEXT_MINUTES_MAX); + +/** + * Optional on create and on patch, and every field inside it optional too. + * + * The bounds live here rather than as database CHECKs, matching + * `MoodEntryTagLink.rating`: an out-of-range value is a client error and + * answers 422 through `returnAllZodIssues`, and a later change to a scale + * cannot strand rows written under the old one. + * + * Sleep, activity, vitals and body symptoms are deliberately absent. They are + * owned by other modules and are read from them + * (`src/lib/mood/linked-context.ts`); accepting them here would mint a second + * home for the same fact. + */ +export const moodContextSchema = z.object({ + workStatus: z.enum(WORK_STATUS_KEYS).nullable().optional(), + workMinutes: contextMinutes.nullable().optional(), + overtimeMinutes: contextMinutes.nullable().optional(), + workLoad: contextRating.nullable().optional(), + workSatisfaction: contextRating.nullable().optional(), + contactCircles: z + .array(z.enum(CONTACT_CIRCLE_KEYS)) + .max(CONTACT_CIRCLE_KEYS.length) + .nullable() + .optional(), + contactForm: z.enum(CONTACT_FORM_KEYS).nullable().optional(), + contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullable().optional(), + contactQuality: contextRating.nullable().optional(), + contactSupport: contextRating.nullable().optional(), + leisureCategories: z + .array(z.enum(LEISURE_CATEGORY_KEYS)) + .max(LEISURE_CATEGORY_KEYS.length) + .nullable() + .optional(), + leisureMinutes: contextMinutes.nullable().optional(), + leisureJoy: contextRating.nullable().optional(), + leisureRecovery: contextRating.nullable().optional(), + eventType: z.enum(EVENT_TYPE_KEYS).nullable().optional(), + eventValence: z + .number() + .int() + .min(CONTEXT_VALENCE_MIN) + .max(CONTEXT_VALENCE_MAX) + .nullable() + .optional(), + // The same plausibility bound the entry's own instant carries: no future + // instants beyond the clock-skew tolerance, nothing before 1900. + eventAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ) + .nullable() + .optional(), + // Capped like the entry note beside it. One note for the whole context. + note: z.string().max(500).nullable().optional(), +}); + export const createMoodEntrySchema = z .object({ mood: moodLevelEnum, @@ -145,6 +230,12 @@ export const createMoodEntrySchema = z // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. note: z.string().max(500).optional(), + // v1.38 — the day's context. Absent leaves an existing context alone on + // the `update:` arm of an `externalId` upsert, for the same reason A2 to + // A5 are left alone: a phone re-posting an entry from a build that has no + // context surface must not blank one somebody filled in on the web. A + // context that says nothing removes the row. + context: moodContextSchema.nullable().optional(), // v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no // future instants beyond a 5-min clock-skew tolerance, no instant before // 1900. Mirrors the measurement + medication-intake bound. @@ -186,6 +277,10 @@ export const updateMoodEntrySchema = z.object({ a4: levelADimension.nullable().optional(), a5: levelADimension.nullable().optional(), note: z.string().max(500).nullable().optional(), + // v1.38 — full replacement of the context when present, matching the way + // `tagKeys` and `ratedFactors` work on this path: omit to leave it alone, + // send it to replace it, send `null` (or an empty one) to remove the row. + context: moodContextSchema.nullable().optional(), // v1.17 W1b — same plausibility bound on the edit path. moodLoggedAt: validateEntryInstant( z.iso.datetime({ offset: true }).transform((s) => new Date(s)), @@ -219,3 +314,4 @@ export const listMoodEntriesSchema = z.object({ export type CreateMoodEntryInput = z.infer; export type UpdateMoodEntryInput = z.infer; export type ListMoodEntriesInput = z.infer; +export type MoodContextRequest = z.infer; diff --git a/tests/integration/mood-context.test.ts b/tests/integration/mood-context.test.ts new file mode 100644 index 000000000..3b97bcede --- /dev/null +++ b/tests/integration/mood-context.test.ts @@ -0,0 +1,357 @@ +/** + * v1.38 — the day context on a mood entry, against real Postgres and the real + * handlers. + * + * The claims that matter here are all about ABSENCE, which is the part a unit + * test with a mocked client cannot make honestly: + * + * - an entry whose sections were never opened stores NO context row; + * - one filled field stores one row with that field set and every other + * column NULL, not a row of defaulted middles; + * - re-posting the same `externalId` updates the context in place rather + * than accumulating rows or leaving a stale one behind; + * - the note goes in through `encryptNote` and comes back through + * `readNote`, with no plaintext column holding it; + * - a value outside the vocabulary is a 422 carrying every issue, not a + * silently dropped field. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { readNote } from "@/lib/crypto/note-cipher"; +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-context"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-context", + email: "mood-context@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function postRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function putRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("mood context write path (real Postgres)", () => { + it("stores no context row for an entry that carries none", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); + + it("stores no context row for an empty context object", async () => { + // A client that renders the sections and sends them back untouched has + // said the same thing as one that sent nothing at all. A row of NULLs + // would record "asked and answered nothing" where the truth is + // "never asked". + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + context: { contactCircles: [], leisureCategories: [], note: "" }, + }), + ); + expect(res.status).toBe(201); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); + + it("stores exactly the one field that was filled, everything else NULL", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T10:00:00.000Z", + context: { workStatus: "regular" }, + }), + ); + expect(res.status).toBe(201); + + const rows = await getPrismaClient().moodContext.findMany(); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.userId).toBe(TEST_USER_ID); + expect(row.workStatus).toBe("regular"); + expect(row.workMinutes).toBeNull(); + expect(row.workLoad).toBeNull(); + expect(row.contactCircles).toBeNull(); + expect(row.contactQuality).toBeNull(); + expect(row.leisureCategories).toBeNull(); + expect(row.eventType).toBeNull(); + expect(row.eventValence).toBeNull(); + expect(row.eventAt).toBeNull(); + expect(row.notesEncrypted).toBeNull(); + }); + + it("round-trips every section, with a zero that stays a zero", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "SCHLECHT", + moodLoggedAt: "2026-05-16T11:00:00.000Z", + context: { + workStatus: "overtime", + workMinutes: 540, + overtimeMinutes: 90, + workLoad: 9, + // Zero is a real answer. A carrier that treated it as absence would + // round-trip it to NULL and pass every truthy check on the way. + workSatisfaction: 0, + contactCircles: ["partner", "colleagues"], + contactForm: "inPerson", + contactExtent: "brief", + contactQuality: 6, + contactSupport: 4, + leisureCategories: ["reading"], + leisureMinutes: 45, + leisureJoy: 7, + leisureRecovery: 5, + eventType: "conflict", + eventValence: -3, + eventAt: "2026-05-16T16:30:00.000Z", + note: "Long meeting ran over.", + }, + }), + ); + expect(res.status).toBe(201); + + const body = (await res.json()) as { + data: { context: Record }; + }; + expect(body.data.context).toMatchObject({ + workStatus: "overtime", + workSatisfaction: 0, + contactCircles: ["partner", "colleagues"], + leisureCategories: ["reading"], + eventValence: -3, + note: "Long meeting ran over.", + }); + + const row = await getPrismaClient().moodContext.findFirstOrThrow(); + expect(row.workMinutes).toBe(540); + expect(row.overtimeMinutes).toBe(90); + expect(row.workLoad).toBe(9); + expect(row.workSatisfaction).toBe(0); + expect(row.contactCircles).toBe('["partner","colleagues"]'); + expect(row.leisureCategories).toBe('["reading"]'); + expect(row.eventAt?.toISOString()).toBe("2026-05-16T16:30:00.000Z"); + // The note is bytes at rest and decrypts back to the same text; nothing + // on this table holds it in the clear. + expect(row.notesEncrypted).not.toBeNull(); + expect(readNote(row.notesEncrypted, null)).toBe("Long meeting ran over."); + expect(JSON.stringify(row)).not.toContain("Long meeting ran over."); + }); + + it("re-posting the same externalId updates the context in place", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const first = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-17T08:00:00.000Z", + externalId: "ios-row-4711", + context: { workStatus: "regular", workLoad: 5 }, + }), + ); + expect(first.status).toBe(201); + + const second = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-17T08:00:00.000Z", + externalId: "ios-row-4711", + context: { workStatus: "overtime" }, + }), + ); + expect(second.status).toBe(201); + + const rows = await getPrismaClient().moodContext.findMany(); + expect(rows).toHaveLength(1); + // The write replaces the context whole: the capture surface always sends + // the sections it is showing, so a field the payload omits is one the + // person cleared. + expect(rows[0].workStatus).toBe("overtime"); + expect(rows[0].workLoad).toBeNull(); + }); + + it("leaves a stored context alone when a re-post carries none", async () => { + // The phone. Its build has no context surface, so it re-posts the entry + // without one — and that must not blank what somebody filled in on the + // web. + const { POST } = await import("@/app/api/mood-entries/route"); + await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-18T08:00:00.000Z", + externalId: "ios-row-4712", + context: { leisureCategories: ["music"], leisureJoy: 8 }, + }), + ); + await POST( + postRequest("/api/mood-entries", { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-18T08:00:00.000Z", + externalId: "ios-row-4712", + }), + ); + + const rows = await getPrismaClient().moodContext.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].leisureCategories).toBe('["music"]'); + expect(rows[0].leisureJoy).toBe(8); + }); + + it("PUT replaces the context and an explicit null removes the row", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const created = await POST( + postRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-19T08:00:00.000Z", + context: { eventType: "goodNews", eventValence: 4 }, + }), + ); + const { data } = (await created.json()) as { data: { id: string } }; + + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + const replaced = await PUT( + putRequest(`/api/mood-entries/${data.id}`, { + context: { eventType: "appointment", note: "Dentist." }, + }), + { params: Promise.resolve({ id: data.id }) }, + ); + expect(replaced.status).toBe(200); + const afterReplace = await getPrismaClient().moodContext.findFirstOrThrow(); + expect(afterReplace.eventType).toBe("appointment"); + expect(afterReplace.eventValence).toBeNull(); + expect(readNote(afterReplace.notesEncrypted, null)).toBe("Dentist."); + + const cleared = await PUT( + putRequest(`/api/mood-entries/${data.id}`, { context: null }), + { params: Promise.resolve({ id: data.id }) }, + ); + expect(cleared.status).toBe(200); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); + + it("the GET surfaces the stored context on the entry", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const created = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-20T08:00:00.000Z", + context: { contactCircles: ["family"], contactQuality: 9 }, + }), + ); + const { data } = (await created.json()) as { data: { id: string } }; + + const { GET } = await import("@/app/api/mood-entries/[id]/route"); + const res = await GET( + new NextRequest(`http://localhost/api/mood-entries/${data.id}`), + { params: Promise.resolve({ id: data.id }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { context: { contactCircles: string[]; contactQuality: number } }; + }; + expect(body.data.context.contactCircles).toEqual(["family"]); + expect(body.data.context.contactQuality).toBe(9); + }); + + it("refuses a value the vocabulary does not name, reporting every issue", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-21T08:00:00.000Z", + context: { + workStatus: "sabbatical", + workLoad: 42, + contactCircles: ["pets"], + }, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string; meta?: unknown }; + expect(body.error).toBeTruthy(); + expect(await getPrismaClient().moodContext.count()).toBe(0); + // The entry rolls back with it: a refused context is a refused request, + // not a half-saved one. + expect(await getPrismaClient().moodEntry.count()).toBe(0); + }); + + it("deleting the entry takes its context with it", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const created = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-22T08:00:00.000Z", + context: { workStatus: "off" }, + }), + ); + const { data } = (await created.json()) as { data: { id: string } }; + expect(await getPrismaClient().moodContext.count()).toBe(1); + + // A user-facing DELETE is a tombstone, so the context stays with the row + // it describes; a hard delete cascades. + await getPrismaClient().moodEntry.delete({ where: { id: data.id } }); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); +}); From 8eb36da2281cfc737701ad600dea86abeac62a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:14:04 +0200 Subject: [PATCH 04/22] feat(backup): carry the mood day context through export and restore - the completeness guard now binds a third model, MoodContext, to a named select; the four omitted columns each carry the reason the backup is right to drop them - the disaster-recovery arm carries the ciphertext, the portable arm the plain text, and every other column rides in both - the restore rebuilds the context after its entry, inside the same transaction, and a file written before contexts existed restores without one - the round trip asserts the restored row by value, zero included --- .../mood-backup-completeness.test.ts | 73 +++++++- .../api/admin/backups/[id]/restore/route.ts | 44 +++++ src/lib/export/full-backup-payload.ts | 99 +++++++++++ src/lib/validations/backup.ts | 39 ++++ .../mood-backup-round-trip.test.ts | 168 ++++++++++++++++++ 5 files changed, 420 insertions(+), 3 deletions(-) diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index ef483325d..20d786091 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -1,6 +1,7 @@ /** - * Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and - * `MoodEntryTagLink` models to the disaster-recovery backup selects. + * Pair guard binding `prisma/schema.prisma`'s `MoodEntry`, + * `MoodEntryTagLink` and `MoodContext` models to the disaster-recovery backup + * selects. * * The sibling of `measurement-backup-completeness.test.ts`, written as its own * file rather than as a second describe inside it, because that guard's own @@ -27,7 +28,8 @@ * this goes red naming that column; add a scalar to the model without adding * it to the select and it goes red naming the new one; rename the mapper's * `moodEntry` parameter and the serialization assertion goes red on all of - * them at once. + * them at once. The same three hold for `MOOD_CONTEXT_BACKUP_SELECT` and the + * `moodContext` mapper parameter beside it. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -52,6 +54,16 @@ const EXCLUDED_MOOD_ENTRY_COLUMNS: Record = { userId: "user-scoped backup; owner is re-derived from the restoring account", }; +const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record = { + moodEntryId: + "re-bound to the restored parent row's id; the context rides inside its entry", + userId: "user-scoped backup; owner is re-derived from the restoring account", + createdAt: + "the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed", + updatedAt: + "the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value", +}; + const EXCLUDED_TAG_LINK_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id, never carried", moodTagId: @@ -173,6 +185,11 @@ describe("mood backup completeness", () => { models, ); const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT"); + const contextColumns = scalarFields( + modelBlock(schema, "MoodContext"), + models, + ); + const contextSelected = selectFields(source, "MOOD_CONTEXT_BACKUP_SELECT"); it("parses both ends into a plausible shape", () => { expect(models.size).toBeGreaterThan(50); @@ -194,6 +211,47 @@ describe("mood backup completeness", () => { expect(linkColumns).not.toContain("moodEntry"); expect(linkColumns).not.toContain("moodTag"); expect(linkSelected.length).toBeGreaterThan(0); + + expect(contextColumns.length).toBeGreaterThan(15); + expect(contextColumns).toContain("notesEncrypted"); + expect(contextColumns).toContain("eventAt"); + expect(contextColumns).not.toContain("moodEntry"); + expect(contextColumns).not.toContain("user"); + expect(contextSelected.length).toBeGreaterThan(15); + }); + + it("the backup select covers every MoodContext column", () => { + const missing = contextColumns.filter( + (c) => + !contextSelected.includes(c) && !(c in EXCLUDED_MOOD_CONTEXT_COLUMNS), + ); + + if (missing.length > 0) { + const report = missing.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `MOOD_CONTEXT_BACKUP_SELECT omits ${missing.length} column(s) of the MoodContext model:\n${report}\n\n` + + "A disaster-recovery restore brings these back as NULL, which reads " + + "as an answer nobody gave rather than as a lost one. Add each to " + + "the select and to both serialization arms, extend " + + "`moodContextSchema` in the backup validations and the restore " + + "write — or add it to EXCLUDED_MOOD_CONTEXT_COLUMNS with the reason " + + "the backup is correct to drop it.", + ); + } + }); + + it("every selected MoodContext column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "moodContext"); + const dropped = contextSelected.filter((c) => !emitted.has(c)); + + if (dropped.length > 0) { + const report = dropped.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `${dropped.length} selected MoodContext column(s) reach neither serialization arm:\n${report}\n\n` + + "The query pays for them and no payload carries them, so a restore " + + "cannot put them back.", + ); + } }); it("the backup select covers every MoodEntry column", () => { @@ -290,5 +348,14 @@ describe("mood backup completeness", () => { ).toContain(column); expect(reason.length).toBeGreaterThan(20); } + for (const [column, reason] of Object.entries( + EXCLUDED_MOOD_CONTEXT_COLUMNS, + )) { + expect( + contextColumns, + `${column} is excluded but is no longer a MoodContext column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } }); }); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index a6196cf0c..431bb15a2 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -858,6 +858,50 @@ const handler = apiHandler( update: restoredData, }); + // The day context, rebuilt after the entry so it can bind to + // whatever id that entry actually got. Delete-then-write rather + // than an upsert with a preserve-when-absent arm: the file + // states the whole context or states that there was none, and a + // restored entry carrying half a file's context and half a live + // row's would be a state neither of them describes. + await tx.moodContext.deleteMany({ + where: { moodEntryId: restored.id }, + }); + if (entry.context) { + const context = entry.context; + await tx.moodContext.create({ + data: { + moodEntryId: restored.id, + userId: ownerId, + workStatus: context.workStatus ?? null, + workMinutes: context.workMinutes ?? null, + overtimeMinutes: context.overtimeMinutes ?? null, + workLoad: context.workLoad ?? null, + workSatisfaction: context.workSatisfaction ?? null, + contactCircles: context.contactCircles ?? null, + contactForm: context.contactForm ?? null, + contactExtent: context.contactExtent ?? null, + contactQuality: context.contactQuality ?? null, + contactSupport: context.contactSupport ?? null, + leisureCategories: context.leisureCategories ?? null, + leisureMinutes: context.leisureMinutes ?? null, + leisureJoy: context.leisureJoy ?? null, + leisureRecovery: context.leisureRecovery ?? null, + eventType: context.eventType ?? null, + eventValence: context.eventValence ?? null, + eventAt: context.eventAt ? new Date(context.eventAt) : null, + // The note comes back the way every other dual-column + // note does: ciphertext verbatim when the file carries + // it, a portable file's plain text re-encrypted on the + // way in. + notesEncrypted: + context.notesEncrypted == null + ? encryptNote(context.note ?? null) + : decodeEncryptedBytes(context.notesEncrypted), + }, + }); + } + await tx.moodEntryTagLink.deleteMany({ where: { moodEntryId: restored.id }, }); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 0d3375c99..8a64e91ef 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -148,6 +148,96 @@ const MOOD_ENTRY_TAG_LINK_SELECT = { moodTag: { select: { key: true, kind: true } }, } as const satisfies Prisma.MoodEntryTagLinkSelect; +/** + * Every restorable `MoodContext` scalar. + * + * `moodEntryId` and `userId` are absent for the reasons the exclusion list in + * `mood-backup-completeness.test.ts` states: the context rides inside its + * entry and is re-bound to whatever id that entry got, and the owner is + * re-derived from the restoring account. The two row stamps are absent for the + * third reason there — they describe when the sub-row was written, not when + * the day happened, and the entry already carries the moment that matters. + * + * Named rather than inlined so the guard can compare it field-by-field against + * the model, exactly as it does for the two selects above. This is the whole + * point of the pair: a column added to `MoodContext` without a line here + * fails, and it fails in the release that added it. + */ +const MOOD_CONTEXT_BACKUP_SELECT = { + workStatus: true, + workMinutes: true, + overtimeMinutes: true, + workLoad: true, + workSatisfaction: true, + contactCircles: true, + contactForm: true, + contactExtent: true, + contactQuality: true, + contactSupport: true, + leisureCategories: true, + leisureMinutes: true, + leisureJoy: true, + leisureRecovery: true, + eventType: true, + eventValence: true, + eventAt: true, + notesEncrypted: true, +} as const satisfies Prisma.MoodContextSelect; + +type BackupMoodContext = Prisma.MoodContextGetPayload<{ + select: typeof MOOD_CONTEXT_BACKUP_SELECT; +}>; + +/** + * One context row, in whichever of the two wire shapes the caller asked for. + * + * The split is the same one `Measurement` makes: a disaster-recovery file + * carries the ciphertext verbatim, because the instance that reads it back + * holds the key; a portable export carries the plain text, because the point + * of a portable file is that somebody can read it. Every other column rides in + * both, since none of them is a storage detail — they are all answers somebody + * gave. + * + * The parameter is spelled `moodContext` rather than `c`, deliberately: the + * completeness guard proves a selected column reaches a payload by matching + * `moodContext.`, and a one-letter parameter shared with another + * mapper in this file would make that matcher unable to fail. + */ +function serialiseMoodContext( + moodContext: BackupMoodContext, + disasterRecovery: boolean, +) { + return { + ...(disasterRecovery + ? { + notesEncrypted: moodContext.notesEncrypted + ? Buffer.from(moodContext.notesEncrypted).toString("base64") + : null, + } + : { note: readNote(moodContext.notesEncrypted, null) }), + workStatus: moodContext.workStatus, + workMinutes: moodContext.workMinutes, + overtimeMinutes: moodContext.overtimeMinutes, + workLoad: moodContext.workLoad, + workSatisfaction: moodContext.workSatisfaction, + // The two multi-selects ride as the stored JSON string, unparsed. The + // restore writes the column back verbatim, so a file cannot arrive with a + // list the parser and the column disagree about. + contactCircles: moodContext.contactCircles, + contactForm: moodContext.contactForm, + contactExtent: moodContext.contactExtent, + contactQuality: moodContext.contactQuality, + contactSupport: moodContext.contactSupport, + leisureCategories: moodContext.leisureCategories, + leisureMinutes: moodContext.leisureMinutes, + leisureJoy: moodContext.leisureJoy, + leisureRecovery: moodContext.leisureRecovery, + eventType: moodContext.eventType, + eventValence: moodContext.eventValence, + eventAt: moodContext.eventAt?.toISOString() ?? null, + }; +} + /** * Build the canonical full-backup payload for `userId`. Portable exports omit * tombstones and document ciphertext; disaster-recovery payloads preserve @@ -259,6 +349,9 @@ export async function buildFullBackupPayload( // definition — so the present/absent tags, the kind most people pick, // were absent from the file and could not come back from it. tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT }, + // The day context, when the entry has one. Sparse by design — most + // entries carry none, and the join answers null on those. + context: { select: MOOD_CONTEXT_BACKUP_SELECT }, }, }), // The account's OWN mood tags. The seeded catalogue is reference data every @@ -505,6 +598,12 @@ export async function buildFullBackupPayload( deletedAt: moodEntry.deletedAt?.toISOString() ?? null, factors, structuredTags, + // The day context, or null. Null rather than an empty object, because + // "no context" and "a context whose every field is empty" are + // different facts and the restore has to keep them apart. + context: moodEntry.context + ? serialiseMoodContext(moodEntry.context, disasterRecovery) + : null, }; }), customMoodTags: customMoodTags.map((tag) => ({ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b8f671448..1f18b39fa 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -287,6 +287,45 @@ const moodEntrySchema = z // RATED-only meaning, so every file written before this one parses // unchanged and restores identically: absent reads as none. structuredTags: z.array(z.string().min(1)).default([]), + // The day context, when the entry had one. Optional and nullable at the + // top: a file written before this existed carries no `context` key at all + // and must parse and restore exactly as it did. Every field inside is + // optional for the same reason a context row's columns are nullable — + // only the sections somebody opened say anything. + // + // The two multi-selects ride as the stored JSON string rather than as + // arrays, so the restore writes the column back verbatim and a file cannot + // arrive with a list the parser and the column disagree about. Nothing is + // re-validated against the vocabulary here: a backup restores what the + // account had, and a key retired between the export and the restore is + // still that person's answer. + context: z + .object({ + workStatus: z.string().nullable().optional(), + workMinutes: z.number().int().nullable().optional(), + overtimeMinutes: z.number().int().nullable().optional(), + workLoad: z.number().int().nullable().optional(), + workSatisfaction: z.number().int().nullable().optional(), + contactCircles: z.string().nullable().optional(), + contactForm: z.string().nullable().optional(), + contactExtent: z.string().nullable().optional(), + contactQuality: z.number().int().nullable().optional(), + contactSupport: z.number().int().nullable().optional(), + leisureCategories: z.string().nullable().optional(), + leisureMinutes: z.number().int().nullable().optional(), + leisureJoy: z.number().int().nullable().optional(), + leisureRecovery: z.number().int().nullable().optional(), + eventType: z.string().nullable().optional(), + eventValence: z.number().int().nullable().optional(), + eventAt: isoDateTime.nullable().optional(), + // Same pair as the entry's own note: a portable file carries the + // plain text, a disaster-recovery file the ciphertext. + note: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + }) + .passthrough() + .nullable() + .optional(), }) .passthrough(); diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 71cbe1236..310e122c5 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -67,6 +67,8 @@ const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z"); const CREATED_AT = new Date("2026-07-19T23:41:00.000Z"); const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z"); const DELETED_AT = new Date("2026-07-20T07:00:00.000Z"); +const CONTEXT_NOTE = "the meeting overran and the evening went with it"; +const CONTEXT_EVENT_AT = new Date("2026-07-19T16:30:00.000Z"); beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -214,6 +216,29 @@ describe("mood backup round trip", () => { { moodTagId: ownTag.id, rating: null }, ], }, + // The day context, with one value from each of the four sections plus + // its own encrypted note. A zero is in there for the same reason it is + // among the level-A values above: zero is a real answer and a carrier + // that read it as absence would round-trip it to NULL and pass every + // truthiness check on the way. + context: { + create: { + userId: OWNER_ID, + workStatus: "overtime", + workMinutes: 600, + workLoad: 8, + workSatisfaction: 0, + contactCircles: JSON.stringify(["partner", "friends"]), + contactForm: "phone", + contactQuality: 7, + leisureCategories: JSON.stringify(["reading"]), + leisureMinutes: 30, + eventType: "conflict", + eventValence: -4, + eventAt: CONTEXT_EVENT_AT, + notesEncrypted: encryptNote(CONTEXT_NOTE), + }, + }, }, }); await prisma.moodEntry.create({ @@ -316,6 +341,149 @@ describe("mood backup round trip", () => { expect(byKey.get("rt_headache")!.rating).toBeNull(); expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull(); expect(byKey.get("rt_sleep_quality")!.rating).toBe(4); + + // The day context comes back as its own row, bound to the restored entry. + // Asserted as "the row is there" before any column is read, so a restore + // that stopped writing it fails by name instead of as a Prisma throw three + // frames down. + expect( + await prisma.moodContext.count({ + where: { moodEntryId: "mood-rt-entry" }, + }), + "the restored entry has no day context — the backup or the restore dropped it", + ).toBe(1); + const context = await prisma.moodContext.findUniqueOrThrow({ + where: { moodEntryId: "mood-rt-entry" }, + }); + expect(context.userId).toBe(OWNER_ID); + expect(context.workStatus).toBe("overtime"); + expect(context.workMinutes).toBe(600); + expect(context.workLoad).toBe(8); + // Zero, not NULL. + expect(context.workSatisfaction).toBe(0); + expect(context.contactCircles).toBe(JSON.stringify(["partner", "friends"])); + expect(context.contactForm).toBe("phone"); + expect(context.contactQuality).toBe(7); + // Untouched sections stay empty rather than arriving as a defaulted middle. + expect(context.contactSupport).toBeNull(); + expect(context.leisureCategories).toBe(JSON.stringify(["reading"])); + expect(context.leisureMinutes).toBe(30); + expect(context.leisureJoy).toBeNull(); + expect(context.eventType).toBe("conflict"); + expect(context.eventValence).toBe(-4); + expect(context.eventAt).toEqual(CONTEXT_EVENT_AT); + expect( + context.notesEncrypted, + "the restored context has no note ciphertext — the backup dropped it", + ).not.toBeNull(); + expect(decryptFromBytes(context.notesEncrypted!)).toBe(CONTEXT_NOTE); + + // The entry that never had a context still has none. A restore that minted + // an empty row for every entry would turn "never asked" into "asked and + // answered nothing" across the whole account. + expect( + await prisma.moodContext.count({ + where: { moodEntryId: "mood-rt-tombstone" }, + }), + ).toBe(0); + }); + + it("a file written before contexts existed restores without one", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + + // The compatibility floor for this change: no `context` key at all, and a + // live row that DOES have one. The restore wipes and rebuilds, so the row + // comes back exactly as the file describes it — which means without a + // context, not with the one that happened to be on disk. + await prisma.moodEntry.create({ + data: { + id: "mood-rt-legacy-context", + userId: OWNER_ID, + date: "2026-07-19", + mood: "OKAY", + score: 3, + source: "MANUAL", + moodLoggedAt: LOGGED_AT, + context: { create: { userId: OWNER_ID, workStatus: "regular" } }, + }, + }); + expect(await prisma.moodContext.count()).toBe(1); + + const legacyPayload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-legacy-context", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + factors: [], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(legacyPayload), + "MOOD_CONTEXT_LEGACY", + ); + expect(response.status).toBe(200); + await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-legacy-context" }, + }); + expect(await prisma.moodContext.count()).toBe(0); + }); + + it("carries the context through a portable export, in plain text", async () => { + const prisma = getPrismaClient(); + await seedOwner(prisma); + await prisma.moodEntry.create({ + data: { + id: "mood-rt-portable", + userId: OWNER_ID, + date: "2026-07-19", + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: LOGGED_AT, + context: { + create: { + userId: OWNER_ID, + leisureCategories: JSON.stringify(["music"]), + leisureJoy: 9, + notesEncrypted: encryptNote(CONTEXT_NOTE), + }, + }, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + const entries = (payload as { moodEntries: Array> }) + .moodEntries; + const context = entries[0].context as Record; + // A portable file is for a person to read: the note is plain text and no + // ciphertext rides along. + expect(context.note).toBe(CONTEXT_NOTE); + expect(context.notesEncrypted).toBeUndefined(); + expect(context.leisureJoy).toBe(9); + expect(context.leisureCategories).toBe(JSON.stringify(["music"])); }); it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => { From 1f609d42e0a26f99632123ff0803c21e65b4a805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:22:33 +0200 Subject: [PATCH 05/22] feat(mood): resolve sleep, activity, vitals and symptoms at read time - one resolver reads the day's figures from the modules that own them; nothing is copied onto the mood row, so correcting a sleep session corrects it here in the same moment - absence answers { present: false } rather than zero, because a night nobody recorded and a night of no sleep are different facts - a module switched off blanks its whole block instead of filtering down to an empty one - keyed by the local day so the capture sheet can ask before an entry exists --- src/app/api/mood/linked-context/route.ts | 74 +++++ src/lib/mood/linked-context.ts | 275 ++++++++++++++++ src/lib/query-keys/mood.ts | 16 + .../integration/mood-context-linkage.test.ts | 297 ++++++++++++++++++ 4 files changed, 662 insertions(+) create mode 100644 src/app/api/mood/linked-context/route.ts create mode 100644 src/lib/mood/linked-context.ts create mode 100644 tests/integration/mood-context-linkage.test.ts diff --git a/src/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts new file mode 100644 index 000000000..ff038fc04 --- /dev/null +++ b/src/app/api/mood/linked-context/route.ts @@ -0,0 +1,74 @@ +/** + * `GET /api/mood/linked-context?date=YYYY-MM-DD` — what the modules that own + * sleep, activity, vitals and body symptoms already know about one day. + * + * Keyed by the local day rather than by an entry id, deliberately: the capture + * sheet needs these figures BEFORE an entry exists, and a route that could + * only answer for a saved row would leave the one surface that most wants + * them unable to ask. The detail view passes the entry's own `date` and `tz` + * and gets the same answer. + * + * Nothing here is stored on the mood row. Every figure is read from its owning + * module on each request, which is what makes a corrected sleep session show + * up on the mood entry with nothing to re-sync. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { resolveLinkedDayContext } from "@/lib/mood/linked-context"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + // The zone the day is anchored to. Absent falls back to the account's own + // display timezone, which is what a fresh capture sheet means; the detail + // view passes the entry's stored `tz` so a row logged elsewhere keeps its + // own day boundaries. + tz: z.string().min(1).max(64).optional(), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "mind"); + + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + annotate({ + action: { name: "mood.linked-context.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "mood.linkedContext.invalid", + }); + } + + const linked = await resolveLinkedDayContext(user.id, { + date: parsed.data.date, + tz: parsed.data.tz ?? user.timezone ?? DEFAULT_TIMEZONE, + }); + + annotate({ + action: { name: "mood.linked-context.read" }, + // Which blocks the person can see at all, so a support question about a + // missing figure can be answered without asking them to describe their + // module settings. + meta: { + day: linked.day, + sleep_available: linked.sleep.available, + activity_available: linked.activity.available, + body_available: linked.body.available, + }, + }); + + return apiSuccess(linked); +}); diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts new file mode 100644 index 000000000..59632f33f --- /dev/null +++ b/src/lib/mood/linked-context.ts @@ -0,0 +1,275 @@ +/** + * What a mood entry's day looked like in the modules that already own it. + * + * The rule this file exists to serve is the one the whole context feature + * hangs off: one fact, one home. Sleep belongs to the sleep module, steps and + * active energy to the activity data, resting heart rate and HRV to the + * measurement engine, body symptoms to the illness module. A mood entry shows + * them and never asks for them a second time, and **nothing here is copied + * onto the mood row** — every figure is resolved at read time from the rows + * that own it, so correcting a sleep session corrects it here too, in the same + * moment, with nothing to re-sync. + * + * Two consequences that are easy to get wrong and are therefore stated: + * + * * **Absence is `{ present: false }`, never `0`.** A night nobody recorded + * and a night of no sleep are different facts, and a surface that renders + * the first as the second is lying about somebody's health record. + * * **A module that is switched off blanks its block.** It does not answer + * zero and it does not answer a filtered-down version of itself — the + * block is simply not there, the same way `snapshot.ts` blanks rather than + * filters. Turning a module off is a statement about what the person wants + * to see, not a filter over what they wanted to see. + * + * The reads are bounded: one local day per entry, resolved through the day-key + * helper that already carries the legacy-Berlin fallback for `tz IS NULL` + * rows. There is no unbounded `findMany` here, and adding one would turn a + * detail view into a table scan on an account with years of history. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; + +/** A figure that either exists or honestly does not. */ +export type LinkedFigure = + { present: false } | { present: true; value: number; unit: string }; + +/** A block that can also be absent because its module is switched off. */ +export type LinkedBlock = + { available: false; reason: "module-disabled" } | ({ available: true } & T); + +export interface LinkedSleep { + /** Time asleep for the night the entry's local day woke up on. */ + asleep: LinkedFigure; + /** Time in bed, when any writer recorded a bed window. */ + inBed: LinkedFigure; +} + +export interface LinkedActivity { + steps: LinkedFigure; + activeEnergy: LinkedFigure; +} + +export interface LinkedVitals { + restingHeartRate: LinkedFigure; + heartRateVariability: LinkedFigure; +} + +export interface LinkedBody { + /** Whether an illness day-log exists for this day at all. */ + logged: boolean; + /** How much the day was limited, 0-3, as the illness module records it. */ + functionalImpact: LinkedFigure; + /** Symptoms linked to that day-log, by their catalogue key. */ + symptoms: string[]; + /** The episode to open in the illness module, when there is one. */ + episodeId: string | null; +} + +export interface LinkedDayContext { + /** The entry's local day, resolved through the entry's own `tz`. */ + day: string; + sleep: LinkedBlock; + activity: LinkedBlock; + /** + * Resting heart rate and HRV. Not gated: the measurement engine is core and + * has no off switch, so this block is always available and answers absence + * per figure instead. + */ + vitals: LinkedBlock; + body: LinkedBlock; +} + +const MODULE_OFF = { available: false, reason: "module-disabled" } as const; +const ABSENT: LinkedFigure = { present: false }; + +function figure(value: number | null | undefined, unit: string): LinkedFigure { + // A stored zero is a real reading and stays one. Only a missing row is + // absent, which is why this tests for null rather than for falsiness. + return value === null || value === undefined + ? ABSENT + : { present: true, value, unit }; +} + +/** Sum a day's rows for a cumulative metric; no rows means absent, not zero. */ +function sumOfDay( + rows: ReadonlyArray<{ type: MeasurementType; value: number }>, + type: MeasurementType, + unit: string, +): LinkedFigure { + const matching = rows.filter((r) => r.type === type); + if (matching.length === 0) return ABSENT; + return { + present: true, + value: matching.reduce((sum, r) => sum + r.value, 0), + unit, + }; +} + +/** Latest reading of the day for a point-in-time metric. */ +function latestOfDay( + rows: ReadonlyArray<{ + type: MeasurementType; + value: number; + measuredAt: Date; + }>, + type: MeasurementType, + unit: string, +): LinkedFigure { + const matching = rows + .filter((r) => r.type === type) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + if (matching.length === 0) return ABSENT; + return { present: true, value: matching[matching.length - 1].value, unit }; +} + +/** + * A UTC window guaranteed to contain one local day, whatever its zone. + * + * Generous on both sides on purpose: the exact boundaries are decided by + * `moodDateKey` afterwards, and this only has to be sure it did not clip the + * day off at either end. Doing the arithmetic here instead would mean + * re-deriving a zone offset that the day-key helper already knows, and that + * second derivation is where the DST hour goes missing. + */ +function localDayWindow(day: string): { from: Date; to: Date } { + // Walk outwards from the naive UTC midnight and keep whatever the day-key + // helper agrees belongs to this day. Cheaper and far safer than arithmetic + // on `new Date(y, m, d)`, which slips by an hour twice a year and has + // already been solved once in this codebase. + const naive = new Date(`${day}T00:00:00.000Z`); + const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000); + const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000); + return { from, to }; +} + +/** Keep only the rows whose own local day is the one asked for. */ +function onDay( + rows: readonly T[], + day: string, + tz: string, +): T[] { + return rows.filter((r) => moodDateKey(r.measuredAt, tz) === day); +} + +/** The measurement types the linked block reads. One query covers all four. */ +const LINKED_MEASUREMENT_TYPES: MeasurementType[] = [ + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", +]; + +/** + * Resolve the linked figures for one entry's local day. + * + * Takes the entry's own `date` and `tz` rather than re-deriving them, because + * the row already decided which day it belongs to and a second derivation here + * could disagree with it — which is precisely the class of bug the per-row + * `tz` column was added to end. + */ +export async function resolveLinkedDayContext( + userId: string, + entry: { date: string; tz: string | null }, +): Promise { + const tz = entry.tz ?? DEFAULT_TIMEZONE; + const day = entry.date; + const { from, to } = localDayWindow(day); + + const [sleepOn, activityOn, illnessOn] = await Promise.all([ + isModuleEnabled(userId, "sleep"), + isModuleEnabled(userId, "workouts"), + isModuleEnabled(userId, "illness"), + ]); + + const measurements = await prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: { in: LINKED_MEASUREMENT_TYPES }, + measuredAt: { gte: from, lte: to }, + }, + select: { + type: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + orderBy: { measuredAt: "asc" }, + }); + + const dayRows = onDay(measurements, day, tz); + + let sleep: LinkedBlock = MODULE_OFF; + if (sleepOn) { + // The stage rows of the whole window, not just the day's, and then the + // night whose WAKE day is this one. A night starts the evening before, so + // filtering the raw rows by day first would cut it in half — the + // reconstruction is the thing that knows where a night begins. + const stageRows = measurements.filter((r) => r.type === "SLEEP_DURATION"); + if (stageRows.length === 0) { + sleep = { available: true, asleep: ABSENT, inBed: ABSENT }; + } else { + const priority = await loadUserSourcePriority(userId); + const night = reconstructSleepNights( + stageRows as unknown as SleepStageRow[], + tz, + priority, + ).find((n) => n.night === day); + sleep = { + available: true, + asleep: figure(night?.asleepMinutes ?? null, "min"), + inBed: figure(night?.inBedMinutes ?? null, "min"), + }; + } + } + + const activity: LinkedBlock = activityOn + ? { + available: true, + steps: sumOfDay(dayRows, "ACTIVITY_STEPS", "steps"), + activeEnergy: sumOfDay(dayRows, "ACTIVE_ENERGY_BURNED", "kcal"), + } + : MODULE_OFF; + + const vitals: LinkedBlock = { + available: true, + restingHeartRate: latestOfDay(dayRows, "RESTING_HEART_RATE", "bpm"), + heartRateVariability: latestOfDay(dayRows, "HEART_RATE_VARIABILITY", "ms"), + }; + + let body: LinkedBlock = MODULE_OFF; + if (illnessOn) { + // Read only. The illness module owns symptom capture and its severity + // scale; the mood surface links into it and captures nothing, which is + // what keeps the two from disagreeing about the same day. + const dayLog = await prisma.illnessDayLog.findFirst({ + where: { userId, date: day, deletedAt: null }, + select: { + episodeId: true, + functionalImpact: true, + symptomLinks: { + select: { severity: true, symptom: { select: { key: true } } }, + }, + }, + }); + body = { + available: true, + logged: dayLog !== null, + functionalImpact: figure(dayLog?.functionalImpact ?? null, "level"), + symptoms: dayLog?.symptomLinks.map((l) => l.symptom.key) ?? [], + episodeId: dayLog?.episodeId ?? null, + }; + } + + return { day, sleep, activity, vitals, body }; +} diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts index 0bc580a2c..bde824988 100644 --- a/src/lib/query-keys/mood.ts +++ b/src/lib/query-keys/mood.ts @@ -42,6 +42,22 @@ export const moodKeys = { */ moodEntriesDay: (date: string) => ["mood-entries", "day", date] as const, + /** + * v1.38 — what the sleep, activity, vitals and illness modules already know + * about one local day, read by the mood capture sheet and the entry detail. + * + * Keyed by the day AND the zone: the same calendar date read under two + * timezones covers two different windows, and one key for both would serve + * whichever answer arrived first. + * + * Its own prefix rather than a segment under `["mood-entries"]`, and + * deliberately: nothing here comes from a mood row, so a mood write has no + * reason to invalidate it, and putting it under that prefix would refetch + * four modules' figures every time somebody tapped a face. + */ + moodLinkedContext: (date: string, tz: string) => + ["mood-linked-context", date, tz] as const, + moodAnalytics: () => ["mood-analytics"] as const, /** * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, diff --git a/tests/integration/mood-context-linkage.test.ts b/tests/integration/mood-context-linkage.test.ts new file mode 100644 index 000000000..98313b0be --- /dev/null +++ b/tests/integration/mood-context-linkage.test.ts @@ -0,0 +1,297 @@ +/** + * v1.38 — the linked day block, proven against real Postgres to be a read of + * the owning module rather than a copy of it. + * + * The claim the whole design rests on cannot be made by a shape test. "The + * response has a `sleep` key" would stay green over an implementation that + * copied the figure onto the mood row at write time and never looked at it + * again — and that implementation is exactly the failure mode this feature + * exists to avoid. So the test changes the SOURCE and reads the mood surface + * again: write a sleep session, read the block, correct the session, read + * again, assert the block moved. + * + * Beside it, the two absences that are easy to get wrong: + * - a module switched off blanks its block rather than answering zero; + * - a day with no rows answers `{ present: false }`, which is a different + * fact from a day of no sleep. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-linkage"; +const DAY = "2026-06-11"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-linkage", + email: "mood-linkage@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +interface LinkedFigure { + present: boolean; + value?: number; + unit?: string; +} +interface LinkedBody { + available: boolean; + reason?: string; + day: string; + sleep: { available: boolean; asleep?: LinkedFigure; inBed?: LinkedFigure }; + activity: { + available: boolean; + reason?: string; + steps?: LinkedFigure; + activeEnergy?: LinkedFigure; + }; + vitals: { + available: boolean; + restingHeartRate?: LinkedFigure; + heartRateVariability?: LinkedFigure; + }; + body: { + available: boolean; + reason?: string; + logged?: boolean; + symptoms?: string[]; + episodeId?: string | null; + }; +} + +async function readLinked(day = DAY): Promise { + const { GET } = await import("@/app/api/mood/linked-context/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/mood/linked-context?date=${day}&tz=Europe%2FBerlin`, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: LinkedBody }; + return body.data; +} + +/** One night's sleep, as the sleep module stores it: minutes per stage. */ +async function writeSleep(minutes: number) { + return getPrismaClient().measurement.create({ + data: { + userId: TEST_USER_ID, + type: "SLEEP_DURATION", + value: minutes, + unit: "min", + // A night that ends on the morning of DAY: the reconstruction keys a + // night on the day it woke up on. + measuredAt: new Date("2026-06-11T05:30:00.000Z"), + sleepStage: "ASLEEP", + source: "MANUAL", + }, + }); +} + +describe("mood context linkage (real Postgres)", () => { + it("a corrected sleep session moves the linked figure, because nothing was copied", async () => { + const row = await writeSleep(400); + + const before = await readLinked(); + expect(before.sleep.available).toBe(true); + expect(before.sleep.asleep).toEqual({ + present: true, + value: 400, + unit: "min", + }); + + // The correction, made where the fact lives. + await getPrismaClient().measurement.update({ + where: { id: row.id }, + data: { value: 455 }, + }); + + const after = await readLinked(); + expect(after.sleep.asleep).toEqual({ + present: true, + value: 455, + unit: "min", + }); + }); + + it("a day with no rows answers absence, not zero", async () => { + const linked = await readLinked(); + expect(linked.sleep.available).toBe(true); + expect(linked.sleep.asleep).toEqual({ present: false }); + expect(linked.activity.steps).toEqual({ present: false }); + expect(linked.vitals.restingHeartRate).toEqual({ present: false }); + // Never a zero: a night nobody recorded and a night of no sleep are + // different facts, and only one of them belongs in a health record. + expect(JSON.stringify(linked)).not.toContain('"value":0'); + }); + + it("sums the day's activity and takes the day's latest vital", async () => { + await getPrismaClient().measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 4000, + unit: "steps", + measuredAt: new Date("2026-06-11T08:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 2500, + unit: "steps", + measuredAt: new Date("2026-06-11T18:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + measuredAt: new Date("2026-06-11T07:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 58, + unit: "bpm", + measuredAt: new Date("2026-06-11T21:00:00.000Z"), + source: "MANUAL", + }, + // The day before, to prove the window does not bleed. + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 9999, + unit: "steps", + measuredAt: new Date("2026-06-10T12:00:00.000Z"), + source: "MANUAL", + }, + ], + }); + + const linked = await readLinked(); + expect(linked.activity.steps).toEqual({ + present: true, + value: 6500, + unit: "steps", + }); + expect(linked.vitals.restingHeartRate).toEqual({ + present: true, + value: 58, + unit: "bpm", + }); + }); + + it("blanks the sleep block when the sleep module is off, rather than answering zero", async () => { + await writeSleep(400); + expect((await readLinked()).sleep.available).toBe(true); + + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { modulePreferencesJson: { sleep: false } }, + }); + + const linked = await readLinked(); + expect(linked.sleep).toEqual({ + available: false, + reason: "module-disabled", + }); + // The block is gone, not zeroed and not filtered down to an empty figure. + expect(linked.sleep).not.toHaveProperty("asleep"); + // And the other blocks are untouched by that choice. + expect(linked.activity.available).toBe(true); + expect(linked.vitals.available).toBe(true); + }); + + it("links the illness day-log read-only, and blanks it when the module is off", async () => { + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: TEST_USER_ID, + label: "cold", + type: "INFECTION", + onsetAt: new Date("2026-06-10T08:00:00.000Z"), + }, + }); + // The symptom catalogue is seeded reference data every instance has, so + // this upserts rather than creating: the point of the test is the link, + // not the row. + const symptom = await getPrismaClient().illnessSymptom.upsert({ + where: { key: "cough" }, + create: { key: "cough", labelKey: "illness.symptom.cough" }, + update: {}, + }); + await getPrismaClient().illnessDayLog.create({ + data: { + userId: TEST_USER_ID, + episodeId: episode.id, + date: DAY, + functionalImpact: 2, + symptomLinks: { create: [{ symptomId: symptom.id, severity: 2 }] }, + }, + }); + + const linked = await readLinked(); + expect(linked.body.available).toBe(true); + expect(linked.body.logged).toBe(true); + expect(linked.body.symptoms).toEqual(["cough"]); + expect(linked.body.episodeId).toBe(episode.id); + + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { modulePreferencesJson: { illness: false } }, + }); + expect((await readLinked()).body).toEqual({ + available: false, + reason: "module-disabled", + }); + }); + + it("refuses a malformed day", async () => { + const { GET } = await import("@/app/api/mood/linked-context/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/mood/linked-context?date=yesterday", + ), + ); + expect(res.status).toBe(422); + }); +}); From 0474b7161d0c263cd0cd3432a48df526fd6ba1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:30:26 +0200 Subject: [PATCH 06/22] feat(mood): four context sections that cost nothing while closed - work, contacts, leisure and one notable event, each a closed accordion with a count badge, on both the capture sheet and the edit dialog - sleep, activity, vitals and the illness journal render read-only beside them, each row naming where its figure comes from and offering a way in to change it there - the body line links into the illness module and captures nothing - six locales for every label and every anchor --- messages/de.json | 17 + messages/en.json | 17 + messages/es.json | 17 + messages/fr.json | 17 + messages/it.json | 17 + messages/pl.json | 17 + src/components/mood/mood-context-fields.tsx | 701 ++++++++++++++++++ src/components/mood/mood-form.tsx | 38 + src/components/mood/mood-list.tsx | 50 ++ src/components/mood/use-linked-day-context.ts | 52 ++ 10 files changed, 943 insertions(+) create mode 100644 src/components/mood/mood-context-fields.tsx create mode 100644 src/components/mood/use-linked-day-context.ts diff --git a/messages/de.json b/messages/de.json index 83bf2a774..debcaf22c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1031,6 +1031,23 @@ "high": "deutlich gut" } } + }, + "dayContext": { + "minutesPlaceholder": "Minuten", + "contactsHelp": "Wie viele Menschen es waren, sagt nichts über den Tag. Was zählt, ist wie der Kontakt sich angefühlt hat.", + "notePlaceholder": "Was zu diesem Tag noch gehört", + "notRecorded": "nicht erfasst", + "linkedTitle": "Aus deinen anderen Modulen", + "linkedHelp": "Gelesen, wo es gepflegt wird. Änderst du es dort, ändert es sich auch hier.", + "linkedSleep": "Geschlafen", + "linkedInBed": "Im Bett", + "linkedSteps": "Schritte", + "linkedActiveEnergy": "Aktive Energie", + "linkedRestingHeartRate": "Ruhepuls", + "linkedHrv": "Herzratenvariabilität", + "bodyLogged": "Im Krankheitstagebuch erfasst: {count} Symptome", + "bodyNotLogged": "Für diesen Tag ist im Krankheitstagebuch nichts erfasst", + "openIllness": "Öffnen" } }, "medications": { diff --git a/messages/en.json b/messages/en.json index f8f791de8..0562c627d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1031,6 +1031,23 @@ "high": "clearly good" } } + }, + "dayContext": { + "minutesPlaceholder": "Minutes", + "contactsHelp": "How many people it was says nothing about the day. What counts is how the contact felt.", + "notePlaceholder": "Anything else that belongs to this day", + "notRecorded": "not recorded", + "linkedTitle": "From your other modules", + "linkedHelp": "Read from where it is kept. Change it there and it changes here.", + "linkedSleep": "Asleep", + "linkedInBed": "In bed", + "linkedSteps": "Steps", + "linkedActiveEnergy": "Active energy", + "linkedRestingHeartRate": "Resting heart rate", + "linkedHrv": "Heart rate variability", + "bodyLogged": "Logged in the illness journal: {count} symptoms", + "bodyNotLogged": "Nothing is logged in the illness journal for this day", + "openIllness": "Open" } }, "medications": { diff --git a/messages/es.json b/messages/es.json index d42236d4d..97276f21a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1031,6 +1031,23 @@ "high": "claramente bueno" } } + }, + "dayContext": { + "minutesPlaceholder": "Minutos", + "contactsHelp": "Cuántas personas fueron no dice nada del día. Lo que cuenta es cómo se sintió el contacto.", + "notePlaceholder": "Cualquier otra cosa que forme parte de este día", + "notRecorded": "sin registrar", + "linkedTitle": "De tus otros módulos", + "linkedHelp": "Se lee de donde está guardado. Cámbialo allí y cambia aquí.", + "linkedSleep": "Dormido", + "linkedInBed": "En la cama", + "linkedSteps": "Pasos", + "linkedActiveEnergy": "Energía activa", + "linkedRestingHeartRate": "Pulso en reposo", + "linkedHrv": "Variabilidad cardiaca", + "bodyLogged": "Registrado en el diario de enfermedad: {count} síntomas", + "bodyNotLogged": "No hay nada registrado en el diario de enfermedad para este día", + "openIllness": "Abrir" } }, "medications": { diff --git a/messages/fr.json b/messages/fr.json index bca12109c..4e5057a54 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1031,6 +1031,23 @@ "high": "nettement positif" } } + }, + "dayContext": { + "minutesPlaceholder": "Minutes", + "contactsHelp": "Le nombre de personnes ne dit rien de la journée. Ce qui compte, c'est le ressenti du contact.", + "notePlaceholder": "Ce qui appartient encore à cette journée", + "notRecorded": "non enregistré", + "linkedTitle": "Depuis tes autres modules", + "linkedHelp": "Lu là où c'est enregistré. Modifie-le là-bas et cela change ici.", + "linkedSleep": "Sommeil", + "linkedInBed": "Au lit", + "linkedSteps": "Pas", + "linkedActiveEnergy": "Énergie active", + "linkedRestingHeartRate": "Fréquence cardiaque au repos", + "linkedHrv": "Variabilité cardiaque", + "bodyLogged": "Consigné dans le journal de maladie : {count} symptômes", + "bodyNotLogged": "Rien n'est consigné dans le journal de maladie pour cette journée", + "openIllness": "Ouvrir" } }, "medications": { diff --git a/messages/it.json b/messages/it.json index 0fa6d76a7..e83bbd6b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1031,6 +1031,23 @@ "high": "chiaramente positivo" } } + }, + "dayContext": { + "minutesPlaceholder": "Minuti", + "contactsHelp": "Quante persone fossero non dice nulla della giornata. Conta come è stato vissuto il contatto.", + "notePlaceholder": "Che altro appartiene a questa giornata", + "notRecorded": "non registrato", + "linkedTitle": "Dagli altri moduli", + "linkedHelp": "Letto da dove è registrato. Cambialo lì e cambia anche qui.", + "linkedSleep": "Dormito", + "linkedInBed": "A letto", + "linkedSteps": "Passi", + "linkedActiveEnergy": "Energia attiva", + "linkedRestingHeartRate": "Frequenza cardiaca a riposo", + "linkedHrv": "Variabilità cardiaca", + "bodyLogged": "Registrato nel diario di malattia: {count} sintomi", + "bodyNotLogged": "Per questa giornata non c'è nulla nel diario di malattia", + "openIllness": "Apri" } }, "medications": { diff --git a/messages/pl.json b/messages/pl.json index bf977d2aa..60fc2564f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1031,6 +1031,23 @@ "high": "wyraźnie dobry" } } + }, + "dayContext": { + "minutesPlaceholder": "Minuty", + "contactsHelp": "Liczba osób nic nie mówi o dniu. Liczy się to, jak kontakt został odebrany.", + "notePlaceholder": "Co jeszcze należy do tego dnia", + "notRecorded": "nie zapisano", + "linkedTitle": "Z pozostałych modułów", + "linkedHelp": "Odczytane stamtąd, gdzie jest przechowywane. Zmień to tam, a zmieni się tutaj.", + "linkedSleep": "Sen", + "linkedInBed": "W łóżku", + "linkedSteps": "Kroki", + "linkedActiveEnergy": "Energia aktywna", + "linkedRestingHeartRate": "Tętno spoczynkowe", + "linkedHrv": "Zmienność rytmu serca", + "bodyLogged": "Zapisano w dzienniku choroby: {count} objawów", + "bodyNotLogged": "Na ten dzień nic nie zapisano w dzienniku choroby", + "openIllness": "Otwórz" } }, "medications": { diff --git a/src/components/mood/mood-context-fields.tsx b/src/components/mood/mood-context-fields.tsx new file mode 100644 index 000000000..533e04384 --- /dev/null +++ b/src/components/mood/mood-context-fields.tsx @@ -0,0 +1,701 @@ +"use client"; + +/** + * The four day-context sections, and the read-only block beside them. + * + * One component for both mood surfaces — the create sheet and the list's edit + * dialog — for the same reason the level-A sliders share one: what one writes + * the other has to be able to show, and a value somebody set on Monday and + * cannot correct on Tuesday is worse than one they were never offered. + * + * Every section is closed by default and renders no inputs while closed, so an + * entry is still one tap and Save. The count badge on a closed section says + * how much is in it, so nothing is hidden silently. + * + * Two copy rules hold throughout, and both are load-bearing rather than + * stylistic. Labels state facts and never judgements: "worked a regular day" + * is a fact and "worked too much" is a verdict on somebody's life. And the + * linked block is read-only — it shows what the sleep, activity, vitals and + * illness modules already hold, names where each figure comes from, and offers + * a way in to change it there. It is never an input here, because the moment + * it were, the same night would have two homes. + */ +import { useMemo } from "react"; +import { + Activity, + Briefcase, + CalendarClock, + Link2, + Palmtree, + Users, +} from "lucide-react"; + +import { DateTimeField } from "@/components/ui/date-time-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; +import { SliderField } from "@/components/ui/slider"; +import { Textarea } from "@/components/ui/textarea"; +import { useTranslations } from "@/lib/i18n/context"; +import { + CONTACT_CIRCLE_KEYS, + CONTACT_EXTENT_KEYS, + CONTACT_FORM_KEYS, + CONTEXT_MINUTES_MAX, + CONTEXT_MINUTES_MIN, + CONTEXT_RATING_MAX, + CONTEXT_RATING_MIN, + CONTEXT_VALENCE_MAX, + CONTEXT_VALENCE_MIN, + EVENT_TYPE_KEYS, + LEISURE_CATEGORY_KEYS, + WORK_STATUS_KEYS, + contextSectionLabelKey, + contextValueLabelKey, +} from "@/lib/mood/context-vocabulary"; +import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context"; + +const CONTEXT_NOTE_MAX_LENGTH = 500; + +/** The context as the form holds it. `null` everywhere means unanswered. */ +export interface MoodContextState { + workStatus: string | null; + workMinutes: number | null; + overtimeMinutes: number | null; + workLoad: number | null; + workSatisfaction: number | null; + contactCircles: string[]; + contactForm: string | null; + contactExtent: string | null; + contactQuality: number | null; + contactSupport: number | null; + leisureCategories: string[]; + leisureMinutes: number | null; + leisureJoy: number | null; + leisureRecovery: number | null; + eventType: string | null; + eventValence: number | null; + /** Local `datetime-local` value, or "" for unanswered. */ + eventAt: string; + note: string; +} + +export const EMPTY_MOOD_CONTEXT: MoodContextState = { + workStatus: null, + workMinutes: null, + overtimeMinutes: null, + workLoad: null, + workSatisfaction: null, + contactCircles: [], + contactForm: null, + contactExtent: null, + contactQuality: null, + contactSupport: null, + leisureCategories: [], + leisureMinutes: null, + leisureJoy: null, + leisureRecovery: null, + eventType: null, + eventValence: null, + eventAt: "", + note: "", +}; + +/** Which state fields each section owns, so a badge counts its own section. */ +const SECTION_FIELDS = { + work: [ + "workStatus", + "workMinutes", + "overtimeMinutes", + "workLoad", + "workSatisfaction", + ], + contacts: [ + "contactCircles", + "contactForm", + "contactExtent", + "contactQuality", + "contactSupport", + ], + leisure: [ + "leisureCategories", + "leisureMinutes", + "leisureJoy", + "leisureRecovery", + ], + events: ["eventType", "eventValence", "eventAt", "note"], +} as const satisfies Record>; + +function isAnswered(value: MoodContextState[keyof MoodContextState]): boolean { + if (value === null) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "string") return value.trim() !== ""; + return true; +} + +/** How many answers a section holds — the collapsed section's badge. */ +export function contextSectionCount( + state: MoodContextState, + section: keyof typeof SECTION_FIELDS, +): number { + return SECTION_FIELDS[section].filter((field) => isAnswered(state[field])) + .length; +} + +/** Does the whole context say anything at all? */ +export function contextIsEmpty(state: MoodContextState): boolean { + return ( + Object.keys(SECTION_FIELDS) as Array + ).every((section) => contextSectionCount(state, section) === 0); +} + +/** + * The context as the API takes it. + * + * `null` when nothing was answered, which is how the write path is told to + * store no row (and to remove one that is there). Not `undefined`: on the edit + * path an omitted key means "leave it alone", and a person who cleared every + * section means the opposite of that. + */ +export function moodContextPayload( + state: MoodContextState, +): Record | null { + if (contextIsEmpty(state)) return null; + return { + workStatus: state.workStatus, + workMinutes: state.workMinutes, + overtimeMinutes: state.overtimeMinutes, + workLoad: state.workLoad, + workSatisfaction: state.workSatisfaction, + contactCircles: state.contactCircles, + contactForm: state.contactForm, + contactExtent: state.contactExtent, + contactQuality: state.contactQuality, + contactSupport: state.contactSupport, + leisureCategories: state.leisureCategories, + leisureMinutes: state.leisureMinutes, + leisureJoy: state.leisureJoy, + leisureRecovery: state.leisureRecovery, + eventType: state.eventType, + eventValence: state.eventValence, + eventAt: state.eventAt ? new Date(state.eventAt).toISOString() : null, + note: state.note.trim() === "" ? null : state.note.trim(), + }; +} + +/** Seed the form from an entry being edited. */ +export function moodContextFromEntry( + context: + | (Partial> & { + eventAt?: string | null; + note?: string | null; + }) + | null + | undefined, +): MoodContextState { + if (!context) return EMPTY_MOOD_CONTEXT; + return { + ...EMPTY_MOOD_CONTEXT, + ...context, + contactCircles: context.contactCircles ?? [], + leisureCategories: context.leisureCategories ?? [], + // The wire carries an ISO instant; the control wants a local wall clock. + eventAt: context.eventAt ? toLocalInputValue(context.eventAt) : "", + note: context.note ?? "", + }; +} + +function toLocalInputValue(iso: string): string { + const at = new Date(iso); + const local = new Date(at.getTime() - at.getTimezoneOffset() * 60 * 1000); + return local.toISOString().slice(0, 16); +} + +/** A selectable chip. Tapping the selected one again takes the answer back. */ +function ChoiceChip({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ChoiceRow({ + label, + field, + keys, + value, + onChange, +}: { + label: string; + field: string; + keys: readonly string[]; + value: string | null; + onChange: (next: string | null) => void; +}) { + const { t } = useTranslations(); + return ( +
+ +
+ {keys.map((key) => ( + onChange(value === key ? null : key)} + /> + ))} +
+
+ ); +} + +function MultiChoiceRow({ + label, + field, + keys, + value, + onChange, +}: { + label: string; + field: string; + keys: readonly string[]; + value: string[]; + onChange: (next: string[]) => void; +}) { + const { t } = useTranslations(); + return ( +
+ +
+ {keys.map((key) => ( + + onChange( + value.includes(key) + ? value.filter((k) => k !== key) + : [...value, key], + ) + } + /> + ))} +
+
+ ); +} + +function MinutesField({ + id, + label, + value, + onChange, +}: { + id: string; + label: string; + value: number | null; + onChange: (next: number | null) => void; +}) { + const { t } = useTranslations(); + return ( + + { + const raw = e.target.value; + // An emptied field is an unanswered field, not a zero. Zero minutes + // is a real answer somebody can still type. + onChange(raw === "" ? null : Number(raw)); + }} + /> + + ); +} + +/** One 0-10 rating with its two anchors, resolved from the vocabulary. */ +function ContextRating({ + field, + value, + onChange, +}: { + field: string; + value: number | null; + onChange: (next: number | null) => void; +}) { + const { t } = useTranslations(); + const low = t(`mood.context.rating.${field}.low`); + const high = t(`mood.context.rating.${field}.high`); + return ( + onChange(null)} + onValueChange={onChange} + /> + ); +} + +interface MoodContextSectionsProps { + value: MoodContextState; + onChange: (next: MoodContextState) => void; +} + +export function MoodContextSections({ + value, + onChange, +}: MoodContextSectionsProps) { + const { t } = useTranslations(); + const set = ( + key: K, + next: MoodContextState[K], + ) => onChange({ ...value, [key]: next }); + + return ( + <> + } + summary={ + + } + > +
+ set("workStatus", next)} + /> + set("workMinutes", next)} + /> + set("overtimeMinutes", next)} + /> + set("workLoad", next)} + /> + set("workSatisfaction", next)} + /> +
+
+ + } + summary={ + + } + > +
+

+ {t("mood.dayContext.contactsHelp")} +

+ set("contactCircles", next)} + /> + set("contactForm", next)} + /> + set("contactExtent", next)} + /> + set("contactQuality", next)} + /> + set("contactSupport", next)} + /> +
+
+ + } + summary={ + + } + > +
+ set("leisureCategories", next)} + /> + set("leisureMinutes", next)} + /> + set("leisureJoy", next)} + /> + set("leisureRecovery", next)} + /> +
+
+ + } + summary={ + + } + > +
+ set("eventType", next)} + /> + set("eventValence", null)} + onValueChange={(next) => set("eventValence", next)} + /> + + set("eventAt", next)} + /> + + +