diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f81deb5d..154891c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,69 @@ ## [Unreleased] +### Added + +- A mood entry can now carry the shape of the day around it. Four sections sit + below the sliders — work, contacts, leisure, and one notable event — and + every one of them is closed until you open it, so a quick check-in is still + a face and Save. What you fill in is counted on the closed section's header, + so nothing you wrote is out of sight without a hint that it is there. The + work section asks how the day went and how long it ran, the contacts section + asks who and how it felt rather than how many people, the leisure section + asks what the free time went into, and the event section holds one thing + that happened with a note beside it. That note is encrypted at rest like + every other free-text field. + +- Sleep, activity and vitals are not asked for a second time. They are read + from the modules that already hold them and shown read-only beside the + entry, with the source of each figure named and a way in to correct it + there. Correct a sleep session in the sleep module and the mood entry shows + the corrected figure the next time you open it, because nothing was copied. + A figure nobody recorded reads as not recorded rather than as zero, and a + module you have switched off leaves its block out entirely instead of + showing empty rows: sleep answers to the sleep module, resting heart rate + and heart-rate variability to recovery, the body line to the illness + journal. Steps and active energy have no module of their own and are always + shown, which is how the rest of the app already treats them. Body symptoms + stay in the illness journal, linked rather than duplicated. + + Where two devices reported the same day, the figures follow the source order + you set rather than adding both together. A phone and a watch both counting + your steps is the ordinary case, and summing the pair would have shown a day + you did not walk. + +- Where the app compares the day's context to the day's mood, it names how + many days each side of the comparison rests on, right beside the sentence, + and it describes a connection rather than a cause. The comparison runs the + same statistics the tag board already uses, including the correction that + keeps a wide sweep from turning one-in-twenty noise into a finding. + +- The iOS contract now carries both the five mood values and the day context. + Everything is optional and nothing an existing build sends has changed + meaning: an app that posts only a five-point label still writes a complete + entry, because the server derives the pleasantness value from that label. + +- Workouts recorded before the phone learned to send heart-rate detail can + pick that detail up afterwards. When the app sends a session the server + already holds and the payload carries the reading-by-reading heart rate + this time, the curve is attached to the workout that is already there and + shows up on its detail page. The session itself is untouched: duration, + calories, the average and maximum heart rate, the times, all stay as they + were first recorded. A workout that already has its curve is left alone, + so the phone can walk back through years of history as often as it likes + without anything being written twice. If one session's readings arrive + unusable, that session alone is reported back and the rest of the batch + still lands. + +### Fixed + +- Marking an older missed dose as taken from the intake history now records + the dose at the slot's own date and time. Marking Tuesday 09:00 from a + Thursday evening used to land on the right slot but stamp the intake with + the current moment; the recorded time now matches the claim being made. The + optimistic paint had promised this all along, and the skipped-to-taken flip + in the same view already behaved this way. + ## [1.37.1] — 2026-08-08 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3fead6c78..33fb729bd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3405,12 +3405,16 @@ paths: tags: - Measurements summary: Typed workout batch ingest (v1.4.25 W16b) - description: Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up + description: 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). - Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request - body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with - `workout.batch.payload_too_large`. + Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. + An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array + and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` + (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is + refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body + ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with + `workout.batch.payload_too_large`.' requestBody: required: true content: @@ -16096,6 +16100,49 @@ components: note: type: string maxLength: 500 + a1: + description: "Pleasantness, 0-10. Optional: the server DERIVES it from `mood` when the entry does not carry one, so an + older build that sends only the five-point label still writes a complete row. A value sent here wins over + the derivation." + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a2: + description: 'Stress, 0-10, INVERSE-oriented: a higher value is more stress. Stored exactly as set; a client that wants + "up is better" flips the sign itself.' + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a3: + description: Energy, 0-10. + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a4: + description: Connectedness, 0-10. + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a5: + description: Stability, 0-10. + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + context: + description: The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it. + anyOf: + - $ref: "#/components/schemas/MoodContext" + - type: "null" moodLoggedAt: type: string format: date-time @@ -16122,6 +16169,163 @@ components: - mood - moodLoggedAt description: One bulk mood entry (UPSERT keyed by `externalId`). + MoodContext: + type: object + properties: + workStatus: + description: Shape of the working day. + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad + number." + anyOf: + - type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + description: One notable event. One per entry, not a list. + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + description: -5 (clearly bad) to +5 (clearly good). + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + note: + description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. + anyOf: + - type: string + maxLength: 500 + - type: "null" + description: "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, + activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read + from there, never captured a second time here." MoodTagLayoutPutRequest: type: object properties: @@ -20100,6 +20304,48 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + description: The legacy 1-5 axis. Unchanged. + a1: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Pleasantness, 0-10. Server-derived from `mood` on every write, so this is populated even for rows a client + posted without it. Consume the value; do not re-derive it. + a2: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it." + a3: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Energy, 0-10, or null. + a4: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Connectedness, 0-10, or null. + a5: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Stability, 0-10, or null. + context: + anyOf: + - $ref: "#/components/schemas/MoodContextOutput" + - type: "null" + description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. tags: anyOf: - type: string @@ -20129,6 +20375,12 @@ components: - date - mood - score + - a1 + - a2 + - a3 + - a4 + - a5 + - context - tags - note - moodLoggedAt @@ -22319,14 +22571,19 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + description: Entries in the request. Equals `inserted + duplicates + skipped.length`. inserted: type: integer minimum: 0 maximum: 9007199254740991 + description: Workout rows this request wrote. duplicates: type: integer minimum: 0 maximum: 9007199254740991 + description: Entries that wrote no workout row because the workout was already stored. The top-level counters count + workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was + attached. skipped: type: array items: @@ -22365,8 +22622,15 @@ components: enum: - inserted - duplicate + - enriched - skipped + description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. + `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; + the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. + `skipped`: the server refused the entry, see `reason`." reason: + description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client + restart, `invalid_samples` for a heart-rate series that failed validation." type: string required: - index @@ -39316,6 +39580,164 @@ components: description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login. + MoodContextOutput: + type: object + properties: + workStatus: + description: Shape of the working day. + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad + number." + anyOf: + - type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + description: One notable event. One per entry, not a list. + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + description: -5 (clearly bad) to +5 (clearly good). + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + note: + description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. + anyOf: + - type: string + maxLength: 500 + - type: "null" + additionalProperties: false + description: "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, + activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read + from there, never captured a second time here." MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index b7b0667fd..22e0b9015 100644 --- a/messages/de.json +++ b/messages/de.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", "quickTagsExpand": "Weitere", "loadError": "Stimmungseinträge konnten nicht geladen werden.", - "sectionLevelA": "Fünf Skalen", + "sectionLevelA": "Gefühlslage", "sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.", "dimensionNotAnswered": "nicht angegeben", "dimensionClear": "leeren", @@ -922,6 +922,132 @@ "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" + } + } + }, + "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": { @@ -3120,8 +3246,6 @@ "navRecovery": "Erholung", "workouts": { "title": "Workouts", - "askWhy": "Warum?", - "askWhyPrompt": "Warum ist diese Einheit so ausgefallen? Erkläre mir meine eigenen Werte dazu.", "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", "emptyState": { "title": "Noch keine Workouts", @@ -3646,12 +3770,21 @@ }, "inTargetTitle": "Im Zielbereich", "dimensions": { - "title": "Fünf Skalen", - "none": "Für diese fünf Skalen ist noch nichts erfasst.", + "title": "Gefühlslage", + "none": "Zur Gefühlslage ist noch nichts erfasst.", "window": "{days} Tage", "noneInWindow": "nichts im Zeitraum", "higherIsMore": "höher = {anchor}", "notRecorded": "Nicht erfasst: {list}." + }, + "contextComparison": { + "title": "Tageskontext und deine Stimmung", + "description": "Wie der Stimmungswert an bisher erfassten Tagen mit diesem Eintrag lag, verglichen mit den übrigen Tagen.", + "lower": "niedriger", + "higher": "höher", + "statement": "An Tagen mit „{label}“ lag die Stimmung im Durchschnitt {direction}: {withAvg} gegenüber {withoutAvg}.", + "counts": "{withDays} Tage mit, {withoutDays} Tage ohne", + "caveat": "Das zeigt einen Zusammenhang, keine Ursache." } }, "derived": { diff --git a/messages/en.json b/messages/en.json index 3e88f213b..afacc42d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Your frequent tags — one tap", "quickTagsExpand": "More", "loadError": "Could not load your mood entries.", - "sectionLevelA": "Five scales", + "sectionLevelA": "State of mind", "sectionLevelAHelp": "Optional. Anything you leave alone stays empty.", "dimensionNotAnswered": "not recorded", "dimensionClear": "clear", @@ -922,6 +922,132 @@ "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" + } + } + }, + "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": { @@ -3120,8 +3246,6 @@ "navRecovery": "Recovery", "workouts": { "title": "Workouts", - "askWhy": "Ask why", - "askWhyPrompt": "Why did this session read the way it did? Walk me through my own numbers from it.", "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", "emptyState": { "title": "No workouts yet", @@ -3646,12 +3770,21 @@ }, "inTargetTitle": "In your range", "dimensions": { - "title": "Five scales", - "none": "Nothing recorded for these five scales yet.", + "title": "State of mind", + "none": "Nothing recorded on your state of mind yet.", "window": "{days} days", "noneInWindow": "nothing in this range", "higherIsMore": "higher = {anchor}", "notRecorded": "Not recorded: {list}." + }, + "contextComparison": { + "title": "Day context and your mood", + "description": "How the mood value sat on the days recorded so far carrying this entry, compared with the other days.", + "lower": "lower", + "higher": "higher", + "statement": "On days with “{label}” the mood averaged {direction}: {withAvg} against {withoutAvg}.", + "counts": "{withDays} days with, {withoutDays} days without", + "caveat": "That shows a connection, not a cause." } }, "derived": { diff --git a/messages/es.json b/messages/es.json index 4307d5674..7cec1ba45 100644 --- a/messages/es.json +++ b/messages/es.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Tus etiquetas frecuentes: un toque", "quickTagsExpand": "Más", "loadError": "No se pudieron cargar tus registros de estado de ánimo.", - "sectionLevelA": "Cinco escalas", + "sectionLevelA": "Estado emocional", "sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.", "dimensionNotAnswered": "sin indicar", "dimensionClear": "borrar", @@ -922,6 +922,132 @@ "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" + } + } + }, + "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": { @@ -3120,8 +3246,6 @@ "navRecovery": "Recuperación", "workouts": { "title": "Entrenamientos", - "askWhy": "Por qué", - "askWhyPrompt": "¿Por qué salió así esta sesión? Explícame mis propios datos de ella.", "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", "emptyState": { "title": "Aún no hay entrenamientos", @@ -3646,12 +3770,21 @@ }, "inTargetTitle": "En tu rango", "dimensions": { - "title": "Cinco escalas", - "none": "Todavía no hay nada registrado en estas cinco escalas.", + "title": "Estado emocional", + "none": "Todavía no hay nada registrado sobre tu estado emocional.", "window": "{days} días", "noneInWindow": "nada en este periodo", "higherIsMore": "más alto = {anchor}", "notRecorded": "Sin registrar: {list}." + }, + "contextComparison": { + "title": "Contexto del día y tu estado de ánimo", + "description": "Cómo se situó el valor de ánimo en los días registrados hasta ahora con esta entrada, frente al resto.", + "lower": "más bajo", + "higher": "más alto", + "statement": "En los días con «{label}» el ánimo promedió {direction}: {withAvg} frente a {withoutAvg}.", + "counts": "{withDays} días con, {withoutDays} días sin", + "caveat": "Esto muestra una relación, no una causa." } }, "derived": { diff --git a/messages/fr.json b/messages/fr.json index 49213e5d1..d91f2cf3d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Vos tags fréquents — en un geste", "quickTagsExpand": "Plus", "loadError": "Impossible de charger vos entrées d'humeur.", - "sectionLevelA": "Cinq échelles", + "sectionLevelA": "État d'esprit", "sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.", "dimensionNotAnswered": "non renseigné", "dimensionClear": "effacer", @@ -922,6 +922,132 @@ "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" + } + } + }, + "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": { @@ -3120,8 +3246,6 @@ "navRecovery": "Récupération", "workouts": { "title": "Entraînements", - "askWhy": "Pourquoi", - "askWhyPrompt": "Pourquoi cette séance s'est-elle passée ainsi ? Explique-moi mes propres chiffres.", "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", "emptyState": { "title": "Aucun entraînement", @@ -3646,12 +3770,21 @@ }, "inTargetTitle": "Dans votre plage", "dimensions": { - "title": "Cinq échelles", - "none": "Rien n'est encore enregistré pour ces cinq échelles.", + "title": "État d'esprit", + "none": "Rien n'est encore enregistré pour l'état d'esprit.", "window": "{days} jours", "noneInWindow": "rien sur cette période", "higherIsMore": "plus haut = {anchor}", "notRecorded": "Non renseigné : {list}." + }, + "contextComparison": { + "title": "Contexte de la journée et ton humeur", + "description": "Où se situait la valeur d'humeur les jours enregistrés jusqu'ici avec cette entrée, par rapport aux autres.", + "lower": "plus bas", + "higher": "plus haut", + "statement": "Les jours avec « {label} », l'humeur était en moyenne {direction} : {withAvg} contre {withoutAvg}.", + "counts": "{withDays} jours avec, {withoutDays} jours sans", + "caveat": "Cela montre un lien, pas une cause." } }, "derived": { diff --git a/messages/it.json b/messages/it.json index 18914e095..60bfd0bf4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -891,7 +891,7 @@ "quickTagsHelp": "I tuoi tag frequenti: un tocco", "quickTagsExpand": "Altro", "loadError": "Impossibile caricare le voci dell'umore.", - "sectionLevelA": "Cinque scale", + "sectionLevelA": "Stato d'animo", "sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.", "dimensionNotAnswered": "non indicato", "dimensionClear": "cancella", @@ -922,6 +922,132 @@ "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" + } + } + }, + "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": { @@ -3120,8 +3246,6 @@ "navRecovery": "Recupero", "workouts": { "title": "Allenamenti", - "askWhy": "Perché", - "askWhyPrompt": "Perché questa sessione è andata così? Spiegami i miei dati di questa sessione.", "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", "emptyState": { "title": "Nessun allenamento", @@ -3646,12 +3770,21 @@ }, "inTargetTitle": "Nel tuo intervallo", "dimensions": { - "title": "Cinque scale", - "none": "Per queste cinque scale non c'è ancora nulla.", + "title": "Stato d'animo", + "none": "Non c'è ancora nulla sullo stato d'animo.", "window": "{days} giorni", "noneInWindow": "nulla in questo periodo", "higherIsMore": "più alto = {anchor}", "notRecorded": "Non registrato: {list}." + }, + "contextComparison": { + "title": "Contesto della giornata e il tuo umore", + "description": "Come si è collocato il valore dell'umore nei giorni finora registrati con questa voce, rispetto agli altri.", + "lower": "più basso", + "higher": "più alto", + "statement": "Nei giorni con «{label}» l'umore è stato in media {direction}: {withAvg} contro {withoutAvg}.", + "counts": "{withDays} giorni con, {withoutDays} giorni senza", + "caveat": "Questo mostra una relazione, non una causa." } }, "derived": { diff --git a/messages/pl.json b/messages/pl.json index c1208562d..cd1f4c71e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", "quickTagsExpand": "Więcej", "loadError": "Nie udało się załadować wpisów nastroju.", - "sectionLevelA": "Pięć skal", + "sectionLevelA": "Samopoczucie", "sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.", "dimensionNotAnswered": "nie podano", "dimensionClear": "wyczyść", @@ -922,6 +922,132 @@ "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" + } + } + }, + "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": { @@ -3120,8 +3246,6 @@ "navRecovery": "Regeneracja", "workouts": { "title": "Treningi", - "askWhy": "Dlaczego", - "askWhyPrompt": "Dlaczego ta sesja wypadła w ten sposób? Wyjaśnij mi moje własne wyniki.", "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", "emptyState": { "title": "Brak treningów", @@ -3646,12 +3770,21 @@ }, "inTargetTitle": "W Twoim zakresie", "dimensions": { - "title": "Pięć skal", - "none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.", + "title": "Samopoczucie", + "none": "Nie zapisano jeszcze żadnych danych o samopoczuciu.", "window": "{days} dni", "noneInWindow": "brak w tym okresie", "higherIsMore": "wyżej = {anchor}", "notRecorded": "Nie zapisano: {list}." + }, + "contextComparison": { + "title": "Kontekst dnia a nastrój", + "description": "Jak wypadał nastrój w dotychczas zapisanych dniach z tym wpisem, w porównaniu z pozostałymi.", + "lower": "niższy", + "higher": "wyższy", + "statement": "W dniach z „{label}” nastrój był średnio {direction}: {withAvg} wobec {withoutAvg}.", + "counts": "{withDays} dni z, {withoutDays} dni bez", + "caveat": "To pokazuje związek, nie przyczynę." } }, "derived": { 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 9043f629c..3116419c1 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") @@ -4133,6 +4134,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 @@ -4164,6 +4171,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 72d2d5df5..89681a2f7 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/__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/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9b35caac1..4af6c2557 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record = { domain: "mind", why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", }, + "app/api/mood/linked-context/route.ts": { + domain: "record", + why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.", + }, "app/api/mood-entries/[id]/route.ts": { domain: "mind", why: "One mood entry of the record, fetch-then-guard against the resolved user.", @@ -1856,8 +1860,9 @@ const ACTOR_ROUTES: Record = { * managed-record configuration adds one more: 196 → 197. The bounded * read-only profile summary makes the next explicit admission: 197 → 198. * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. + * The mood day's linked figures, read-only, add one: 200 → 201. */ -const FROZEN_ENTRY_COUNT = 212; +const FROZEN_ENTRY_COUNT = 213; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index da328d9aa..149213e80 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -862,6 +862,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/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__/bulk-dimension-bounds.test.ts b/src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts new file mode 100644 index 000000000..0cc7bb6d7 --- /dev/null +++ b/src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts @@ -0,0 +1,92 @@ +/** + * The bulk path's per-entry dimension bound, pinned to the single-entry one. + * + * The bulk route validates `a1`..`a5` per entry rather than in the batch + * schema, because the endpoint's contract is that a bad row is skipped and the + * rest of the batch lands. That split buys the tolerance and costs a second + * copy of "0 to 10", and a second copy of a bound is a bound that drifts: the + * day somebody widens the scale on one path, the other silently keeps + * refusing values the product now accepts, and it does it 500 rows at a time. + * + * So the two are compared here by behaviour rather than by reading each + * other's source — the same value is offered to both schemas and they have to + * agree on it, at both ends of the scale and one step outside each. + * + * Mutation check: change the bulk route's `.max(10)` to `.max(9)` and the + * boundary row goes red naming 10; change the single-entry `levelADimension` + * bound instead and the same row goes red from the other side. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createMoodEntrySchema } from "@/lib/validations/mood"; + +const ROOT = process.cwd(); +const BULK_ROUTE = join( + ROOT, + "src", + "app", + "api", + "mood-entries", + "bulk", + "route.ts", +); + +/** + * The bulk schema is module-private, so it is read as source and its bound is + * extracted. A matcher that found nothing would prove nothing, which is why + * the extraction asserts its own match count before anything is compared. + */ +function bulkDimensionBound(): { min: number; max: number } { + const source = readFileSync(BULK_ROUTE, "utf8"); + const start = source.indexOf("const bulkDimensionsSchema = z.object({"); + expect( + start, + "bulkDimensionsSchema not found — the per-entry parse was renamed and this guard now proves nothing", + ).toBeGreaterThan(-1); + const body = source.slice(start, source.indexOf("\n});", start)); + const matches = [ + ...body.matchAll(/\.int\(\)\.min\((-?\d+)\)\.max\((\d+)\)/g), + ]; + expect( + matches.length, + "no bounded dimension fields found inside bulkDimensionsSchema", + ).toBe(5); + const bounds = new Set(matches.map((m) => `${m[1]}..${m[2]}`)); + expect( + [...bounds], + "the five bulk dimensions do not share one bound", + ).toHaveLength(1); + return { min: Number(matches[0][1]), max: Number(matches[0][2]) }; +} + +/** Does the single-entry create schema accept this `a1`? */ +function singleEntryAccepts(a1: number): boolean { + return createMoodEntrySchema.safeParse({ + mood: "OKAY", + moodLoggedAt: "2026-05-01T08:00:00.000Z", + a1, + }).success; +} + +describe("bulk dimension bounds agree with the single-entry schema", () => { + const bulk = bulkDimensionBound(); + + it("reads a plausible bound out of the bulk route", () => { + expect(bulk.max).toBeGreaterThan(bulk.min); + expect(Number.isInteger(bulk.min)).toBe(true); + expect(Number.isInteger(bulk.max)).toBe(true); + }); + + it("accepts the same values at both ends of the scale", () => { + expect(singleEntryAccepts(bulk.min)).toBe(true); + expect(singleEntryAccepts(bulk.max)).toBe(true); + }); + + it("refuses the same values one step outside it", () => { + expect(singleEntryAccepts(bulk.min - 1)).toBe(false); + expect(singleEntryAccepts(bulk.max + 1)).toBe(false); + }); +}); 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/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 938be2a60..00a50e75b 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -41,6 +41,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { markSyncCheckpoint } from "@/lib/sync/checkpoint"; import { getScoreForMood, + moodContextSchema, moodLevelEnum, moodSourceEnum, } from "@/lib/validations/mood"; @@ -51,7 +52,8 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { deriveA1 } from "@/lib/mood/level-a"; +import { levelAForWrite } from "@/lib/mood/level-a"; +import { persistMoodContext } from "@/lib/mood/context"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -94,6 +96,39 @@ const bulkEntrySchema = z.object({ .max(30) .optional(), note: z.string().max(500).optional(), + /** + * v1.38 — the five level-A self-state values, each 0-10, each optional. + * A batch that carries none still writes complete rows: the server derives + * `a1` from the five-point label, so an older client build needs no change + * to stay correct. + * + * `unknown` here and parsed with {@link bulkDimensionsSchema} PER ENTRY in + * the loop below, for the same reason `context` is: the doc comment beside + * this field used to promise that an out-of-range value marks THIS entry + * skipped while the field itself sat in the batch schema and 422'd all five + * hundred. The words were right and the code was not. A phone draining a + * year of local history must not lose the year to one row. + * @per-entry-parse: bulkDimensionsSchema, in the loop below + */ + a1: z.unknown().optional(), + a2: z.unknown().optional(), + a3: z.unknown().optional(), + a4: z.unknown().optional(), + a5: z.unknown().optional(), + /** + * v1.38 — the day's context. Optional, and absent leaves a stored context + * alone on a re-post, matching the single-entry path: a build with no + * context surface must not blank one filled in on the web. + * + * Declared as `unknown` here and parsed with `moodContextSchema` PER ENTRY + * in the loop below, deliberately. Validating it in the batch schema would + * make one entry carrying a retired key fail all five hundred, and this + * endpoint's whole contract — stated in its own OpenAPI description — is + * that a bad row is `skipped` and the rest of the batch lands. A phone + * draining a year of local history must not lose the year to one row. + * @per-entry-parse: moodContextSchema, in the loop below + */ + context: z.unknown().optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), /** @@ -111,6 +146,23 @@ const bulkEntrySchema = z.object({ externalId: z.string().min(1).max(120).optional(), }); +/** + * The five dimensions, validated for one entry. + * + * The bound is the scale itself, matching the single-entry schema exactly — + * one definition of "0 to 10" would be better still, but the single-entry + * schema states it as a shared const and this one has to answer per entry, so + * the two are pinned to each other by + * `src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts` instead. + */ +const bulkDimensionsSchema = z.object({ + a1: z.number().int().min(0).max(10).nullable().optional(), + a2: z.number().int().min(0).max(10).nullable().optional(), + a3: z.number().int().min(0).max(10).nullable().optional(), + a4: z.number().int().min(0).max(10).nullable().optional(), + a5: z.number().int().min(0).max(10).nullable().optional(), +}); + const bulkPayloadSchema = z.object({ entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), }); @@ -275,13 +327,53 @@ async function postBulk(request: NextRequest): Promise { continue; } + // The context, parsed for THIS entry. A value outside the vocabulary + // skips this row and nothing else. + const parsedContext = + entry.context === undefined + ? { success: true as const, data: undefined } + : moodContextSchema.nullable().safeParse(entry.context); + if (!parsedContext.success) { + const reason = "invalid_context"; + skipped.push({ index: i, reason }); + results.push({ + index: i, + status: "skipped", + reason, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + continue; + } + + // The five dimensions, parsed for THIS entry. Out of range skips this row + // and nothing else. + const parsedDimensions = bulkDimensionsSchema.safeParse({ + a1: entry.a1, + a2: entry.a2, + a3: entry.a3, + a4: entry.a4, + a5: entry.a5, + }); + if (!parsedDimensions.success) { + const reason = "invalid_dimensions"; + skipped.push({ index: i, reason }); + results.push({ + index: i, + status: "skipped", + reason, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + continue; + } + const date = moodDateKey(entry.moodLoggedAt, tz); const score = getScoreForMood(entry.mood); - // v1.37 — pleasantness derived from the same label the score comes from. - // The batch request shape is unchanged: this path carries no level-A - // input, so A2 to A5 are left alone rather than written as nulls, and a - // row that already carries hand-set values keeps them across a re-import. - const moodA1 = deriveA1(entry.mood); + // v1.37 / v1.38 — the same resolution the single-entry path uses: + // pleasantness derived from the label unless the entry states its own, and + // the other four present only when this entry carried them. An omitted + // dimension is left alone rather than written as null, so a row that + // already carries hand-set values keeps them across a re-import. + const levelA = levelAForWrite(entry.mood, parsedDimensions.data); try { // v1.12.1 — when the entry carries a source-stable `externalId`, @@ -353,7 +445,7 @@ async function postBulk(request: NextRequest): Promise { tz, mood: entry.mood, score, - moodA1, + ...levelA, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), @@ -370,7 +462,7 @@ async function postBulk(request: NextRequest): Promise { // the same row. mood: entry.mood, score, - moodA1, + ...levelA, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), @@ -392,6 +484,10 @@ async function postBulk(request: NextRequest): Promise { // transaction that throw now rolls the mood row back with it, so a // "skipped" result means nothing was written — which is what the // client was already being told. + // v1.38 — the day context, inside the same transaction as the row, so + // an entry reported as skipped really did write nothing. + await persistMoodContext(tx, upserted.id, user.id, parsedContext.data); + if ( (entry.tagKeys && entry.tagKeys.length > 0) || (entry.ratedFactors && entry.ratedFactors.length > 0) 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/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts new file mode 100644 index 000000000..97e13d4c0 --- /dev/null +++ b/src/app/api/mood/linked-context/route.ts @@ -0,0 +1,93 @@ +/** + * `GET /api/mood/linked-context?date=YYYY-MM-DD&tz=…` — 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 capture sheet sends the browser's zone, which is the zone + * the day it is about to write will be anchored to; the edit dialog sends the + * entry's stored `tz`, so an entry logged in another country keeps its own day + * boundaries instead of being re-read under whichever zone the correction is + * being made from. + * + * 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. + * + * **This route declares `record`, not `mind`, and that is the whole security + * decision on it.** Its payload is sleep minutes, steps, active energy, + * resting heart rate, heart-rate variability and an illness day-log with its + * symptom keys — the `measurements` and `illness` sections, reached from a + * mood surface. Declared as `mind` it was a hole with a mood-shaped label on + * it: a delegate given only the mind section could walk arbitrary dates + * through this one URL and read two sections the owner had declined to share, + * and no mood entry needed to exist for any of those dates. `record` is the + * convention for a read that crosses sections (`/api/dashboard/snapshot`, + * `/api/daily/digest` are the precedents) and a scoped grant never reaches + * one, so a scoped delegate loses the linked block entirely rather than + * receiving a filtered version of it. That is the honest answer: a block that + * silently dropped the sections a grant does not cover would show a day that + * looks empty where the truth is that it is none of the reader's business. + */ +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; the edit dialog 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) => { + // `record`: see the file header. A scoped grant is refused here by + // construction rather than served a narrowed payload. + const { user } = await requireRecordAuth("read", "record"); + + 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/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 750ea47f5..3688a6863 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -54,6 +54,8 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { readNote } from "@/lib/crypto/note-cipher"; +import { levelAForWire } from "@/lib/mood/level-a"; +import { contextForWire, type MoodContextWire } from "@/lib/mood/context"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { decodeCursor, @@ -107,6 +109,25 @@ interface MoodUpsert { date: string; mood: string; score: number; + /** + * v1.38 — the five level-A self-state values, each 0-10 and each nullable. + * `a2` is inverse-oriented: a higher number means more stress, and a client + * that wants "up is better" flips the sign itself rather than the server + * storing a flipped value. + * + * Server-authoritative: `a1` is derived from the five-point label on every + * write, so a client that sends only `mood` still mirrors a complete row. + */ + a1: number | null; + a2: number | null; + a3: number | null; + a4: number | null; + a5: number | null; + /** + * v1.38 — the day's context, or null when the entry carries none. Null and + * an object of nulls are different answers and the feed keeps them apart. + */ + context: MoodContextWire | null; tags: string | null; note: string | null; moodLoggedAt: string; @@ -299,6 +320,12 @@ export const GET = apiHandler(async (request: NextRequest) => { date: true, mood: true, score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + context: true, tags: true, note: true, noteEncrypted: true, @@ -407,6 +434,10 @@ export const GET = apiHandler(async (request: NextRequest) => { date: row.date, mood: row.mood, score: row.score, + // Under the same wire keys the write path accepts, so a client reads + // back exactly what it can post. + ...levelAForWire(row), + context: row.context ? contextForWire(row.context) : null, tags: row.tags, note: readNote(row.noteEncrypted, row.note), moodLoggedAt: row.moodLoggedAt.toISOString(), diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 1b8742e5b..05a5dba93 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -23,7 +23,7 @@ vi.mock("@/lib/db", () => ({ createMany: vi.fn(), }, workoutSamples: { - createMany: vi.fn(), + createManyAndReturn: vi.fn(), }, user: { findUnique: vi.fn(), @@ -109,6 +109,23 @@ function makeRequest( }); } +/** + * `workout.findMany` serves two callers in this route: the pre-flight + * probe for rows that already exist (it passes a `where`), and — inside + * the fake above — the stand-in for `INSERT ... RETURNING` (`select` + * only). Keying on `select.id` alone stopped telling the two apart once + * the probe began selecting `id` so an enrichment can address the stored + * workout, so the RETURNING arm is the one WITHOUT a `where`. + */ +function mockInsertReturning( + rows: Array<{ id: string; source: string; externalId: string }>, +) { + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + where?: unknown; + select?: Record; + }) => (args.where === undefined && args.select?.id ? rows : [])) as never); +} + function validWorkout( externalId: string, overrides: Record = {}, @@ -161,7 +178,13 @@ beforeEach(() => { ); vi.mocked(prisma.workout.findFirst).mockResolvedValue(null); vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 }); - vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 }); + // The series write reports which rows landed, so an enrichment can tell + // an attach from a row a concurrent writer got in first. The fake + // echoes every row back as landed. + vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation( + (async (args: { data: Array<{ workoutId: string }> }) => + args.data.map((row) => ({ workoutId: row.workoutId }))) as never, + ); // v1.4.43 W9 — default to no per-user source-priority override so // the write-time picker walks the canonical default ladder. Tests // that exercise a custom ladder override this in-line. @@ -434,25 +457,18 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => { createdAt: new Date("2026-05-14T10:00:01.000Z"), }, ] as never); - vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { - select?: Record; - }) => { - if (args.select?.id) { - return [ - { - id: "apple-id", - source: "APPLE_HEALTH", - externalId: "shared-external-id", - }, - { - id: "manual-id", - source: "MANUAL", - externalId: "shared-external-id", - }, - ]; - } - return []; - }) as never); + mockInsertReturning([ + { + id: "apple-id", + source: "APPLE_HEALTH", + externalId: "shared-external-id", + }, + { + id: "manual-id", + source: "MANUAL", + externalId: "shared-external-id", + }, + ]); const res = await POST( makeRequest({ @@ -480,20 +496,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); // Post-insert lookup returns the newly-inserted workout id so the // route can be attached by FK. - vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { - select?: { id?: true; source?: true; externalId?: true }; - }) => { - if (args.select?.id) { - return [ - { - id: "wkt-fresh-id", - source: "APPLE_HEALTH", - externalId: "uuid-with-route", - }, - ]; - } - return []; - }) as never); + mockInsertReturning([ + { + id: "wkt-fresh-id", + source: "APPLE_HEALTH", + externalId: "uuid-with-route", + }, + ]); const res = await POST( makeRequest({ @@ -523,20 +532,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => { describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); - vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { - select?: { id?: true }; - }) => { - if (args.select?.id) { - return [ - { - id: "wkt-indoor-id", - source: "APPLE_HEALTH", - externalId: "uuid-indoor", - }, - ]; - } - return []; - }) as never); + mockInsertReturning([ + { + id: "wkt-indoor-id", + source: "APPLE_HEALTH", + externalId: "uuid-indoor", + }, + ]); const res = await POST( makeRequest({ @@ -556,8 +558,9 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { // The route table is untouched — an indoor workout has no GPS route. expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); // The HR series lands in the dedicated child table by FK. - expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0]; + expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock + .calls[0]?.[0]; const rows = ( call as { data: Array<{ @@ -575,20 +578,13 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { it("attaches both a route and an HR series on one workout", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); - vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { - select?: { id?: true }; - }) => { - if (args.select?.id) { - return [ - { - id: "wkt-both-id", - source: "APPLE_HEALTH", - externalId: "uuid-both", - }, - ]; - } - return []; - }) as never); + mockInsertReturning([ + { + id: "wkt-both-id", + source: "APPLE_HEALTH", + externalId: "uuid-both", + }, + ]); const res = await POST( makeRequest({ @@ -610,14 +606,15 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { ); expect(res.status).toBe(200); expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1); - expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1); + expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1); const routeRows = ( vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as { data: Array<{ workoutId: string }>; } ).data; const sampleRows = ( - vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as { + vi.mocked(prisma.workoutSamples.createManyAndReturn).mock + .calls[0]?.[0] as { data: Array<{ workoutId: string }>; } ).data; @@ -632,7 +629,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { makeRequest({ workouts: [validWorkout("uuid-no-series")] }), ); expect(res.status).toBe(200); - expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled(); + expect(prisma.workoutSamples.createManyAndReturn).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index ac9f1c65d..61cfec22c 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -30,8 +30,8 @@ * - `Content-Length` ceiling at 5 MB before parsing — anything * larger returns 413 so the iOS client falls back to one workout * per call. - * - Per-entry status (`inserted | duplicate | skipped`) so the iOS - * sync cursor can checkpoint accurately. + * - Per-entry status (`inserted | duplicate | enriched | skipped`) so + * the iOS sync cursor can checkpoint accurately. * * Idempotency contract: * - HTTP-level via `Idempotency-Key` (replays cached envelope). @@ -39,6 +39,13 @@ * composite index. Re-posting the same batch surfaces duplicates * rather than failing the call. * + * Heart-rate series enrichment: a re-post that matches a stored + * workout and carries a `samples` array attaches that series when the + * workout has none. The workout row itself stays first-write-wins — + * nothing on it is written, not one column — and a workout that already + * holds a series is a no-op, so the client's history sweep is repeatable + * as often as it likes. See the enrichment block below. + * * Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through * Prisma's `createManyAndReturn`. The returned composite keys identify the * exact rows this request inserted, so per-entry statuses, child rows, and @@ -66,10 +73,11 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; +import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout"; import { - createBatchWorkoutSchema, - MAX_WORKOUTS_PER_BATCH, -} from "@/lib/validations/workout"; + INVALID_SAMPLES_REASON, + parseWorkoutBatch, +} from "@/lib/workouts/batch-ingest-parse"; import { classifyExternalId, unstableExternalIdMeta, @@ -106,16 +114,14 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024; /** * Per-entry outcome the iOS client uses to advance its sync cursor. - * `inserted` and `duplicate` both indicate the row landed (or was - * already present); the client can checkpoint past them identically. - * `skipped` covers entries the server cannot durably store — currently - * unused by this endpoint (the Zod schema rejects malformed entries - * with a 422 before we reach the per-entry pass), but reserved on the - * envelope so the response shape parallels the measurements batch and - * future server-side validation can surface fine-grained skips without - * breaking the iOS DTO. + * `inserted`, `duplicate` and `enriched` all indicate the row landed (or + * was already present); the client can checkpoint past them identically. + * `enriched` additionally says the entry's heart-rate series was attached + * to a workout that had none, which is what lets a history sweep count + * its own progress. `skipped` covers entries the server refused: an + * unusable external id, or a `samples` array that failed validation. */ -type EntryStatus = "inserted" | "duplicate" | "skipped"; +type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped"; interface EntryResult { index: number; status: EntryStatus; @@ -251,15 +257,18 @@ async function postBatch(request: NextRequest): Promise { ); } - const parsed = createBatchWorkoutSchema.safeParse(rawBody); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - return apiError(issue?.message ?? "Invalid batch", 400, { + // Strict on every field, per-entry on `samples` alone — see + // `parseWorkoutBatch` for why the series is the one field whose + // failure must not take the rest of a history sweep down with it. + const parsed = parseWorkoutBatch(rawBody); + if (!parsed.ok) { + return apiError(parsed.message, 400, { errorCode: "workout.batch.invalid", }); } const { workouts } = parsed.data; + const refusedSampleIndices = new Set(parsed.refusedSampleIndices); // Pre-flight duplicate detection so we can return per-entry status. // The composite unique index is `(userId, source, externalId)` — @@ -289,6 +298,18 @@ async function postBatch(request: NextRequest): Promise { const results: EntryResult[] = new Array(workouts.length); const unstableShapes: UnstableExternalIdShape[] = []; const prepared: Prepared[] = workouts.flatMap((w, index) => { + // An entry whose series failed validation is refused whole. Storing + // the workout without the curve the caller asked us to keep would be + // a silent loss, and the row may well already exist anyway. + if (refusedSampleIndices.has(index)) { + results[index] = { + index, + status: "skipped", + reason: INVALID_SAMPLES_REASON, + }; + return []; + } + // The `(userId, source, externalId)` dedup key is only idempotent // while the id is STABLE across client launches. An id that rotates // per process (an object description carrying a memory address) @@ -408,6 +429,7 @@ async function postBatch(request: NextRequest): Promise { const survivors = prepared.filter((p) => survivingIndices.has(p.index)); let duplicateCount = droppedByWriteDedup.length; let insertedCount = 0; + let enrichedCount = 0; const insertedIdByIndex = new Map(); if (survivors.length > 0) { @@ -418,6 +440,9 @@ async function postBatch(request: NextRequest): Promise { .map((p) => p.dedupKey) .filter((k): k is { source: string; externalId: string } => k !== null); + // The same probe answers both questions: does the row exist, and + // does it already carry a series. `id` comes along so an enrichment + // can address the stored workout without a second round trip. const existing = dedupCandidates.length ? await prisma.workout.findMany({ where: { @@ -427,30 +452,65 @@ async function postBatch(request: NextRequest): Promise { externalId: k.externalId, })), }, - select: { source: true, externalId: true }, + select: { + id: true, + source: true, + externalId: true, + samples: { select: { workoutId: true } }, + }, }) : []; - const existingSet = new Set( - existing.map((row) => `${row.source}::${row.externalId}`), - ); + const existingByKey = new Map(); + for (const row of existing) { + if (row.externalId === null) continue; + existingByKey.set(`${row.source}::${row.externalId}`, { + id: row.id, + hasSeries: row.samples !== null, + }); + } const toInsert: Prepared[] = []; + /** Stored workouts this batch hands a heart-rate series to. */ + const toEnrich: Array<{ + index: number; + workoutId: string; + samples: NonNullable; + }> = []; + /** Workouts already claimed by an earlier entry of THIS batch. */ + const enrichClaimed = new Set(); for (const p of survivors) { const keyTuple = p.dedupKey ? `${p.dedupKey.source}::${p.dedupKey.externalId}` : null; - if (keyTuple !== null && existingSet.has(keyTuple)) { - results[p.index] = { index: p.index, status: "duplicate" }; - duplicateCount += 1; + const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined; + if (known) { + if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) { + enrichClaimed.add(known.id); + // Enrichment. The status is assigned after the write, because + // a concurrent writer may get the series in first — in which + // case this entry is the plain duplicate it always was. A + // second entry of this batch naming the same workout takes the + // duplicate arm above: one workout, one series, one `enriched`. + toEnrich.push({ + index: p.index, + workoutId: known.id, + samples: p.samples, + }); + } else { + results[p.index] = { index: p.index, status: "duplicate" }; + duplicateCount += 1; + } } else { results[p.index] = { index: p.index, status: "inserted" }; toInsert.push(p); } } - if (toInsert.length > 0) { + if (toInsert.length > 0 || toEnrich.length > 0) { const CHUNK = 100; + /** Workout ids whose series row this request actually wrote. */ + const seriesLanded = new Set(); await prisma.$transaction(async (tx) => { const withExternalId = toInsert.filter((p) => p.dedupKey !== null); @@ -517,6 +577,19 @@ async function postBatch(request: NextRequest): Promise { } } + // Enrichment rides the SAME child write as a fresh workout's + // series — one mechanism, one place where a series is stored. + // The only difference is where the `workoutId` came from: the + // pre-flight probe instead of this request's INSERT ... RETURNING. + // Nothing here writes to the workout row. + for (const e of toEnrich) { + samplesToInsert.push({ + workoutId: e.workoutId, + samples: e.samples.samples as Prisma.InputJsonValue, + sampleCount: e.samples.sampleCount, + }); + } + for (let i = 0; i < routesToInsert.length; i += CHUNK) { await tx.workoutRoute.createMany({ data: routesToInsert.slice(i, i + CHUNK), @@ -524,10 +597,16 @@ async function postBatch(request: NextRequest): Promise { }); } for (let i = 0; i < samplesToInsert.length; i += CHUNK) { - await tx.workoutSamples.createMany({ + // RETURNING names the rows that actually landed. `workoutId` is + // unique, so a workout that gained a series between the probe + // and this write is skipped here and reports as a duplicate — + // the re-post stays a no-op and never overwrites a stored curve. + const landed = await tx.workoutSamples.createManyAndReturn({ data: samplesToInsert.slice(i, i + CHUNK), skipDuplicates: true, + select: { workoutId: true }, }); + for (const row of landed) seriesLanded.add(row.workoutId); } }); @@ -540,6 +619,18 @@ async function postBatch(request: NextRequest): Promise { duplicateCount += 1; } } + + // An enriched entry is still a duplicate of the workout row: the + // top-level counters count rows, and no row was inserted for it. + // The per-entry status carries what happened to the series. + for (const e of toEnrich) { + results[e.index] = { + index: e.index, + status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate", + }; + if (seriesLanded.has(e.workoutId)) enrichedCount += 1; + duplicateCount += 1; + } } } @@ -554,6 +645,7 @@ async function postBatch(request: NextRequest): Promise { processed: workouts.length, inserted: insertedCount, duplicates: duplicateCount, + enriched: enrichedCount, skipped: skipped.length, }, }); @@ -584,12 +676,14 @@ async function postBatch(request: NextRequest): Promise { } } + // Counts only. The series itself never reaches a wide event. annotate({ action: { name: "workout.batch.ingest" }, meta: { processed: workouts.length, inserted: insertedCount, duplicates: duplicateCount, + enriched: enrichedCount, skipped: skipped.length, }, }); @@ -598,11 +692,17 @@ async function postBatch(request: NextRequest): Promise { // caches when at least one row landed. Workouts ride on the // measurements bucket because achievements / analytics also touch // workout-derived metrics. - if (insertedCount > 0) { + // An enrichment changes what the workout reads as — the detail seam + // gains its curve, the list gains its glyph — so it busts the cache + // too, even though no workout row was written. + if (insertedCount > 0 || enrichedCount > 0) { invalidateUserMeasurements(user.id); + } + if (insertedCount > 0) { // One arrival per exact INSERT ... RETURNING winner. Historical rows still // stop at the shared salience classifier before any queue work. + // An enrichment raises none: the workout arrived when it was stored. void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch( () => {}, ); diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 2780caf84..c242b1dce 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -9,7 +9,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { useWorkoutDetail } from "@/hooks/use-workouts"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { BackLink } from "@/components/ui/back-link"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; @@ -31,7 +30,7 @@ import { * Layout, top to bottom (mobile-first single column): * hero header → reserved Activity-Insight seam (renders nothing today) * → stats grid + sport-average line → HR curve → effort zones → GPS - * route → per-km splits → "that day" → coach launch. + * route → per-km splits → "that day". * * "That day" answers the last question the page is for: what the rest of * the day looked like around the session. It renders the day's own pulse @@ -40,8 +39,8 @@ import { * * Every data-less section returns `null` (hide, don't render empty), so * an aggregates-only workout (a Strava ride with no wearable, a manual - * entry) reads as hero + stats + "that day" + coach — compact but - * honest, no empty shells. + * entry) reads as hero + stats + "that day" — compact but honest, no + * empty shells. * * Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67 * enrichment fields). The `canonicalId` pointer still resolves a @@ -120,18 +119,6 @@ export default function InsightsWorkoutDetailPage({ - {/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the - snapshot the first coach turn reads. v1.31.0 — `workoutId` pins - THIS session's own numbers as one additional snapshot section, so - the answer is about this workout rather than about training in - general, and the opener auto-sends so it lands directly. */} - )} diff --git a/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx index 714f74fa4..fafffd6e2 100644 --- a/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx +++ b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx @@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; /** * v1.8.6 QA — the in-target share must appear exactly once on the mood @@ -11,10 +10,18 @@ import { queryKeys } from "@/lib/query-keys"; * (inTargetPct present) the same-number `in-target` takeaway is dropped * from the narrative feed so the percentage is not duplicated one row * below the headline tile. + * + * Driven against `` rather than its parent since the + * breakdown cluster moved into its own `next/dynamic` chunk: under a static + * render the parent paints the loader skeleton and nothing this file is about + * is in the markup. The breakdown module takes the resolved payload as a prop, + * so the aggregate no longer has to be seeded into a query cache to reach the + * assertion. The provider and the auth stub stay because the "what stands + * out" card inside the cluster fetches the discovery surface itself. */ // `useAuth` runs its own /me query; stub it to a signed-in user so the -// section's `enabled: isAuthenticated` gate opens without a fetch. +// self-fetching card inside the cluster resolves without a request. vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { id: "u1" }, @@ -25,7 +32,8 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); -import { MoodInsightsSections } from "../mood-insights-sections"; +import { MoodInsightsBreakdowns } from "../mood-insights-breakdowns"; +import type { MoodInsightsResponse } from "../mood-insights-shared"; type Response = { summary: { totalEntries: number; inTargetPct: number | null }; @@ -62,12 +70,12 @@ function renderWithData(data: Response) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - // Seed the cache so the synchronous SSR render sees resolved data. - queryClient.setQueryData(queryKeys.moodInsights(), data); return renderToStaticMarkup( - + , ); @@ -105,7 +113,7 @@ function baseData(inTargetPct: number | null): Response { }; } -describe(" — in-target dedup", () => { +describe(" — in-target dedup", () => { it("renders the tile and drops the duplicate in-target narrative", () => { const html = renderWithData(baseData(72)); // The tile carries the percentage exactly once. diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts index 6ec2c1718..efa37cfba 100644 --- a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts +++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts @@ -4,6 +4,14 @@ import { join } from "node:path"; const COMPONENT_DIR = join(__dirname, ".."); const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx"); +/** + * The two modules the mood insights surface is rendered from. The breakdown + * cluster lives behind `next/dynamic` in its own file; both are the surface. + */ +const INSIGHTS_SURFACE_FILES = [ + join(COMPONENT_DIR, "mood-insights-sections.tsx"), + join(COMPONENT_DIR, "mood-insights-breakdowns.tsx"), +]; /** * v1.8.6 — the notes timeline display is removed from the mood insights @@ -19,23 +27,31 @@ describe("mood notes timeline removal", () => { }); it("no longer renders the notes timeline from the insights sections", () => { - const src = readFileSync( - join(COMPONENT_DIR, "mood-insights-sections.tsx"), - "utf8", - ); - expect(src).not.toContain("MoodNotesTimeline"); - expect(src).not.toContain("notesTimeline"); - expect(src).not.toContain("notesTimelineTitle"); + // Both halves of the surface: the eager module that paints the calendar + // and the assessment, and the deferred module that carries every + // breakdown. A guard reading only one of them would go quiet the moment + // the other grew a notes feed. + for (const file of INSIGHTS_SURFACE_FILES) { + const src = readFileSync(file, "utf8"); + expect(src).not.toContain("MoodNotesTimeline"); + expect(src).not.toContain("notesTimeline"); + expect(src).not.toContain("notesTimelineTitle"); + } }); it("mounts the narrative feed in the insights sections", () => { - const src = readFileSync( - join(COMPONENT_DIR, "mood-insights-sections.tsx"), - "utf8", - ); // v1.12.7 — the narrative one-liners now ride the merged "What stands out" // card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally. // The structural guard tracks the mount point on the sections surface. + // + // v1.38 — that mount point moved into `mood-insights-breakdowns.tsx` when + // the below-the-fold cluster went behind `next/dynamic`. Read as the union + // of the two files, so the guard asserts the card is mounted SOMEWHERE on + // the surface rather than in one particular file — which is what it was + // always about. + const src = INSIGHTS_SURFACE_FILES.map((f) => readFileSync(f, "utf8")).join( + "\n", + ); expect(src).toContain("MoodWhatStandsOut"); expect(src).toContain("MoodInTargetTile"); }); diff --git a/src/components/insights/mood/mood-context-comparison.tsx b/src/components/insights/mood/mood-context-comparison.tsx new file mode 100644 index 000000000..3df6ff9ba --- /dev/null +++ b/src/components/insights/mood/mood-context-comparison.tsx @@ -0,0 +1,74 @@ +"use client"; + +/** + * How the day's mood sat on days carrying each context value. + * + * The copy rule this component exists to hold, stated once so nobody has to + * infer it from the strings: **no sentence here names a cause.** Not "family + * lowers your mood", not "overtime costs you two points". What the data + * supports is "on the days recorded so far with X, the average was lower", and + * that is what it says — with the two counts on the same line as the claim, + * not behind a tooltip, because a reader who cannot see that a row rests on + * six days cannot weigh it. + * + * The rows arriving here have already cleared the day floors and a + * family-wide Benjamini-Hochberg correction. Nothing in this file re-filters + * them; a second threshold in the presentation layer would be a second place + * to get the statistics wrong. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { contextValueLabelKey } from "@/lib/mood/context-vocabulary"; +import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab"; + +export function MoodContextComparison({ + rows, +}: { + rows: ContextComparisonRow[]; +}) { + const { t } = useTranslations(); + if (rows.length === 0) return null; + + return ( +
+

