Merge branch 'main' into feat/encounter-phase-1

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 12:22:04 +02:00
commit a83be074ca
65 files changed
+7864 -624

No files matched your search

+63
View File
@@ -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
+426 -4
View File
@@ -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:
+138 -5
View File
@@ -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": {
+138 -5
View File
@@ -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": {
+138 -5
View File
@@ -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": {
+138 -5
View File
@@ -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": {
+138 -5
View File
@@ -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": {
+138 -5
View File
@@ -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": {
@@ -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");
+101
View File
@@ -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
+11
View File
@@ -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) +
+70 -3
View File
@@ -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<string, string> = {
userId: "user-scoped backup; owner is re-derived from the restoring account",
};
const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record<string, string> = {
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<string, string> = {
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);
}
});
});
+6 -1
View File
@@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, string> = {
* 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` —
@@ -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 },
});
@@ -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"];
+36 -5
View File
@@ -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,
});
},
);
@@ -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);
});
});
@@ -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", () => ({
+104 -8
View File
@@ -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<Response> {
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<Response> {
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<Response> {
// 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<Response> {
// 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)
+146 -106
View File
@@ -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,
);
+93
View File
@@ -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);
});
+31
View File
@@ -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(),
@@ -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<string, unknown>;
}) => (args.where === undefined && args.select?.id ? rows : [])) as never);
}
function validWorkout(
externalId: string,
overrides: Record<string, unknown> = {},
@@ -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<string, unknown>;
}) => {
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();
});
});
+128 -28
View File
@@ -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<Response> {
);
}
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<Response> {
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<Response> {
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
let duplicateCount = droppedByWriteDedup.length;
let insertedCount = 0;
let enrichedCount = 0;
const insertedIdByIndex = new Map<number, string>();
if (survivors.length > 0) {
@@ -418,6 +440,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
.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<Response> {
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<string, { id: string; hasSeries: boolean }>();
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<Prepared["samples"]>;
}> = [];
/** Workouts already claimed by an earlier entry of THIS batch. */
const enrichClaimed = new Set<string>();
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<string>();
await prisma.$transaction(async (tx) => {
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
@@ -517,6 +577,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// 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<Response> {
});
}
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<Response> {
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<Response> {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
enriched: enrichedCount,
skipped: skipped.length,
},
});
@@ -584,12 +676,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// 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<Response> {
// 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(
() => {},
);
+3 -16
View File
@@ -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({
<WorkoutDetailRoute workout={data} />
<WorkoutDetailSplits workout={data} />
<WorkoutDetailDayContext workout={data} />
{/* 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. */}
<CoachLaunchButton
scope={{ metric: "workouts" }}
workoutId={data.id}
label={t("insights.workouts.askWhy")}
prefill={t("insights.workouts.askWhyPrompt")}
autoSend
/>
</>
)}
</SubPageShell>
@@ -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 `<MoodInsightsBreakdowns>` 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodInsightsSections />
<MoodInsightsBreakdowns
data={data as unknown as MoodInsightsResponse}
/>
</I18nProvider>
</QueryClientProvider>,
);
@@ -105,7 +113,7 @@ function baseData(inTargetPct: number | null): Response {
};
}
describe("<MoodInsightsSections> — in-target dedup", () => {
describe("<MoodInsightsBreakdowns> — 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.
@@ -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");
});
@@ -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 (
<div className="space-y-3" data-slot="mood-context-comparison">
<p className="text-muted-foreground text-sm">
{t("insights.mood.contextComparison.description")}
</p>
<ul className="space-y-3">
{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 (
<li
key={`${row.field}:${row.value}`}
className="border-border/60 space-y-1 border-t pt-3 first:border-t-0 first:pt-0"
data-slot="mood-context-comparison-row"
>
<p className="text-foreground text-sm">
{t("insights.mood.contextComparison.statement", {
label,
direction,
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
})}
</p>
{/* The counts ride with the statement, in the same block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{t("insights.mood.contextComparison.counts", {
withDays: String(row.withDays),
withoutDays: String(row.withoutDays),
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-xs">
{t("insights.mood.contextComparison.caveat")}
</p>
</div>
);
}
@@ -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: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
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.
<div className="space-y-4">
{/* 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.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
{/* 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. */}
<MoodWhatStandsOut narratives={narratives} />
{/* 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 && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
{hasContextComparison && (
<SectionCard
title={t("insights.mood.contextComparison.title")}
icon={Briefcase}
>
<MoodContextComparison rows={contextRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
</div>
);
}
@@ -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: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
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: () => <Skeleton className="min-h-24 w-full rounded-md" />,
},
);
const MoodInsightsBreakdowns = dynamic(
() =>
import("./mood-insights-breakdowns").then((mod) => ({
default: mod.MoodInsightsBreakdowns,
})),
{
ssr: false,
loading: () => <Skeleton className="min-h-48 w-full rounded-xl" />,
},
);
/**
* 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 (
<Card>
<CardHeader>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
/**
* 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.
<div className="space-y-4">
{/* 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.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
{/* 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. */}
<MoodWhatStandsOut narratives={narratives} />
{/* 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 && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
</div>
);
// 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 <MoodInsightsBreakdowns data={data} />;
}
@@ -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 (
<Card>
<CardHeader>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
@@ -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",
});
});
});
@@ -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<LedgerRow, "at">,
action: "taken" | "skipped",
): Record<string, unknown> {
return action === "skipped"
? { skipped: true, scheduledFor: row.at }
: { skipped: false, scheduledFor: row.at, takenAt: row.at };
}
@@ -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(
@@ -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");
});
});
+701
View File
@@ -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<string, ReadonlyArray<keyof MoodContextState>>;
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<keyof typeof SECTION_FIELDS>
).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<string, unknown> | 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<Omit<MoodContextState, "eventAt" | "note">> & {
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 (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
data-slot="mood-context-choice"
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
selected
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
{label}
</button>
);
}
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 (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-normal">
{label}
</Label>
<div className="flex flex-wrap gap-1.5">
{keys.map((key) => (
<ChoiceChip
key={key}
label={t(contextValueLabelKey(field, key))}
selected={value === key}
onClick={() => onChange(value === key ? null : key)}
/>
))}
</div>
</div>
);
}
function MultiChoiceRow({
label,
field,
keys,
value,
onChange,
}: {
label: string;
field: string;
keys: readonly string[];
value: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useTranslations();
return (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-normal">
{label}
</Label>
<div className="flex flex-wrap gap-1.5">
{keys.map((key) => (
<ChoiceChip
key={key}
label={t(contextValueLabelKey(field, key))}
selected={value.includes(key)}
onClick={() =>
onChange(
value.includes(key)
? value.filter((k) => k !== key)
: [...value, key],
)
}
/>
))}
</div>
</div>
);
}
function MinutesField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: number | null;
onChange: (next: number | null) => void;
}) {
const { t } = useTranslations();
return (
<FieldGroup htmlFor={id} label={label}>
<Input
id={id}
type="number"
inputMode="numeric"
min={CONTEXT_MINUTES_MIN}
max={CONTEXT_MINUTES_MAX}
step={5}
value={value === null ? "" : String(value)}
placeholder={t("mood.dayContext.minutesPlaceholder")}
onChange={(e) => {
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));
}}
/>
</FieldGroup>
);
}
/** 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 (
<SliderField
label={t(contextValueLabelKey("field", field))}
value={value}
min={CONTEXT_RATING_MIN}
max={CONTEXT_RATING_MAX}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("mood.dimensionNotAnswered")}
valueText={
value === null
? undefined
: t("mood.dimensionValueText", {
value,
max: CONTEXT_RATING_MAX,
low,
high,
})
}
clearLabel={t("mood.dimensionClear")}
onClear={() => onChange(null)}
onValueChange={onChange}
/>
);
}
interface MoodContextSectionsProps {
value: MoodContextState;
onChange: (next: MoodContextState) => void;
}
export function MoodContextSections({
value,
onChange,
}: MoodContextSectionsProps) {
const { t } = useTranslations();
const set = <K extends keyof MoodContextState>(
key: K,
next: MoodContextState[K],
) => onChange({ ...value, [key]: next });
return (
<>
<SheetSection
title={t(contextSectionLabelKey("work"))}
icon={<Briefcase />}
summary={
<SheetSectionCount count={contextSectionCount(value, "work")} />
}
>
<div className="space-y-4" data-slot="mood-context-work">
<ChoiceRow
label={t(contextValueLabelKey("field", "workStatus"))}
field="workStatus"
keys={WORK_STATUS_KEYS}
value={value.workStatus}
onChange={(next) => set("workStatus", next)}
/>
<MinutesField
id="mood-context-work-minutes"
label={t(contextValueLabelKey("field", "workMinutes"))}
value={value.workMinutes}
onChange={(next) => set("workMinutes", next)}
/>
<MinutesField
id="mood-context-overtime-minutes"
label={t(contextValueLabelKey("field", "overtimeMinutes"))}
value={value.overtimeMinutes}
onChange={(next) => set("overtimeMinutes", next)}
/>
<ContextRating
field="workLoad"
value={value.workLoad}
onChange={(next) => set("workLoad", next)}
/>
<ContextRating
field="workSatisfaction"
value={value.workSatisfaction}
onChange={(next) => set("workSatisfaction", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("contacts"))}
icon={<Users />}
summary={
<SheetSectionCount count={contextSectionCount(value, "contacts")} />
}
>
<div className="space-y-4" data-slot="mood-context-contacts">
<p className="text-muted-foreground text-xs">
{t("mood.dayContext.contactsHelp")}
</p>
<MultiChoiceRow
label={t(contextValueLabelKey("field", "contactCircles"))}
field="contactCircles"
keys={CONTACT_CIRCLE_KEYS}
value={value.contactCircles}
onChange={(next) => set("contactCircles", next)}
/>
<ChoiceRow
label={t(contextValueLabelKey("field", "contactForm"))}
field="contactForm"
keys={CONTACT_FORM_KEYS}
value={value.contactForm}
onChange={(next) => set("contactForm", next)}
/>
<ChoiceRow
label={t(contextValueLabelKey("field", "contactExtent"))}
field="contactExtent"
keys={CONTACT_EXTENT_KEYS}
value={value.contactExtent}
onChange={(next) => set("contactExtent", next)}
/>
<ContextRating
field="contactQuality"
value={value.contactQuality}
onChange={(next) => set("contactQuality", next)}
/>
<ContextRating
field="contactSupport"
value={value.contactSupport}
onChange={(next) => set("contactSupport", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("leisure"))}
icon={<Palmtree />}
summary={
<SheetSectionCount count={contextSectionCount(value, "leisure")} />
}
>
<div className="space-y-4" data-slot="mood-context-leisure">
<MultiChoiceRow
label={t(contextValueLabelKey("field", "leisureCategories"))}
field="leisureCategories"
keys={LEISURE_CATEGORY_KEYS}
value={value.leisureCategories}
onChange={(next) => set("leisureCategories", next)}
/>
<MinutesField
id="mood-context-leisure-minutes"
label={t(contextValueLabelKey("field", "leisureMinutes"))}
value={value.leisureMinutes}
onChange={(next) => set("leisureMinutes", next)}
/>
<ContextRating
field="leisureJoy"
value={value.leisureJoy}
onChange={(next) => set("leisureJoy", next)}
/>
<ContextRating
field="leisureRecovery"
value={value.leisureRecovery}
onChange={(next) => set("leisureRecovery", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("events"))}
icon={<CalendarClock />}
summary={
<SheetSectionCount count={contextSectionCount(value, "events")} />
}
>
<div className="space-y-4" data-slot="mood-context-events">
<ChoiceRow
label={t(contextValueLabelKey("field", "eventType"))}
field="eventType"
keys={EVENT_TYPE_KEYS}
value={value.eventType}
onChange={(next) => set("eventType", next)}
/>
<SliderField
label={t(contextValueLabelKey("field", "eventValence"))}
value={value.eventValence}
min={CONTEXT_VALENCE_MIN}
max={CONTEXT_VALENCE_MAX}
step={1}
lowAnchor={t("mood.context.rating.eventValence.low")}
highAnchor={t("mood.context.rating.eventValence.high")}
emptyLabel={t("mood.dimensionNotAnswered")}
clearLabel={t("mood.dimensionClear")}
onClear={() => set("eventValence", null)}
onValueChange={(next) => set("eventValence", next)}
/>
<FieldGroup
htmlFor="mood-context-event-at"
label={t(contextValueLabelKey("field", "eventAt"))}
>
<DateTimeField
id="mood-context-event-at"
value={value.eventAt}
onChange={(next) => set("eventAt", next)}
/>
</FieldGroup>
<FieldGroup
htmlFor="mood-context-note"
label={t(contextValueLabelKey("field", "notes"))}
>
<Textarea
id="mood-context-note"
value={value.note}
onChange={(e) => set("note", e.target.value)}
placeholder={t("mood.dayContext.notePlaceholder")}
maxLength={CONTEXT_NOTE_MAX_LENGTH}
rows={3}
/>
</FieldGroup>
</div>
</SheetSection>
</>
);
}
function formatFigure(
figure: LinkedFigure | undefined,
notRecorded: string,
): string {
if (!figure || !figure.present) return notRecorded;
return `${figure.value} ${figure.unit}`;
}
/**
* The read-only block: what the other modules already hold for this day.
*
* Every row names where its number comes from and offers a way in to change it
* there. A block whose module is switched off is not rendered at all — not
* zeroed, not greyed out — because turning a module off is a statement about
* what somebody wants to see.
*/
export function MoodLinkedDaySection({
linked,
}: {
linked: LinkedDayContext | null;
}) {
const { t } = useTranslations();
const notRecorded = t("mood.dayContext.notRecorded");
const rows = useMemo(() => {
if (!linked) return [];
const out: Array<{ label: string; value: string; href: string }> = [];
if (linked.sleep.available) {
out.push({
label: t("mood.dayContext.linkedSleep"),
value: formatFigure(linked.sleep.asleep, notRecorded),
href: "/insights/sleep",
});
out.push({
label: t("mood.dayContext.linkedInBed"),
value: formatFigure(linked.sleep.inBed, notRecorded),
href: "/insights/sleep",
});
}
if (linked.activity.available) {
out.push({
label: t("mood.dayContext.linkedSteps"),
value: formatFigure(linked.activity.steps, notRecorded),
href: "/insights/steps",
});
out.push({
label: t("mood.dayContext.linkedActiveEnergy"),
value: formatFigure(linked.activity.activeEnergy, notRecorded),
href: "/insights/steps",
});
}
if (linked.vitals.available) {
out.push({
label: t("mood.dayContext.linkedRestingHeartRate"),
value: formatFigure(linked.vitals.restingHeartRate, notRecorded),
href: "/measurements",
});
out.push({
label: t("mood.dayContext.linkedHrv"),
value: formatFigure(linked.vitals.heartRateVariability, notRecorded),
href: "/measurements",
});
}
return out;
}, [linked, notRecorded, t]);
if (!linked) return null;
const bodyLine = linked.body.available
? linked.body.logged
? t("mood.dayContext.bodyLogged", {
count: String(linked.body.symptoms.length),
})
: t("mood.dayContext.bodyNotLogged")
: null;
return (
<SheetSection
title={t("mood.dayContext.linkedTitle")}
icon={<Link2 />}
summary={<SheetSectionCount count={rows.length} />}
>
<div className="space-y-3" data-slot="mood-linked-day">
<p className="text-muted-foreground text-xs">
{t("mood.dayContext.linkedHelp")}
</p>
<dl className="space-y-1.5">
{rows.map((row) => (
<div
key={row.label}
className="flex items-center justify-between gap-3"
>
<dt className="text-muted-foreground text-xs">{row.label}</dt>
<dd className="text-foreground text-sm tabular-nums">
{row.value}
</dd>
</div>
))}
</dl>
{bodyLine ? (
<div className="border-border/60 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">
<Activity className="mr-1 inline h-3 w-3" aria-hidden="true" />
{bodyLine}
</span>
<a
href={
linked.body.available && linked.body.episodeId
? `/illness/${linked.body.episodeId}`
: "/illness"
}
className="text-primary text-xs underline underline-offset-2"
>
{t("mood.dayContext.openIllness")}
</a>
</div>
) : null}
</div>
</SheetSection>
);
}
+42
View File
@@ -52,6 +52,15 @@ import {
seedA1FromMood,
type LevelAState,
} from "./mood-level-a-fields";
import {
EMPTY_MOOD_CONTEXT,
MoodContextSections,
MoodLinkedDaySection,
contextIsEmpty,
moodContextPayload,
type MoodContextState,
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
import { moodFaceIcon } from "./mood-tag-icons";
@@ -128,6 +137,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [a1Touched, setA1Touched] = useState(false);
const [levelAOpen, setLevelAOpen] = useState(false);
// v1.38 — the day's context. Four closed sections; nothing here is required
// and an entry that never opens one stores no context row at all.
const [context, setContext] = useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
@@ -154,6 +166,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// A level-A value the user set counts as typed input: Reset must ask
// before it discards an answer that took five slider moves.
Object.values(levelA).some((v) => v !== null) ||
// A filled context section is typed input too: Reset asks before it
// discards a day somebody described.
!contextIsEmpty(context) ||
note.trim() !== "";
// v1.37 — picking a face suggests a pleasantness value; the suggestion
@@ -184,6 +199,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
setLevelA(EMPTY_LEVEL_A);
setA1Touched(false);
setLevelAOpen(false);
setContext(EMPTY_MOOD_CONTEXT);
setTagsInput("");
setTagKeys([]);
setNote("");
@@ -225,6 +241,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// v1.37 — only the values somebody answered. An untouched slider
// sends nothing, and the server writes NULL rather than a middle.
...levelACreatePayload(levelA),
// v1.38 — omitted when nothing was answered, so the create stores no
// context row rather than a row of NULLs.
...(contextIsEmpty(context)
? {}
: { context: moodContextPayload(context) }),
moodLoggedAt: timestamp,
});
@@ -295,6 +316,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// primary input and a fast log is a single tap + Save.
const moodPicked = mood !== "";
// v1.38 — what the other modules already hold for the day being logged.
// Keyed on the timestamp field, so moving the entry to yesterday shows
// yesterday's night. Only fetched once a face is picked: an unopened sheet
// must not cost four modules a query.
// No third argument, and deliberately: this entry does not exist yet, so it
// has no stored zone. It will be anchored to the browser's, which is the
// zone the hook falls back to — the figures shown here are the figures the
// saved row will sit beside.
const linked = useLinkedDayContext(dayOfLocalInput(moodLoggedAt), moodPicked);
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
// plus the note (when present), so a collapsed section communicates what
// it holds.
@@ -420,6 +451,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
onOpenChange={setLevelAOpen}
/>
{/* v1.38 — the day around the entry: work, contacts, leisure and a
notable event. Four closed sections; opening none of them is a
complete entry. */}
<MoodContextSections value={context} onChange={setContext} />
{/* v1.38 — read-only, and read from the modules that own it. Sleep,
activity and vitals are never asked for a second time here; the
body line links into the illness journal instead of growing a
second symptom form. */}
<MoodLinkedDaySection linked={linked} />
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
and the note field. The badge counts whichever of these carry
input. */}
+61
View File
@@ -94,6 +94,16 @@ import {
levelAUpdatePayload,
type LevelAState,
} from "./mood-level-a-fields";
import type { MoodContextWire } from "@/lib/mood/context";
import {
EMPTY_MOOD_CONTEXT,
MoodContextSections,
MoodLinkedDaySection,
moodContextFromEntry,
moodContextPayload,
type MoodContextState,
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
@@ -118,6 +128,14 @@ interface MoodEntry {
a4?: number | null;
a5?: number | null;
note: string | null;
// v1.38 — the day context, null on entries that carry none.
context: MoodContextWire | null;
/**
* The IANA zone this entry's `date` is anchored to, or null on a legacy row.
* Read by the linked-day block so an entry logged in another country is
* looked up under ITS day rather than under the corrector's.
*/
tz: string | null;
source: string;
moodLoggedAt: string;
}
@@ -201,6 +219,12 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
// so what the capture sheet wrote is visible and changeable here.
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
// v1.38 — the day context on the edit path, seeded from the entry so what
// the capture sheet wrote is visible and correctable here. A value that can
// be written on one surface and not corrected on another is worse than one
// nobody was offered.
const [editContext, setEditContext] =
useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editSeed, setEditSeed] = useState<{
@@ -209,6 +233,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
context: MoodContextState;
note: string;
moodLoggedAt: string;
}>({
@@ -217,6 +242,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: [],
ratedFactors: [],
levelA: EMPTY_LEVEL_A,
context: EMPTY_MOOD_CONTEXT,
note: "",
moodLoggedAt: "",
});
@@ -410,6 +436,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys,
ratedFactors,
levelA,
context,
note,
moodLoggedAt,
}: {
@@ -419,6 +446,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
context: MoodContextState;
note: string | null;
moodLoggedAt: string;
}) => {
@@ -431,6 +459,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// user cleared has to travel as an explicit null or the old answer
// survives an edit that visibly removed it.
...levelAUpdatePayload(levelA),
// Always stated on this path, and `null` when every section is empty:
// the dialog shows the whole context, so an omission here would leave
// a context the person just cleared sitting on the row.
context: moodContextPayload(context),
note,
moodLoggedAt,
});
@@ -446,6 +478,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: editLevelA,
context: editContext,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
});
@@ -465,12 +498,26 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: editLevelA,
context: editContext,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
},
blocked: updateMutation.isPending || deleteMutation.isPending,
});
// v1.38 — the linked figures for the entry being edited, read for its own
// local day. Only while a dialog is open: a list of fifty rows must not cost
// fifty module reads.
const editLinked = useLinkedDayContext(
dayOfLocalInput(editMoodLoggedAt),
editing !== null,
// The entry's OWN zone, not the browser's. An entry logged abroad decided
// which day it belongs to when it was written; re-reading that day under
// whichever zone the correction is being made from would shift the night
// beside it by hours with nothing on screen saying so.
editing?.tz ?? null,
);
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
function startEdit(entry: MoodEntry) {
@@ -482,6 +529,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: levelAFromEntry(entry),
context: moodContextFromEntry(entry.context),
note: entry.note ?? "",
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
};
@@ -491,6 +539,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditTagKeys(seed.tagKeys);
setEditRatedFactors(seed.ratedFactors);
setEditLevelA(seed.levelA);
setEditContext(seed.context);
setEditNote(seed.note);
setEditMoodLoggedAt(seed.moodLoggedAt);
setEditSeed(seed);
@@ -544,6 +593,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: editTagKeys,
ratedFactors: editRatedFactors,
levelA: editLevelA,
context: editContext,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
@@ -1044,6 +1094,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
set. */}
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
{/* v1.38 — the day context, seeded from the entry. */}
<MoodContextSections
value={editContext}
onChange={setEditContext}
/>
{/* v1.38 — read-only, from the modules that own it. Resolved for
the entry's own day, so an entry moved to another date shows
that day's figures. */}
<MoodLinkedDaySection linked={editLinked} />
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
@@ -0,0 +1,100 @@
"use client";
/**
* Read what the other modules already hold for one local day.
*
* A hook rather than a prop drilled from a page, because both mood surfaces
* need it and both know only the wall-clock value in their timestamp field.
*
* **Which zone that day is read in depends on which surface is asking, and the
* two are genuinely different questions.** The capture sheet is about to write
* a new entry, and that entry will be anchored to the zone the browser is in,
* so the browser's zone is the right one and the figures it shows are the
* figures the saved row will sit beside. The edit dialog is looking at an
* entry that already decided which day it belongs to and stored the zone it
* decided in: reading that entry's day under the corrector's browser zone
* would show a night from an entry logged in another country shifted by
* several hours, and nothing on the screen would say so. So the caller passes
* the entry's stored `tz` and the hook does not guess.
*
* The query key carries both the day and the zone. One key for both would let
* whichever answer arrived first serve the other — the same-key /
* different-shape cache poisoning the key factory exists to prevent, in its
* quieter same-shape / different-window form. Two surfaces asking about the
* same calendar date under two zones is exactly that case, and it is reachable
* from one screen: the list can hold an entry logged abroad while the sheet
* writes one logged here.
*/
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
/**
* The browser's own zone — the right answer for an entry that does not exist
* yet, and the wrong one for an entry that already stored its own.
*/
export function browserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 10);
}
/**
* @param day the local day to read, `YYYY-MM-DD`
* @param enabled whether to fetch at all
* @param timezone the zone `day` is anchored to. Omit ONLY where the day has
* no zone of its own yet — a new entry the capture sheet is composing. An
* entry being edited passes its stored `tz`.
*/
/**
* The zone a linked-day read should use, and the URL that follows from it.
*
* Pure and exported so the decision can be tested without a renderer: the
* whole finding this answers was a doc comment claiming the edit dialog passed
* the entry's zone while the code always sent the browser's, and a claim like
* that is only worth as much as the assertion under it.
*
* A stored zone wins. Absent — a legacy row with `tz IS NULL`, or an entry
* that does not exist yet — falls back to the browser's, which is honest about
* being an assumption and is what the capture sheet means anyway. It does NOT
* fall back to Europe/Berlin: the server owns that legacy rule, and sending a
* guess would override the one place it is written down.
*/
export function linkedDayRequest(
day: string,
storedTimezone: string | null | undefined,
browserTz: string,
): { tz: string; url: string } {
const tz =
storedTimezone && storedTimezone.length > 0 ? storedTimezone : browserTz;
return {
tz,
url: `/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(tz)}`,
};
}
export function useLinkedDayContext(
day: string,
enabled = true,
timezone?: string | null,
) {
const { tz, url } = linkedDayRequest(day, timezone, browserTimezone());
const query = useQuery({
queryKey: queryKeys.moodLinkedContext(day, tz),
queryFn: () => apiGet<LinkedDayContext>(url),
enabled,
// The figures come from other modules and change on their own schedule; a
// minute of staleness is cheaper than refetching four modules every time
// the sheet re-renders.
staleTime: 60_000,
});
return query.data ?? null;
}
+4
View File
@@ -204,6 +204,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// IllnessDayLog / LabResult note columns above; Bytes via the shared codec.
{ model: "MoodEntry", field: "noteEncrypted", kind: "bytes" },
{ model: "Measurement", field: "notesEncrypted", kind: "bytes" },
// v1.38 — the one free-text note on a mood entry's day context. Same codec
// as the entry note beside it; one column for the whole context rather than
// one per section, so there is one rotation entry rather than four.
{ model: "MoodContext", field: "notesEncrypted", kind: "bytes" },
// ───── v1.25 medication free-text notes (Bytes columns) ─────
// Side-effect log note, dose-change titration note, and inventory-item
+4
View File
@@ -71,6 +71,10 @@ export const WIPE_MODELS = [
"MedicationComplianceRollup",
// ── Mood ────────────────────────────────────────────────────────────────
// The day context, before the entry it hangs off: it cascades either way,
// but a parent deleted first leaves the child's own count reporting zero for
// rows that existed, and the confirmation screen quotes those counts.
"MoodContext",
"MoodEntry",
"MoodEntryRollup",
"MoodReminderDispatch",
+2
View File
@@ -80,6 +80,7 @@ export const BACKED_UP_MODELS = [
// ── Mood ──────────────────────────────────────────────────────────────────
"MoodEntry",
"MoodContext",
"MoodEntryTagLink",
"MoodTag",
"MoodTagCategory",
@@ -226,6 +227,7 @@ export const TWO_ENDED_MODELS = [
"MedicationIntakeEvent",
"MedicationSideEffect",
"MoodEntry",
"MoodContext",
"MoodEntryTagLink",
"MoodTag",
"LabResult",
+99
View File
@@ -155,6 +155,96 @@ const MOOD_ENTRY_TAG_LINK_SELECT = {
moodTag: { select: { key: true, kind: true } },
} as const satisfies Prisma.MoodEntryTagLinkSelect;
/**
* Every restorable `MoodContext` scalar.
*
* `moodEntryId` and `userId` are absent for the reasons the exclusion list in
* `mood-backup-completeness.test.ts` states: the context rides inside its
* entry and is re-bound to whatever id that entry got, and the owner is
* re-derived from the restoring account. The two row stamps are absent for the
* third reason there — they describe when the sub-row was written, not when
* the day happened, and the entry already carries the moment that matters.
*
* Named rather than inlined so the guard can compare it field-by-field against
* the model, exactly as it does for the two selects above. This is the whole
* point of the pair: a column added to `MoodContext` without a line here
* fails, and it fails in the release that added it.
*/
const MOOD_CONTEXT_BACKUP_SELECT = {
workStatus: true,
workMinutes: true,
overtimeMinutes: true,
workLoad: true,
workSatisfaction: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
contactQuality: true,
contactSupport: true,
leisureCategories: true,
leisureMinutes: true,
leisureJoy: true,
leisureRecovery: true,
eventType: true,
eventValence: true,
eventAt: true,
notesEncrypted: true,
} as const satisfies Prisma.MoodContextSelect;
type BackupMoodContext = Prisma.MoodContextGetPayload<{
select: typeof MOOD_CONTEXT_BACKUP_SELECT;
}>;
/**
* One context row, in whichever of the two wire shapes the caller asked for.
*
* The split is the same one `Measurement` makes: a disaster-recovery file
* carries the ciphertext verbatim, because the instance that reads it back
* holds the key; a portable export carries the plain text, because the point
* of a portable file is that somebody can read it. Every other column rides in
* both, since none of them is a storage detail — they are all answers somebody
* gave.
*
* The parameter is spelled `moodContext` rather than `c`, deliberately: the
* completeness guard proves a selected column reaches a payload by matching
* `moodContext.<column>`, and a one-letter parameter shared with another
* mapper in this file would make that matcher unable to fail.
*/
function serialiseMoodContext(
moodContext: BackupMoodContext,
disasterRecovery: boolean,
) {
return {
...(disasterRecovery
? {
notesEncrypted: moodContext.notesEncrypted
? Buffer.from(moodContext.notesEncrypted).toString("base64")
: null,
}
: { note: readNote(moodContext.notesEncrypted, null) }),
workStatus: moodContext.workStatus,
workMinutes: moodContext.workMinutes,
overtimeMinutes: moodContext.overtimeMinutes,
workLoad: moodContext.workLoad,
workSatisfaction: moodContext.workSatisfaction,
// The two multi-selects ride as the stored JSON string, unparsed. The
// restore writes the column back verbatim, so a file cannot arrive with a
// list the parser and the column disagree about.
contactCircles: moodContext.contactCircles,
contactForm: moodContext.contactForm,
contactExtent: moodContext.contactExtent,
contactQuality: moodContext.contactQuality,
contactSupport: moodContext.contactSupport,
leisureCategories: moodContext.leisureCategories,
leisureMinutes: moodContext.leisureMinutes,
leisureJoy: moodContext.leisureJoy,
leisureRecovery: moodContext.leisureRecovery,
eventType: moodContext.eventType,
eventValence: moodContext.eventValence,
eventAt: moodContext.eventAt?.toISOString() ?? null,
};
}
/**
* Build the canonical full-backup payload for `userId`. Portable exports omit
* tombstones and document ciphertext; disaster-recovery payloads preserve
@@ -267,6 +357,9 @@ export async function buildFullBackupPayload(
// definition — so the present/absent tags, the kind most people pick,
// were absent from the file and could not come back from it.
tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT },
// The day context, when the entry has one. Sparse by design — most
// entries carry none, and the join answers null on those.
context: { select: MOOD_CONTEXT_BACKUP_SELECT },
},
}),
// The account's OWN mood tags. The seeded catalogue is reference data every
@@ -520,6 +613,12 @@ export async function buildFullBackupPayload(
deletedAt: moodEntry.deletedAt?.toISOString() ?? null,
factors,
structuredTags,
// The day context, or null. Null rather than an empty object, because
// "no context" and "a context whose every field is empty" are
// different facts and the restore has to keep them apart.
context: moodEntry.context
? serialiseMoodContext(moodEntry.context, disasterRecovery)
: null,
};
}),
customMoodTags: customMoodTags.map((tag) => ({
@@ -0,0 +1,166 @@
/**
* The context × mood comparison: its floors, its correction, and the two
* things it must never do.
*
* The floors are the point of this file. A board that always has something on
* it is a board that means nothing, and the two ways to get one are to show a
* value seen on three days and to filter a wide sweep on a raw p < 0.05. Both
* are tested at their boundary here, because a floor nobody has watched hold
* is a number in a constant.
*/
import { describe, expect, it } from "vitest";
import {
CONTEXT_MIN_ABSENT_DAYS,
CONTEXT_MIN_PRESENT_DAYS,
computeContextMoodComparison,
type ContextDayRow,
} from "@/lib/insights/mood-context-crosstab";
const EMPTY_CONTEXT = {
workStatus: null,
contactCircles: null,
contactForm: null,
contactExtent: null,
leisureCategories: null,
eventType: null,
};
function day(
index: number,
moodA1: number | null,
context: Partial<ContextDayRow> = {},
): ContextDayRow {
return {
day: `2026-03-${String(index + 1).padStart(2, "0")}`,
moodA1,
...EMPTY_CONTEXT,
...context,
};
}
/** `present` days carrying `workStatus: overtime` at `lowMood`, the rest high. */
function overtimeFixture(present: number, absent: number): ContextDayRow[] {
const rows: ContextDayRow[] = [];
for (let i = 0; i < present; i++) {
rows.push(day(i, i % 2 === 0 ? 2 : 3, { workStatus: "overtime" }));
}
for (let i = 0; i < absent; i++) {
rows.push(day(present + i, i % 2 === 0 ? 8 : 7, { workStatus: "regular" }));
}
return rows;
}
describe("context × mood comparison", () => {
it("reports the two counts and the two means it compared", () => {
const rows = computeContextMoodComparison(overtimeFixture(10, 12));
const overtime = rows.find((r) => r.value === "overtime");
expect(overtime, "the overtime comparison did not survive").toBeDefined();
expect(overtime!.field).toBe("workStatus");
expect(overtime!.withDays).toBe(10);
expect(overtime!.withoutDays).toBe(12);
expect(overtime!.withAvg).toBe(2.5);
expect(overtime!.withoutAvg).toBe(7.5);
expect(overtime!.delta).toBe(-5);
});
it("holds the present-day floor at its boundary", () => {
// One below the floor: nothing, however clean the separation looks.
const below = computeContextMoodComparison(
overtimeFixture(CONTEXT_MIN_PRESENT_DAYS - 1, 12),
);
expect(below.some((r) => r.value === "overtime")).toBe(false);
// Exactly at it: the row is allowed to exist.
const at = computeContextMoodComparison(
overtimeFixture(CONTEXT_MIN_PRESENT_DAYS, 12),
);
expect(at.some((r) => r.value === "overtime")).toBe(true);
});
it("holds the absent-day floor at its boundary", () => {
const below = computeContextMoodComparison(
overtimeFixture(10, CONTEXT_MIN_ABSENT_DAYS - 1),
);
expect(below.some((r) => r.value === "overtime")).toBe(false);
const at = computeContextMoodComparison(
overtimeFixture(10, CONTEXT_MIN_ABSENT_DAYS),
);
expect(at.some((r) => r.value === "overtime")).toBe(true);
});
it("folds a day logged twice into one observation", () => {
// Two entries on one day is not two days. Without the fold, somebody who
// logs three times on their worst day drags every comparison that day
// belongs to.
const rows = overtimeFixture(10, 12);
const doubled = [...rows, { ...rows[0], moodA1: 0 }];
const once = computeContextMoodComparison(rows).find(
(r) => r.value === "overtime",
);
const twice = computeContextMoodComparison(doubled).find(
(r) => r.value === "overtime",
);
expect(twice!.withDays).toBe(once!.withDays);
expect(twice!.withAvg).toBe(once!.withAvg);
});
it("skips days with no mood value rather than treating them as zero", () => {
const rows = overtimeFixture(10, 12);
rows.push(day(50, null, { workStatus: "overtime" }));
const overtime = computeContextMoodComparison(rows).find(
(r) => r.value === "overtime",
);
expect(overtime!.withDays).toBe(10);
});
it("reads a multi-select value out of its stored JSON list", () => {
const rows: ContextDayRow[] = [];
for (let i = 0; i < 10; i++) {
rows.push(
day(i, i % 2 === 0 ? 3 : 4, {
contactCircles: JSON.stringify(["partner", "family"]),
}),
);
}
for (let i = 0; i < 12; i++) {
rows.push(
day(10 + i, i % 2 === 0 ? 8 : 7, {
contactCircles: JSON.stringify(["friends"]),
}),
);
}
const circles = computeContextMoodComparison(rows);
expect(circles.find((r) => r.value === "partner")?.withDays).toBe(10);
expect(circles.find((r) => r.value === "friends")?.withDays).toBe(12);
});
it("drops noise a raw p < 0.05 would have surfaced", () => {
// Twenty-odd values tested against a mood series with no structure in it.
// Some pair will look significant at 0.05 sooner or later; the point of
// the family-wide correction is that it does not reach the board.
const values = [
...Array.from({ length: 40 }, (_, i) =>
day(i, (i * 7) % 11, {
contactCircles: JSON.stringify([
["partner", "child", "family", "friends"][i % 4],
]),
leisureCategories: JSON.stringify([
["film", "reading", "gaming", "music"][i % 4],
]),
eventType: ["goodNews", "badNews", "success", "conflict"][i % 4],
}),
),
];
const rows = computeContextMoodComparison(values);
for (const row of rows) {
expect(row.qValue).toBeLessThanOrEqual(0.1);
}
});
it("answers nothing at all on an empty history", () => {
expect(computeContextMoodComparison([])).toEqual([]);
expect(computeContextMoodComparison([day(0, null)])).toEqual([]);
});
});
+122
View File
@@ -16,6 +16,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -200,6 +201,105 @@ async function buildMoodDimensionFeatures(
return blocks.length > 0 ? blocks : undefined;
}
/**
* The day-context comparisons, as the model may state them.
*
* Every row has already cleared the day floors and a family-wide
* Benjamini-Hochberg correction, so nothing here is a raw p < 0.05 finding.
* The counts ride with each row and the block carries one sentence saying
* what the rows are and are not — a model follows a stated constraint far
* more reliably than it infers one from a field name, and the constraint here
* is the difference between an observation and a claim about somebody's life.
*
* Bounded: the same 90-day window the dimensions use, and the comparison's own
* cap on how many rows survive.
*/
async function buildMoodContextBlock(userId: string): Promise<{
context?: {
note: string;
comparisons: Array<{
field: string;
value: string;
withDays: number;
withoutDays: number;
withAvg: number;
withoutAvg: number;
}>;
};
}> {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const rows = await prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: since },
context: { isNot: null },
},
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: {
date: true,
moodA1: true,
context: {
select: {
workStatus: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
leisureCategories: true,
eventType: true,
},
},
},
});
if (rows.length === 0) return {};
// Days WITHOUT a context belong on the "without" side of every comparison,
// so the second read takes the whole window rather than only the rows above.
const allDays = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: { date: true, moodA1: true },
});
const contextByDay = new Map(rows.map((row) => [row.date, row.context]));
const comparisons = computeContextMoodComparison(
allDays.map((row) => {
const context = contextByDay.get(row.date) ?? null;
return {
day: row.date,
moodA1: row.moodA1,
workStatus: context?.workStatus ?? null,
contactCircles: context?.contactCircles ?? null,
contactForm: context?.contactForm ?? null,
contactExtent: context?.contactExtent ?? null,
leisureCategories: context?.leisureCategories ?? null,
eventType: context?.eventType ?? null,
};
}),
);
if (comparisons.length === 0) return {};
return {
context: {
note:
"Each row compares the average mood value on days that carried a " +
"context entry against the days that did not, over the last 90 days. " +
"These are associations and nothing more: state them with their day " +
"counts, and never say that the context caused the mood.",
comparisons: comparisons.map((row) => ({
field: row.field,
value: row.value,
withDays: row.withDays,
withoutDays: row.withoutDays,
withAvg: row.withAvg,
withoutAvg: row.withoutAvg,
})),
},
};
}
/**
* The dimensions block and the note that says how to read it beside the
* block-level `asOf`. Absent entirely when nobody has answered a dimension,
@@ -346,6 +446,27 @@ export interface AggregatedFeatures {
* Present only alongside `dimensions`.
*/
dimensionsAsOfNote?: string;
/**
* v1.38 — how the average mood value sat on days carrying each day-context
* entry against the days that did not, over the last 90 days.
*
* Present only when at least one comparison cleared the day floors and the
* family-wide correction. The `note` beside the rows states what they are:
* a model that is told in a sentence that these are associations states
* them as associations, and one that is left to infer it from a field name
* does not.
*/
context?: {
note: string;
comparisons: Array<{
field: string;
value: string;
withDays: number;
withoutDays: number;
withAvg: number;
withoutAvg: number;
}>;
};
};
sleep?: {
avg7: number | null;
@@ -1241,6 +1362,7 @@ export async function extractFeatures(
trend30,
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
...(await buildMoodContextBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
+57
View File
@@ -47,6 +47,10 @@ export {
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import {
computeContextMoodComparison,
type ContextComparisonRow,
} from "@/lib/insights/mood-context-crosstab";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
computeBetterDays,
@@ -171,6 +175,20 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
/**
* v1.38 — the day's context, when the entry carries one. Only the
* categorical fields: the comparison below tests membership of a value, and
* the numeric ratings are a different question this milestone does not ask.
* Optional so every fixture written before contexts existed keeps its shape.
*/
context?: {
workStatus: string | null;
contactCircles: string | null;
contactForm: string | null;
contactExtent: string | null;
leisureCategories: string | null;
eventType: string | null;
} | null;
/**
* v1.37 — the five level-A values, each 0-10 and each nullable. Optional so
* every fixture written before they existed keeps its narrow shape, and
@@ -453,6 +471,13 @@ export interface MoodAggregates {
*/
stability: MoodStability | null;
tags: TagSummaryRow[];
/**
* v1.38 — how the day's pleasantness sat on days carrying each context
* value against the days that did not. A comparison and never a cause: the
* copy that renders it says so, and every row carries the two counts it was
* computed from so a reader can weigh it themselves.
*/
contextComparison: ContextComparisonRow[];
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
structuredTags: StructuredTagRow[];
/**
@@ -582,6 +607,23 @@ export function computeMoodAggregates(args: {
userPriorityJson,
});
// v1.38 — every context value against the day's pleasantness, one BH-FDR
// family across the whole sweep. Entries with no context contribute to the
// "without" side of each comparison, which is what makes the counts mean
// what the copy says they mean.
const contextComparison = computeContextMoodComparison(
entries.map((entry) => ({
day: entry.date,
moodA1: entry.a1 ?? null,
workStatus: entry.context?.workStatus ?? null,
contactCircles: entry.context?.contactCircles ?? null,
contactForm: entry.context?.contactForm ?? null,
contactExtent: entry.context?.contactExtent ?? null,
leisureCategories: entry.context?.leisureCategories ?? null,
eventType: entry.context?.eventType ?? null,
})),
);
// Distinct day keys the user logged on — drives the streak takeaway.
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
@@ -624,6 +666,7 @@ export function computeMoodAggregates(args: {
betterDays,
tagMetricCrosstab,
factorCrosstab,
contextComparison,
narratives: computeMoodNarratives({
daily: moodDaily,
weekday,
@@ -677,6 +720,19 @@ export async function fetchMoodAggregates(
// the breakdown can fold structured tags next to the flat ones.
// v1.14.0 — also pull the per-link `rating` + the factor's
// kind/scale/inverse so RATED factors feed the factor crosstab.
// v1.38 — the categorical context fields. The numeric ratings are not
// read here: this surface compares membership of a value against the
// day's pleasantness, and a rating is a different question.
context: {
select: {
workStatus: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
leisureCategories: true,
eventType: true,
},
},
tagLinks: {
select: {
rating: true,
@@ -716,6 +772,7 @@ export async function fetchMoodAggregates(
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
context: row.context,
// BINARY links carry the structured-tag breakdown; RATED links
// (a non-null `rating`) carry the factor score. One join, two axes.
structuredTags: row.tagLinks
+205
View File
@@ -0,0 +1,205 @@
/**
* Day context against the day's mood, with the count on every statement.
*
* The question this answers is "on the days you recorded a conflict, how did
* the mood value sit against the days you did not" — and the answer is a
* comparison, never a cause. Nothing in this file, and nothing rendered from
* it, may say that a context value made a day worse. It did not, and the data
* cannot know whether it did.
*
* The statistics are borrowed rather than rebuilt. The Welch t-test, the
* Benjamini-Hochberg step-up, the per-side day floors and the FDR threshold
* all come from the machinery the tag crosstab already runs. That matters more
* here than it sounds: a context sweep tests a few dozen values at once, and a
* raw p < 0.05 over that many comparisons surfaces one-in-twenty noise as a
* finding. The BH correction is the difference between a board that means
* something and a board that always has something on it.
*
* The floors are constants with names because Phase 3's model will import
* them rather than declare a second set that drifts from these.
*/
import {
CROSSTAB_FDR_Q,
CROSSTAB_MIN_ABSENT_DAYS,
CROSSTAB_MIN_PRESENT_DAYS,
} from "@/lib/insights/mood-crosstab";
import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
import { welchTTest } from "@/lib/insights/correlations";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
decodeKeyList,
} from "@/lib/mood/context-vocabulary";
/**
* The smallest number of days a context value may be seen on and still be
* shown at all.
*
* Five is the floor the rest of the mood surfaces already use, and it is a
* floor rather than a target: the concept that shaped this feature asks for
* ten, and a reader looking at a row backed by five days should be able to see
* that number and discount it accordingly. Which is why the count is on the
* statement and not in a tooltip.
*/
export const CONTEXT_MIN_PRESENT_DAYS = CROSSTAB_MIN_PRESENT_DAYS;
export const CONTEXT_MIN_ABSENT_DAYS = CROSSTAB_MIN_ABSENT_DAYS;
/** Most rows the board shows, so it stays readable rather than exhaustive. */
export const CONTEXT_MAX_ROWS = 8;
/** One row of a stored context, as the comparison reads it. */
export interface ContextDayRow {
/** The entry's local day. One row per day; a second entry on a day is folded. */
day: string;
/** The day's pleasantness value, 0-10. Days without one are skipped. */
moodA1: number | null;
workStatus: string | null;
contactCircles: string | null;
contactForm: string | null;
contactExtent: string | null;
leisureCategories: string | null;
eventType: string | null;
}
export interface ContextComparisonRow {
/** Which context field this row is about, e.g. `workStatus`. */
field: string;
/** The value inside that field, e.g. `overtime`. */
value: string;
/** Days the value was recorded and the day had a mood value. */
withDays: number;
/** Days it was not, and the day had a mood value. */
withoutDays: number;
/** Mean pleasantness on days carrying the value. */
withAvg: number;
/** Mean pleasantness on the other days. */
withoutAvg: number;
/** `withAvg - withoutAvg`. Signed, and never described as an effect. */
delta: number;
pValue: number;
qValue: number;
}
/** The single-valued fields, and the vocabulary each draws from. */
const SINGLE_FIELDS: Array<{
field: keyof ContextDayRow & string;
keys: readonly string[];
}> = [
{ field: "workStatus", keys: WORK_STATUS_KEYS },
{ field: "contactForm", keys: CONTACT_FORM_KEYS },
{ field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ field: "eventType", keys: EVENT_TYPE_KEYS },
];
/** The multi-select fields, stored as JSON arrays in a string column. */
const MULTI_FIELDS: Array<{
field: keyof ContextDayRow & string;
keys: readonly string[];
}> = [
{ field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
];
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function mean(values: number[]): number {
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
/**
* Compare every context value against the day's pleasantness.
*
* Pure over already-fetched rows, with the database read left to the caller —
* the shape `mood-aggregates.ts` uses, so the whole comparison is testable
* without a container.
*/
export function computeContextMoodComparison(
rows: readonly ContextDayRow[],
): ContextComparisonRow[] {
// One observation per day. Two entries on one day would otherwise weight
// that day twice, and a day somebody logged three times is not three days.
const byDay = new Map<string, ContextDayRow>();
for (const row of rows) {
if (row.moodA1 === null) continue;
if (!byDay.has(row.day)) byDay.set(row.day, row);
}
if (byDay.size === 0) return [];
const days = [...byDay.values()];
const candidates: ContextComparisonRow[] = [];
const test = (
field: string,
value: string,
carries: (row: ContextDayRow) => boolean,
) => {
const withVals: number[] = [];
const withoutVals: number[] = [];
for (const row of days) {
// `moodA1` is non-null by construction above; the guard keeps the
// narrowing local rather than asserting it.
if (row.moodA1 === null) continue;
if (carries(row)) withVals.push(row.moodA1);
else withoutVals.push(row.moodA1);
}
if (
withVals.length < CONTEXT_MIN_PRESENT_DAYS ||
withoutVals.length < CONTEXT_MIN_ABSENT_DAYS
) {
return;
}
const withAvg = mean(withVals);
const withoutAvg = mean(withoutVals);
const delta = withAvg - withoutAvg;
if (delta === 0) return;
const welch = welchTTest(withVals, withoutVals);
candidates.push({
field,
value,
withDays: withVals.length,
withoutDays: withoutVals.length,
withAvg: round(withAvg, 2),
withoutAvg: round(withoutAvg, 2),
delta: round(delta, 2),
pValue: welch.status === "ok" ? welch.pValue : 1,
qValue: 1,
});
};
for (const { field, keys } of SINGLE_FIELDS) {
for (const value of keys) {
test(field, value, (row) => row[field] === value);
}
}
for (const { field, keys } of MULTI_FIELDS) {
for (const value of keys) {
test(field, value, (row) =>
decodeKeyList(row[field] as string | null).includes(value),
);
}
}
if (candidates.length === 0) return [];
// One family across every field × value pair tested. Correcting per field
// would be six small families instead of one honest one, and the whole
// reason to correct is that the sweep is wide.
const qValues = benjaminiHochberg(candidates.map((c) => c.pValue));
return candidates
.map((c, i) => ({ ...c, qValue: round(qValues[i], 3) }))
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
.sort(
(a, b) =>
a.qValue - b.qValue ||
Math.abs(b.delta) - Math.abs(a.delta) ||
a.value.localeCompare(b.value),
)
.slice(0, CONTEXT_MAX_ROWS);
}
@@ -0,0 +1,144 @@
/**
* Pair guard binding `context-vocabulary.ts` to the six locale bundles.
*
* The generic i18n guards cannot carry this one. `contextValueLabelKey` builds
* its keys from a template with a literal `mood.context.` prefix, so the
* reverse-coverage matcher records the whole subtree as reachable and stops
* being able to tell a live label from a stale one — the exact degradation
* that file's own doc comment warns about, arriving here by construction
* rather than by accident. So the check that matters is written here, against
* the arrays themselves, in both directions:
*
* * every key the vocabulary names has a non-empty label in all six locales;
* * every label under those groups belongs to a key the vocabulary names.
*
* The second direction is the one that catches a value removed from the
* vocabulary whose translations were left behind, and the first is the one
* that catches a value added without them.
*
* Mutation check: drop `"garden"` from `LEISURE_CATEGORY_KEYS` and the orphan
* assertion goes red naming it in all six bundles; delete
* `mood.context.eventType.conflict` from `messages/pl.json` and the coverage
* assertion goes red naming pl.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_SECTION_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
CONTEXT_RATING_FIELDS,
contextSectionLabelKey,
contextValueLabelKey,
} from "@/lib/mood/context-vocabulary";
const MESSAGES = join(__dirname, "../../../../messages");
const LOCALES = readdirSync(MESSAGES)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
function resolve(root: Record<string, unknown>, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
root,
);
}
/** The value groups whose members come straight from a vocabulary array. */
const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
{ group: "workStatus", keys: WORK_STATUS_KEYS },
{ group: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ group: "contactForm", keys: CONTACT_FORM_KEYS },
{ group: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ group: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
{ group: "eventType", keys: EVENT_TYPE_KEYS },
];
describe("mood context vocabulary labels", () => {
it("reads six locales and a non-empty vocabulary", () => {
// Both ends have to be there before anything below proves anything. A
// guard that walks an empty list is a green light nobody earned.
expect(LOCALES.length).toBe(6);
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
expect(total).toBeGreaterThan(30);
expect(CONTEXT_RATING_FIELDS.length).toBeGreaterThan(0);
});
for (const locale of LOCALES) {
it(`${locale} carries a label for every vocabulary value`, () => {
const root = bundle(locale);
const missing: string[] = [];
for (const { group, keys } of VALUE_GROUPS) {
for (const key of keys) {
const label = resolve(root, contextValueLabelKey(group, key));
if (typeof label !== "string" || label.trim() === "") {
missing.push(contextValueLabelKey(group, key));
}
}
}
for (const section of CONTEXT_SECTION_KEYS) {
const label = resolve(root, contextSectionLabelKey(section));
if (typeof label !== "string" || label.trim() === "") {
missing.push(contextSectionLabelKey(section));
}
}
for (const field of CONTEXT_RATING_FIELDS) {
for (const anchor of ["low", "high"] as const) {
const key = `mood.context.rating.${field}.${anchor}`;
const label = resolve(root, key);
if (typeof label !== "string" || label.trim() === "") {
missing.push(key);
}
}
}
expect(
missing,
`messages/${locale}.json is missing ${missing.length} context label(s)`,
).toEqual([]);
});
it(`${locale} carries no label the vocabulary no longer names`, () => {
const root = bundle(locale);
const orphans: string[] = [];
for (const { group, keys } of VALUE_GROUPS) {
const node = resolve(root, `mood.context.${group}`);
expect(
node && typeof node === "object",
`mood.context.${group} is absent from ${locale} — this matcher would prove nothing`,
).toBe(true);
for (const present of Object.keys(node as Record<string, unknown>)) {
if (!keys.includes(present)) {
orphans.push(`mood.context.${group}.${present}`);
}
}
}
expect(
orphans,
`messages/${locale}.json carries ${orphans.length} label(s) for values the vocabulary does not name`,
).toEqual([]);
});
}
});
@@ -0,0 +1,62 @@
/**
* The linked block's gating, checked against the one ownership table.
*
* This file exists because the first draft of `linked-context.ts` hand-wrote
* that map and got it wrong in both directions at once: it gated ambient steps
* and active energy on `workouts`, which hides movement behind a toggle the
* user never associated with it, and it declared resting heart rate and HRV
* "core, no owner", which served the `recovery` domain to accounts that had
* switched it off. Neither was catchable by any test that existed, because
* both ends of the claim lived in the same file.
*
* So the claims are pinned to `moduleForMeasurementType()` — the table the MCP
* wire, the correlations reader and the Coach snapshot all resolve through.
* The resolver reads it at runtime, and these assertions say what it must keep
* answering for the block shapes to stay honest.
*
* Mutation check: assign `ACTIVITY_STEPS` an owner in
* `measurement-scope.ts` and the ambient row goes red; move
* `HEART_RATE_VARIABILITY` to a different module from `RESTING_HEART_RATE` and
* the shared-owner row goes red naming both.
*/
import { describe, expect, it } from "vitest";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
describe("linked day context — module ownership", () => {
it("keeps sleep owned by the sleep module", () => {
expect(moduleForMeasurementType("SLEEP_DURATION")).toBe("sleep");
});
it("leaves ambient movement unowned, so the block is never gated", () => {
// `workouts` gates workout SESSIONS. Steps and active energy are on the
// ownership table's reviewed-unscoped list, and gating them here made the
// mood sheet stricter than every other surface in the product.
expect(moduleForMeasurementType("ACTIVITY_STEPS")).toBeNull();
expect(moduleForMeasurementType("ACTIVE_ENERGY_BURNED")).toBeNull();
});
it("keeps both vitals owned by recovery, and by the SAME module", () => {
const rhr = moduleForMeasurementType("RESTING_HEART_RATE");
const hrv = moduleForMeasurementType("HEART_RATE_VARIABILITY");
expect(rhr).toBe("recovery");
expect(hrv).toBe("recovery");
// The block carries one `available` flag for the pair, so the pair has to
// answer to one module. The day they diverge, that single flag becomes a
// lie about whichever of the two it does not describe, and the block has
// to be split before the table is changed.
expect(
hrv,
"the vitals block gates both figures on one module; these two no longer share an owner",
).toBe(rhr);
});
it("carries the HRV fallback in the same module as the primary type", () => {
// A ring-only account resolves HRV to its RMSSD fallback. Left unowned,
// the fallback would serve exactly the recovery data the primary type
// refuses.
expect(moduleForMeasurementType("HRV_RMSSD")).toBe(
moduleForMeasurementType("HEART_RATE_VARIABILITY"),
);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* The closed vocabularies a mood entry's day context is written from.
*
* Four sections — work, contacts, leisure, a notable event — each with a small
* fixed key list and a handful of numbers. Every key here becomes three
* things at once: a string stored in somebody's row, six locale labels, and a
* feature name any later analysis names in its output. Renaming one after it
* has been stored means a data migration, six locale edits and a re-fit, so
* the list is closed and lives in exactly one file. The Zod enums derive from
* these arrays, which is what makes "a value the vocabulary does not name
* cannot be stored" a structural property rather than a convention.
*
* What is deliberately NOT here:
*
* * Sleep, activity and vitals. They already have a home, and a mood entry
* links to that home instead of asking a second time
* (`src/lib/mood/linked-context.ts`). A stored copy would drift the moment
* somebody corrected a sleep session.
* * Body symptoms. The illness module owns symptom capture and severity, and
* the mood surface links into it rather than growing a parallel form.
* * A productivity rating and a self-determination rating. Both were on the
* table and both were cut: every field a person can leave empty still
* costs a control, six translations and a model feature, and neither of
* those two earned that.
*
* Labels are neutral by rule. "Worked a regular day" is a fact; "worked too
* much" is a judgement, and a judgement about somebody's day belongs to nobody
* but them. Contact circles are groups of people and are never scored: how
* many people somebody saw is not a good or a bad number, which is why the
* quality of the contact is a separate field from the fact of it.
*/
/** Every numeric self-rating in the context shares the level-A 0-10 scale. */
export const CONTEXT_RATING_MIN = 0;
export const CONTEXT_RATING_MAX = 10;
/**
* Minutes bound for the duration fields. A day holds 1440 of them, and a
* bound is what keeps a mistyped digit from landing as a real answer that
* then drags every average it touches.
*/
export const CONTEXT_MINUTES_MIN = 0;
export const CONTEXT_MINUTES_MAX = 1440;
/** How a notable event landed, from clearly bad to clearly good. */
export const CONTEXT_VALENCE_MIN = -5;
export const CONTEXT_VALENCE_MAX = 5;
/**
* The shape of the working day. `off` covers every kind of not-working day —
* a weekend, leave, sick leave — because the reason belongs to the modules
* that own it, not to a mood entry.
*/
export const WORK_STATUS_KEYS = [
"off",
"partial",
"regular",
"overtime",
] as const;
/**
* Who the day's contact was with. Multi-select: a day is rarely one circle.
* `professional` is a therapist, a doctor, a counsellor — a contact that is
* neither private nor a colleague, and one people do have on the days they
* most want to look back at.
*/
export const CONTACT_CIRCLE_KEYS = [
"partner",
"child",
"family",
"friends",
"colleagues",
"acquaintances",
"professional",
"other",
] as const;
/** How the contact happened. */
export const CONTACT_FORM_KEYS = [
"inPerson",
"phone",
"video",
"written",
] as const;
/** Roughly how long it lasted, in the coarse bands people actually recall. */
export const CONTACT_EXTENT_KEYS = ["brief", "medium", "extended"] as const;
/** What the free time was spent on. Multi-select. */
export const LEISURE_CATEGORY_KEYS = [
"film",
"reading",
"gaming",
"music",
"creative",
"outing",
"garden",
"cooking",
"timeWithChild",
"relaxation",
"other",
] as const;
/**
* The kind of thing that happened. One per entry: a day with two notable
* events is a day with two things worth writing in the note, and a list here
* would invite ticking every box rather than naming the one that mattered.
*/
export const EVENT_TYPE_KEYS = [
"goodNews",
"badNews",
"success",
"disappointment",
"conflict",
"appointment",
"familyEvent",
"change",
"unexpected",
] as const;
/**
* Every 0-10 self-rating on the context row, in capture order.
*
* Each one has a low and a high anchor in the locale bundles
* (`mood.context.rating.<field>.{low,high}`), because a bare number on a
* ten-point scale means whatever the person reading it decides it means, and
* an anchor is what makes the value they set last week comparable to the one
* they set today. `workLoad` is inverse-oriented — higher is heavier, not
* better — and it is stored exactly as the person set it against the anchors
* they read. A surface that needs "up is better" flips the sign itself, the
* same rule `MoodEntry.stressA2` follows.
*/
export const CONTEXT_RATING_FIELDS = [
"workLoad",
"workSatisfaction",
"contactQuality",
"contactSupport",
"leisureJoy",
"leisureRecovery",
] as const;
/** The four sections, in capture order. */
export const CONTEXT_SECTION_KEYS = [
"work",
"contacts",
"leisure",
"events",
] as const;
export type ContextSection = (typeof CONTEXT_SECTION_KEYS)[number];
/**
* i18n key for one vocabulary value.
*
* Resolved from data rather than written at a call site, which is why
* `mood.context` sits in the reverse-coverage guard's dynamic allowlist beside
* `mood.dimension` and `mood.tag`.
*/
export function contextValueLabelKey(
field: string,
value: string,
): `mood.context.${string}.${string}` {
return `mood.context.${field}.${value}`;
}
/** i18n key for a section heading. */
export function contextSectionLabelKey(
section: ContextSection,
): `mood.context.section.${ContextSection}` {
return `mood.context.section.${section}`;
}
/**
* The multi-select lists as they are stored: a JSON array in a string column,
* matching `MoodEntry.tags`.
*
* Encoding lives here rather than at the three call sites that need it,
* because a column read by a route, a backup and an analysis is exactly the
* kind of thing that ends up with three parsers and two of them wrong.
*/
export function encodeKeyList(keys: readonly string[] | null): string | null {
if (!keys || keys.length === 0) return null;
return JSON.stringify([...keys]);
}
/** Decode a stored key list. A malformed value reads as empty, never throws. */
export function decodeKeyList(stored: string | null): string[] {
if (!stored) return [];
try {
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
return parsed.filter((v): v is string => typeof v === "string");
} catch {
return [];
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* The one place a mood entry's day context crosses between the wire and the
* `mood_contexts` row.
*
* Three call sites need this — the create route, the edit route and the bulk
* route — plus the backup builder and the restore. Left to themselves they
* would each build their own `data` object, and one of them would eventually
* forget a column: exactly how the level-A values shipped twice under two
* spellings before `shapeLevelA` existed. So the mapping lives here, once, in
* both directions.
*
* Everything is built field by field from the parsed body. Nothing is spread
* from a request (`CLAUDE.md`, no mass assignment), and the two multi-select
* lists go through the vocabulary module's encoder rather than a local
* `JSON.stringify`.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
import { decodeKeyList, encodeKeyList } from "@/lib/mood/context-vocabulary";
/** Either the plain client or a transaction client, as the tag-link helper takes. */
type MoodContextDb = PrismaClient | Prisma.TransactionClient;
/**
* The context as a client sends it. Every field optional; a `null` is an
* explicit "take this back", an absent key says nothing at all.
*/
export interface MoodContextInput {
workStatus?: string | null;
workMinutes?: number | null;
overtimeMinutes?: number | null;
workLoad?: number | null;
workSatisfaction?: number | null;
contactCircles?: string[] | null;
contactForm?: string | null;
contactExtent?: string | null;
contactQuality?: number | null;
contactSupport?: number | null;
leisureCategories?: string[] | null;
leisureMinutes?: number | null;
leisureJoy?: number | null;
leisureRecovery?: number | null;
eventType?: string | null;
eventValence?: number | null;
eventAt?: Date | null;
note?: string | null;
}
/** The context as it sits on a row, before the note is decrypted. */
export interface MoodContextRow {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string | null;
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string | null;
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
eventAt: Date | null;
notesEncrypted: Uint8Array | null;
}
/** The context as a client reads it back. Lists decoded, note decrypted. */
export interface MoodContextWire {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string[];
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string[];
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
eventAt: string | null;
note: string | null;
}
/**
* Does this sub-object actually say anything?
*
* An entry whose context sections were all left closed must store **no row**.
* A row of nothing but NULLs would record "asked and answered nothing" where
* the truth is "never asked", and every count over the table would then be a
* count of empty forms. An empty list counts as nothing, because a client that
* renders a multi-select and sends back `[]` has said the same thing as one
* that sent nothing at all.
*/
export function isEmptyContextInput(input: MoodContextInput): boolean {
const values: Array<unknown> = [
input.workStatus,
input.workMinutes,
input.overtimeMinutes,
input.workLoad,
input.workSatisfaction,
input.contactForm,
input.contactExtent,
input.contactQuality,
input.contactSupport,
input.leisureMinutes,
input.leisureJoy,
input.leisureRecovery,
input.eventType,
input.eventValence,
input.eventAt,
];
if (values.some((v) => v !== undefined && v !== null)) return false;
if (input.contactCircles && input.contactCircles.length > 0) return false;
if (input.leisureCategories && input.leisureCategories.length > 0)
return false;
if (input.note !== undefined && input.note !== null && input.note !== "") {
return false;
}
return true;
}
/**
* The write payload for a context row, whole.
*
* Whole rather than per-field, and that is the contract: a context write
* REPLACES the stored context. The capture surface always sends the sections
* it is showing, so a field the payload omits is a field the person cleared,
* and a preserve-when-absent rule here would make an emptied section
* impossible to save. The entry's own columns work the other way round
* (`levelAForWrite`) because those writers are upserts a phone re-posts
* blind; this object is only ever built from a payload that carried a
* `context` key at all.
*/
export function contextRowData(input: MoodContextInput) {
return {
workStatus: input.workStatus ?? null,
workMinutes: input.workMinutes ?? null,
overtimeMinutes: input.overtimeMinutes ?? null,
workLoad: input.workLoad ?? null,
workSatisfaction: input.workSatisfaction ?? null,
contactCircles: encodeKeyList(input.contactCircles ?? null),
contactForm: input.contactForm ?? null,
contactExtent: input.contactExtent ?? null,
contactQuality: input.contactQuality ?? null,
contactSupport: input.contactSupport ?? null,
leisureCategories: encodeKeyList(input.leisureCategories ?? null),
leisureMinutes: input.leisureMinutes ?? null,
leisureJoy: input.leisureJoy ?? null,
leisureRecovery: input.leisureRecovery ?? null,
eventType: input.eventType ?? null,
eventValence: input.eventValence ?? null,
eventAt: input.eventAt ?? null,
notesEncrypted: encryptNote(input.note ?? null),
};
}
/** A stored context row as the client reads it. */
export function contextForWire(row: MoodContextRow): MoodContextWire {
return {
workStatus: row.workStatus,
workMinutes: row.workMinutes,
overtimeMinutes: row.overtimeMinutes,
workLoad: row.workLoad,
workSatisfaction: row.workSatisfaction,
contactCircles: decodeKeyList(row.contactCircles),
contactForm: row.contactForm,
contactExtent: row.contactExtent,
contactQuality: row.contactQuality,
contactSupport: row.contactSupport,
leisureCategories: decodeKeyList(row.leisureCategories),
leisureMinutes: row.leisureMinutes,
leisureJoy: row.leisureJoy,
leisureRecovery: row.leisureRecovery,
eventType: row.eventType,
eventValence: row.eventValence,
eventAt: row.eventAt ? row.eventAt.toISOString() : null,
// Fail-closed like every other note read: a present ciphertext under an
// unknown key id throws rather than answering an empty note.
note: readNote(row.notesEncrypted, null),
};
}
/**
* Write, replace or remove the context of one entry, inside the caller's
* transaction.
*
* `undefined` means the request said nothing about the context and the stored
* one is left alone. A context that says nothing removes the row, which is the
* same rule the create path applies: no row rather than a row of NULLs.
*/
export async function persistMoodContext(
tx: MoodContextDb,
moodEntryId: string,
userId: string,
input: MoodContextInput | null | undefined,
): Promise<"written" | "removed" | "untouched"> {
if (input === undefined) return "untouched";
if (input === null || isEmptyContextInput(input)) {
await tx.moodContext.deleteMany({ where: { moodEntryId } });
return "removed";
}
const data = contextRowData(input);
await tx.moodContext.upsert({
where: { moodEntryId },
create: { moodEntryId, userId, ...data },
update: data,
});
return "written";
}
+424
View File
@@ -0,0 +1,424 @@
/**
* What a mood entry's day looked like in the modules that already own it.
*
* The rule this file exists to serve is the one the whole context feature
* hangs off: one fact, one home. A mood entry shows what the other modules
* already know and never asks for it a second time, and **nothing here is
* copied onto the mood row** — every figure is resolved at read time from the
* rows that own it, so correcting a sleep session corrects it here too, in the
* same moment, with nothing to re-sync.
*
* Three consequences that are easy to get wrong and are therefore stated:
*
* * **Absence is `{ present: false }`, never `0`.** A night nobody recorded
* and a night of no sleep are different facts, and a surface that renders
* the first as the second is lying about somebody's health record.
* * **A module that is switched off blanks its block.** It does not answer
* zero and it does not answer a filtered-down version of itself — the
* block is simply not there, the same way `snapshot.ts` blanks rather than
* filters. Turning a module off is a statement about what the person wants
* to see, not a filter over what they wanted to see.
* * **Which module owns which metric is asked, never assumed.** The first
* draft of this file hand-wrote the ownership: it called steps and active
* energy `workouts`, and it called resting heart rate and HRV core with no
* owner at all. Both were wrong in the two directions that matter — the
* first hid ambient movement behind a toggle the user never associated
* with it, the second served the `recovery` domain to an account that had
* switched it off, which made the release note's own promise false. The
* answer comes from `moduleForMeasurementType()` now, which is the one
* table the MCP wire, the correlations reader and the Coach snapshot all
* resolve through. A second copy of an ownership map is a copy that drifts.
*
* Cross-source de-dup runs on every measurement figure, through the same
* `pickCanonicalSourceRows` ladder the crosstab and the doctor report use.
* Without it, an account syncing a phone and a watch reads its steps doubled
* on the mood sheet — the cumulative channels sum, and two sources reporting
* the same day sum twice.
*
* The reads are bounded: one local day per entry, resolved through the day-key
* helper that already carries the legacy-Berlin fallback for `tz IS NULL`
* rows. There is no unbounded `findMany` here, and adding one would turn a
* detail view into a table scan on an account with years of history.
*/
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import type { ModuleKey } from "@/lib/modules/registry";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
/** A figure that either exists or honestly does not. */
export type LinkedFigure =
{ present: false } | { present: true; value: number; unit: string };
/** A block that can also be absent because its module is switched off. */
export type LinkedBlock<T> =
{ available: false; reason: "module-disabled" } | ({ available: true } & T);
export interface LinkedSleep {
/** Time asleep for the night the entry's local day woke up on. */
asleep: LinkedFigure;
/** Time in bed, when any writer recorded a bed window. */
inBed: LinkedFigure;
}
/**
* Ambient movement. Never gated: `workouts` gates workout SESSIONS, and steps
* and active energy are on the ownership table's reviewed-unscoped list for
* exactly that reason. The block carries an `available` flag anyway so every
* block in the payload has one shape.
*/
export interface LinkedActivity {
steps: LinkedFigure;
activeEnergy: LinkedFigure;
}
export interface LinkedVitals {
restingHeartRate: LinkedFigure;
heartRateVariability: LinkedFigure;
}
export interface LinkedBody {
/** Whether an illness day-log exists for this day at all. */
logged: boolean;
/** How much the day was limited, 0-3, as the illness module records it. */
functionalImpact: LinkedFigure;
/** Symptoms linked to that day-log, by their catalogue key. */
symptoms: string[];
/** The episode to open in the illness module, when there is one. */
episodeId: string | null;
}
export interface LinkedDayContext {
/** The entry's local day, resolved through the entry's own `tz`. */
day: string;
sleep: LinkedBlock<LinkedSleep>;
activity: LinkedBlock<LinkedActivity>;
/**
* Resting heart rate and HRV. Gated on `recovery`, which is the module the
* ownership table has assigned both types since v1.30.22 — including the
* RMSSD fallback, pinned there so a ring-only account cannot reach through
* the fallback for what the primary type refuses.
*/
vitals: LinkedBlock<LinkedVitals>;
body: LinkedBlock<LinkedBody>;
}
const MODULE_OFF = { available: false, reason: "module-disabled" } as const;
const ABSENT: LinkedFigure = { present: false };
function figure(value: number | null | undefined, unit: string): LinkedFigure {
// A stored zero is a real reading and stays one. Only a missing row is
// absent, which is why this tests for null rather than for falsiness.
return value === null || value === undefined
? ABSENT
: { present: true, value, unit };
}
/** A measurement row as this resolver reads it. */
interface LinkedRow {
type: MeasurementType;
value: number;
measuredAt: Date;
source: MeasurementSource;
deviceType: string | null;
}
/**
* One day's rows for one metric, collapsed to a single source.
*
* The picker walks the user's source ladder and then the device-type ladder,
* so a day reported by both a phone and a watch keeps one stream. Everything
* downstream — the sum, the latest — then describes one device rather than an
* accidental union of two. A metric with no ladder key returns its rows
* untouched, which is the picker's own pass-through and is correct for a type
* no two sources compete over.
*/
function canonicalRowsOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
day: string,
tz: string,
priorityJson: unknown,
): LinkedRow[] {
const matching = rows.filter((r) => r.type === type);
if (matching.length === 0) return [];
const metricKey = metricKeyForType(type);
if (metricKey === null) return matching;
// The rows are already narrowed to one local day, so the picker's day key
// is constant here; it still goes through the real helper rather than a
// constant, because the ladder resolution is what is wanted and the bucket
// shape is the picker's business.
const { canonicalRows } = pickCanonicalSourceRows(
matching,
metricKey,
priorityJson,
(d) => moodDateKey(d, tz),
);
return canonicalRows.length > 0 ? canonicalRows : matching;
}
/**
* A cumulative metric's day total, over one source.
*
* No rows means absent, not zero: a day nobody's phone reported and a day
* spent motionless are different facts.
*/
function sumOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
unit: string,
day: string,
tz: string,
priorityJson: unknown,
): LinkedFigure {
const matching = canonicalRowsOfDay(rows, type, day, tz, priorityJson);
if (matching.length === 0) return ABSENT;
return {
present: true,
value: matching.reduce((sum, r) => sum + r.value, 0),
unit,
};
}
/** Latest reading of the day for a point-in-time metric, over one source. */
function latestOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
unit: string,
day: string,
tz: string,
priorityJson: unknown,
): LinkedFigure {
const matching = canonicalRowsOfDay(rows, type, day, tz, priorityJson).sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
if (matching.length === 0) return ABSENT;
return { present: true, value: matching[matching.length - 1].value, unit };
}
/**
* A UTC window guaranteed to contain one local day, whatever its zone.
*
* Generous on both sides on purpose: the exact boundaries are decided by
* `moodDateKey` afterwards, and this only has to be sure it did not clip the
* day off at either end. Doing the arithmetic here instead would mean
* re-deriving a zone offset that the day-key helper already knows, and that
* second derivation is where the DST hour goes missing.
*/
function localDayWindow(day: string): { from: Date; to: Date } {
// Walk outwards from the naive UTC midnight and keep whatever the day-key
// helper agrees belongs to this day. Cheaper and far safer than arithmetic
// on `new Date(y, m, d)`, which slips by an hour twice a year and has
// already been solved once in this codebase.
const naive = new Date(`${day}T00:00:00.000Z`);
const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000);
const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000);
return { from, to };
}
/** Keep only the rows whose own local day is the one asked for. */
function onDay<T extends { measuredAt: Date }>(
rows: readonly T[],
day: string,
tz: string,
): T[] {
return rows.filter((r) => moodDateKey(r.measuredAt, tz) === day);
}
/** The measurement types the linked block reads. One query covers all five. */
const LINKED_MEASUREMENT_TYPES = [
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
] as const satisfies readonly MeasurementType[];
/**
* The modules that own anything this resolver reads, asked of the one
* ownership table rather than restated here.
*
* `illness` is added by hand because it owns a table rather than a measurement
* type, so the measurement-keyed table has nothing to say about it. Everything
* else is derived, which is what stops this file from growing a second opinion
* about who owns resting heart rate.
*/
const LINKED_MODULE_KEYS: readonly ModuleKey[] = Array.from(
new Set<ModuleKey>([
...LINKED_MEASUREMENT_TYPES.map(moduleForMeasurementType).filter(
(m): m is ModuleKey => m !== null,
),
"illness",
]),
);
/** Whether the module owning `type` is on, for a resolved gate map. */
function typeAvailable(
type: MeasurementType,
enabled: ReadonlyMap<ModuleKey, boolean>,
): boolean {
const owner = moduleForMeasurementType(type);
// `null` means the ownership table has reviewed this type and assigned it no
// module — ambient movement is the case here. Ungated is the answer, and it
// is the same answer the Coach snapshot and the MCP wire give.
return owner === null ? true : (enabled.get(owner) ?? true);
}
/**
* Resolve the linked figures for one entry's local day.
*
* Takes the entry's own `date` and `tz` rather than re-deriving them, because
* the row already decided which day it belongs to and a second derivation here
* could disagree with it — which is precisely the class of bug the per-row
* `tz` column was added to end.
*/
export async function resolveLinkedDayContext(
userId: string,
entry: { date: string; tz: string | null },
): Promise<LinkedDayContext> {
const tz = entry.tz ?? DEFAULT_TIMEZONE;
const day = entry.date;
const { from, to } = localDayWindow(day);
// Every gate this resolver needs, resolved once against the modules the
// ownership table names. Nothing here decides who owns what.
const gateStates = await Promise.all(
LINKED_MODULE_KEYS.map(
async (key) => [key, await isModuleEnabled(userId, key)] as const,
),
);
const enabled = new Map<ModuleKey, boolean>(gateStates);
const priority = await loadUserSourcePriority(userId);
const measurements: LinkedRow[] = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: { in: [...LINKED_MEASUREMENT_TYPES] },
measuredAt: { gte: from, lte: to },
},
select: {
type: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
// The canonical-source picker's determinism precondition: it keeps
// insertion order inside a bucket, so a stable read order is what makes
// its device-type tie-break reproducible rather than whatever the planner
// returned this time.
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
});
const dayRows = onDay(measurements, day, tz);
let sleep: LinkedBlock<LinkedSleep> = MODULE_OFF;
if (typeAvailable("SLEEP_DURATION", enabled)) {
// The stage rows of the whole window, not just the day's, and then the
// night whose WAKE day is this one. A night starts the evening before, so
// filtering the raw rows by day first would cut it in half — the
// reconstruction is the thing that knows where a night begins. It runs its
// own per-night source de-dup, which is why these rows do not go through
// the day picker first.
const stageRows = measurements.filter((r) => r.type === "SLEEP_DURATION");
if (stageRows.length === 0) {
sleep = { available: true, asleep: ABSENT, inBed: ABSENT };
} else {
const night = reconstructSleepNights(
stageRows as unknown as SleepStageRow[],
tz,
priority,
).find((n) => n.night === day);
sleep = {
available: true,
asleep: figure(night?.asleepMinutes ?? null, "min"),
inBed: figure(night?.inBedMinutes ?? null, "min"),
};
}
}
// Ambient movement, ungated by the ownership table's own verdict. The
// `typeAvailable` call is not decoration: it is what makes a future change
// to that verdict reach this surface without an edit here.
const activity: LinkedBlock<LinkedActivity> = typeAvailable(
"ACTIVITY_STEPS",
enabled,
)
? {
available: true,
steps: sumOfDay(dayRows, "ACTIVITY_STEPS", "steps", day, tz, priority),
activeEnergy: sumOfDay(
dayRows,
"ACTIVE_ENERGY_BURNED",
"kcal",
day,
tz,
priority,
),
}
: MODULE_OFF;
const vitals: LinkedBlock<LinkedVitals> = typeAvailable(
"RESTING_HEART_RATE",
enabled,
)
? {
available: true,
restingHeartRate: latestOfDay(
dayRows,
"RESTING_HEART_RATE",
"bpm",
day,
tz,
priority,
),
heartRateVariability: latestOfDay(
dayRows,
"HEART_RATE_VARIABILITY",
"ms",
day,
tz,
priority,
),
}
: MODULE_OFF;
let body: LinkedBlock<LinkedBody> = MODULE_OFF;
if (enabled.get("illness") ?? true) {
// Read only. The illness module owns symptom capture and its severity
// scale; the mood surface links into it and captures nothing, which is
// what keeps the two from disagreeing about the same day.
const dayLog = await prisma.illnessDayLog.findFirst({
where: { userId, date: day, deletedAt: null },
select: {
episodeId: true,
functionalImpact: true,
symptomLinks: {
select: { severity: true, symptom: { select: { key: true } } },
},
},
});
body = {
available: true,
logged: dayLog !== null,
functionalImpact: figure(dayLog?.functionalImpact ?? null, "level"),
symptoms: dayLog?.symptomLinks.map((l) => l.symptom.key) ?? [],
episodeId: dayLog?.episodeId ?? null,
};
}
return { day, sleep, activity, vitals, body };
}
+111
View File
@@ -24,6 +24,14 @@ import {
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
import {
dataEnvelope,
errorEnvelope,
@@ -188,6 +196,77 @@ const notFoundResponse = {
},
} as const;
/**
* v1.38 — the day's context on a mood entry, in the one shape both the write
* path and the sync feed use.
*
* Every field optional and nullable. The two multi-selects are closed key
* lists; a value outside them is refused rather than stored, which is what
* keeps the analytics and the six locale label sets from drifting apart from
* the data.
*
* Exported so `./sync.ts` publishes the same object rather than declaring a
* second one — two definitions of one payload is how a client ends up decoding
* a field the server stopped sending.
*/
export const moodContextWire = z
.object({
workStatus: z
.enum(WORK_STATUS_KEYS)
.nullish()
.describe("Shape of the working day."),
workMinutes: z.number().int().min(0).max(1440).nullish(),
overtimeMinutes: z.number().int().min(0).max(1440).nullish(),
workLoad: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe(
"0-10, INVERSE-oriented: a higher value is a heavier day, not a better one.",
),
workSatisfaction: z.number().int().min(0).max(10).nullish(),
contactCircles: z
.array(z.enum(CONTACT_CIRCLE_KEYS))
.nullish()
.describe(
"Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad number.",
),
contactForm: z.enum(CONTACT_FORM_KEYS).nullish(),
contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullish(),
contactQuality: z.number().int().min(0).max(10).nullish(),
contactSupport: z.number().int().min(0).max(10).nullish(),
leisureCategories: z.array(z.enum(LEISURE_CATEGORY_KEYS)).nullish(),
leisureMinutes: z.number().int().min(0).max(1440).nullish(),
leisureJoy: z.number().int().min(0).max(10).nullish(),
leisureRecovery: z.number().int().min(0).max(10).nullish(),
eventType: z
.enum(EVENT_TYPE_KEYS)
.nullish()
.describe("One notable event. One per entry, not a list."),
eventValence: z
.number()
.int()
.min(-5)
.max(5)
.nullish()
.describe("-5 (clearly bad) to +5 (clearly good)."),
eventAt: z.iso.datetime({ offset: true }).nullish(),
note: z
.string()
.max(500)
.nullish()
.describe(
"One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire.",
),
})
.meta({
id: "MoodContext",
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.",
});
// ── Bulk mood backfill (iOS SyncMode) — mirrors the route's
// `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response.
const bulkMoodEntry = z
@@ -212,6 +291,38 @@ const bulkMoodEntry = z
"Rated mood factors; an out-of-scale rating marks THIS entry skipped, never the batch.",
),
note: z.string().max(500).optional(),
a1: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe(
"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.",
),
a2: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe(
'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.',
),
a3: z.number().int().min(0).max(10).nullish().describe("Energy, 0-10."),
a4: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe("Connectedness, 0-10."),
a5: z.number().int().min(0).max(10).nullish().describe("Stability, 0-10."),
context: moodContextWire
.nullish()
.describe(
"The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it.",
),
moodLoggedAt: z.iso
.datetime({ offset: true })
.describe("ISO instant the entry was logged."),
+24 -1
View File
@@ -8,6 +8,7 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementResource } from "./measurements";
import { moodContextWire } from "./mood";
import { dataEnvelope, stdResponses } from "./shared";
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
@@ -97,7 +98,29 @@ const syncMoodUpsert = z
id: z.string(),
date: z.string().describe("YYYY-MM-DD anchored to the row's `tz`."),
mood: z.string(),
score: z.number().int(),
score: z.number().int().describe("The legacy 1-5 axis. Unchanged."),
a1: z
.number()
.int()
.nullable()
.describe(
"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: z
.number()
.int()
.nullable()
.describe(
"Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it.",
),
a3: z.number().int().nullable().describe("Energy, 0-10, or null."),
a4: z.number().int().nullable().describe("Connectedness, 0-10, or null."),
a5: z.number().int().nullable().describe("Stability, 0-10, or null."),
context: moodContextWire
.nullable()
.describe(
"The day's context, or null when the entry carries none. Null and an object of nulls are different answers.",
),
tags: z.string().nullable().describe("JSON array of tag keys, or null."),
note: z.string().nullable(),
moodLoggedAt: z.iso.datetime({ offset: true }),
+42 -9
View File
@@ -20,22 +20,55 @@ import {
// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors
// the measurements batch shape but reports the `workouts` count rather
// than the entries count, and the `skipped` field is reserved (the
// Zod schema rejects malformed entries with a 400 before the per-entry
// pass, so today's responses always carry an empty array).
// than the entries count. `skipped` carries the entries the server
// refused: an unusable external id, or a `samples` array that failed
// validation (`reason: "invalid_samples"`).
//
// `enriched` says the entry matched a stored workout that had no
// heart-rate series and the series it carried was attached. The
// workout row itself was not written, so such an entry also counts
// towards `duplicates`. That rule is part of the published contract,
// not just a note here: it rides the field descriptions below, because
// a client summing the counters has to know which bucket an
// enrichment lands in.
const workoutBatchEntryResult = z
.object({
index: z.number().int().nonnegative(),
status: z.enum(["inserted", "duplicate", "skipped"]),
reason: z.string().optional(),
status: z
.enum(["inserted", "duplicate", "enriched", "skipped"])
.describe(
"`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: z
.string()
.optional()
.describe(
"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.",
),
})
.meta({ id: "WorkoutBatchEntryResult" });
const workoutBatchResponse = z
.object({
processed: z.number().int().nonnegative(),
inserted: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
processed: z
.number()
.int()
.nonnegative()
.describe(
"Entries in the request. Equals `inserted + duplicates + skipped.length`.",
),
inserted: z
.number()
.int()
.nonnegative()
.describe("Workout rows this request wrote."),
duplicates: z
.number()
.int()
.nonnegative()
.describe(
"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: z.array(
z.object({
index: z.number().int().nonnegative(),
@@ -275,7 +308,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["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 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`.",
'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 | 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: { "application/json": { schema: createBatchWorkoutSchema } },
+16
View File
@@ -42,6 +42,22 @@ export const moodKeys = {
*/
moodEntriesDay: (date: string) => ["mood-entries", "day", date] as const,
/**
* v1.38 — what the sleep, activity, vitals and illness modules already know
* about one local day, read by the mood capture sheet and the entry detail.
*
* Keyed by the day AND the zone: the same calendar date read under two
* timezones covers two different windows, and one key for both would serve
* whichever answer arrived first.
*
* Its own prefix rather than a segment under `["mood-entries"]`, and
* deliberately: nothing here comes from a mood row, so a mood write has no
* reason to invalidate it, and putting it under that prefix would refetch
* four modules' figures every time somebody tapped a face.
*/
moodLinkedContext: (date: string, tz: string) =>
["mood-linked-context", date, tz] as const,
moodAnalytics: () => ["mood-analytics"] as const,
/**
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
+39
View File
@@ -289,6 +289,45 @@ const moodEntrySchema = z
// RATED-only meaning, so every file written before this one parses
// unchanged and restores identically: absent reads as none.
structuredTags: z.array(z.string().min(1)).default([]),
// The day context, when the entry had one. Optional and nullable at the
// top: a file written before this existed carries no `context` key at all
// and must parse and restore exactly as it did. Every field inside is
// optional for the same reason a context row's columns are nullable —
// only the sections somebody opened say anything.
//
// The two multi-selects ride as the stored JSON string rather than as
// arrays, so the restore writes the column back verbatim and a file cannot
// arrive with a list the parser and the column disagree about. Nothing is
// re-validated against the vocabulary here: a backup restores what the
// account had, and a key retired between the export and the restore is
// still that person's answer.
context: z
.object({
workStatus: z.string().nullable().optional(),
workMinutes: z.number().int().nullable().optional(),
overtimeMinutes: z.number().int().nullable().optional(),
workLoad: z.number().int().nullable().optional(),
workSatisfaction: z.number().int().nullable().optional(),
contactCircles: z.string().nullable().optional(),
contactForm: z.string().nullable().optional(),
contactExtent: z.string().nullable().optional(),
contactQuality: z.number().int().nullable().optional(),
contactSupport: z.number().int().nullable().optional(),
leisureCategories: z.string().nullable().optional(),
leisureMinutes: z.number().int().nullable().optional(),
leisureJoy: z.number().int().nullable().optional(),
leisureRecovery: z.number().int().nullable().optional(),
eventType: z.string().nullable().optional(),
eventValence: z.number().int().nullable().optional(),
eventAt: isoDateTime.nullable().optional(),
// Same pair as the entry's own note: a portable file carries the
// plain text, a disaster-recovery file the ciphertext.
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
+96
View File
@@ -9,6 +9,20 @@
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_MINUTES_MAX,
CONTEXT_MINUTES_MIN,
CONTEXT_RATING_MAX,
CONTEXT_RATING_MIN,
CONTEXT_VALENCE_MAX,
CONTEXT_VALENCE_MIN,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
// --- CRUD schemas for mood entries ---
@@ -114,6 +128,77 @@ const ratedFactors = z.array(ratedFactor).max(30);
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
const levelADimension = z.number().int().min(0).max(10);
// v1.38 — the day's context. Every enum below derives from
// `context-vocabulary.ts`, which is what makes "a value the vocabulary does
// not name cannot be stored" structural rather than a convention: adding a key
// to the stored set means adding it to the list the labels and the analytics
// read too, in one edit.
const contextRating = z
.number()
.int()
.min(CONTEXT_RATING_MIN)
.max(CONTEXT_RATING_MAX);
const contextMinutes = z
.number()
.int()
.min(CONTEXT_MINUTES_MIN)
.max(CONTEXT_MINUTES_MAX);
/**
* Optional on create and on patch, and every field inside it optional too.
*
* The bounds live here rather than as database CHECKs, matching
* `MoodEntryTagLink.rating`: an out-of-range value is a client error and
* answers 422 through `returnAllZodIssues`, and a later change to a scale
* cannot strand rows written under the old one.
*
* Sleep, activity, vitals and body symptoms are deliberately absent. They are
* owned by other modules and are read from them
* (`src/lib/mood/linked-context.ts`); accepting them here would mint a second
* home for the same fact.
*/
export const moodContextSchema = z.object({
workStatus: z.enum(WORK_STATUS_KEYS).nullable().optional(),
workMinutes: contextMinutes.nullable().optional(),
overtimeMinutes: contextMinutes.nullable().optional(),
workLoad: contextRating.nullable().optional(),
workSatisfaction: contextRating.nullable().optional(),
contactCircles: z
.array(z.enum(CONTACT_CIRCLE_KEYS))
.max(CONTACT_CIRCLE_KEYS.length)
.nullable()
.optional(),
contactForm: z.enum(CONTACT_FORM_KEYS).nullable().optional(),
contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullable().optional(),
contactQuality: contextRating.nullable().optional(),
contactSupport: contextRating.nullable().optional(),
leisureCategories: z
.array(z.enum(LEISURE_CATEGORY_KEYS))
.max(LEISURE_CATEGORY_KEYS.length)
.nullable()
.optional(),
leisureMinutes: contextMinutes.nullable().optional(),
leisureJoy: contextRating.nullable().optional(),
leisureRecovery: contextRating.nullable().optional(),
eventType: z.enum(EVENT_TYPE_KEYS).nullable().optional(),
eventValence: z
.number()
.int()
.min(CONTEXT_VALENCE_MIN)
.max(CONTEXT_VALENCE_MAX)
.nullable()
.optional(),
// The same plausibility bound the entry's own instant carries: no future
// instants beyond the clock-skew tolerance, nothing before 1900.
eventAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
)
.nullable()
.optional(),
// Capped like the entry note beside it. One note for the whole context.
note: z.string().max(500).nullable().optional(),
});
export const createMoodEntrySchema = z
.object({
mood: moodLevelEnum,
@@ -145,6 +230,12 @@ export const createMoodEntrySchema = z
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
// v1.38 — the day's context. Absent leaves an existing context alone on
// the `update:` arm of an `externalId` upsert, for the same reason A2 to
// A5 are left alone: a phone re-posting an entry from a build that has no
// context surface must not blank one somebody filled in on the web. A
// context that says nothing removes the row.
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
@@ -186,6 +277,10 @@ export const updateMoodEntrySchema = z.object({
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
note: z.string().max(500).nullable().optional(),
// v1.38 — full replacement of the context when present, matching the way
// `tagKeys` and `ratedFactors` work on this path: omit to leave it alone,
// send it to replace it, send `null` (or an empty one) to remove the row.
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
@@ -219,3 +314,4 @@ export const listMoodEntriesSchema = z.object({
export type CreateMoodEntryInput = z.infer<typeof createMoodEntrySchema>;
export type UpdateMoodEntryInput = z.infer<typeof updateMoodEntrySchema>;
export type ListMoodEntriesInput = z.infer<typeof listMoodEntriesSchema>;
export type MoodContextRequest = z.infer<typeof moodContextSchema>;
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
INVALID_SAMPLES_REASON,
parseWorkoutBatch,
} from "@/lib/workouts/batch-ingest-parse";
function entry(overrides: Record<string, unknown> = {}) {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-uuid-1",
...overrides,
};
}
describe("parseWorkoutBatch", () => {
it("parses a clean batch and refuses nothing", () => {
const result = parseWorkoutBatch({
workouts: [
entry({ samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 120 }] }),
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.refusedSampleIndices).toEqual([]);
expect(result.data.workouts[0]?.samples).toHaveLength(1);
});
it("refuses only the entries whose samples array is malformed", () => {
const result = parseWorkoutBatch({
workouts: [
entry({ externalId: "hk-a", samples: [{ t: "nope", hr: 4000 }] }),
entry({ externalId: "hk-b" }),
entry({ externalId: "hk-c", samples: [] }),
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.refusedSampleIndices).toEqual([0, 2]);
// The survivors keep their full typed shape.
expect(result.data.workouts).toHaveLength(3);
expect(result.data.workouts[1]?.externalId).toBe("hk-b");
expect(result.data.workouts[0]?.samples).toBeUndefined();
});
it("keeps failing the whole batch when any other field is wrong", () => {
const result = parseWorkoutBatch({
workouts: [
entry({ endedAt: "2026-05-14T06:00:00.000Z" }),
entry({ externalId: "hk-b", samples: [{ t: "nope" }] }),
],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.message).toMatch(/endedAt/i);
});
it("fails the batch when the envelope itself is not a batch", () => {
expect(parseWorkoutBatch({ workouts: [] }).ok).toBe(false);
expect(parseWorkoutBatch(null).ok).toBe(false);
expect(parseWorkoutBatch({ workouts: "nope" }).ok).toBe(false);
});
it("names the per-entry reason code the route reports", () => {
expect(INVALID_SAMPLES_REASON).toBe("invalid_samples");
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Batch-ingest body parsing for `POST /api/workouts/batch`.
*
* The wire contract is `createBatchWorkoutSchema` and stays exactly that:
* this module never widens what the endpoint accepts. What it changes is
* WHERE one specific failure lands.
*
* A malformed field anywhere in the batch fails the whole call with a 400,
* which is right for a client that got the shape wrong. The `samples`
* array is the exception, because of how it arrives: the native client
* re-posts workouts the server already stores purely to hand over their
* heart-rate curve, sweeping years of history in batches of up to a
* hundred. One session whose series came out of HealthKit malformed would
* otherwise stall the entire sweep at a 400 the client cannot repair by
* retrying — every other workout in that batch is fine and has nothing to
* do with the bad one.
*
* So an entry whose `samples` array fails validation is refused on its
* own, exactly the way an entry with an unusable `externalId` already is
* (see `classifyExternalId` in the route), and the rest of the batch
* lands. The refused entry is NOT stripped down and stored without its
* series: the caller asked for the curve to be saved, so a half-write
* would be a silent loss. It is reported back with its index so the
* client knows which session to fix.
*
* Every other validation failure keeps failing the batch.
*/
import {
createBatchWorkoutSchema,
type CreateBatchWorkoutInput,
} from "@/lib/validations/workout";
/** Per-entry `reason` code for a `samples` array that failed validation. */
export const INVALID_SAMPLES_REASON = "invalid_samples";
export type WorkoutBatchParseResult =
| {
ok: true;
data: CreateBatchWorkoutInput;
/** Batch indices whose `samples` array was refused. */
refusedSampleIndices: number[];
}
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Indices of entries whose ONLY problem is the `samples` array. An issue
* path of `["workouts", 3, "samples", ...]` names entry 3; anything else
* means the batch is broken in a way this path does not forgive.
*/
function collectSampleFailureIndices(
issues: ReadonlyArray<{ path: PropertyKey[] }>,
): number[] | null {
const indices = new Set<number>();
for (const issue of issues) {
const [root, index, field] = issue.path;
if (root !== "workouts" || typeof index !== "number" || field !== "samples")
return null;
indices.add(index);
}
return indices.size > 0 ? [...indices].sort((a, b) => a - b) : null;
}
/**
* Parses a batch body, refusing per entry when the only failures are
* `samples` arrays. On that path the offending entries are re-parsed
* without their series so the surviving entries keep their fully typed
* shape; the caller drops the refused indices before any write.
*/
export function parseWorkoutBatch(rawBody: unknown): WorkoutBatchParseResult {
const strict = createBatchWorkoutSchema.safeParse(rawBody);
if (strict.success) {
return { ok: true, data: strict.data, refusedSampleIndices: [] };
}
const refusedSampleIndices = collectSampleFailureIndices(strict.error.issues);
if (
refusedSampleIndices === null ||
!isRecord(rawBody) ||
!Array.isArray(rawBody.workouts)
) {
return {
ok: false,
message: strict.error.issues[0]?.message ?? "Invalid batch",
};
}
const refused = new Set(refusedSampleIndices);
const stripped = {
...rawBody,
workouts: rawBody.workouts.map((entry, index) => {
if (!refused.has(index) || !isRecord(entry)) return entry;
const { samples: _samples, ...rest } = entry;
return rest;
}),
};
const relaxed = createBatchWorkoutSchema.safeParse(stripped);
if (!relaxed.success) {
return {
ok: false,
message: relaxed.error.issues[0]?.message ?? "Invalid batch",
};
}
return { ok: true, data: relaxed.data, refusedSampleIndices };
}
@@ -106,6 +106,7 @@ const COUNT_BACK: Record<
MedicationSideEffect: (p, userId) =>
p.medicationSideEffect.count({ where: { userId } }),
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }),
MoodEntryTagLink: (p, userId) =>
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
@@ -241,6 +242,14 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
source: "MOODLOG",
moodLoggedAt: AT("2026-07-01T20:00:00.000Z"),
tagLinks: { create: { moodTagId: moodTag.id, rating: 4 } },
context: {
create: {
userId: OWNER_ID,
workStatus: "regular",
contactCircles: JSON.stringify(["family"]),
leisureJoy: 6,
},
},
},
});
@@ -67,6 +67,8 @@ const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z");
const CREATED_AT = new Date("2026-07-19T23:41:00.000Z");
const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z");
const DELETED_AT = new Date("2026-07-20T07:00:00.000Z");
const CONTEXT_NOTE = "the meeting overran and the evening went with it";
const CONTEXT_EVENT_AT = new Date("2026-07-19T16:30:00.000Z");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
@@ -214,6 +216,29 @@ describe("mood backup round trip", () => {
{ moodTagId: ownTag.id, rating: null },
],
},
// The day context, with one value from each of the four sections plus
// its own encrypted note. A zero is in there for the same reason it is
// among the level-A values above: zero is a real answer and a carrier
// that read it as absence would round-trip it to NULL and pass every
// truthiness check on the way.
context: {
create: {
userId: OWNER_ID,
workStatus: "overtime",
workMinutes: 600,
workLoad: 8,
workSatisfaction: 0,
contactCircles: JSON.stringify(["partner", "friends"]),
contactForm: "phone",
contactQuality: 7,
leisureCategories: JSON.stringify(["reading"]),
leisureMinutes: 30,
eventType: "conflict",
eventValence: -4,
eventAt: CONTEXT_EVENT_AT,
notesEncrypted: encryptNote(CONTEXT_NOTE),
},
},
},
});
await prisma.moodEntry.create({
@@ -316,6 +341,149 @@ describe("mood backup round trip", () => {
expect(byKey.get("rt_headache")!.rating).toBeNull();
expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull();
expect(byKey.get("rt_sleep_quality")!.rating).toBe(4);
// The day context comes back as its own row, bound to the restored entry.
// Asserted as "the row is there" before any column is read, so a restore
// that stopped writing it fails by name instead of as a Prisma throw three
// frames down.
expect(
await prisma.moodContext.count({
where: { moodEntryId: "mood-rt-entry" },
}),
"the restored entry has no day context — the backup or the restore dropped it",
).toBe(1);
const context = await prisma.moodContext.findUniqueOrThrow({
where: { moodEntryId: "mood-rt-entry" },
});
expect(context.userId).toBe(OWNER_ID);
expect(context.workStatus).toBe("overtime");
expect(context.workMinutes).toBe(600);
expect(context.workLoad).toBe(8);
// Zero, not NULL.
expect(context.workSatisfaction).toBe(0);
expect(context.contactCircles).toBe(JSON.stringify(["partner", "friends"]));
expect(context.contactForm).toBe("phone");
expect(context.contactQuality).toBe(7);
// Untouched sections stay empty rather than arriving as a defaulted middle.
expect(context.contactSupport).toBeNull();
expect(context.leisureCategories).toBe(JSON.stringify(["reading"]));
expect(context.leisureMinutes).toBe(30);
expect(context.leisureJoy).toBeNull();
expect(context.eventType).toBe("conflict");
expect(context.eventValence).toBe(-4);
expect(context.eventAt).toEqual(CONTEXT_EVENT_AT);
expect(
context.notesEncrypted,
"the restored context has no note ciphertext — the backup dropped it",
).not.toBeNull();
expect(decryptFromBytes(context.notesEncrypted!)).toBe(CONTEXT_NOTE);
// The entry that never had a context still has none. A restore that minted
// an empty row for every entry would turn "never asked" into "asked and
// answered nothing" across the whole account.
expect(
await prisma.moodContext.count({
where: { moodEntryId: "mood-rt-tombstone" },
}),
).toBe(0);
});
it("a file written before contexts existed restores without one", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await seedOwner(prisma);
// The compatibility floor for this change: no `context` key at all, and a
// live row that DOES have one. The restore wipes and rebuilds, so the row
// comes back exactly as the file describes it — which means without a
// context, not with the one that happened to be on disk.
await prisma.moodEntry.create({
data: {
id: "mood-rt-legacy-context",
userId: OWNER_ID,
date: "2026-07-19",
mood: "OKAY",
score: 3,
source: "MANUAL",
moodLoggedAt: LOGGED_AT,
context: { create: { userId: OWNER_ID, workStatus: "regular" } },
},
});
expect(await prisma.moodContext.count()).toBe(1);
const legacyPayload = {
schemaVersion: "2",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: OWNER_ID,
appSettings: null,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [
{
id: "mood-rt-legacy-context",
date: "2026-07-19",
mood: "OKAY",
score: 3,
tags: null,
source: "MANUAL",
loggedAt: LOGGED_AT.toISOString(),
externalId: null,
deletedAt: null,
factors: [],
},
],
customMoodTags: [],
nutrientDays: [],
};
const response = await restoreFromPayload(
prisma,
parseBackupPayload(legacyPayload),
"MOOD_CONTEXT_LEGACY",
);
expect(response.status).toBe(200);
await prisma.moodEntry.findUniqueOrThrow({
where: { id: "mood-rt-legacy-context" },
});
expect(await prisma.moodContext.count()).toBe(0);
});
it("carries the context through a portable export, in plain text", async () => {
const prisma = getPrismaClient();
await seedOwner(prisma);
await prisma.moodEntry.create({
data: {
id: "mood-rt-portable",
userId: OWNER_ID,
date: "2026-07-19",
mood: "GUT",
score: 4,
source: "MANUAL",
moodLoggedAt: LOGGED_AT,
context: {
create: {
userId: OWNER_ID,
leisureCategories: JSON.stringify(["music"]),
leisureJoy: 9,
notesEncrypted: encryptNote(CONTEXT_NOTE),
},
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
const entries = (payload as { moodEntries: Array<Record<string, unknown>> })
.moodEntries;
const context = entries[0].context as Record<string, unknown>;
// A portable file is for a person to read: the note is plain text and no
// ciphertext rides along.
expect(context.note).toBe(CONTEXT_NOTE);
expect(context.notesEncrypted).toBeUndefined();
expect(context.leisureJoy).toBe(9);
expect(context.leisureCategories).toBe(JSON.stringify(["music"]));
});
it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => {
@@ -0,0 +1,542 @@
/**
* v1.38 — the linked day block, proven against real Postgres to be a read of
* the owning module rather than a copy of it.
*
* The claim the whole design rests on cannot be made by a shape test. "The
* response has a `sleep` key" would stay green over an implementation that
* copied the figure onto the mood row at write time and never looked at it
* again — and that implementation is exactly the failure mode this feature
* exists to avoid. So the test changes the SOURCE and reads the mood surface
* again: write a sleep session, read the block, correct the session, read
* again, assert the block moved.
*
* Beside it, the two absences that are easy to get wrong:
* - a module switched off blanks its block rather than answering zero;
* - a day with no rows answers `{ present: false }`, which is a different
* fact from a day of no sleep.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-mood-linkage";
const DAY = "2026-06-11";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "mood-linkage",
email: "mood-linkage@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface LinkedFigure {
present: boolean;
value?: number;
unit?: string;
}
interface LinkedBody {
available: boolean;
reason?: string;
day: string;
sleep: { available: boolean; asleep?: LinkedFigure; inBed?: LinkedFigure };
activity: {
available: boolean;
reason?: string;
steps?: LinkedFigure;
activeEnergy?: LinkedFigure;
};
vitals: {
available: boolean;
restingHeartRate?: LinkedFigure;
heartRateVariability?: LinkedFigure;
};
body: {
available: boolean;
reason?: string;
logged?: boolean;
symptoms?: string[];
episodeId?: string | null;
};
}
async function readLinked(day = DAY): Promise<LinkedBody> {
const { GET } = await import("@/app/api/mood/linked-context/route");
const res = await GET(
new NextRequest(
`http://localhost/api/mood/linked-context?date=${day}&tz=Europe%2FBerlin`,
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: LinkedBody };
return body.data;
}
/** One night's sleep, as the sleep module stores it: minutes per stage. */
async function writeSleep(minutes: number) {
return getPrismaClient().measurement.create({
data: {
userId: TEST_USER_ID,
type: "SLEEP_DURATION",
value: minutes,
unit: "min",
// A night that ends on the morning of DAY: the reconstruction keys a
// night on the day it woke up on.
measuredAt: new Date("2026-06-11T05:30:00.000Z"),
sleepStage: "ASLEEP",
source: "MANUAL",
},
});
}
describe("mood context linkage (real Postgres)", () => {
it("a corrected sleep session moves the linked figure, because nothing was copied", async () => {
const row = await writeSleep(400);
const before = await readLinked();
expect(before.sleep.available).toBe(true);
expect(before.sleep.asleep).toEqual({
present: true,
value: 400,
unit: "min",
});
// The correction, made where the fact lives.
await getPrismaClient().measurement.update({
where: { id: row.id },
data: { value: 455 },
});
const after = await readLinked();
expect(after.sleep.asleep).toEqual({
present: true,
value: 455,
unit: "min",
});
});
it("a day with no rows answers absence, not zero", async () => {
const linked = await readLinked();
expect(linked.sleep.available).toBe(true);
expect(linked.sleep.asleep).toEqual({ present: false });
expect(linked.activity.steps).toEqual({ present: false });
expect(linked.vitals.restingHeartRate).toEqual({ present: false });
// Never a zero: a night nobody recorded and a night of no sleep are
// different facts, and only one of them belongs in a health record.
expect(JSON.stringify(linked)).not.toContain('"value":0');
});
it("sums the day's activity and takes the day's latest vital", async () => {
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 4000,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 2500,
unit: "steps",
measuredAt: new Date("2026-06-11T18:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 61,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 58,
unit: "bpm",
measuredAt: new Date("2026-06-11T21:00:00.000Z"),
source: "MANUAL",
},
// The day before, to prove the window does not bleed.
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 9999,
unit: "steps",
measuredAt: new Date("2026-06-10T12:00:00.000Z"),
source: "MANUAL",
},
],
});
const linked = await readLinked();
expect(linked.activity.steps).toEqual({
present: true,
value: 6500,
unit: "steps",
});
expect(linked.vitals.restingHeartRate).toEqual({
present: true,
value: 58,
unit: "bpm",
});
});
it("blanks the sleep block when the sleep module is off, rather than answering zero", async () => {
await writeSleep(400);
expect((await readLinked()).sleep.available).toBe(true);
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { sleep: false } },
});
const linked = await readLinked();
expect(linked.sleep).toEqual({
available: false,
reason: "module-disabled",
});
// The block is gone, not zeroed and not filtered down to an empty figure.
expect(linked.sleep).not.toHaveProperty("asleep");
// And the other blocks are untouched by that choice.
expect(linked.activity.available).toBe(true);
expect(linked.vitals.available).toBe(true);
});
it("links the illness day-log read-only, and blanks it when the module is off", async () => {
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId: TEST_USER_ID,
label: "cold",
type: "INFECTION",
onsetAt: new Date("2026-06-10T08:00:00.000Z"),
},
});
// The symptom catalogue is seeded reference data every instance has, so
// this upserts rather than creating: the point of the test is the link,
// not the row.
const symptom = await getPrismaClient().illnessSymptom.upsert({
where: { key: "cough" },
create: { key: "cough", labelKey: "illness.symptom.cough" },
update: {},
});
await getPrismaClient().illnessDayLog.create({
data: {
userId: TEST_USER_ID,
episodeId: episode.id,
date: DAY,
functionalImpact: 2,
symptomLinks: { create: [{ symptomId: symptom.id, severity: 2 }] },
},
});
const linked = await readLinked();
expect(linked.body.available).toBe(true);
expect(linked.body.logged).toBe(true);
expect(linked.body.symptoms).toEqual(["cough"]);
expect(linked.body.episodeId).toBe(episode.id);
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { illness: false } },
});
expect((await readLinked()).body).toEqual({
available: false,
reason: "module-disabled",
});
});
it("sums one source per day, not both, when two report the same day", async () => {
// Two sources reporting the same day is the ordinary case for anyone
// syncing a phone and a watch, and summing both doubles the number on the
// mood sheet. The repo's cure is the canonical-source picker every other
// steps/energy read already runs through.
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 6000,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "APPLE_HEALTH",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 5900,
unit: "steps",
measuredAt: new Date("2026-06-11T09:00:00.000Z"),
source: "FITBIT",
},
{
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value: 500,
unit: "kcal",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "APPLE_HEALTH",
},
{
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value: 480,
unit: "kcal",
measuredAt: new Date("2026-06-11T09:00:00.000Z"),
source: "FITBIT",
},
],
});
const linked = await readLinked();
// One stream, whichever the ladder picks — never the two added together.
expect([6000, 5900]).toContain(linked.activity.steps?.value);
expect([500, 480]).toContain(linked.activity.activeEnergy?.value);
expect(linked.activity.steps?.value).not.toBe(11900);
expect(linked.activity.activeEnergy?.value).not.toBe(980);
});
it("takes one source's vital for the day rather than whichever row is last", async () => {
// The default resting-heart-rate ladder ranks FITBIT above APPLE_HEALTH,
// and the fixture puts the LOWER-ranked source last in the day on purpose:
// a source-blind "latest of the day" answers 71, the canonical stream
// answers 62. Without that ordering the two rules agree and the case
// proves nothing.
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 60,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "FITBIT",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 62,
unit: "bpm",
measuredAt: new Date("2026-06-11T12:00:00.000Z"),
source: "FITBIT",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 71,
unit: "bpm",
// 21:00 Berlin, deliberately still inside the same local day: at
// 22:00 UTC in June this row would belong to the NEXT Berlin day and
// the case would pass without proving anything.
measuredAt: new Date("2026-06-11T19:00:00.000Z"),
source: "APPLE_HEALTH",
},
],
});
const linked = await readLinked();
// The picked source's latest, not the day's latest.
expect(linked.vitals.restingHeartRate).toEqual({
present: true,
value: 62,
unit: "bpm",
});
});
it("keeps ambient movement available when the workouts module is off", async () => {
// `workouts` gates workout SESSIONS. Steps and active energy are ambient
// movement and are deliberately unscoped — the module ownership table says
// so, and gating them here made the mood sheet stricter than every other
// surface in the product.
await getPrismaClient().measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 3000,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "MANUAL",
},
});
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { workouts: false } },
});
const linked = await readLinked();
expect(linked.activity.available).toBe(true);
expect(linked.activity.steps).toEqual({
present: true,
value: 3000,
unit: "steps",
});
});
it("blanks the vitals block when the recovery module is off", async () => {
// Resting heart rate and heart-rate variability are owned by `recovery`,
// which the ownership table has said since v1.30.22. The block claimed to
// be core and answered them regardless, which made the release note's
// "a module you have switched off leaves its block out entirely" false.
await getPrismaClient().measurement.create({
data: {
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 58,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "MANUAL",
},
});
expect((await readLinked()).vitals.available).toBe(true);
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { recovery: false } },
});
const linked = await readLinked();
expect(linked.vitals).toEqual({
available: false,
reason: "module-disabled",
});
expect(linked.vitals).not.toHaveProperty("restingHeartRate");
});
it("refuses a delegate whose grant does not open the whole record", async () => {
// The finding this case exists for: the route reads sleep, activity,
// vitals and the illness journal, and it used to declare `mind`. A
// delegate given the mind section alone could therefore walk arbitrary
// dates and read four other sections of the owner's record — no mood
// entry even had to exist for the date.
//
// The convention for a read that crosses sections is `record`, and a
// scoped grant never reaches one. So the scoped delegate loses the linked
// block entirely, which is the honest answer rather than a filtered one.
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 7777,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 55,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "MANUAL",
},
],
});
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId: TEST_USER_ID,
label: "cold",
type: "INFECTION",
onsetAt: new Date("2026-06-10T08:00:00.000Z"),
},
});
await getPrismaClient().illnessDayLog.create({
data: {
userId: TEST_USER_ID,
episodeId: episode.id,
date: DAY,
functionalImpact: 3,
},
});
const delegate = await getPrismaClient().user.create({
data: {
id: "user-mood-linkage-delegate",
username: "mood-linkage-delegate",
email: "mood-linkage-delegate@example.test",
timezone: "Europe/Berlin",
},
});
await getPrismaClient().accountGrant.create({
data: {
grantorId: TEST_USER_ID,
granteeId: delegate.id,
access: "READ",
acceptedAt: new Date(),
// Only the mind section. Everything this route reads is outside it.
scopeJson: ["mind"] as never,
},
});
const session = await getPrismaClient().session.create({
data: {
userId: delegate.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
await switchSessionTo(session.id, TEST_USER_ID);
const { GET } = await import("@/app/api/mood/linked-context/route");
const res = await GET(
new NextRequest(
`http://localhost/api/mood/linked-context?date=${DAY}&tz=Europe%2FBerlin`,
),
);
expect(res.status).toBe(403);
const raw = await res.text();
// Asserted on the bytes, not on a parsed field: the point is that not one
// of the owner's figures reached the response.
expect(raw).not.toContain("7777");
expect(raw).not.toContain("55");
expect(raw).not.toContain("functionalImpact");
});
it("refuses a malformed day", async () => {
const { GET } = await import("@/app/api/mood/linked-context/route");
const res = await GET(
new NextRequest(
"http://localhost/api/mood/linked-context?date=yesterday",
),
);
expect(res.status).toBe(422);
});
});
+515
View File
@@ -0,0 +1,515 @@
/**
* v1.38 — the day context on a mood entry, against real Postgres and the real
* handlers.
*
* The claims that matter here are all about ABSENCE, which is the part a unit
* test with a mocked client cannot make honestly:
*
* - an entry whose sections were never opened stores NO context row;
* - one filled field stores one row with that field set and every other
* column NULL, not a row of defaulted middles;
* - re-posting the same `externalId` updates the context in place rather
* than accumulating rows or leaving a stale one behind;
* - the note goes in through `encryptNote` and comes back through
* `readNote`, with no plaintext column holding it;
* - a value outside the vocabulary is a 422 carrying every issue, not a
* silently dropped field.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readNote } from "@/lib/crypto/note-cipher";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-mood-context";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "mood-context",
email: "mood-context@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function postRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function putRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("mood context write path (real Postgres)", () => {
it("stores no context row for an entry that carries none", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
}),
);
expect(res.status).toBe(201);
expect(await getPrismaClient().moodContext.count()).toBe(0);
});
it("stores no context row for an empty context object", async () => {
// A client that renders the sections and sends them back untouched has
// said the same thing as one that sent nothing at all. A row of NULLs
// would record "asked and answered nothing" where the truth is
// "never asked".
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
context: { contactCircles: [], leisureCategories: [], note: "" },
}),
);
expect(res.status).toBe(201);
expect(await getPrismaClient().moodContext.count()).toBe(0);
});
it("stores exactly the one field that was filled, everything else NULL", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T10:00:00.000Z",
context: { workStatus: "regular" },
}),
);
expect(res.status).toBe(201);
const rows = await getPrismaClient().moodContext.findMany();
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.userId).toBe(TEST_USER_ID);
expect(row.workStatus).toBe("regular");
expect(row.workMinutes).toBeNull();
expect(row.workLoad).toBeNull();
expect(row.contactCircles).toBeNull();
expect(row.contactQuality).toBeNull();
expect(row.leisureCategories).toBeNull();
expect(row.eventType).toBeNull();
expect(row.eventValence).toBeNull();
expect(row.eventAt).toBeNull();
expect(row.notesEncrypted).toBeNull();
});
it("round-trips every section, with a zero that stays a zero", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "SCHLECHT",
moodLoggedAt: "2026-05-16T11:00:00.000Z",
context: {
workStatus: "overtime",
workMinutes: 540,
overtimeMinutes: 90,
workLoad: 9,
// Zero is a real answer. A carrier that treated it as absence would
// round-trip it to NULL and pass every truthy check on the way.
workSatisfaction: 0,
contactCircles: ["partner", "colleagues"],
contactForm: "inPerson",
contactExtent: "brief",
contactQuality: 6,
contactSupport: 4,
leisureCategories: ["reading"],
leisureMinutes: 45,
leisureJoy: 7,
leisureRecovery: 5,
eventType: "conflict",
eventValence: -3,
eventAt: "2026-05-16T16:30:00.000Z",
note: "Long meeting ran over.",
},
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { context: Record<string, unknown> };
};
expect(body.data.context).toMatchObject({
workStatus: "overtime",
workSatisfaction: 0,
contactCircles: ["partner", "colleagues"],
leisureCategories: ["reading"],
eventValence: -3,
note: "Long meeting ran over.",
});
const row = await getPrismaClient().moodContext.findFirstOrThrow();
expect(row.workMinutes).toBe(540);
expect(row.overtimeMinutes).toBe(90);
expect(row.workLoad).toBe(9);
expect(row.workSatisfaction).toBe(0);
expect(row.contactCircles).toBe('["partner","colleagues"]');
expect(row.leisureCategories).toBe('["reading"]');
expect(row.eventAt?.toISOString()).toBe("2026-05-16T16:30:00.000Z");
// The note is bytes at rest and decrypts back to the same text; nothing
// on this table holds it in the clear.
expect(row.notesEncrypted).not.toBeNull();
expect(readNote(row.notesEncrypted, null)).toBe("Long meeting ran over.");
expect(JSON.stringify(row)).not.toContain("Long meeting ran over.");
});
it("re-posting the same externalId updates the context in place", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const first = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-17T08:00:00.000Z",
externalId: "ios-row-4711",
context: { workStatus: "regular", workLoad: 5 },
}),
);
expect(first.status).toBe(201);
const second = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-17T08:00:00.000Z",
externalId: "ios-row-4711",
context: { workStatus: "overtime" },
}),
);
expect(second.status).toBe(201);
const rows = await getPrismaClient().moodContext.findMany();
expect(rows).toHaveLength(1);
// The write replaces the context whole: the capture surface always sends
// the sections it is showing, so a field the payload omits is one the
// person cleared.
expect(rows[0].workStatus).toBe("overtime");
expect(rows[0].workLoad).toBeNull();
});
it("leaves a stored context alone when a re-post carries none", async () => {
// The phone. Its build has no context surface, so it re-posts the entry
// without one — and that must not blank what somebody filled in on the
// web.
const { POST } = await import("@/app/api/mood-entries/route");
await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-18T08:00:00.000Z",
externalId: "ios-row-4712",
context: { leisureCategories: ["music"], leisureJoy: 8 },
}),
);
await POST(
postRequest("/api/mood-entries", {
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-18T08:00:00.000Z",
externalId: "ios-row-4712",
}),
);
const rows = await getPrismaClient().moodContext.findMany();
expect(rows).toHaveLength(1);
expect(rows[0].leisureCategories).toBe('["music"]');
expect(rows[0].leisureJoy).toBe(8);
});
it("PUT replaces the context and an explicit null removes the row", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const created = await POST(
postRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-19T08:00:00.000Z",
context: { eventType: "goodNews", eventValence: 4 },
}),
);
const { data } = (await created.json()) as { data: { id: string } };
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
const replaced = await PUT(
putRequest(`/api/mood-entries/${data.id}`, {
context: { eventType: "appointment", note: "Dentist." },
}),
{ params: Promise.resolve({ id: data.id }) },
);
expect(replaced.status).toBe(200);
const afterReplace = await getPrismaClient().moodContext.findFirstOrThrow();
expect(afterReplace.eventType).toBe("appointment");
expect(afterReplace.eventValence).toBeNull();
expect(readNote(afterReplace.notesEncrypted, null)).toBe("Dentist.");
const cleared = await PUT(
putRequest(`/api/mood-entries/${data.id}`, { context: null }),
{ params: Promise.resolve({ id: data.id }) },
);
expect(cleared.status).toBe(200);
expect(await getPrismaClient().moodContext.count()).toBe(0);
});
it("the GET surfaces the stored context on the entry", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const created = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-20T08:00:00.000Z",
context: { contactCircles: ["family"], contactQuality: 9 },
}),
);
const { data } = (await created.json()) as { data: { id: string } };
const { GET } = await import("@/app/api/mood-entries/[id]/route");
const res = await GET(
new NextRequest(`http://localhost/api/mood-entries/${data.id}`),
{ params: Promise.resolve({ id: data.id }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { context: { contactCircles: string[]; contactQuality: number } };
};
expect(body.data.context.contactCircles).toEqual(["family"]);
expect(body.data.context.contactQuality).toBe(9);
});
it("refuses a value the vocabulary does not name, reporting every issue", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-21T08:00:00.000Z",
context: {
workStatus: "sabbatical",
workLoad: 42,
contactCircles: ["pets"],
},
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string; meta?: unknown };
expect(body.error).toBeTruthy();
expect(await getPrismaClient().moodContext.count()).toBe(0);
// The entry rolls back with it: a refused context is a refused request,
// not a half-saved one.
expect(await getPrismaClient().moodEntry.count()).toBe(0);
});
it("the bulk path stores a context and skips only the entry that is wrong", async () => {
// The contract this endpoint states in its own description: a bad row is
// skipped and the batch lands. A phone draining a year of local history
// must not lose the year to one row carrying a retired key.
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
postRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-23T08:00:00.000Z",
externalId: "ios-bulk-1",
a2: 6,
context: { workStatus: "partial", workMinutes: 240 },
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-23T12:00:00.000Z",
externalId: "ios-bulk-2",
context: { workStatus: "sabbatical" },
},
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-23T18:00:00.000Z",
externalId: "ios-bulk-3",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
skipped: Array<{ index: number; reason: string }>;
};
};
expect(body.data.inserted).toBe(2);
expect(body.data.skipped).toEqual([
{ index: 1, reason: "invalid_context" },
]);
const entries = await getPrismaClient().moodEntry.findMany({
orderBy: { moodLoggedAt: "asc" },
include: { context: true },
});
expect(entries).toHaveLength(2);
// The good row landed whole: the level-A value it sent, the derived one it
// did not, and its context.
expect(entries[0].stressA2).toBe(6);
expect(entries[0].moodA1).toBe(7);
expect(entries[0].context?.workStatus).toBe("partial");
expect(entries[0].context?.workMinutes).toBe(240);
// The row that carried no context has none, rather than an empty one.
expect(entries[1].context).toBeNull();
expect(entries[1].moodA1).toBe(9);
});
it("the bulk path skips an out-of-range dimension without failing the batch", async () => {
// The endpoint's own description promises that a bad value marks THAT
// entry skipped and never the batch. `a1`..`a5` sat in the batch schema
// and 422'd all of it, so the words and the behaviour disagreed.
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
postRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-25T08:00:00.000Z",
externalId: "ios-dim-1",
a3: 7,
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-25T12:00:00.000Z",
externalId: "ios-dim-2",
a1: 11,
},
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-25T18:00:00.000Z",
externalId: "ios-dim-3",
},
],
}),
);
// Not 422: one bad row must not cost the other two.
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
skipped: Array<{ index: number; reason: string }>;
};
};
expect(body.data.inserted).toBe(2);
expect(body.data.skipped).toEqual([
{ index: 1, reason: "invalid_dimensions" },
]);
const entries = await getPrismaClient().moodEntry.findMany({
orderBy: { moodLoggedAt: "asc" },
});
expect(entries).toHaveLength(2);
expect(entries[0].energyA3).toBe(7);
expect(entries[1].moodA1).toBe(9);
});
it("the sync feed carries both levels under the keys the write path takes", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
await POST(
postRequest("/api/mood-entries", {
mood: "SCHLECHT",
moodLoggedAt: "2026-05-24T08:00:00.000Z",
a2: 8,
context: {
eventType: "badNews",
eventValence: -4,
note: "Called in the afternoon.",
},
}),
);
const { GET } = await import("@/app/api/sync/changes/route");
const res = await GET(new NextRequest("http://localhost/api/sync/changes"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
changes: {
mood: {
upserts: Array<{
a1: number | null;
a2: number | null;
a5: number | null;
context: {
eventType: string | null;
eventValence: number | null;
note: string | null;
contactCircles: string[];
} | null;
}>;
};
};
};
};
const upsert = body.data.changes.mood.upserts[0];
// Derived server-side, so a client that sent only a label still mirrors a
// complete row.
expect(upsert.a1).toBe(3);
expect(upsert.a2).toBe(8);
// Untouched dimensions stay absent rather than arriving as a middle value.
expect(upsert.a5).toBeNull();
expect(upsert.context?.eventType).toBe("badNews");
expect(upsert.context?.eventValence).toBe(-4);
// The note is decrypted for the client; the ciphertext never rides.
expect(upsert.context?.note).toBe("Called in the afternoon.");
expect(upsert.context?.contactCircles).toEqual([]);
expect(JSON.stringify(upsert)).not.toContain("notesEncrypted");
});
it("deleting the entry takes its context with it", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const created = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-22T08:00:00.000Z",
context: { workStatus: "off" },
}),
);
const { data } = (await created.json()) as { data: { id: string } };
expect(await getPrismaClient().moodContext.count()).toBe(1);
// A user-facing DELETE is a tombstone, so the context stays with the row
// it describes; a hard delete cascades.
await getPrismaClient().moodEntry.delete({ where: { id: data.id } });
expect(await getPrismaClient().moodContext.count()).toBe(0);
});
});
@@ -0,0 +1,460 @@
/**
* Integration suite for the heart-rate series enrichment path of
* `POST /api/workouts/batch`, against a real Postgres in a testcontainer.
*
* The case: the native client posted a workout before it learned to send
* the per-workout HR series. The row exists, the curve does not. A later
* re-post of the SAME `(source, externalId)` carrying a `samples` array
* attaches the series to the stored workout and reports the entry as
* `enriched`.
*
* What the suite pins:
* - The attach happens once and reports `enriched`
* - A second re-post over an existing series is a no-op and never
* reports `enriched` twice
* - The workout row is byte-identical across the enrichment — every
* column, `updatedAt` included, compared as a whole row
* - A malformed `samples` array is refused per entry; the rest of the
* batch still lands
* - A batch mixing insert / enrich / duplicate / skip reports each
* entry with its own status
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-workout-enrich-integration-32-bytes-min-1234567890";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_USER_ID = "user-workout-enrich-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "workout-enrich",
email: "workout-enrich@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface WorkoutFixture {
sportType?: string;
startedAt?: string;
endedAt?: string;
totalEnergyKcal?: number;
totalDistanceM?: number;
avgHeartRate?: number;
maxHeartRate?: number;
minHeartRate?: number;
source?: string;
externalId?: string;
metadata?: Record<string, unknown>;
samples?: unknown;
}
interface BatchResponse {
processed: number;
inserted: number;
duplicates: number;
skipped: Array<{ index: number; reason: string }>;
entries: Array<{ index: number; status: string; reason?: string }>;
}
function makeRequest(body: { workouts: WorkoutFixture[] }): NextRequest {
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function baseWorkout(
externalId: string,
overrides: WorkoutFixture = {},
): WorkoutFixture {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId,
...overrides,
};
}
const SERIES = [
{ t: "2026-05-14T06:30:00.000Z", hr: 121 },
{ t: "2026-05-14T06:30:05.000Z", hr: 128, cadence: 168 },
{ t: "2026-05-14T06:30:10.000Z", hr: 133 },
];
async function post(body: {
workouts: WorkoutFixture[];
}): Promise<{ status: number; data: BatchResponse }> {
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(makeRequest(body));
const json = (await res.json()) as { data: BatchResponse };
return { status: res.status, data: json.data };
}
describe("POST /api/workouts/batch — HR series enrichment (real Postgres)", () => {
it("attaches the series to a stored workout that has none and reports enriched", async () => {
const first = await post({
workouts: [baseWorkout("hk-uuid-enrich-001")],
});
expect(first.status).toBe(200);
expect(first.data.inserted).toBe(1);
expect(
await getPrismaClient().workoutSamples.count({
where: { workout: { userId: TEST_USER_ID } },
}),
).toBe(0);
const second = await post({
workouts: [baseWorkout("hk-uuid-enrich-001", { samples: SERIES })],
});
expect(second.status).toBe(200);
expect(second.data.inserted).toBe(0);
expect(second.data.entries).toEqual([{ index: 0, status: "enriched" }]);
const stored = await getPrismaClient().workout.findFirst({
where: { userId: TEST_USER_ID, externalId: "hk-uuid-enrich-001" },
include: { samples: true },
});
expect(stored?.samples).not.toBeNull();
expect(stored?.samples?.sampleCount).toBe(3);
expect(stored?.samples?.samples).toEqual(SERIES);
// The workout row count is unchanged — enrichment never mints a row.
expect(
await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
}),
).toBe(1);
});
it("treats a re-post over an existing series as a no-op and never reports enriched twice", async () => {
await post({ workouts: [baseWorkout("hk-uuid-enrich-002")] });
const enriching = await post({
workouts: [baseWorkout("hk-uuid-enrich-002", { samples: SERIES })],
});
expect(enriching.data.entries[0]?.status).toBe("enriched");
const storedSeries = await getPrismaClient().workoutSamples.findFirst({
where: { workout: { externalId: "hk-uuid-enrich-002" } },
});
expect(storedSeries).not.toBeNull();
const again = await post({
workouts: [
baseWorkout("hk-uuid-enrich-002", {
samples: [
...SERIES,
{ t: "2026-05-14T06:30:15.000Z", hr: 141 },
{ t: "2026-05-14T06:30:20.000Z", hr: 145 },
],
}),
],
});
expect(again.status).toBe(200);
expect(again.data.entries[0]?.status).toBe("duplicate");
expect(again.data.duplicates).toBe(1);
// The stored series is the first one, untouched — the no-op does not
// overwrite a curve the server already holds.
const afterSeries = await getPrismaClient().workoutSamples.findFirst({
where: { workout: { externalId: "hk-uuid-enrich-002" } },
});
expect(afterSeries).toEqual(storedSeries);
expect(
await getPrismaClient().workoutSamples.count({
where: { workout: { userId: TEST_USER_ID } },
}),
).toBe(1);
});
it("hands the attach to exactly one of two concurrent enrichments", async () => {
await post({ workouts: [baseWorkout("hk-uuid-enrich-008")] });
// Both requests probe a workout with no series, so both mean to
// attach one. The unique index on `workout_id` decides: one write
// lands, the other conflicts and is skipped. The loser must report
// the duplicate it turned out to be rather than claim an attach the
// database never made — that is the arm this case exists for, and
// the outcome is deterministic even though the winner is not.
const { POST } = await import("@/app/api/workouts/batch/route");
const body = {
workouts: [baseWorkout("hk-uuid-enrich-008", { samples: SERIES })],
};
const [resA, resB] = await Promise.all([
POST(makeRequest(body)),
POST(makeRequest(body)),
]);
expect(resA.status).toBe(200);
expect(resB.status).toBe(200);
const [jsonA, jsonB] = (await Promise.all([resA.json(), resB.json()])) as [
{ data: BatchResponse },
{ data: BatchResponse },
];
const statuses = [
jsonA.data.entries[0]?.status,
jsonB.data.entries[0]?.status,
].sort();
expect(statuses).toEqual(["duplicate", "enriched"]);
// Both responses still count the entry as a duplicate row.
expect(jsonA.data.duplicates).toBe(1);
expect(jsonB.data.duplicates).toBe(1);
expect(jsonA.data.inserted + jsonB.data.inserted).toBe(0);
const series = await getPrismaClient().workoutSamples.findMany({
where: { workout: { userId: TEST_USER_ID } },
});
expect(series).toHaveLength(1);
expect(series[0]?.sampleCount).toBe(3);
expect(
await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
}),
).toBe(1);
});
it("reports one enrichment when two entries in one batch aim at the same workout", async () => {
await post({ workouts: [baseWorkout("hk-uuid-enrich-007")] });
// Same external id twice, far enough apart in time that the
// write-time twin dedup keeps both entries. Only one series can land.
const res = await post({
workouts: [
baseWorkout("hk-uuid-enrich-007", { samples: SERIES }),
baseWorkout("hk-uuid-enrich-007", {
startedAt: "2026-05-14T09:00:00.000Z",
endedAt: "2026-05-14T09:45:00.000Z",
samples: [{ t: "2026-05-14T09:00:00.000Z", hr: 155 }],
}),
],
});
expect(res.status).toBe(200);
expect(
res.data.entries.filter((e) => e.status === "enriched"),
).toHaveLength(1);
expect(
await getPrismaClient().workoutSamples.count({
where: { workout: { userId: TEST_USER_ID } },
}),
).toBe(1);
});
it("leaves every workout column byte-identical across an enrichment", async () => {
await post({
workouts: [
baseWorkout("hk-uuid-enrich-003", {
totalEnergyKcal: 388,
totalDistanceM: 7100,
avgHeartRate: 141,
maxHeartRate: 169,
minHeartRate: 96,
metadata: { device: "watch" },
}),
],
});
const before = await getPrismaClient().workout.findFirstOrThrow({
where: { userId: TEST_USER_ID, externalId: "hk-uuid-enrich-003" },
});
// A re-post whose every field contradicts the stored row. First-write-
// wins still governs the workout itself; only the series is attached.
const enriched = await post({
workouts: [
baseWorkout("hk-uuid-enrich-003", {
sportType: "cycling",
startedAt: "2026-05-14T09:00:00.000Z",
endedAt: "2026-05-14T10:30:00.000Z",
totalEnergyKcal: 999,
totalDistanceM: 42000,
avgHeartRate: 111,
maxHeartRate: 198,
minHeartRate: 55,
metadata: { device: "phone" },
samples: SERIES,
}),
],
});
expect(enriched.data.entries[0]?.status).toBe("enriched");
const after = await getPrismaClient().workout.findFirstOrThrow({
where: { userId: TEST_USER_ID, externalId: "hk-uuid-enrich-003" },
});
// Whole-row comparison — a spot check would miss a column nobody
// thought to name, and `updatedAt` proves no write touched the row.
expect(after).toEqual(before);
});
it("refuses a malformed samples array per entry without failing the batch", async () => {
await post({ workouts: [baseWorkout("hk-uuid-enrich-004")] });
const mixed = await post({
workouts: [
baseWorkout("hk-uuid-enrich-004", {
samples: [{ t: "not-a-timestamp", hr: 4000 }],
}),
baseWorkout("hk-uuid-enrich-005", {
startedAt: "2026-05-15T06:30:00.000Z",
endedAt: "2026-05-15T07:15:00.000Z",
}),
],
});
expect(mixed.status).toBe(200);
expect(mixed.data.entries[0]?.status).toBe("skipped");
expect(mixed.data.entries[0]?.reason).toBe("invalid_samples");
expect(mixed.data.entries[1]?.status).toBe("inserted");
expect(mixed.data.inserted).toBe(1);
expect(mixed.data.skipped).toEqual([
{ index: 0, reason: "invalid_samples" },
]);
// Nothing attached to the workout the bad entry addressed.
expect(
await getPrismaClient().workoutSamples.count({
where: { workout: { userId: TEST_USER_ID } },
}),
).toBe(0);
// And the second entry did land.
expect(
await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
}),
).toBe(2);
});
it("refuses an over-cap samples array per entry rather than the whole batch", async () => {
await post({ workouts: [baseWorkout("hk-uuid-enrich-006")] });
const oversized = Array.from({ length: 30_001 }, (_v, i) => ({
t: new Date(Date.UTC(2026, 4, 14, 6, 30, 0) + i * 1000).toISOString(),
hr: 120,
}));
const res = await post({
workouts: [baseWorkout("hk-uuid-enrich-006", { samples: oversized })],
});
expect(res.status).toBe(200);
expect(res.data.entries[0]?.status).toBe("skipped");
expect(res.data.entries[0]?.reason).toBe("invalid_samples");
expect(
await getPrismaClient().workoutSamples.count({
where: { workout: { userId: TEST_USER_ID } },
}),
).toBe(0);
});
it("reports insert, enrich, duplicate and skip side by side in one batch", async () => {
await post({
workouts: [
baseWorkout("hk-uuid-mix-known-series", {
startedAt: "2026-05-10T06:00:00.000Z",
endedAt: "2026-05-10T06:45:00.000Z",
}),
baseWorkout("hk-uuid-mix-known-plain", {
startedAt: "2026-05-11T06:00:00.000Z",
endedAt: "2026-05-11T06:45:00.000Z",
}),
],
});
const mixed = await post({
workouts: [
// 0 — new workout, inserts
baseWorkout("hk-uuid-mix-new", {
startedAt: "2026-05-12T06:00:00.000Z",
endedAt: "2026-05-12T06:45:00.000Z",
}),
// 1 — known workout with a series in the payload, enriches
baseWorkout("hk-uuid-mix-known-series", {
startedAt: "2026-05-10T06:00:00.000Z",
endedAt: "2026-05-10T06:45:00.000Z",
samples: SERIES,
}),
// 2 — known workout, no series in the payload, unchanged
baseWorkout("hk-uuid-mix-known-plain", {
startedAt: "2026-05-11T06:00:00.000Z",
endedAt: "2026-05-11T06:45:00.000Z",
}),
// 3 — unusable external id, refused per entry
baseWorkout(" ", {
startedAt: "2026-05-13T06:00:00.000Z",
endedAt: "2026-05-13T06:45:00.000Z",
}),
],
});
expect(mixed.status).toBe(200);
expect(mixed.data.processed).toBe(4);
expect(mixed.data.entries.map((e) => e.status)).toEqual([
"inserted",
"enriched",
"duplicate",
"skipped",
]);
expect(mixed.data.inserted).toBe(1);
// An enriched entry is still a duplicate of the workout row itself —
// the counters keep counting rows, the entry status carries the detail.
expect(mixed.data.duplicates).toBe(2);
expect(mixed.data.skipped).toHaveLength(1);
const series = await getPrismaClient().workoutSamples.findMany({
where: { workout: { userId: TEST_USER_ID } },
include: { workout: { select: { externalId: true } } },
});
expect(series).toHaveLength(1);
expect(series[0]?.workout.externalId).toBe("hk-uuid-mix-known-series");
expect(
await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
}),
).toBe(3);
});
});