mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'main' into feat/encounter-phase-1
This commit is contained in:
commit
a83be074ca
65 files changed
+7864
-624
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => {},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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."),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user