+ {t("insights.mood.contextComparison.description")} +

+
    + {rows.map((row) => { + const label = t(contextValueLabelKey(row.field, row.value)); + const direction = + row.delta < 0 + ? t("insights.mood.contextComparison.lower") + : t("insights.mood.contextComparison.higher"); + return ( +
  • +

    + {t("insights.mood.contextComparison.statement", { + label, + direction, + withAvg: row.withAvg.toFixed(1), + withoutAvg: row.withoutAvg.toFixed(1), + })} +

    + {/* The counts ride with the statement, in the same block a + screen reader reaches in the same breath. */} +

    + {t("insights.mood.contextComparison.counts", { + withDays: String(row.withDays), + withoutDays: String(row.withoutDays), + })} +

    +
  • + ); + })} +
+

+ {t("insights.mood.contextComparison.caveat")} +

+
+ ); +} diff --git a/src/components/insights/mood/mood-insights-breakdowns.tsx b/src/components/insights/mood/mood-insights-breakdowns.tsx new file mode 100644 index 000000000..4c304be52 --- /dev/null +++ b/src/components/insights/mood/mood-insights-breakdowns.tsx @@ -0,0 +1,276 @@ +"use client"; + +/** + * Every mood breakdown below the fold, in one deferred module. + * + * The classification tiles, the "what stands out" card, the four Recharts + * mini-charts, both tag axes, the influence board, the two crosstabs, the + * day-context comparison and the correlation cards. Between them they pull in + * roughly a dozen card components and their icon sets, and none of it is + * visible until the reader has scrolled past the calendar, the line chart, the + * target card and the assessment. + * + * So it is a module rather than a section of its sibling, and its sibling + * loads it through `next/dynamic` — the same treatment the insights mother + * page gives its below-the-hero blocks. Splitting it out is what keeps the + * eager graph for this route down to the calendar and the assessment: while + * every one of these cards was a static import of `mood-insights-sections`, + * fetching the page paid for all of them before painting anything. + * + * It takes the resolved payload as a prop instead of running its own query. + * TanStack would have deduped a second read, but a prop means this module + * cannot render a different snapshot of the same aggregate from the one the + * regions above it drew. + */ +import { + BarChart3, + Briefcase, + CalendarRange, + Clock, + Gauge, + Grid3x3, + Link2, + Tag, + Tags, + TrendingUp, +} from "lucide-react"; +import dynamic from "next/dynamic"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared"; +// v1.12.1 — the four Recharts mini-charts here are deferred via +// `next/dynamic`. The mood hero line chart above them is already dynamic on +// the page, so static-importing these pulled Recharts into the initial chunk +// for no first-paint benefit. Each loader paints a skeleton sized to the +// chart's own band so the deferred chunk arrives without a layout shift +// (charts stay Recharts, visually identical). +// +// v1.16.7 established the shared-barrel pattern for the group; all four +// loaders resolve the app-wide chart-runtime boundary, so they share the ONE +// recharts chunk with every other chart surface: the cards reveal together, +// and recharts ships exactly once for the whole app. +const MoodDimensionTrends = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodDimensionTrends, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); +const MoodDistributionChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodDistributionChart, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); +const MoodWeekdayChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodWeekdayChart, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); +const MoodTimeOfDayChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodTimeOfDayChart, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); +import { MoodContextComparison } from "./mood-context-comparison"; +import { MoodTagBreakdown } from "./mood-tag-breakdown"; +import { MoodCorrelationCards } from "./mood-correlation-cards"; +import { MoodStructuredTagBreakdown } from "./mood-structured-tag-breakdown"; +import { MoodWhatStandsOut } from "./mood-what-stands-out"; +import { MoodInTargetTile } from "./mood-in-target-tile"; +import { MoodStabilityTile } from "./mood-stability-tile"; +import { MoodTagInfluence } from "./mood-tag-influence"; +import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab"; +import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab"; + +export function MoodInsightsBreakdowns({ + data, +}: { + data: MoodInsightsResponse; +}) { + const { t } = useTranslations(); + + // The in-target tile is the canonical surface for the in-target share. + // When it renders (inTargetPct present) drop the same-number `in-target` + // takeaway from the feed so the percentage appears exactly once on the + // page. The narrative still rides the API/LLM payload unchanged. + const inTargetShown = data.summary.inTargetPct != null; + const narratives = inTargetShown + ? data.narratives.filter((item) => item.kind !== "in-target") + : data.narratives; + + const hasTags = data.tags.length > 0; + const hasStructuredTags = data.structuredTags.length > 0; + const hasStabilityTile = data.stability != null; + const hasInTargetTile = data.summary.inTargetPct != null; + + // F1 — fold the structured + flat influence axes into one list ranked by + // absolute delta, so the strongest "this tag moves my mood" rows lead + // regardless of which axis they came from. Defensive against a stale + // server-cache payload minted before the v1.11.5 shape landed (the + // aggregate is cached up to 60 s; a rollout can serve one old read). + const influenceRows = [ + ...(data.tagInfluence?.structured ?? []), + ...(data.tagInfluence?.flat ?? []), + ].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + const hasInfluence = influenceRows.length > 0; + const crosstabRows = data.tagMetricCrosstab ?? []; + const hasCrosstab = crosstabRows.length > 0; + // Defensive against a stale server-cache payload minted before this shape + // landed, the same way the influence rows above are. + const contextRows = data.contextComparison ?? []; + const hasContextComparison = contextRows.length > 0; + const factorCrosstabRows = data.factorCrosstab ?? []; + const hasFactorCrosstab = factorCrosstabRows.length > 0; + const dimensionSummaries = data.dimensions ?? []; + const hasDimensions = dimensionSummaries.some((d) => d.present); + + return ( + // v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and + // the better-days Einschätzung (now right after the Ziel card) render in + // their own regions above; this region carries the Einordnung classification + // tiles (in-target share + stability band) FIRST, then the "what stands + // out" card, then the descriptive breakdown sections. The classification + // answers "where do I stand", and the rest is the supporting detail. +
+ {/* Einordnung — the classification tiles. */} + {(hasInTargetTile || hasStabilityTile) && ( + // Two-up only when BOTH tiles render; a lone tile spans full width so + // it never leaves a half-width orphan with dead space beside it. +
+ {hasInTargetTile && ( + + )} + {hasStabilityTile && } +
+ )} + + {/* v1.12.7 — the single "What stands out" card folds the narrative + one-liners AND the FDR-controlled discovered relations into one tile + (was two separate cards). Self-fetches the discovery surface and + renders nothing when both halves are empty. */} + + + {/* v1.37 — the five level-A dimensions. Renders only once at least one + of them carries a value; an account that has never opened the detail + section sees the page it saw before. */} + {hasDimensions && ( + + + + )} + +
+ + + + + + +
+ + {data.timeOfDay.reliable && ( + + + + )} + + {hasStructuredTags && ( + + + + )} + + {hasTags && ( + + + + )} + + {hasInfluence && ( + + + + )} + + {hasCrosstab && ( + + + + )} + + {hasContextComparison && ( + + + + )} + + {hasFactorCrosstab && ( + + + + )} + + +

+ {t("insights.mood.correlationsDescription")} +

+ +
+
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 19c64ac55..be65c1124 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -3,195 +3,78 @@ import dynamic from "next/dynamic"; import { useQuery } from "@tanstack/react-query"; -import type { ComponentType } from "react"; -import { - BarChart3, - CalendarDays, - CalendarRange, - Clock, - Gauge, - Grid3x3, - Link2, - Sparkles, - Tag, - Tags, - TrendingUp, -} from "lucide-react"; +import { CalendarDays, Sparkles } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { MoodHeatmap } from "@/components/charts/mood-heatmap"; -// v1.12.1 — the three Recharts mini-charts on this below-fold cluster are -// deferred via `next/dynamic`. The mood hero line chart above them is -// already dynamic on the page, so static-importing these pulled Recharts -// into the initial chunk for no first-paint benefit. Each loader paints a -// skeleton sized to the chart's own band so the deferred chunk arrives -// without a layout shift (charts stay Recharts, visually identical). Types -// stay value-free imports so they don't drag the chunk back in. -import type { MoodDistributionRow } from "./mood-distribution-chart"; -import type { MoodDimensionSummaryData } from "./mood-dimension-trends"; -import type { MoodWeekdayRow } from "./mood-weekday-chart"; -import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart"; -// v1.16.7 established the shared-barrel pattern for the trio; all three -// loaders now resolve the app-wide chart-runtime boundary, so they share -// the ONE recharts chunk with every other chart surface: the cards reveal -// together, and recharts ships exactly once for the whole app. -const MoodDimensionTrends = dynamic( - () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.MoodDimensionTrends, - })), - { - ssr: false, - loading: () => ( - - ), - }, -); -const MoodDistributionChart = dynamic( - () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.MoodDistributionChart, - })), - { - ssr: false, - loading: () => ( - - ), - }, -); -const MoodWeekdayChart = dynamic( - () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.MoodWeekdayChart, - })), - { - ssr: false, - loading: () => ( - - ), - }, -); -const MoodTimeOfDayChart = dynamic( - () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.MoodTimeOfDayChart, - })), - { - ssr: false, - loading: () => ( - - ), - }, -); -import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown"; -import { - MoodCorrelationCards, - type MoodMetricCorrelationData, -} from "./mood-correlation-cards"; -import { - MoodStructuredTagBreakdown, - type MoodStructuredTagRow, -} from "./mood-structured-tag-breakdown"; -import { type MoodNarrativeItem } from "./mood-narrative-feed"; -import { MoodWhatStandsOut } from "./mood-what-stands-out"; -import { MoodInTargetTile } from "./mood-in-target-tile"; -import { - MoodStabilityTile, - type MoodStabilityData, -} from "./mood-stability-tile"; -import { - MoodTagInfluence, - type MoodTagInfluenceRow, -} from "./mood-tag-influence"; -import { MoodBetterDays, type MoodBetterDayFactor } from "./mood-better-days"; -import { - MoodTagMetricCrosstab, - type MoodTagMetricCrosstabRow, -} from "./mood-tag-metric-crosstab"; -import { - MoodFactorMetricCrosstab, - type MoodFactorMetricCrosstabRow, -} from "./mood-factor-metric-crosstab"; +import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared"; import { apiGet } from "@/lib/api/api-fetch"; +/** + * Every breakdown below the fold, in its own chunk. + * + * The calendar and the assessment are what this route paints first; the + * dozen-odd breakdown cards under them — both tag axes, the influence board, + * the two crosstabs, the day-context comparison, the four Recharts + * mini-charts, the correlation cards — are not visible until the reader has + * scrolled past the line chart and the target card. Static-importing them + * made every cold mount of `/insights/mood` pay for all of them before + * painting anything, and it is what put this route on its bundle ceiling. + * + * Deferred behind `next/dynamic` like the below-the-hero blocks on the + * insights mother page. `ssr: false` because the whole subtree is a client + * render off a client query anyway, and the skeleton holds the block open at + * roughly the height of the classification tiles so the chunk lands without + * shoving the page. + */ +/** + * The better-days assessment, deferred for one specific reason: it renders the + * catalogue's tag icons, and that resolver + * (`@/components/mood/mood-tag-icons`) statically imports about a hundred and + * sixty lucide icons. Eager, that map rode the first-load graph of a route + * whose first paint is a calendar. The card itself sits a screen down, under + * the line chart and the target card. + * + * It shares the icon map with the breakdown module below, so the two resolve + * out of the same lazy group rather than duplicating it. + */ +const MoodBetterDays = dynamic( + () => + import("./mood-better-days").then((mod) => ({ + default: mod.MoodBetterDays, + })), + { + ssr: false, + loading: () => , + }, +); + +const MoodInsightsBreakdowns = dynamic( + () => + import("./mood-insights-breakdowns").then((mod) => ({ + default: mod.MoodInsightsBreakdowns, + })), + { + ssr: false, + loading: () => , + }, +); + /** * v1.8.5 — additional Mood Insights sections. * - * Reads the pre-computed aggregate bundle from `/api/mood/insights` - * (cheap cached server read, no LLM) and paints the heatmap, - * distribution, weekday pattern, tag breakdown, and mood × metric - * correlation cards. Renders nothing while loading / on error / on an - * empty data set so the page degrades gracefully to the line chart. + * Reads the pre-computed aggregate bundle from `/api/mood/insights` (cheap + * cached server read, no LLM) and paints the calendar and the assessment + * itself, handing the payload to the deferred breakdown module for everything + * under them. Renders nothing while loading / on error / on an empty data set + * so the page degrades gracefully to the line chart. */ -interface MoodInsightsResponse { - summary: { - totalEntries: number; - inTargetPct: number | null; - }; - heatmap: { - windowDays: number; - cells: Array<{ date: string; score: number; samples: number }>; - }; - distribution: MoodDistributionRow[]; - // Optional only to tolerate a stale pre-v1.37 cached payload during a - // rollout; the live endpoint always populates it. - dimensions?: MoodDimensionSummaryData[]; - weekday: MoodWeekdayRow[]; - timeOfDay: MoodTimeOfDayPattern; - stability: MoodStabilityData | null; - tags: MoodTagRow[]; - structuredTags: MoodStructuredTagRow[]; - // Optional only to tolerate a stale pre-v1.11.5 cached payload during a - // rollout; the live endpoint always populates both. - tagInfluence?: { - flat: MoodTagInfluenceRow[]; - structured: MoodTagInfluenceRow[]; - }; - betterDays?: MoodBetterDayFactor[]; - // Optional only to tolerate a stale pre-v1.12.0 cached payload during a - // rollout; the live endpoint always populates it. - tagMetricCrosstab?: MoodTagMetricCrosstabRow[]; - // Optional only to tolerate a stale pre-v1.14.0 cached payload during a - // rollout; the live endpoint always populates it. - factorCrosstab?: MoodFactorMetricCrosstabRow[]; - narratives: MoodNarrativeItem[]; - correlations: { - sleep: MoodMetricCorrelationData; - steps: MoodMetricCorrelationData; - pulse: MoodMetricCorrelationData; - weight: MoodMetricCorrelationData; - bloodPressureSystolic: MoodMetricCorrelationData; - }; -} - -function SectionCard({ - title, - icon, - children, -}: { - title: string; - // v1.12.6 — every mood section header now routes through `TileHeader` - // (icon + white heading) so the subpage reads as one card language. - icon: ComponentType<{ className?: string }>; - children: React.ReactNode; -}) { - return ( - - - - - {children} - - ); -} - /** * v1.12.7 — the mood spine is split into three render regions so the page can * thread the operator's exact top-to-bottom order without prop-drilling the @@ -242,40 +125,8 @@ export function MoodInsightsSections({ const heatmapCells = Object.fromEntries( data.heatmap.cells.map((cell) => [cell.date, cell]), ); - - const hasTags = data.tags.length > 0; - const hasStructuredTags = data.structuredTags.length > 0; - - // The in-target tile is the canonical surface for the in-target share. - // When it renders (inTargetPct present) drop the same-number `in-target` - // takeaway from the feed so the percentage appears exactly once on the - // page. The narrative still rides the API/LLM payload unchanged. - const inTargetShown = data.summary.inTargetPct != null; - const narratives = inTargetShown - ? data.narratives.filter((item) => item.kind !== "in-target") - : data.narratives; - - const hasStabilityTile = data.stability != null; - const hasInTargetTile = data.summary.inTargetPct != null; - - // F1 — fold the structured + flat influence axes into one list ranked by - // absolute delta, so the strongest "this tag moves my mood" rows lead - // regardless of which axis they came from. Defensive against a stale - // server-cache payload minted before the v1.11.5 shape landed (the - // aggregate is cached up to 60 s; a rollout can serve one old read). - const influenceRows = [ - ...(data.tagInfluence?.structured ?? []), - ...(data.tagInfluence?.flat ?? []), - ].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); - const hasInfluence = influenceRows.length > 0; const betterDays = data.betterDays ?? []; const hasBetterDays = betterDays.length > 0; - const crosstabRows = data.tagMetricCrosstab ?? []; - const hasCrosstab = crosstabRows.length > 0; - const factorCrosstabRows = data.factorCrosstab ?? []; - const hasFactorCrosstab = factorCrosstabRows.length > 0; - const dimensionSummaries = data.dimensions ?? []; - const hasDimensions = dimensionSummaries.some((d) => d.present); // v1.12.7 — the Stimmungskalender is lifted above the line chart on the page, // so it renders as its own region here. @@ -321,115 +172,8 @@ export function MoodInsightsSections({ ); } - return ( - // v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and - // the better-days Einschätzung (now right after the Ziel card) render in - // their own regions above; this region carries the Einordnung classification - // tiles (in-target share + stability band) FIRST, then the "what stands - // out" card, then the descriptive breakdown sections. The classification - // answers "where do I stand", and the rest is the supporting detail. -
- {/* Einordnung — the classification tiles. */} - {(hasInTargetTile || hasStabilityTile) && ( - // Two-up only when BOTH tiles render; a lone tile spans full width so - // it never leaves a half-width orphan with dead space beside it. -
- {hasInTargetTile && ( - - )} - {hasStabilityTile && } -
- )} - - {/* v1.12.7 — the single "What stands out" card folds the narrative - one-liners AND the FDR-controlled discovered relations into one tile - (was two separate cards). Self-fetches the discovery surface and - renders nothing when both halves are empty. */} - - - {/* v1.37 — the five level-A dimensions. Renders only once at least one - of them carries a value; an account that has never opened the detail - section sees the page it saw before. */} - {hasDimensions && ( - - - - )} - -
- - - - - - -
- - {data.timeOfDay.reliable && ( - - - - )} - - {hasStructuredTags && ( - - - - )} - - {hasTags && ( - - - - )} - - {hasInfluence && ( - - - - )} - - {hasCrosstab && ( - - - - )} - - {hasFactorCrosstab && ( - - - - )} - - -

- {t("insights.mood.correlationsDescription")} -

- -
-
- ); + // The rest region is one deferred subtree now: everything in it sits below + // the fold, and the payload rides in as a prop so the deferred module cannot + // draw a different snapshot of the aggregate from the regions above it. + return ; } diff --git a/src/components/insights/mood/mood-insights-shared.tsx b/src/components/insights/mood/mood-insights-shared.tsx new file mode 100644 index 000000000..fe4842984 --- /dev/null +++ b/src/components/insights/mood/mood-insights-shared.tsx @@ -0,0 +1,96 @@ +"use client"; + +/** + * The payload shape and the card frame both mood-insights modules need. + * + * Extracted when the breakdown cluster moved behind `next/dynamic`: the + * eager module (heatmap + assessment) and the deferred one (every breakdown) + * both describe the same `/api/mood/insights` response and both draw the same + * card, and a second copy of either would be the drift this file exists to + * prevent. Nothing heavy lives here — the type is erased at build time and the + * frame is a `Card` with a `TileHeader` in it — so importing it from the eager + * module costs the first-load graph nothing. + */ +import type { ComponentType } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; + +import type { MoodDistributionRow } from "./mood-distribution-chart"; +import type { MoodDimensionSummaryData } from "./mood-dimension-trends"; +import type { MoodWeekdayRow } from "./mood-weekday-chart"; +import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart"; +import type { MoodTagRow } from "./mood-tag-breakdown"; +import type { MoodMetricCorrelationData } from "./mood-correlation-cards"; +import type { MoodStructuredTagRow } from "./mood-structured-tag-breakdown"; +import type { MoodNarrativeItem } from "./mood-narrative-feed"; +import type { MoodStabilityData } from "./mood-stability-tile"; +import type { MoodTagInfluenceRow } from "./mood-tag-influence"; +import type { MoodBetterDayFactor } from "./mood-better-days"; +import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab"; +import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab"; +import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab"; + +export interface MoodInsightsResponse { + summary: { + totalEntries: number; + inTargetPct: number | null; + }; + heatmap: { + windowDays: number; + cells: Array<{ date: string; score: number; samples: number }>; + }; + distribution: MoodDistributionRow[]; + // Optional only to tolerate a stale pre-v1.37 cached payload during a + // rollout; the live endpoint always populates it. + dimensions?: MoodDimensionSummaryData[]; + weekday: MoodWeekdayRow[]; + timeOfDay: MoodTimeOfDayPattern; + stability: MoodStabilityData | null; + tags: MoodTagRow[]; + structuredTags: MoodStructuredTagRow[]; + // Optional only to tolerate a stale pre-v1.11.5 cached payload during a + // rollout; the live endpoint always populates both. + tagInfluence?: { + flat: MoodTagInfluenceRow[]; + structured: MoodTagInfluenceRow[]; + }; + betterDays?: MoodBetterDayFactor[]; + // Optional only to tolerate a stale pre-v1.12.0 cached payload during a + // rollout; the live endpoint always populates it. + tagMetricCrosstab?: MoodTagMetricCrosstabRow[]; + /** v1.38 — context value against the day's mood, counts included. */ + contextComparison?: ContextComparisonRow[]; + // Optional only to tolerate a stale pre-v1.14.0 cached payload during a + // rollout; the live endpoint always populates it. + factorCrosstab?: MoodFactorMetricCrosstabRow[]; + narratives: MoodNarrativeItem[]; + correlations: { + sleep: MoodMetricCorrelationData; + steps: MoodMetricCorrelationData; + pulse: MoodMetricCorrelationData; + weight: MoodMetricCorrelationData; + bloodPressureSystolic: MoodMetricCorrelationData; + }; +} + +export function SectionCard({ + title, + icon, + children, +}: { + title: string; + // v1.12.6 — every mood section header now routes through `TileHeader` + // (icon + white heading) so the subpage reads as one card language. + icon: ComponentType<{ className?: string }>; + children: React.ReactNode; +}) { + return ( + + + + + {children} + + ); +} diff --git a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts index 420d73a1b..496be10f6 100644 --- a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts +++ b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyOptimisticSlotMark, + slotMarkRequestBody, complianceFromLedger, formatSlotDelta, groupLedgerByDay, @@ -195,3 +196,23 @@ describe("applyOptimisticSlotMark", () => { expect(next.rows[0].status).toBe("taken_late"); }); }); + +describe("slotMarkRequestBody", () => { + const row = { at: "2026-08-04T07:00:00.000Z" }; + + it("anchors takenAt on the slot instant, not on now", () => { + const body = slotMarkRequestBody(row, "taken"); + expect(body).toEqual({ + skipped: false, + scheduledFor: "2026-08-04T07:00:00.000Z", + takenAt: "2026-08-04T07:00:00.000Z", + }); + }); + + it("keeps the skipped arm free of takenAt", () => { + expect(slotMarkRequestBody(row, "skipped")).toEqual({ + skipped: true, + scheduledFor: "2026-08-04T07:00:00.000Z", + }); + }); +}); diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts index 06f51e084..9bb009e35 100644 --- a/src/components/medications/dose-history-ledger-compute.ts +++ b/src/components/medications/dose-history-ledger-compute.ts @@ -204,3 +204,18 @@ export function applyOptimisticSlotMark( }); return { ...payload, rows }; } + +/** + * Request body for marking a ledger slot from the Verlauf. The taken arm + * anchors `takenAt` on the slot instant: marking Tuesday 09:00 from a + * Thursday evening records a dose taken Tuesday 09:00, which is the claim + * the person is making. The optimistic paint above already promised this. + */ +export function slotMarkRequestBody( + row: Pick, + action: "taken" | "skipped", +): Record { + return action === "skipped" + ? { skipped: true, scheduledFor: row.at } + : { skipped: false, scheduledFor: row.at, takenAt: row.at }; +} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 9204ed83d..82b6ae50e 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -106,6 +106,7 @@ import { formatSlotDelta, groupLedgerByDay, isSlotActionable, + slotMarkRequestBody, type LedgerPayload, type LedgerRow, } from "@/components/medications/dose-history-ledger-compute"; @@ -273,18 +274,13 @@ export function DoseHistoryLedger({ try { // Pin the displayed slot so the server marks THAT dose (the canonical - // slot upsert), not a now-snap to the nearest one. The POST returns - // the created event; its id drives the Undo affordance (the same - // soft-delete route the cards use). + // slot upsert), not a now-snap to the nearest one — and anchor + // `takenAt` on the same instant, matching the optimistic paint. The + // POST returns the created event; its id drives the Undo affordance + // (the same soft-delete route the cards use). const created = await apiPost<{ id?: string } | undefined>( `/api/medications/${medicationId}/intake`, - action === "skipped" - ? { skipped: true, scheduledFor: row.at } - : { - skipped: false, - scheduledFor: row.at, - takenAt: new Date().toISOString(), - }, + slotMarkRequestBody(row, action), ); const eventId: string | undefined = created?.id; toast.success( diff --git a/src/components/mood/__tests__/linked-day-timezone.test.ts b/src/components/mood/__tests__/linked-day-timezone.test.ts new file mode 100644 index 000000000..49a64e915 --- /dev/null +++ b/src/components/mood/__tests__/linked-day-timezone.test.ts @@ -0,0 +1,108 @@ +/** + * Which zone a linked-day read is made in, and which surface decides it. + * + * The finding this file answers was not a wrong number — it was a doc comment + * that said "the detail view passes the entry's stored tz" beside code that + * always sent the browser's. Nothing was red, because nothing asserted the + * claim. Two ends, so two kinds of assertion: + * + * * the resolution itself, on the pure helper, at both branches; + * * the two call sites, structurally, because the resolution being right is + * worth nothing if the edit dialog never hands it the entry's zone. + * + * Both matchers assert a non-zero match count. An empty match set fails rather + * than passing quietly, which is how a guard stays a guard after somebody + * renames the thing it greps for. + * + * Mutation check: drop the `editing?.tz` argument at the edit call site and + * the call-site row goes red; make the helper prefer its browser argument and + * the resolution rows go red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { linkedDayRequest } from "../use-linked-day-context"; + +const HERE = join(__dirname, ".."); +const LIST = readFileSync(join(HERE, "mood-list.tsx"), "utf8"); +const FORM = readFileSync(join(HERE, "mood-form.tsx"), "utf8"); + +/** The arguments of every `useLinkedDayContext(...)` call in a source file. */ +function callArguments(source: string): string[] { + const calls: string[] = []; + const needle = "useLinkedDayContext("; + let at = source.indexOf(needle); + while (at !== -1) { + // Only a CALL, never the import line that names the same identifier. + const open = at + needle.length - 1; + let depth = 0; + let end = open; + for (let i = open; i < source.length; i++) { + if (source[i] === "(") depth++; + else if (source[i] === ")") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + calls.push(source.slice(open + 1, end)); + at = source.indexOf(needle, end); + } + return calls; +} + +describe("linked-day timezone", () => { + it("prefers a stored zone over the browser's", () => { + const { tz, url } = linkedDayRequest( + "2026-06-11", + "America/New_York", + "Europe/Berlin", + ); + expect(tz).toBe("America/New_York"); + expect(url).toContain("tz=America%2FNew_York"); + expect(url).toContain("date=2026-06-11"); + }); + + it("falls back to the browser's zone when the row has none", () => { + // A legacy row (`tz IS NULL`) and an entry that does not exist yet are the + // same case here. Not Europe/Berlin: the server owns the legacy rule and a + // guess sent from the client would override the one place it is written. + expect(linkedDayRequest("2026-06-11", null, "Europe/Berlin").tz).toBe( + "Europe/Berlin", + ); + expect(linkedDayRequest("2026-06-11", "", "Europe/Berlin").tz).toBe( + "Europe/Berlin", + ); + expect(linkedDayRequest("2026-06-11", undefined, "Asia/Tokyo").tz).toBe( + "Asia/Tokyo", + ); + }); + + it("has the edit dialog hand over the entry's own zone", () => { + const calls = callArguments(LIST); + expect( + calls.length, + "no useLinkedDayContext call found in mood-list.tsx — this matcher proves nothing", + ).toBe(1); + expect( + calls[0], + "the edit dialog reads the linked day under the browser's zone; an entry logged abroad would be looked up on the wrong day", + ).toContain("editing?.tz"); + }); + + it("leaves the capture sheet on the browser's zone", () => { + const calls = callArguments(FORM); + expect( + calls.length, + "no useLinkedDayContext call found in mood-form.tsx — this matcher proves nothing", + ).toBe(1); + // A new entry has no stored zone to pass, so the third argument is absent + // on purpose. Passing something here would be inventing a zone for a row + // that has not chosen one. + expect(calls[0]).not.toContain("tz"); + }); +}); 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)} + /> + + +