mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #735 from MBombeck/feat/mood-level-a
Five dimensions behind the mood check-in
This commit is contained in:
47 files changed
+3667
-74
No files matched your search
@@ -28,6 +28,10 @@ regexes = [
|
||||
# Coach cadence-suggestion label keys are dotted message keys, not secrets;
|
||||
# one trips the generic-api-key entropy heuristic.
|
||||
'''coach\.reminderSuggestion\.cadence''',
|
||||
# Level-A mood dimension label and anchor keys are dotted message keys, not
|
||||
# secrets; they sit on fields named `labelKey` / `lowAnchorKey`, and the
|
||||
# generic-api-key rule reads any `*Key: "<high-entropy string>"` as a token.
|
||||
'''mood\.dimension\.''',
|
||||
]
|
||||
|
||||
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- A mood entry now records five separate values instead of one. The quick
|
||||
check-in is unchanged: pick a face, save, done in a tap, and its answer maps
|
||||
onto a new pleasantness scale so nothing about that entry is second class.
|
||||
Underneath it, a section that stays closed until you open it holds five
|
||||
sliders running 0 to 10, each with wording at both ends: how the day felt,
|
||||
how much strain it carried, how much energy there was, how connected you
|
||||
were, and how able to act you felt. Anything you leave alone stays empty
|
||||
rather than landing in the middle, and clearing a slider takes the answer
|
||||
back. Entries you have already logged carry the mapped value for the first
|
||||
scale and nothing invented for the other four.
|
||||
|
||||
- The mood page trends all five, over seven, thirty or ninety days, each drawn
|
||||
the way you answered it. A scale you have never filled in is named as not
|
||||
recorded rather than drawn flat through the middle. The daily briefing and
|
||||
the assistant read all five as well, each with its own age, so a value from
|
||||
last week is stated with its date instead of as today's.
|
||||
|
||||
- Factor ratings are no longer offered on a new entry. A one-to-five score for
|
||||
work stress and a nought-to-ten stress scale are two answers to the same
|
||||
question, and only the second is asked now. Nothing was removed: ratings
|
||||
already logged still show, still edit, and still feed the breakdowns that
|
||||
read them.
|
||||
|
||||
### Security
|
||||
|
||||
- The PDF renderer behind the document vault moves to a release that closes a
|
||||
|
||||
@@ -126,6 +126,7 @@ const eslintConfig = defineConfig([
|
||||
"src/components/cycle/bbt-chart.tsx",
|
||||
"src/components/insights/derived/delta-sparkline.tsx",
|
||||
"src/components/insights/intraday-pulse-chart.tsx",
|
||||
"src/components/insights/mood/mood-dimension-trends.tsx",
|
||||
"src/components/insights/mood/mood-distribution-chart.tsx",
|
||||
"src/components/insights/mood/mood-time-of-day-chart.tsx",
|
||||
"src/components/insights/mood/mood-weekday-chart.tsx",
|
||||
|
||||
+42
-3
@@ -887,11 +887,42 @@
|
||||
"iconGroupMisc": "Sonstiges"
|
||||
},
|
||||
"sectionMoreTags": "Weitere Tags",
|
||||
"sectionFactors": "Faktoren",
|
||||
"sectionNoteDetails": "Notiz & Details",
|
||||
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
|
||||
"quickTagsExpand": "Weitere",
|
||||
"loadError": "Stimmungseinträge konnten nicht geladen werden."
|
||||
"loadError": "Stimmungseinträge konnten nicht geladen werden.",
|
||||
"sectionLevelA": "Fünf Skalen",
|
||||
"sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.",
|
||||
"dimensionNotAnswered": "nicht angegeben",
|
||||
"dimensionClear": "leeren",
|
||||
"dimensionValueText": "{value} von {max}: {low} bis {high}",
|
||||
"dimension": {
|
||||
"a1": {
|
||||
"label": "Stimmung",
|
||||
"low": "sehr unangenehm",
|
||||
"high": "sehr angenehm"
|
||||
},
|
||||
"a2": {
|
||||
"label": "Stress",
|
||||
"low": "vollkommen entspannt",
|
||||
"high": "extrem belastet"
|
||||
},
|
||||
"a3": {
|
||||
"label": "Energie",
|
||||
"low": "erschöpft",
|
||||
"high": "energiegeladen"
|
||||
},
|
||||
"a4": {
|
||||
"label": "Verbundenheit",
|
||||
"low": "isoliert",
|
||||
"high": "verstanden und unterstützt"
|
||||
},
|
||||
"a5": {
|
||||
"label": "Stabilität",
|
||||
"low": "vollständig überfordert",
|
||||
"high": "sehr handlungsfähig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
@@ -3612,7 +3643,15 @@
|
||||
"statLabel": "Wie das berechnet wurde",
|
||||
"explainerLabel": "Wie das berechnet wurde"
|
||||
},
|
||||
"inTargetTitle": "Im Zielbereich"
|
||||
"inTargetTitle": "Im Zielbereich",
|
||||
"dimensions": {
|
||||
"title": "Fünf Skalen",
|
||||
"none": "Für diese fünf Skalen ist noch nichts erfasst.",
|
||||
"window": "{days} Tage",
|
||||
"noneInWindow": "nichts im Zeitraum",
|
||||
"higherIsMore": "höher = {anchor}",
|
||||
"notRecorded": "Nicht erfasst: {list}."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
|
||||
+42
-3
@@ -887,11 +887,42 @@
|
||||
"iconGroupMisc": "Other"
|
||||
},
|
||||
"sectionMoreTags": "More tags",
|
||||
"sectionFactors": "Factors",
|
||||
"sectionNoteDetails": "Note & details",
|
||||
"quickTagsHelp": "Your frequent tags — one tap",
|
||||
"quickTagsExpand": "More",
|
||||
"loadError": "Could not load your mood entries."
|
||||
"loadError": "Could not load your mood entries.",
|
||||
"sectionLevelA": "Five scales",
|
||||
"sectionLevelAHelp": "Optional. Anything you leave alone stays empty.",
|
||||
"dimensionNotAnswered": "not recorded",
|
||||
"dimensionClear": "clear",
|
||||
"dimensionValueText": "{value} of {max}: {low} to {high}",
|
||||
"dimension": {
|
||||
"a1": {
|
||||
"label": "Mood",
|
||||
"low": "very unpleasant",
|
||||
"high": "very pleasant"
|
||||
},
|
||||
"a2": {
|
||||
"label": "Stress",
|
||||
"low": "completely relaxed",
|
||||
"high": "extremely strained"
|
||||
},
|
||||
"a3": {
|
||||
"label": "Energy",
|
||||
"low": "drained",
|
||||
"high": "full of energy"
|
||||
},
|
||||
"a4": {
|
||||
"label": "Connectedness",
|
||||
"low": "isolated",
|
||||
"high": "understood and supported"
|
||||
},
|
||||
"a5": {
|
||||
"label": "Stability",
|
||||
"low": "completely overwhelmed",
|
||||
"high": "fully able to act"
|
||||
}
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
@@ -3612,7 +3643,15 @@
|
||||
"statLabel": "How this was computed",
|
||||
"explainerLabel": "How this was computed"
|
||||
},
|
||||
"inTargetTitle": "In your range"
|
||||
"inTargetTitle": "In your range",
|
||||
"dimensions": {
|
||||
"title": "Five scales",
|
||||
"none": "Nothing recorded for these five scales yet.",
|
||||
"window": "{days} days",
|
||||
"noneInWindow": "nothing in this range",
|
||||
"higherIsMore": "higher = {anchor}",
|
||||
"notRecorded": "Not recorded: {list}."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
|
||||
+42
-3
@@ -887,11 +887,42 @@
|
||||
"iconGroupMisc": "Otros"
|
||||
},
|
||||
"sectionMoreTags": "Más etiquetas",
|
||||
"sectionFactors": "Factores",
|
||||
"sectionNoteDetails": "Nota y detalles",
|
||||
"quickTagsHelp": "Tus etiquetas frecuentes: un toque",
|
||||
"quickTagsExpand": "Más",
|
||||
"loadError": "No se pudieron cargar tus registros de estado de ánimo."
|
||||
"loadError": "No se pudieron cargar tus registros de estado de ánimo.",
|
||||
"sectionLevelA": "Cinco escalas",
|
||||
"sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.",
|
||||
"dimensionNotAnswered": "sin indicar",
|
||||
"dimensionClear": "borrar",
|
||||
"dimensionValueText": "{value} de {max}: {low} a {high}",
|
||||
"dimension": {
|
||||
"a1": {
|
||||
"label": "Estado de ánimo",
|
||||
"low": "muy desagradable",
|
||||
"high": "muy agradable"
|
||||
},
|
||||
"a2": {
|
||||
"label": "Estrés",
|
||||
"low": "completamente relajado",
|
||||
"high": "extremadamente tenso"
|
||||
},
|
||||
"a3": {
|
||||
"label": "Energía",
|
||||
"low": "agotado",
|
||||
"high": "lleno de energía"
|
||||
},
|
||||
"a4": {
|
||||
"label": "Conexión",
|
||||
"low": "aislado",
|
||||
"high": "comprendido y apoyado"
|
||||
},
|
||||
"a5": {
|
||||
"label": "Estabilidad",
|
||||
"low": "completamente desbordado",
|
||||
"high": "con plena capacidad de acción"
|
||||
}
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
@@ -3612,7 +3643,15 @@
|
||||
"statLabel": "Cómo se calculó esto",
|
||||
"explainerLabel": "Cómo se calculó esto"
|
||||
},
|
||||
"inTargetTitle": "En tu rango"
|
||||
"inTargetTitle": "En tu rango",
|
||||
"dimensions": {
|
||||
"title": "Cinco escalas",
|
||||
"none": "Todavía no hay nada registrado en estas cinco escalas.",
|
||||
"window": "{days} días",
|
||||
"noneInWindow": "nada en este periodo",
|
||||
"higherIsMore": "más alto = {anchor}",
|
||||
"notRecorded": "Sin registrar: {list}."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
|
||||
+42
-3
@@ -887,11 +887,42 @@
|
||||
"iconGroupMisc": "Autres"
|
||||
},
|
||||
"sectionMoreTags": "Plus de tags",
|
||||
"sectionFactors": "Facteurs",
|
||||
"sectionNoteDetails": "Note et détails",
|
||||
"quickTagsHelp": "Vos tags fréquents — en un geste",
|
||||
"quickTagsExpand": "Plus",
|
||||
"loadError": "Impossible de charger vos entrées d'humeur."
|
||||
"loadError": "Impossible de charger vos entrées d'humeur.",
|
||||
"sectionLevelA": "Cinq échelles",
|
||||
"sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.",
|
||||
"dimensionNotAnswered": "non renseigné",
|
||||
"dimensionClear": "effacer",
|
||||
"dimensionValueText": "{value} sur {max} : {low} à {high}",
|
||||
"dimension": {
|
||||
"a1": {
|
||||
"label": "Humeur",
|
||||
"low": "très désagréable",
|
||||
"high": "très agréable"
|
||||
},
|
||||
"a2": {
|
||||
"label": "Stress",
|
||||
"low": "parfaitement détendu",
|
||||
"high": "extrêmement tendu"
|
||||
},
|
||||
"a3": {
|
||||
"label": "Énergie",
|
||||
"low": "épuisé",
|
||||
"high": "plein d'énergie"
|
||||
},
|
||||
"a4": {
|
||||
"label": "Lien social",
|
||||
"low": "isolé",
|
||||
"high": "compris et soutenu"
|
||||
},
|
||||
"a5": {
|
||||
"label": "Stabilité",
|
||||
"low": "complètement dépassé",
|
||||
"high": "pleinement capable d'agir"
|
||||
}
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
@@ -3612,7 +3643,15 @@
|
||||
"statLabel": "Comment ceci est calculé",
|
||||
"explainerLabel": "Comment ceci est calculé"
|
||||
},
|
||||
"inTargetTitle": "Dans votre plage"
|
||||
"inTargetTitle": "Dans votre plage",
|
||||
"dimensions": {
|
||||
"title": "Cinq échelles",
|
||||
"none": "Rien n'est encore enregistré pour ces cinq échelles.",
|
||||
"window": "{days} jours",
|
||||
"noneInWindow": "rien sur cette période",
|
||||
"higherIsMore": "plus haut = {anchor}",
|
||||
"notRecorded": "Non renseigné : {list}."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
|
||||
+42
-3
@@ -887,11 +887,42 @@
|
||||
"iconGroupMisc": "Altro"
|
||||
},
|
||||
"sectionMoreTags": "Altri tag",
|
||||
"sectionFactors": "Fattori",
|
||||
"sectionNoteDetails": "Nota e dettagli",
|
||||
"quickTagsHelp": "I tuoi tag frequenti: un tocco",
|
||||
"quickTagsExpand": "Altro",
|
||||
"loadError": "Impossibile caricare le voci dell'umore."
|
||||
"loadError": "Impossibile caricare le voci dell'umore.",
|
||||
"sectionLevelA": "Cinque scale",
|
||||
"sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.",
|
||||
"dimensionNotAnswered": "non indicato",
|
||||
"dimensionClear": "cancella",
|
||||
"dimensionValueText": "{value} su {max}: da {low} a {high}",
|
||||
"dimension": {
|
||||
"a1": {
|
||||
"label": "Umore",
|
||||
"low": "molto spiacevole",
|
||||
"high": "molto piacevole"
|
||||
},
|
||||
"a2": {
|
||||
"label": "Stress",
|
||||
"low": "completamente rilassato",
|
||||
"high": "estremamente sotto pressione"
|
||||
},
|
||||
"a3": {
|
||||
"label": "Energia",
|
||||
"low": "esausto",
|
||||
"high": "pieno di energia"
|
||||
},
|
||||
"a4": {
|
||||
"label": "Connessione",
|
||||
"low": "isolato",
|
||||
"high": "capito e sostenuto"
|
||||
},
|
||||
"a5": {
|
||||
"label": "Stabilità",
|
||||
"low": "completamente sopraffatto",
|
||||
"high": "pienamente capace di agire"
|
||||
}
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
@@ -3612,7 +3643,15 @@
|
||||
"statLabel": "Come è stato calcolato",
|
||||
"explainerLabel": "Come è stato calcolato"
|
||||
},
|
||||
"inTargetTitle": "Nel tuo intervallo"
|
||||
"inTargetTitle": "Nel tuo intervallo",
|
||||
"dimensions": {
|
||||
"title": "Cinque scale",
|
||||
"none": "Per queste cinque scale non c'è ancora nulla.",
|
||||
"window": "{days} giorni",
|
||||
"noneInWindow": "nulla in questo periodo",
|
||||
"higherIsMore": "più alto = {anchor}",
|
||||
"notRecorded": "Non registrato: {list}."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
|
||||
+42
-3
@@ -887,11 +887,42 @@
|
||||
"iconGroupMisc": "Inne"
|
||||
},
|
||||
"sectionMoreTags": "Więcej tagów",
|
||||
"sectionFactors": "Czynniki",
|
||||
"sectionNoteDetails": "Notatka i szczegóły",
|
||||
"quickTagsHelp": "Twoje częste tagi — jedno dotknięcie",
|
||||
"quickTagsExpand": "Więcej",
|
||||
"loadError": "Nie udało się załadować wpisów nastroju."
|
||||
"loadError": "Nie udało się załadować wpisów nastroju.",
|
||||
"sectionLevelA": "Pięć skal",
|
||||
"sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.",
|
||||
"dimensionNotAnswered": "nie podano",
|
||||
"dimensionClear": "wyczyść",
|
||||
"dimensionValueText": "{value} z {max}: od {low} do {high}",
|
||||
"dimension": {
|
||||
"a1": {
|
||||
"label": "Nastrój",
|
||||
"low": "bardzo nieprzyjemnie",
|
||||
"high": "bardzo przyjemnie"
|
||||
},
|
||||
"a2": {
|
||||
"label": "Stres",
|
||||
"low": "całkowicie odprężony",
|
||||
"high": "skrajnie obciążony"
|
||||
},
|
||||
"a3": {
|
||||
"label": "Energia",
|
||||
"low": "wyczerpany",
|
||||
"high": "pełen energii"
|
||||
},
|
||||
"a4": {
|
||||
"label": "Więź",
|
||||
"low": "odizolowany",
|
||||
"high": "rozumiany i wspierany"
|
||||
},
|
||||
"a5": {
|
||||
"label": "Stabilność",
|
||||
"low": "całkowicie przytłoczony",
|
||||
"high": "w pełni zdolny do działania"
|
||||
}
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
@@ -3612,7 +3643,15 @@
|
||||
"statLabel": "Jak to obliczono",
|
||||
"explainerLabel": "Jak to obliczono"
|
||||
},
|
||||
"inTargetTitle": "W Twoim zakresie"
|
||||
"inTargetTitle": "W Twoim zakresie",
|
||||
"dimensions": {
|
||||
"title": "Pięć skal",
|
||||
"none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.",
|
||||
"window": "{days} dni",
|
||||
"noneInWindow": "brak w tym okresie",
|
||||
"higherIsMore": "wyżej = {anchor}",
|
||||
"notRecorded": "Nie zapisano: {list}."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- v1.37 — the five level-A self-state values on a mood entry.
|
||||
--
|
||||
-- A mood entry has carried one 1-5 value since the first release. That value
|
||||
-- keeps its scale and its meaning; these five ride beside it, each 0-10 with
|
||||
-- its own anchors: pleasantness, stress, energy, connectedness and stability.
|
||||
--
|
||||
-- All five are nullable, which is what makes this an additive change rather
|
||||
-- than a table rewrite, and nullable is also the honest shape: only the first
|
||||
-- one can be derived from what a row already carries, and the other four stay
|
||||
-- empty until somebody answers them. A default would fill a hundred thousand
|
||||
-- historical rows with a number nobody chose.
|
||||
--
|
||||
-- No CHECK constraint on the range. Bounds are enforced server-side, the same
|
||||
-- way `mood_entry_tag_links.rating` documents: a future change to the scale
|
||||
-- must not strand rows written under the old one.
|
||||
ALTER TABLE "mood_entries"
|
||||
ADD COLUMN "mood_a1" INTEGER,
|
||||
ADD COLUMN "stress_a2" INTEGER,
|
||||
ADD COLUMN "energy_a3" INTEGER,
|
||||
ADD COLUMN "connection_a4" INTEGER,
|
||||
ADD COLUMN "stability_a5" INTEGER;
|
||||
|
||||
-- Map every entry written before these columns existed onto the pleasantness
|
||||
-- scale. The five-point label is the only thing a historical row can answer
|
||||
-- from, so it is the only column filled: stress, energy, connectedness and
|
||||
-- stability stay NULL, because there is nothing to derive them from and an
|
||||
-- invented number would be indistinguishable from one somebody gave.
|
||||
--
|
||||
-- The anchors are 1/3/5/7/9, matching `MOOD_A1_MAP` in
|
||||
-- `src/lib/validations/mood.ts` — the runtime map and these arms are compared
|
||||
-- to each other by a test, so the two cannot drift apart. Not 0 and 10 at the
|
||||
-- ends: the five-point endpoints mean as bad or as good as that instrument
|
||||
-- can express, not as bad or as good as a person can feel, and pinning them
|
||||
-- to the extremes would make every mapped row read as more extreme than every
|
||||
-- hand-set one.
|
||||
--
|
||||
-- `WHERE mood_a1 IS NULL` makes a re-run a no-op instead of an overwrite. A
|
||||
-- migration re-applied against a database that has since taken real answers
|
||||
-- must not replace them with the mapped value.
|
||||
UPDATE mood_entries
|
||||
SET mood_a1 = CASE mood
|
||||
WHEN 'LAUSIG' THEN 1
|
||||
WHEN 'SCHLECHT' THEN 3
|
||||
WHEN 'OKAY' THEN 5
|
||||
WHEN 'GUT' THEN 7
|
||||
WHEN 'SUPER_GUT' THEN 9
|
||||
END
|
||||
WHERE mood_a1 IS NULL
|
||||
AND mood IN ('LAUSIG', 'SCHLECHT', 'OKAY', 'GUT', 'SUPER_GUT');
|
||||
@@ -4016,6 +4016,48 @@ model MoodEntry {
|
||||
date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin
|
||||
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
|
||||
score Int // 1-5
|
||||
/// v1.37 — the five level-A self-state values, each 0-10 with its own
|
||||
/// anchors. `MOOD_DIMENSIONS` (`src/lib/mood/dimensions.ts`) is the one
|
||||
/// table binding a column to its label, its anchors and its orientation.
|
||||
///
|
||||
/// `score` above stays the 1-5 legacy axis and is not re-scaled: eleven
|
||||
/// surfaces derive numbers from it on that assumption, three of them into
|
||||
/// prose an assistant then states as fact. The two axes coexist by design.
|
||||
/// `moodA1` is derived from the five-point label on every write, so a quick
|
||||
/// check-in is a full entry rather than a degraded one; A2 to A5 are only
|
||||
/// ever set by hand, and stay NULL when nobody answered them. Absence reads
|
||||
/// as absence — a fabricated middle value would poison both the trend and
|
||||
/// any later model trained on these columns.
|
||||
///
|
||||
/// Queryable plaintext, deliberately — the same trade `CycleDayLog`
|
||||
/// documents at its `basalBodyTempC`. Encrypting them would remove the
|
||||
/// trend read, the factor crosstab and every aggregate at once, and the
|
||||
/// values are self-ratings on a fixed scale rather than prose. The free-text
|
||||
/// note beside them stays encrypted, because that is where the prose is.
|
||||
///
|
||||
/// `stressA2` is stored literally, exactly as the user set it against the
|
||||
/// anchors they read, so a higher value means more stress and a worse day.
|
||||
/// A surface that needs "up is better" flips the sign through the `inverse`
|
||||
/// flag on the dimension table, never inline. The same rule already governs
|
||||
/// `MoodTag.inverse`, and it exists because a stored value that disagrees
|
||||
/// with the label the user saw cannot be recovered afterwards.
|
||||
///
|
||||
/// A delegate holding a READ grant sees these columns, because a grant is
|
||||
/// whole-record by design and carries no per-domain scope. That is
|
||||
/// deliberate, not an oversight: withholding part of a record from a
|
||||
/// delegate would be a new capability, not a field select.
|
||||
///
|
||||
/// None of these reaches the health score, and that too is a decision. The
|
||||
/// WELLBEING pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers its
|
||||
/// thresholds come from; a daily self-rated slider is a different kind of
|
||||
/// evidence, and averaging the two would weaken the line that makes the
|
||||
/// score worth showing to a doctor. The reasoning is written out at
|
||||
/// `src/lib/analytics/score/wellbeing.ts`.
|
||||
moodA1 Int? @map("mood_a1") // 0-10, pleasantness
|
||||
stressA2 Int? @map("stress_a2") // 0-10, inverse-oriented
|
||||
energyA3 Int? @map("energy_a3") // 0-10
|
||||
connectionA4 Int? @map("connection_a4") // 0-10
|
||||
stabilityA5 Int? @map("stability_a5") // 0-10
|
||||
tags String? // JSON array of tag keys
|
||||
/// v1.4.30 — first-class free-text note. Replaces the
|
||||
/// `tags: ["note:<text>"]` workaround the iOS app shipped through
|
||||
@@ -4178,6 +4220,16 @@ model MoodTag {
|
||||
/// mood *factor* the user scores per entry (e.g. work, social, sleep
|
||||
/// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one
|
||||
/// taxonomy + one ingestion path for both flavours.
|
||||
///
|
||||
/// v1.37 — `RATED` is frozen, not removed. The capture sheet stopped
|
||||
/// offering factor ratings when the level-A columns arrived on `MoodEntry`:
|
||||
/// a 1-5 score for "work stress" and a 0-10 stress self-state are different
|
||||
/// questions, and asking both on one entry invites two answers to one
|
||||
/// thing. Nothing was deleted — no tag row, no link, no rating — so every
|
||||
/// existing score keeps rendering, keeps editing and keeps feeding the
|
||||
/// factor crosstab. Re-enabling the offer on the create sheet is a decision
|
||||
/// about what to ask, not a bug fix; do not restore it because it looks
|
||||
/// like an omission.
|
||||
kind String @default("BINARY") @map("kind") // "BINARY" | "RATED"
|
||||
/// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale
|
||||
/// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags.
|
||||
|
||||
@@ -53,6 +53,12 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
"mood.tag",
|
||||
"mood.tagCategory",
|
||||
// `mood.dimension`: the five level-A dimensions are a table
|
||||
// (`src/lib/mood/dimensions.ts`) whose rows carry their own label and anchor
|
||||
// keys, so every leaf is resolved from a value rather than written as a
|
||||
// literal. The table is the reason the keys cannot drift apart from the
|
||||
// columns they describe.
|
||||
"mood.dimension",
|
||||
// The whole namespace is a runtime lookup table: the client resolves an
|
||||
// error envelope's `meta.errorCode` through it (`localizedApiError`), so no
|
||||
// leaf ever appears as a literal at a call site. Adding a key here is only
|
||||
|
||||
@@ -774,6 +774,19 @@ const handler = apiHandler(
|
||||
date: entry.date,
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
// The five level-A values, carried the same way `syncVersion`
|
||||
// is: absent in the file means the file predates the columns,
|
||||
// and an upsert whose update arm stated them would wipe live
|
||||
// answers off every matched row when an older backup is
|
||||
// restored over a going concern. A file that has them writes
|
||||
// them, including an explicit null; a file that does not says
|
||||
// nothing. Never derived — filling one in here would put a
|
||||
// number the person never gave into a restored row.
|
||||
...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }),
|
||||
...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }),
|
||||
...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }),
|
||||
...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }),
|
||||
...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }),
|
||||
tags: entry.tags ?? null,
|
||||
// The note comes back the way every other dual-column note
|
||||
// does: ciphertext verbatim when the file carries it, and a
|
||||
|
||||
@@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
@@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// v1.37 — pleasantness derived from the row's own label, not
|
||||
// from the file's `score`. This is the one writer whose score
|
||||
// comes out of the file rather than out of the label, so the
|
||||
// two can disagree; the column that records a self-assessment
|
||||
// has to agree with the label the user actually picked.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
@@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
@@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// See the upsert arm above: derived from the label, because
|
||||
// an imported file's `score` is its own field.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
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 { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -57,7 +58,11 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
|
||||
return apiSuccess({
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(shapeMoodNote(entry)),
|
||||
tags: parseTags(entry.tags),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -120,7 +125,32 @@ export const PUT = apiHandler(
|
||||
if (data.mood !== undefined) {
|
||||
updateData.mood = data.mood;
|
||||
updateData.score = getScoreForMood(data.mood);
|
||||
// v1.37 — pleasantness follows the label the same way `score` does, so
|
||||
// an entry corrected from "okay" to "bad" cannot keep yesterday's A1.
|
||||
// Only when the label actually moved, though: the edit dialog sends the
|
||||
// whole entry back on every save, and re-deriving on an unchanged label
|
||||
// would overwrite a value the person set by hand every time they
|
||||
// corrected the timestamp. An explicit `a1` below overrides either way.
|
||||
if (data.mood !== existing.mood) {
|
||||
updateData.moodA1 = deriveA1(data.mood);
|
||||
}
|
||||
}
|
||||
// v1.37 — level-A values are per-field on this path: omitted keeps the
|
||||
// stored value, an explicit number replaces it, an explicit null clears
|
||||
// it. Built one at a time from the parsed body, never spread.
|
||||
//
|
||||
// Pleasantness is the exception, and it is the same exception the create
|
||||
// path makes: an entry always carries a five-point label, so it always
|
||||
// implies a pleasantness value, and a null falls back to the derivation
|
||||
// rather than emptying the column. One rule on both routes — the capture
|
||||
// surfaces therefore offer no clear control for it.
|
||||
if (data.a1 !== undefined) {
|
||||
updateData.moodA1 = data.a1 ?? deriveA1(data.mood ?? existing.mood);
|
||||
}
|
||||
if (data.a2 !== undefined) updateData.stressA2 = data.a2;
|
||||
if (data.a3 !== undefined) updateData.energyA3 = data.a3;
|
||||
if (data.a4 !== undefined) updateData.connectionA4 = data.a4;
|
||||
if (data.a5 !== undefined) updateData.stabilityA5 = data.a5;
|
||||
if (data.moodLoggedAt !== undefined) {
|
||||
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
|
||||
// displayTimezone. Also refresh the `tz` column so the row's
|
||||
@@ -290,7 +320,8 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
...entry,
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(entry),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the update response renders the tag set without a
|
||||
|
||||
@@ -51,6 +51,7 @@ 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 { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
// v1.12.1 — when the entry carries a source-stable `externalId`,
|
||||
@@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
@@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
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 { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -125,7 +126,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(e),
|
||||
// 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
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
@@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) {
|
||||
source,
|
||||
externalId,
|
||||
} = parsed.data;
|
||||
// v1.37 — the five level-A values, built field by field from the parsed
|
||||
// body (never spread) and resolved once for every branch below. A1 falls
|
||||
// back to the derivation from `mood`; the other four are whatever the user
|
||||
// set, or nothing.
|
||||
const levelA = levelAForWrite(mood, {
|
||||
a1: parsed.data.a1,
|
||||
a2: parsed.data.a2,
|
||||
a3: parsed.data.a3,
|
||||
a4: parsed.data.a4,
|
||||
a5: parsed.data.a5,
|
||||
});
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
@@ -265,6 +281,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -272,11 +289,19 @@ async function postMoodEntry(request: NextRequest) {
|
||||
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),
|
||||
@@ -290,6 +315,9 @@ async function postMoodEntry(request: NextRequest) {
|
||||
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),
|
||||
@@ -374,7 +402,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
return apiSuccess(
|
||||
{
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(entry),
|
||||
// v1.37 — level-A values under their wire keys (see the list read).
|
||||
...shapeLevelA(shapeMoodNote(entry)),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the create response renders the tag set without a
|
||||
|
||||
@@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
|
||||
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
|
||||
export { BbtChart } from "@/components/cycle/bbt-chart";
|
||||
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
|
||||
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
|
||||
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
|
||||
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
|
||||
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
MOOD_DIMENSIONS,
|
||||
MOOD_DIMENSION_WINDOWS,
|
||||
type MoodDimensionWindow,
|
||||
} from "@/lib/mood/dimensions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.37 — the five level-A dimensions over time.
|
||||
*
|
||||
* Each dimension is drawn in the orientation the person answered it in and
|
||||
* under the label they read, so a high stress line means a stressful stretch
|
||||
* rather than a good one. Nothing is flipped for the picture's convenience;
|
||||
* the legend says which way each scale runs.
|
||||
*
|
||||
* A dimension nobody has answered is not drawn and is named underneath as not
|
||||
* recorded. Drawing it flat through the middle would put five answers on the
|
||||
* chart for a question that was asked once.
|
||||
*/
|
||||
|
||||
export interface MoodDimensionPointData {
|
||||
date: string;
|
||||
value: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface MoodDimensionSummaryData {
|
||||
key: string;
|
||||
present: boolean;
|
||||
inverse: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg90: number | null;
|
||||
latest: number | null;
|
||||
latestDate: string | null;
|
||||
newestDaysAgo: number | null;
|
||||
series: MoodDimensionPointData[];
|
||||
}
|
||||
|
||||
/** One colour per dimension, from the same semantic set the other charts use. */
|
||||
const COLOR_BY_KEY: Record<string, string> = {
|
||||
a1: "var(--chart-1)",
|
||||
a2: "var(--destructive)",
|
||||
a3: "var(--dracula-orange)",
|
||||
a4: "var(--info)",
|
||||
a5: "var(--success)",
|
||||
};
|
||||
|
||||
function meanForWindow(
|
||||
summary: MoodDimensionSummaryData,
|
||||
window: MoodDimensionWindow,
|
||||
): number | null {
|
||||
if (window === 7) return summary.avg7;
|
||||
if (window === 30) return summary.avg30;
|
||||
return summary.avg90;
|
||||
}
|
||||
|
||||
export function MoodDimensionTrends({
|
||||
dimensions,
|
||||
}: {
|
||||
dimensions: MoodDimensionSummaryData[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [window, setWindow] = useState<MoodDimensionWindow>(30);
|
||||
|
||||
const present = dimensions.filter((d) => d.present);
|
||||
const missing = dimensions.filter((d) => !d.present);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const summary of present) {
|
||||
for (const point of summary.series) {
|
||||
const row = byDate.get(point.date) ?? { date: point.date };
|
||||
row[summary.key] = point.value;
|
||||
byDate.set(point.date, row);
|
||||
}
|
||||
}
|
||||
const rows = [...byDate.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date)),
|
||||
);
|
||||
// The series carry the longest window; the shorter ones are a slice of the
|
||||
// same points rather than a second read.
|
||||
return rows.slice(Math.max(0, rows.length - window));
|
||||
}, [present, window]);
|
||||
|
||||
const labelFor = (key: string) => {
|
||||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === key);
|
||||
return dimension ? t(dimension.labelKey) : key;
|
||||
};
|
||||
|
||||
if (present.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.mood.dimensions.none")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="mood-dimension-trends">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{MOOD_DIMENSION_WINDOWS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setWindow(option)}
|
||||
aria-pressed={window === option}
|
||||
className={cn(
|
||||
"min-h-9 rounded-full border px-3 text-xs transition-colors",
|
||||
window === option
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("insights.mood.dimensions.window", { days: option })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-[clamp(160px,34vh,220px)] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={(value: string) => value.slice(5)}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 10]}
|
||||
ticks={[0, 5, 10]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
labelStyle={{ color: "var(--muted-foreground)" }}
|
||||
formatter={(value, name) => [
|
||||
String(value ?? ""),
|
||||
labelFor(String(name)),
|
||||
]}
|
||||
/>
|
||||
{present.map((summary) => (
|
||||
<Line
|
||||
key={summary.key}
|
||||
type="monotone"
|
||||
dataKey={summary.key}
|
||||
name={summary.key}
|
||||
stroke={COLOR_BY_KEY[summary.key] ?? "var(--chart-1)"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
// A gap is a day nobody answered; joining across it would draw
|
||||
// a value that was never given.
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{present.map((summary) => {
|
||||
const mean = meanForWindow(summary, window);
|
||||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key);
|
||||
return (
|
||||
<li
|
||||
key={summary.key}
|
||||
className="flex items-baseline justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: COLOR_BY_KEY[summary.key] ?? "var(--chart-1)",
|
||||
}}
|
||||
/>
|
||||
<span>{labelFor(summary.key)}</span>
|
||||
{summary.inverse && dimension ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.dimensions.higherIsMore", {
|
||||
anchor: t(dimension.highAnchorKey),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 tabular-nums">
|
||||
{mean === null
|
||||
? t("insights.mood.dimensions.noneInWindow")
|
||||
: mean}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{missing.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.dimensions.notRecorded", {
|
||||
list: missing.map((d) => labelFor(d.key)).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,12 +34,25 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
|
||||
// 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) => ({
|
||||
@@ -127,6 +140,9 @@ interface MoodInsightsResponse {
|
||||
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;
|
||||
@@ -258,6 +274,8 @@ export function MoodInsightsSections({
|
||||
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.
|
||||
@@ -334,6 +352,15 @@ export function MoodInsightsSections({
|
||||
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")}
|
||||
|
||||
@@ -85,7 +85,10 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
|
||||
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
|
||||
expect(formSrc).toContain('tagsInput.trim() !== ""');
|
||||
expect(formSrc).toContain("tagKeys.length > 0");
|
||||
expect(formSrc).toContain("ratedFactors.length > 0");
|
||||
// v1.37 — a level-A value the user set is typed input too. The rated
|
||||
// factors this line used to name are no longer captured here; see the
|
||||
// level-A describe below.
|
||||
expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)");
|
||||
expect(formSrc).toContain('note.trim() !== ""');
|
||||
// The `isDirty` expression itself must not read `moodLoggedAt`.
|
||||
const isDirtyExpr = formSrc.slice(
|
||||
@@ -115,31 +118,51 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodForm — rated factors (v1.12.0)", () => {
|
||||
it("lifts a ratedFactors set and threads it to the tag picker", () => {
|
||||
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
|
||||
expect(formSrc).toMatch(
|
||||
/function rateFactor\(key: string, rating: number \| null\)/,
|
||||
);
|
||||
// The picker receives both the binary toggle and the factor rate
|
||||
// callbacks so it can render BINARY chips and RATED controls.
|
||||
expect(formSrc).toMatch(
|
||||
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
|
||||
describe("MoodForm — level A replaces the factor ratings (v1.37)", () => {
|
||||
it("no longer offers a factor rating on a new entry", () => {
|
||||
// A 1-5 score for "work stress" and a 0-10 stress self-state are two
|
||||
// answers to one question. The create sheet asks the second one only.
|
||||
// Nothing was deleted: the catalogue rows, the stored ratings and the
|
||||
// edit dialog below all still work, which the next describe pins.
|
||||
expect(formSrc).not.toContain("onRateFactor=");
|
||||
expect(formSrc).not.toContain('mode="rated"');
|
||||
expect(formSrc).not.toContain("ratedFactors");
|
||||
// The binary structured-tag contract stays byte-identical.
|
||||
expect(formSrc).toContain('mode="binary"');
|
||||
expect(formSrc).toContain(
|
||||
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
|
||||
expect(formSrc).toContain(
|
||||
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
|
||||
it("offers the five level-A sliders and sends only the answered ones", () => {
|
||||
expect(formSrc).toContain("<MoodLevelASection");
|
||||
expect(formSrc).toContain("...levelACreatePayload(levelA)");
|
||||
// Picking a face seeds pleasantness; moving that slider ends the seeding.
|
||||
expect(formSrc).toMatch(
|
||||
/function pickMood\(value: string\) \{[\s\S]*seedA1FromMood\(prev, value, a1Touched\)/,
|
||||
);
|
||||
// The binary structured-tag contract stays byte-identical.
|
||||
expect(formSrc).toContain(
|
||||
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
|
||||
expect(formSrc).toContain("onA1Touched={() => setA1Touched(true)}");
|
||||
// Nothing here defaults a value: the state starts empty and stays empty.
|
||||
expect(formSrc).toContain("useState<LevelAState>(EMPTY_LEVEL_A)");
|
||||
});
|
||||
|
||||
it("offers no clear control for pleasantness, matching the write path", () => {
|
||||
// An entry always carries a label, so it always implies a pleasantness
|
||||
// value and both write routes derive one from a null. A clear button here
|
||||
// would be a control the server undoes on the way to the database.
|
||||
const fieldsSrc = readFileSync(
|
||||
resolve(__dirname, "../mood-level-a-fields.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(fieldsSrc).toMatch(
|
||||
/onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — rated factor editing", () => {
|
||||
// Unchanged by v1.37 on purpose: the create sheet stopped asking, and an
|
||||
// entry that already carries a rating must still be correctable.
|
||||
it("hydrates, renders, and submits the entry's current ratedFactors", () => {
|
||||
expect(listSrc).toContain("ratedFactors: RatedFactor[];");
|
||||
expect(listSrc).toContain(
|
||||
@@ -156,6 +179,18 @@ describe("MoodList — rated factor editing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — level A on the edit path (v1.37)", () => {
|
||||
it("seeds the five values from the entry and sends them back, nulls included", () => {
|
||||
// What the capture sheet writes, this dialog has to show. A value visible
|
||||
// in a trend and nowhere else is a value the person cannot correct.
|
||||
expect(listSrc).toContain("levelA: levelAFromEntry(entry)");
|
||||
expect(listSrc).toContain("setEditLevelA(seed.levelA)");
|
||||
expect(listSrc).toContain("<MoodLevelASection");
|
||||
// Nulls travel: this path replaces, so a cleared value has to be stated.
|
||||
expect(listSrc).toContain("...levelAUpdatePayload(levelA)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — delete failure toast (v1.11.5)", () => {
|
||||
it("surfaces a toast on a rejected delete instead of failing silently", () => {
|
||||
expect(listSrc).toContain('import { toast } from "sonner"');
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,7 +44,14 @@ import {
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelACreatePayload,
|
||||
seedA1FromMood,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
@@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
|
||||
count: MOOD_LEVELS.length,
|
||||
selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood),
|
||||
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
|
||||
onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value),
|
||||
});
|
||||
// v1.37 — the five level-A values. Pleasantness follows the picked face
|
||||
// until the user moves it themselves; the other four start and stay empty
|
||||
// unless somebody answers them.
|
||||
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [a1Touched, setA1Touched] = useState(false);
|
||||
const [levelAOpen, setLevelAOpen] = useState(false);
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
|
||||
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
|
||||
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// v1.8.5 (C1) — first-class free-text note. The model + API already
|
||||
// accepted `note`; the web form was the only surface that couldn't
|
||||
// write it.
|
||||
@@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood !== "" ||
|
||||
tagsInput.trim() !== "" ||
|
||||
tagKeys.length > 0 ||
|
||||
ratedFactors.length > 0 ||
|
||||
// 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) ||
|
||||
note.trim() !== "";
|
||||
|
||||
// v1.37 — picking a face suggests a pleasantness value; the suggestion
|
||||
// stands until the user moves that slider, after which the two are
|
||||
// independent. Moving the slider never changes which face is lit.
|
||||
function pickMood(value: string) {
|
||||
setMood(value);
|
||||
setLevelA((prev) => seedA1FromMood(prev, value, a1Touched));
|
||||
}
|
||||
|
||||
function toggleTagKey(key: string) {
|
||||
setTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.12.0 — set or clear a rated factor's score. `rating === null`
|
||||
// removes the factor from the set (re-tapping the active step clears).
|
||||
function rateFactor(key: string, rating: number | null) {
|
||||
setRatedFactors((prev) => {
|
||||
const rest = prev.filter((f) => f.key !== key);
|
||||
return rating === null ? rest : [...rest, { key, rating }];
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
@@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setLevelA(EMPTY_LEVEL_A);
|
||||
setA1Touched(false);
|
||||
setLevelAOpen(false);
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setRatedFactors([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setError(null);
|
||||
@@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
|
||||
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : undefined,
|
||||
// v1.37 — only the values somebody answered. An untouched slider
|
||||
// sends nothing, and the server writes NULL rather than a middle.
|
||||
...levelACreatePayload(levelA),
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
@@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
aria-label={t(level.labelKey)}
|
||||
data-slot="mood-face"
|
||||
data-mood={level.value}
|
||||
onClick={() => setMood(level.value)}
|
||||
onClick={() => pickMood(level.value)}
|
||||
{...getMoodRadioProps(index)}
|
||||
// v1.12 — selected-state unified with the tag tiles and
|
||||
// factor steps: `border-primary bg-primary/15 text-primary`
|
||||
@@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
|
||||
exactly as the existing segmented control. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionFactors")}
|
||||
icon={<SlidersHorizontal />}
|
||||
summary={<SheetSectionCount count={ratedFactors.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
mode="rated"
|
||||
/>
|
||||
</SheetSection>
|
||||
{/* v1.37 — the five level-A values, closed by default. The rated
|
||||
factors that used to sit here are no longer offered on a new
|
||||
entry: a 1-5 score for "work stress" and a 0-10 stress
|
||||
self-state are different questions, and asking both invites
|
||||
two answers to one thing. Existing ratings keep working
|
||||
everywhere they already do, including the edit dialog, and no
|
||||
catalogue row was removed. */}
|
||||
<MoodLevelASection
|
||||
value={levelA}
|
||||
onChange={setLevelA}
|
||||
onA1Touched={() => setA1Touched(true)}
|
||||
open={levelAOpen}
|
||||
onOpenChange={setLevelAOpen}
|
||||
/>
|
||||
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
and the note field. The badge counts whichever of these carry
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The five level-A sliders, and the state rules around them.
|
||||
*
|
||||
* One component for both mood surfaces — the create sheet and the list's edit
|
||||
* dialog — because what one writes the other has to be able to show. A value a
|
||||
* user set on Monday and cannot see on Tuesday is worse than one they were
|
||||
* never offered.
|
||||
*
|
||||
* The section is closed by default and nothing in it is required. The five
|
||||
* faces above it remain a complete entry on their own: one tap still saves.
|
||||
*/
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { SliderField } from "@/components/ui/slider";
|
||||
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
import { getA1ForMood } from "@/lib/validations/mood";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** The five values as the form holds them. `null` means unanswered. */
|
||||
export interface LevelAState {
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
export const EMPTY_LEVEL_A: LevelAState = {
|
||||
a1: null,
|
||||
a2: null,
|
||||
a3: null,
|
||||
a4: null,
|
||||
a5: null,
|
||||
};
|
||||
|
||||
/** How many of the five carry an answer — the collapsed section's badge. */
|
||||
export function levelAAnsweredCount(state: LevelAState): number {
|
||||
return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five values as the API takes them, with the unanswered ones left out.
|
||||
*
|
||||
* Omitted rather than sent as null on the create path: absent means "nothing
|
||||
* to say", and the server writes NULL for it. Nothing here ever sends a
|
||||
* default, which is the whole reason the columns are nullable.
|
||||
*/
|
||||
export function levelACreatePayload(
|
||||
state: LevelAState,
|
||||
): Partial<Record<keyof LevelAState, number>> {
|
||||
const out: Partial<Record<keyof LevelAState, number>> = {};
|
||||
for (const dimension of MOOD_DIMENSIONS) {
|
||||
const value = state[dimension.key];
|
||||
if (value !== null) out[dimension.key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five values as the edit path takes them — nulls included.
|
||||
*
|
||||
* The edit path replaces rather than adds, so a value the user cleared has to
|
||||
* travel as an explicit null. Sending nothing would leave the old answer in
|
||||
* place and the dialog would lie about what it just saved.
|
||||
*/
|
||||
export function levelAUpdatePayload(state: LevelAState): LevelAState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the state from an entry being edited.
|
||||
*
|
||||
* The reader is the API's own wire shape, so a row that has never carried
|
||||
* these values arrives as `undefined` and lands as `null` — unanswered, which
|
||||
* is what it is.
|
||||
*/
|
||||
export function levelAFromEntry(entry: {
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
}): LevelAState {
|
||||
return {
|
||||
a1: entry.a1 ?? null,
|
||||
a2: entry.a2 ?? null,
|
||||
a3: entry.a3 ?? null,
|
||||
a4: entry.a4 ?? null,
|
||||
a5: entry.a5 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed pleasantness from a freshly picked face — but only while the user
|
||||
* has not moved that slider themselves.
|
||||
*
|
||||
* This is the one interaction in the sheet that will confuse a later reader,
|
||||
* so it is written down here rather than inferred from two call sites: picking
|
||||
* a face suggests a pleasantness value, and moving the slider takes the
|
||||
* suggestion over. After that the faces and the slider are independent, and in
|
||||
* particular moving the slider never changes which face is lit — the two are
|
||||
* different questions at different resolutions, not two views of one answer.
|
||||
*/
|
||||
export function seedA1FromMood(
|
||||
state: LevelAState,
|
||||
mood: string,
|
||||
a1Touched: boolean,
|
||||
): LevelAState {
|
||||
if (a1Touched) return state;
|
||||
return { ...state, a1: mood ? getA1ForMood(mood) : null };
|
||||
}
|
||||
|
||||
interface MoodLevelASectionProps {
|
||||
value: LevelAState;
|
||||
onChange: (next: LevelAState) => void;
|
||||
/** Called when the user moves pleasantness themselves, which ends the re-seeding. */
|
||||
onA1Touched?: () => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MoodLevelASection({
|
||||
value,
|
||||
onChange,
|
||||
onA1Touched,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MoodLevelASectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<SheetSection
|
||||
title={t("mood.sectionLevelA")}
|
||||
icon={<Gauge />}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
summary={<SheetSectionCount count={levelAAnsweredCount(value)} />}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-level-a">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mood.sectionLevelAHelp")}
|
||||
</p>
|
||||
{MOOD_DIMENSIONS.map((dimension) => {
|
||||
const current = value[dimension.key];
|
||||
const low = t(dimension.lowAnchorKey);
|
||||
const high = t(dimension.highAnchorKey);
|
||||
return (
|
||||
<SliderField
|
||||
key={dimension.key}
|
||||
label={t(dimension.labelKey)}
|
||||
value={current}
|
||||
min={dimension.min}
|
||||
max={dimension.max}
|
||||
step={1}
|
||||
lowAnchor={low}
|
||||
highAnchor={high}
|
||||
emptyLabel={t("mood.dimensionNotAnswered")}
|
||||
valueText={
|
||||
current === null
|
||||
? undefined
|
||||
: t("mood.dimensionValueText", {
|
||||
value: current,
|
||||
max: dimension.max,
|
||||
low,
|
||||
high,
|
||||
})
|
||||
}
|
||||
clearLabel={t("mood.dimensionClear")}
|
||||
// Pleasantness has no clear control, and that is the same rule
|
||||
// the server keeps: an entry always carries a five-point label,
|
||||
// so it always implies a pleasantness value, and there is no
|
||||
// state in which the field is honestly unanswered. Offering a
|
||||
// control that the write path would immediately undo would be a
|
||||
// button that quietly does nothing. The other four are genuinely
|
||||
// optional and keep theirs.
|
||||
onClear={
|
||||
dimension.key === "a1"
|
||||
? undefined
|
||||
: () => onChange({ ...value, [dimension.key]: null })
|
||||
}
|
||||
onValueChange={(next) => {
|
||||
if (dimension.key === "a1") onA1Touched?.();
|
||||
onChange({ ...value, [dimension.key]: next });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,13 @@ import {
|
||||
} from "@/components/data-list";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelAFromEntry,
|
||||
levelAUpdatePayload,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
|
||||
@@ -104,6 +111,12 @@ interface MoodEntry {
|
||||
// v1.8.5 — structured-tag keys + free-text note.
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
// v1.37 — the five level-A values, absent on entries written before them.
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
@@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const [editTagsInput, setEditTagsInput] = useState("");
|
||||
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
|
||||
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// 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);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editSeed, setEditSeed] = useState<{
|
||||
@@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagsInput: string;
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
note: string;
|
||||
moodLoggedAt: string;
|
||||
}>({
|
||||
@@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagsInput: "",
|
||||
tagKeys: [],
|
||||
ratedFactors: [],
|
||||
levelA: EMPTY_LEVEL_A,
|
||||
note: "",
|
||||
moodLoggedAt: "",
|
||||
});
|
||||
@@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags,
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
levelA,
|
||||
note,
|
||||
moodLoggedAt,
|
||||
}: {
|
||||
@@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags: string[] | null;
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
@@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags,
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
// Nulls included: this path replaces rather than adds, so a value the
|
||||
// user cleared has to travel as an explicit null or the old answer
|
||||
// survives an edit that visibly removed it.
|
||||
...levelAUpdatePayload(levelA),
|
||||
note,
|
||||
moodLoggedAt,
|
||||
});
|
||||
@@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...editRatedFactors].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
});
|
||||
@@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...editRatedFactors].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
},
|
||||
@@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: levelAFromEntry(entry),
|
||||
note: entry.note ?? "",
|
||||
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
|
||||
};
|
||||
@@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setEditTagsInput(seed.tagsInput);
|
||||
setEditTagKeys(seed.tagKeys);
|
||||
setEditRatedFactors(seed.ratedFactors);
|
||||
setEditLevelA(seed.levelA);
|
||||
setEditNote(seed.note);
|
||||
setEditMoodLoggedAt(seed.moodLoggedAt);
|
||||
setEditSeed(seed);
|
||||
@@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
tagKeys: editTagKeys,
|
||||
ratedFactors: editRatedFactors,
|
||||
levelA: editLevelA,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
moodLoggedAt: measuredDate.toISOString(),
|
||||
});
|
||||
@@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.37 — the five level-A values, seeded from the entry.
|
||||
Pleasantness is not re-seeded from the face here: this dialog
|
||||
edits an entry that already has an answer, and replacing it
|
||||
with the derived one would quietly discard what the person
|
||||
set. */}
|
||||
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `<Slider>` / `<SliderField>` — the shape and the unanswered state.
|
||||
*
|
||||
* The suite renders server-side (`@testing-library/react` is not a dependency
|
||||
* here), so nothing below drags a thumb or presses an arrow key; the keyboard
|
||||
* behaviour is Radix's own contract and the manual pass covers it. What is
|
||||
* proven here is what the component decides: that an unanswered field renders
|
||||
* no number and announces itself as unanswered rather than as the value its
|
||||
* parked thumb sits on, that a zero is rendered as an answer, and that the
|
||||
* anchors reach the markup as muted legend text.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { Slider, SliderField } from "../slider";
|
||||
|
||||
function field(props: Partial<React.ComponentProps<typeof SliderField>> = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<SliderField
|
||||
label="Stress"
|
||||
value={7}
|
||||
onValueChange={() => {}}
|
||||
emptyLabel="nicht angegeben"
|
||||
valueText="7 von 10"
|
||||
lowAnchor="vollkommen entspannt"
|
||||
highAnchor="extrem belastet"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<Slider>", () => {
|
||||
it("renders the Radix parts under their slots", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Slider value={[4]} min={0} max={10} step={1} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="slider"');
|
||||
expect(html).toContain('data-slot="slider-track"');
|
||||
expect(html).toContain('data-slot="slider-range"');
|
||||
expect(html).toContain('data-slot="slider-thumb"');
|
||||
});
|
||||
|
||||
it("announces the caller's text instead of the bare number", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Slider
|
||||
value={[6]}
|
||||
min={0}
|
||||
max={10}
|
||||
valueText="6 von 10: sehr unangenehm bis sehr angenehm"
|
||||
thumbLabel="Stimmung"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'aria-valuetext="6 von 10: sehr unangenehm bis sehr angenehm"',
|
||||
);
|
||||
expect(html).toContain('aria-label="Stimmung"');
|
||||
// The thumb is a slider to assistive tech, with the bounds published.
|
||||
// `aria-valuenow` is deliberately not asserted: Radix sets it after mount,
|
||||
// so it is absent from server markup and a test demanding it here would be
|
||||
// measuring the renderer rather than the component.
|
||||
expect(html).toContain('role="slider"');
|
||||
expect(html).toContain('aria-valuemin="0"');
|
||||
expect(html).toContain('aria-valuemax="10"');
|
||||
expect(html).toContain('aria-orientation="horizontal"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SliderField>", () => {
|
||||
it("renders the label, the value and both anchors", () => {
|
||||
const html = field();
|
||||
expect(html).toContain("Stress");
|
||||
expect(html).toContain("vollkommen entspannt");
|
||||
expect(html).toContain("extrem belastet");
|
||||
expect(html).toContain('data-slot="slider-field-value"');
|
||||
expect(html).toContain(">7<");
|
||||
});
|
||||
|
||||
it("puts the answer in content colour and the anchors in muted", () => {
|
||||
const html = field();
|
||||
expect(html).toContain("tabular-nums");
|
||||
expect(html).toContain("text-foreground");
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).toContain("text-xs");
|
||||
});
|
||||
|
||||
it("renders an unanswered field as unanswered, not as its parked value", () => {
|
||||
const html = field({ value: null, valueText: "should not be announced" });
|
||||
expect(html).toContain('data-unset="true"');
|
||||
expect(html).toContain("nicht angegeben");
|
||||
// The thumb sits at the low end so the track reads empty, but nothing
|
||||
// claims that low end as the person's answer.
|
||||
expect(html).toContain('aria-valuetext="nicht angegeben"');
|
||||
expect(html).not.toContain("should not be announced");
|
||||
});
|
||||
|
||||
it("renders a zero as an answer", () => {
|
||||
// Zero is a real point on this scale. A field that treated it as absence
|
||||
// would show the empty label and lose an answer somebody gave.
|
||||
const html = field({ value: 0, valueText: "0 von 10" });
|
||||
expect(html).not.toContain('data-unset="true"');
|
||||
expect(html).not.toContain("nicht angegeben");
|
||||
expect(html).toContain('aria-valuetext="0 von 10"');
|
||||
});
|
||||
|
||||
it("offers to clear an answer only when there is one to clear", () => {
|
||||
const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" });
|
||||
expect(set).toContain('data-slot="slider-field-clear"');
|
||||
expect(set).toContain("zurücksetzen");
|
||||
|
||||
const unanswered = field({
|
||||
value: null,
|
||||
onClear: () => {},
|
||||
clearLabel: "zurücksetzen",
|
||||
});
|
||||
expect(unanswered).not.toContain('data-slot="slider-field-clear"');
|
||||
|
||||
const noHandler = field();
|
||||
expect(noHandler).not.toContain('data-slot="slider-field-clear"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Numeric range entry.
|
||||
*
|
||||
* The alternative this replaces is eleven discrete buttons, which is what the
|
||||
* rated-factor picker does for a 1-5 scale. That pattern does not survive the
|
||||
* jump to 0-10: eleven 32 px targets with 4 px gaps come to roughly 392 px
|
||||
* before the label, and the narrow phone this app is mostly used on is 390 px
|
||||
* wide. A slider answers the same question in a fixed width at any scale, and
|
||||
* arrow keys move it a step at a time, which eleven buttons never did.
|
||||
*
|
||||
* `Slider` is the bare control. `SliderField` is the labelled row around it —
|
||||
* name on the left, value on the right, the two scale anchors underneath — and
|
||||
* it is what a capture surface should reach for, because the anchors are what
|
||||
* make a 7 mean anything.
|
||||
*/
|
||||
function Slider({
|
||||
className,
|
||||
valueText,
|
||||
thumbLabel,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root> & {
|
||||
/**
|
||||
* What a screen reader announces instead of the bare number. A slider that
|
||||
* says "6" tells somebody who cannot see the anchors nothing at all; the
|
||||
* caller passes the number together with what the ends of the scale mean.
|
||||
*/
|
||||
valueText?: string;
|
||||
/** Accessible name for the thumb when the field label is not adjacent. */
|
||||
thumbLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
|
||||
// The 44 px hit target WCAG 2.5.5 asks for, without a 44 px track:
|
||||
// the padding is vertical only, so the visible rail stays 6 px.
|
||||
"py-[19px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="bg-primary absolute h-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
aria-label={thumbLabel}
|
||||
aria-valuetext={valueText}
|
||||
className="border-primary bg-background focus-visible:ring-ring/50 block size-4 rounded-full border-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled slider with its scale anchors.
|
||||
*
|
||||
* `value` is nullable and the null is load-bearing: a question nobody answered
|
||||
* is not a question answered in the middle. An unset field renders no number
|
||||
* and fills no track, and only an interaction commits a value. `onClear`, when
|
||||
* given, renders the control that takes an answer back — without it a user who
|
||||
* nudged a slider by accident can never return the field to empty.
|
||||
*/
|
||||
function SliderField({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
onClear,
|
||||
clearLabel,
|
||||
emptyLabel,
|
||||
valueText,
|
||||
lowAnchor,
|
||||
highAnchor,
|
||||
min = 0,
|
||||
max = 10,
|
||||
step = 1,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
onValueChange: (value: number) => void;
|
||||
onClear?: () => void;
|
||||
clearLabel?: string;
|
||||
/** Rendered in place of the number while the field is unanswered. */
|
||||
emptyLabel: string;
|
||||
/** Announced by a screen reader; carries the anchors, not only the number. */
|
||||
valueText?: string;
|
||||
lowAnchor: string;
|
||||
highAnchor: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
className?: string;
|
||||
} & Omit<
|
||||
React.ComponentProps<typeof SliderPrimitive.Root>,
|
||||
"value" | "onValueChange" | "min" | "max" | "step" | "className"
|
||||
>) {
|
||||
const unset = value === null;
|
||||
return (
|
||||
<div
|
||||
data-slot="slider-field"
|
||||
data-unset={unset ? "true" : undefined}
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
{/* Content colour for the label and the value, muted for the anchors
|
||||
below: the anchors are the legend, the value is the answer. */}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
data-slot="slider-field-value"
|
||||
className={cn(
|
||||
"text-sm tabular-nums",
|
||||
unset ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{unset ? emptyLabel : value}
|
||||
</span>
|
||||
{!unset && onClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
data-slot="slider-field-clear"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
// An unanswered field parks the thumb at the low end with an empty
|
||||
// track, so it reads as "nothing here" rather than as the value the
|
||||
// low end happens to carry. The announced text says the same.
|
||||
value={[unset ? min : value]}
|
||||
onValueChange={(next) => onValueChange(next[0])}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
thumbLabel={label}
|
||||
valueText={unset ? emptyLabel : valueText}
|
||||
{...props}
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between gap-2 text-xs">
|
||||
<span>{lowAnchor}</span>
|
||||
<span className="text-right">{highAnchor}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider, SliderField };
|
||||
@@ -400,3 +400,51 @@ describe("formatMoodEntriesForExport with userTz", () => {
|
||||
expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMoodEntriesForExport level-A columns", () => {
|
||||
const base = {
|
||||
date: "2026-05-15",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: null,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: new Date("2026-05-15T18:30:00Z"),
|
||||
};
|
||||
|
||||
it("appends the five values without moving an existing column", () => {
|
||||
const csv = toCSV(
|
||||
formatMoodEntriesForExport([
|
||||
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
|
||||
]),
|
||||
);
|
||||
const [header, row] = csv.split("\n");
|
||||
// The pre-existing columns keep their order and their positions. Somebody's
|
||||
// spreadsheet has a formula pointed at column D.
|
||||
expect(header).toBe(
|
||||
"date,mood,score,tags,note,source,loggedAt,a1,a2,a3,a4,a5",
|
||||
);
|
||||
expect(row.split(",").slice(0, 3)).toEqual(["2026-05-15", "OKAY", "3"]);
|
||||
});
|
||||
|
||||
it("writes an empty cell for an unanswered value and a zero for a zero", () => {
|
||||
const csv = toCSV(
|
||||
formatMoodEntriesForExport([
|
||||
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
|
||||
]),
|
||||
);
|
||||
const cells = csv.split("\n")[1].split(",");
|
||||
// a1, a2, a3, a4, a5 — the last two were never answered.
|
||||
expect(cells.slice(-5)).toEqual(["4", "9", "0", "", ""]);
|
||||
});
|
||||
|
||||
it("leaves every level-A cell empty for an entry written before the columns", () => {
|
||||
const csv = toCSV(formatMoodEntriesForExport([base]));
|
||||
expect(csv.split("\n")[1].split(",").slice(-5)).toEqual([
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,25 @@
|
||||
/**
|
||||
* The WELLBEING pillar reads validated instruments, and only those.
|
||||
*
|
||||
* PHQ-9, GAD-7 and WHO-5 over a 90-day window. Every threshold below cites
|
||||
* the paper it comes from, which is the whole reason this pillar can be shown
|
||||
* to a doctor.
|
||||
*
|
||||
* Daily mood does not feed it, and that is a decision rather than an
|
||||
* oversight. Since v1.37 a mood entry carries a 0-10 self-rated pleasantness
|
||||
* on most rows, including a mapped value on historical ones, so the number is
|
||||
* available and folding it in would be a small change. It stays out: a
|
||||
* self-rated slider moved once a day and a scored clinical questionnaire are
|
||||
* different kinds of evidence, and averaging them into one figure that cites
|
||||
* Kroenke, Spitzer and the WHO would weaken exactly the line that makes the
|
||||
* figure worth anything. There is also no good answer to "why did my score
|
||||
* drop because I logged a bad afternoon".
|
||||
*
|
||||
* Daily mood keeps its own surfaces — the trend page, the assistant, the
|
||||
* briefing — where its resolution is the point. Bringing it in here would be
|
||||
* a score decision, taken on the score line, with its own coverage rule and
|
||||
* its own line in the explainer; it is not something to add on the way past.
|
||||
*/
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
|
||||
import type { PillarValue, WellbeingPillarInput } from "./types";
|
||||
|
||||
@@ -336,6 +336,12 @@ export function formatMoodEntriesForExport(
|
||||
note?: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: Date;
|
||||
/** The five level-A values, absent on entries written before they existed. */
|
||||
moodA1?: number | null;
|
||||
stressA2?: number | null;
|
||||
energyA3?: number | null;
|
||||
connectionA4?: number | null;
|
||||
stabilityA5?: number | null;
|
||||
}>,
|
||||
userTz?: string,
|
||||
): ExportableRecord[] {
|
||||
@@ -347,5 +353,14 @@ export function formatMoodEntriesForExport(
|
||||
note: e.note ?? "",
|
||||
source: e.source,
|
||||
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
|
||||
// Appended after the existing columns rather than slotted in beside
|
||||
// `score`, because this file lands in somebody's spreadsheet and a column
|
||||
// that moves breaks every formula pointed at it. An unanswered value is an
|
||||
// empty cell, not a zero: zero is a real answer on this scale.
|
||||
a1: e.moodA1 ?? "",
|
||||
a2: e.stressA2 ?? "",
|
||||
a3: e.energyA3 ?? "",
|
||||
a4: e.connectionA4 ?? "",
|
||||
a5: e.stabilityA5 ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -117,6 +117,11 @@ const MOOD_ENTRY_BACKUP_SELECT = {
|
||||
date: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
tags: true,
|
||||
note: true,
|
||||
noteEncrypted: true,
|
||||
@@ -480,6 +485,15 @@ export async function buildFullBackupPayload(
|
||||
date: moodEntry.date,
|
||||
mood: moodEntry.mood,
|
||||
score: moodEntry.score,
|
||||
// The five level-A values, under the keys the write path takes. Both
|
||||
// arms carry them: they are the entry's own answers, not a storage
|
||||
// detail, and a portable export that dropped them would hand back a
|
||||
// file describing the day less well than the row it came from.
|
||||
a1: moodEntry.moodA1,
|
||||
a2: moodEntry.stressA2,
|
||||
a3: moodEntry.energyA3,
|
||||
a4: moodEntry.connectionA4,
|
||||
a5: moodEntry.stabilityA5,
|
||||
tags: moodEntry.tags,
|
||||
source: moodEntry.source,
|
||||
loggedAt: moodEntry.moodLoggedAt.toISOString(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeMoodDimensionSeries,
|
||||
type MoodDimensionRow,
|
||||
} from "@/lib/insights/mood-dimension-series";
|
||||
|
||||
const TODAY = "2026-08-08";
|
||||
|
||||
function dayKey(daysAgo: number): string {
|
||||
const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`));
|
||||
d.setUTCDate(d.getUTCDate() - daysAgo);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function row(daysAgo: number, values: Partial<MoodDimensionRow>) {
|
||||
return {
|
||||
date: dayKey(daysAgo),
|
||||
a1: null,
|
||||
a2: null,
|
||||
a3: null,
|
||||
a4: null,
|
||||
a5: null,
|
||||
...values,
|
||||
} as MoodDimensionRow;
|
||||
}
|
||||
|
||||
describe("computeMoodDimensionSeries", () => {
|
||||
it("reports all five dimensions even when only one was ever answered", () => {
|
||||
const out = computeMoodDimensionSeries([row(0, { a1: 7 })], TODAY);
|
||||
expect(out.map((d) => d.key)).toEqual(["a1", "a2", "a3", "a4", "a5"]);
|
||||
expect(out[0].present).toBe(true);
|
||||
// The other four are absent, not zero. This is the whole rule.
|
||||
for (const dimension of out.slice(1)) {
|
||||
expect(dimension.present).toBe(false);
|
||||
expect(dimension.count).toBe(0);
|
||||
expect(dimension.avg7).toBeNull();
|
||||
expect(dimension.avg30).toBeNull();
|
||||
expect(dimension.avg90).toBeNull();
|
||||
expect(dimension.latest).toBeNull();
|
||||
expect(dimension.newestDaysAgo).toBeNull();
|
||||
expect(dimension.series).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a dimension answered with zeros as present", () => {
|
||||
// Zero is a real answer on this scale. A presence check on truthiness
|
||||
// would report a genuinely calm week as no data at all.
|
||||
const out = computeMoodDimensionSeries(
|
||||
[row(0, { a2: 0 }), row(1, { a2: 0 })],
|
||||
TODAY,
|
||||
);
|
||||
const a2 = out.find((d) => d.key === "a2")!;
|
||||
expect(a2.present).toBe(true);
|
||||
expect(a2.count).toBe(2);
|
||||
expect(a2.avg7).toBe(0);
|
||||
expect(a2.latest).toBe(0);
|
||||
});
|
||||
|
||||
it("averages several entries on the same day into one point", () => {
|
||||
const out = computeMoodDimensionSeries(
|
||||
[row(1, { a1: 4 }), row(1, { a1: 8 }), row(0, { a1: 6 })],
|
||||
TODAY,
|
||||
);
|
||||
const a1 = out[0];
|
||||
expect(a1.series).toEqual([
|
||||
{ date: dayKey(1), value: 6, samples: 2 },
|
||||
{ date: dayKey(0), value: 6, samples: 1 },
|
||||
]);
|
||||
// The daily mean is the unit every other mood surface reports, so a
|
||||
// number here can be held against one there.
|
||||
expect(a1.count).toBe(3);
|
||||
});
|
||||
|
||||
it("windows the means at 7, 30 and 90 days", () => {
|
||||
const out = computeMoodDimensionSeries(
|
||||
[
|
||||
row(1, { a3: 10 }), // inside all three
|
||||
row(20, { a3: 4 }), // inside 30 and 90
|
||||
row(60, { a3: 1 }), // inside 90 only
|
||||
row(200, { a3: 0 }), // outside every window
|
||||
],
|
||||
TODAY,
|
||||
);
|
||||
const a3 = out.find((d) => d.key === "a3")!;
|
||||
expect(a3.avg7).toBe(10);
|
||||
expect(a3.avg30).toBe(7);
|
||||
expect(a3.avg90).toBe(5);
|
||||
// The out-of-window row contributes to nothing, not even the count.
|
||||
expect(a3.count).toBe(3);
|
||||
expect(a3.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("carries the newest value with its age, per dimension", () => {
|
||||
// The point of a per-dimension age: stress can be a week stale while
|
||||
// pleasantness was answered this morning.
|
||||
const out = computeMoodDimensionSeries(
|
||||
[row(0, { a1: 8 }), row(6, { a2: 9 })],
|
||||
TODAY,
|
||||
);
|
||||
const a1 = out.find((d) => d.key === "a1")!;
|
||||
const a2 = out.find((d) => d.key === "a2")!;
|
||||
expect(a1.latest).toBe(8);
|
||||
expect(a1.latestDate).toBe(dayKey(0));
|
||||
expect(a1.newestDaysAgo).toBe(0);
|
||||
expect(a2.latest).toBe(9);
|
||||
expect(a2.newestDaysAgo).toBe(6);
|
||||
});
|
||||
|
||||
it("carries the orientation rather than flipping the stored value", () => {
|
||||
const out = computeMoodDimensionSeries([row(0, { a2: 9 })], TODAY);
|
||||
const a2 = out.find((d) => d.key === "a2")!;
|
||||
expect(a2.inverse).toBe(true);
|
||||
// Stored literally: a 9 the user set is a 9 here. Flipping on the way
|
||||
// through would make the chart disagree with the label they answered.
|
||||
expect(a2.latest).toBe(9);
|
||||
expect(out.filter((d) => d.inverse).map((d) => d.key)).toEqual(["a2"]);
|
||||
});
|
||||
|
||||
it("ignores a malformed day key and a future-dated row", () => {
|
||||
const out = computeMoodDimensionSeries(
|
||||
[
|
||||
{ date: "not-a-date", a1: 9, a2: null, a3: null, a4: null, a5: null },
|
||||
row(-3, { a1: 1 }),
|
||||
row(2, { a1: 5 }),
|
||||
],
|
||||
TODAY,
|
||||
);
|
||||
const a1 = out[0];
|
||||
expect(a1.count).toBe(1);
|
||||
expect(a1.latest).toBe(5);
|
||||
});
|
||||
|
||||
it("answers five empty summaries for no rows at all", () => {
|
||||
const out = computeMoodDimensionSeries([], TODAY);
|
||||
expect(out).toHaveLength(5);
|
||||
expect(out.every((d) => !d.present)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* v1.37 — per-dimension staleness on the level-A blocks.
|
||||
*
|
||||
* The failure this exists to prevent has already happened once on the vitals
|
||||
* surfaces: a value was handed to the narrator with no notion of when it was
|
||||
* taken, and last week's reading came back as this morning's. The five
|
||||
* dimensions make that failure easier, not harder, because their ages differ
|
||||
* — somebody taps a face every morning and opens the detail sliders once a
|
||||
* week, so pleasantness is fresh while stress is six days old.
|
||||
*
|
||||
* The rule under test: every dimension carries its own age, and `current`
|
||||
* answers "may this back a present-tense sentence" through the one shared
|
||||
* freshness rule rather than through a comparison written here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
|
||||
import {
|
||||
TODAY_CLAIM_MAX_AGE_DAYS,
|
||||
isCurrentForTodayClaim,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
|
||||
const TODAY = "2026-08-08";
|
||||
|
||||
function dayKey(daysAgo: number): string {
|
||||
const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`));
|
||||
d.setUTCDate(d.getUTCDate() - daysAgo);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The shape `buildMoodDimensionFeatures` derives, without a database. */
|
||||
function blocksFor(
|
||||
rows: Array<{ daysAgo: number; a1?: number; a2?: number }>,
|
||||
): Array<{
|
||||
key: string;
|
||||
latest: number | null;
|
||||
newestDaysAgo: number | null;
|
||||
current: boolean;
|
||||
}> {
|
||||
const summaries = computeMoodDimensionSeries(
|
||||
rows.map((r) => ({
|
||||
date: dayKey(r.daysAgo),
|
||||
a1: r.a1 ?? null,
|
||||
a2: r.a2 ?? null,
|
||||
a3: null,
|
||||
a4: null,
|
||||
a5: null,
|
||||
})),
|
||||
TODAY,
|
||||
);
|
||||
return summaries
|
||||
.filter((s) => s.present)
|
||||
.map((s) => ({
|
||||
key: s.key,
|
||||
latest: s.latest,
|
||||
newestDaysAgo: s.newestDaysAgo,
|
||||
current: isCurrentForTodayClaim(s.newestDaysAgo),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("level-A staleness", () => {
|
||||
it("calls this morning's pleasantness current and a five-day-old stress stale", () => {
|
||||
// The fixture the whole file is about: one entry today with a mood, one
|
||||
// five days ago that also answered stress.
|
||||
const blocks = blocksFor([
|
||||
{ daysAgo: 0, a1: 8 },
|
||||
{ daysAgo: 5, a1: 4, a2: 9 },
|
||||
]);
|
||||
|
||||
const a1 = blocks.find((b) => b.key === "a1")!;
|
||||
const a2 = blocks.find((b) => b.key === "a2")!;
|
||||
|
||||
expect(a1.newestDaysAgo).toBe(0);
|
||||
expect(a1.current).toBe(true);
|
||||
|
||||
expect(a2.latest).toBe(9);
|
||||
expect(a2.newestDaysAgo).toBe(5);
|
||||
// The claim under test: nothing may say "your stress is high today" from
|
||||
// this row. It is history and is stated with its date.
|
||||
expect(a2.current).toBe(false);
|
||||
});
|
||||
|
||||
it("never carries a latest without an age beside it", () => {
|
||||
// The structural half of the fix. A `latest` alone is exactly what
|
||||
// produced the wrong sentence, so the shape is asserted, not the value.
|
||||
for (const block of blocksFor([{ daysAgo: 2, a1: 5, a2: 5 }])) {
|
||||
expect(block.latest).not.toBeNull();
|
||||
expect(block.newestDaysAgo).not.toBeNull();
|
||||
expect(typeof block.current).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unknown age as stale rather than as fresh", () => {
|
||||
// A missing age is not a fresh one. This is the shared rule's own
|
||||
// behaviour and is asserted here so a local reimplementation that
|
||||
// defaulted to `true` would be caught.
|
||||
expect(isCurrentForTodayClaim(null)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("puts the boundary exactly where the shared rule puts it", () => {
|
||||
// Yesterday still backs a present claim; the day before does not. Read
|
||||
// off the exported constant rather than the literal 1, so a change to the
|
||||
// window moves this test with it instead of stranding it.
|
||||
const fresh = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS, a1: 6 }]);
|
||||
expect(fresh[0].current).toBe(true);
|
||||
|
||||
const stale = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS + 1, a1: 6 }]);
|
||||
expect(stale[0].current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
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 { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -64,6 +69,158 @@ interface DataCoverage {
|
||||
newestDaysAgo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One level-A dimension as the model reads it.
|
||||
*
|
||||
* `latest` never travels without `newestDaysAgo` and `current` beside it. A
|
||||
* value with no age is exactly what produced a reading from last week stated
|
||||
* as this morning's, so the age is part of the shape rather than something a
|
||||
* caller is trusted to look up.
|
||||
*/
|
||||
export interface MoodDimensionFeature {
|
||||
key: string;
|
||||
/** The wording behind the numbers, so a 7 is not read as 7 out of 5. */
|
||||
scale: string;
|
||||
/** True when a higher number means a worse day — stress, and only stress. */
|
||||
inverse: boolean;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
latest: number | null;
|
||||
/** The local day the newest value belongs to. */
|
||||
latestDate: string | null;
|
||||
/** Whole days between that day and today. */
|
||||
newestDaysAgo: number | null;
|
||||
/**
|
||||
* Whether that value may back a present-tense claim. False for an absent
|
||||
* age too: a missing age is not a fresh one.
|
||||
*/
|
||||
current: boolean;
|
||||
/**
|
||||
* Present only when `current` is false, and says in words what the flag
|
||||
* says in a boolean. The model reads prose more reliably than it reads a
|
||||
* field name, and a stale dimension narrated as today's is the one failure
|
||||
* this block exists to prevent.
|
||||
*/
|
||||
staleNotice?: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level-A blocks for the model, read once over a bounded window.
|
||||
*
|
||||
* The rollup tier caches the mean of `score` and nothing else, so this is its
|
||||
* own read rather than a widened rollup: a cached tier for four columns that
|
||||
* may all be NULL would cost a migration to answer a question the live read
|
||||
* answers in one indexed range scan. If a surface measurably slows, the tier
|
||||
* grows then, as a read-swap with a fallback on a coverage miss — never as a
|
||||
* second engine running beside this one.
|
||||
*
|
||||
* Only dimensions somebody answered are returned. An empty block would invite
|
||||
* a sentence about a question that was never asked.
|
||||
*
|
||||
* A STALE dimension is still returned, carrying its age and an instruction,
|
||||
* rather than filtered out the way the health-status surfaces drop a reading
|
||||
* too old to describe. That is a deliberate difference and worth the sentence:
|
||||
* those surfaces write one line about one metric, so a value they cannot
|
||||
* narrate has nothing left to contribute and dropping it is the whole fix.
|
||||
* This block is context for an open conversation, where "your stress was 9 on
|
||||
* the third, and you have not answered it since" is a useful and true thing to
|
||||
* say — and where silence is worse than history, because the reader then has
|
||||
* no way to tell an unanswered dimension from one nobody has looked at in a
|
||||
* week. The safety therefore rides on `current` plus `staleNotice` rather than
|
||||
* on absence, and both are asserted at the shipped composition in
|
||||
* `tests/integration/mood-dimension-features.test.ts`.
|
||||
*/
|
||||
async function buildMoodDimensionFeatures(
|
||||
userId: string,
|
||||
): Promise<MoodDimensionFeature[] | undefined> {
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const { t } = getServerTranslator("en");
|
||||
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 2000,
|
||||
select: {
|
||||
date: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
// The same calculator the trend page uses, so the number in a Coach
|
||||
// sentence and the number on the chart cannot disagree.
|
||||
const summaries = computeMoodDimensionSeries(
|
||||
rows.map((row) => ({
|
||||
date: row.date,
|
||||
a1: row.moodA1,
|
||||
a2: row.stressA2,
|
||||
a3: row.energyA3,
|
||||
a4: row.connectionA4,
|
||||
a5: row.stabilityA5,
|
||||
})),
|
||||
userDayKey(new Date(), tz),
|
||||
);
|
||||
|
||||
const blocks = summaries
|
||||
.filter((summary) => summary.present)
|
||||
.map((summary) => {
|
||||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key)!;
|
||||
const current = isCurrentForTodayClaim(summary.newestDaysAgo);
|
||||
return {
|
||||
key: summary.key,
|
||||
// The anchors are resolved through the bundle rather than repeated
|
||||
// here: the model must read the same words the person read when they
|
||||
// moved the slider, and a second copy of that wording would drift
|
||||
// from the one on screen the first time either was edited. English,
|
||||
// like the legacy `scale` line beside it.
|
||||
scale: `${t(dimension.labelKey)}, 0-10: 0 = ${t(dimension.lowAnchorKey)}, 10 = ${t(dimension.highAnchorKey)}`,
|
||||
inverse: summary.inverse,
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
latest: summary.latest,
|
||||
latestDate: summary.latestDate,
|
||||
newestDaysAgo: summary.newestDaysAgo,
|
||||
current,
|
||||
...(current
|
||||
? {}
|
||||
: {
|
||||
staleNotice: summary.latestDate
|
||||
? `Last answered on ${summary.latestDate}. State it as history with that date; do not describe it as today.`
|
||||
: "No dated value. Do not describe this dimension as current.",
|
||||
}),
|
||||
count: summary.count,
|
||||
};
|
||||
});
|
||||
|
||||
return blocks.length > 0 ? blocks : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* so an account that only ever taps a face carries neither key.
|
||||
*/
|
||||
async function buildMoodDimensionBlock(userId: string): Promise<{
|
||||
dimensions?: MoodDimensionFeature[];
|
||||
dimensionsAsOfNote?: string;
|
||||
}> {
|
||||
const dimensions = await buildMoodDimensionFeatures(userId);
|
||||
if (!dimensions) return {};
|
||||
return {
|
||||
dimensions,
|
||||
dimensionsAsOfNote:
|
||||
"The block-level asOf describes the newest mood ENTRY on the 1-5 axis. " +
|
||||
"For the five dimensions below, each carries its own newestDaysAgo and " +
|
||||
"current, and those are the ones that govern what may be said about " +
|
||||
"them today; they are often older than the entry itself.",
|
||||
};
|
||||
}
|
||||
|
||||
export interface AggregatedFeatures {
|
||||
weight?: {
|
||||
latest: number;
|
||||
@@ -161,6 +318,34 @@ export interface AggregatedFeatures {
|
||||
trend30: "improving" | "declining" | "stable" | null;
|
||||
totalEntries: number;
|
||||
coverage: DataCoverage;
|
||||
/**
|
||||
* v1.37 — the five level-A self-state values, one block each, and only for
|
||||
* the dimensions somebody actually answered. `scale` above describes the
|
||||
* legacy 1-5 axis and is handed to the model verbatim, so it is left
|
||||
* exactly as it was; these carry their own scale line beside it.
|
||||
*
|
||||
* Every block states its own age, because the ages differ: stress can be
|
||||
* six days old while pleasantness was answered this morning, and a model
|
||||
* given one age for the pair narrates the stale one as today's.
|
||||
*/
|
||||
dimensions?: MoodDimensionFeature[];
|
||||
/**
|
||||
* Says which age governs which claim, because two of them ride in the
|
||||
* same block and they disagree by design.
|
||||
*
|
||||
* `annotateSnapshotFreshness` stamps every metric block with an `asOf`
|
||||
* built from `coverage.newestDaysAgo`, which for mood is the age of the
|
||||
* newest ENTRY — the 1-5 axis. Somebody who taps a face each morning and
|
||||
* opens the sliders once a week has `asOf.daysAgo` of 0 and a stress
|
||||
* reading six days old, so a reader taking the block-level stamp as
|
||||
* covering the dimensions would state last week's answer as today's.
|
||||
* That is the exact sentence this milestone exists to prevent, and it is
|
||||
* cheaper to name the precedence than to leave two true numbers looking
|
||||
* like a contradiction.
|
||||
*
|
||||
* Present only alongside `dimensions`.
|
||||
*/
|
||||
dimensionsAsOfNote?: string;
|
||||
};
|
||||
sleep?: {
|
||||
avg7: number | null;
|
||||
@@ -1055,6 +1240,7 @@ export async function extractFeatures(
|
||||
latest: moodLatestScore,
|
||||
trend30,
|
||||
totalEntries: moodTotalEntries,
|
||||
...(await buildMoodDimensionBlock(userId)),
|
||||
coverage: {
|
||||
count: moodTotalEntries,
|
||||
spanDays,
|
||||
|
||||
@@ -35,6 +35,15 @@ import {
|
||||
computeMoodNarratives,
|
||||
type MoodNarrative,
|
||||
} from "@/lib/insights/mood-narratives";
|
||||
import {
|
||||
computeMoodDimensionSeries,
|
||||
type MoodDimensionSummary,
|
||||
} from "@/lib/insights/mood-dimension-series";
|
||||
export {
|
||||
computeMoodDimensionSeries,
|
||||
type MoodDimensionPoint,
|
||||
type MoodDimensionSummary,
|
||||
} from "@/lib/insights/mood-dimension-series";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
|
||||
@@ -162,6 +171,16 @@ export interface MoodAggregateEntry {
|
||||
* aggregate tests (flat tags only) keep their narrow fixtures.
|
||||
*/
|
||||
structuredTags?: StructuredTagRef[];
|
||||
/**
|
||||
* 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
|
||||
* nullable because an unanswered dimension is absent rather than average.
|
||||
*/
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
/**
|
||||
* v1.14.0 — per-entry RATED factor scores (work / sleep-quality /
|
||||
* stress …) from the same `mood_entry_tag_links` join, but carrying the
|
||||
@@ -414,6 +433,13 @@ export interface MoodAggregates {
|
||||
cells: HeatmapCell[];
|
||||
};
|
||||
distribution: DistributionRow[];
|
||||
/**
|
||||
* v1.37 — one summary per level-A dimension: 7 / 30 / 90-day means, the
|
||||
* newest value with its age, and the daily series behind them. A dimension
|
||||
* nobody answered reports `present: false` and an empty series; the surface
|
||||
* says so rather than drawing a line through the middle of the scale.
|
||||
*/
|
||||
dimensions: MoodDimensionSummary[];
|
||||
weekday: WeekdayRow[];
|
||||
/**
|
||||
* v1.9.0 — average mood per part of day, bucketed in each entry's own
|
||||
@@ -578,6 +604,17 @@ export function computeMoodAggregates(args: {
|
||||
cells: computeHeatmapCells(entries, now, windowDays),
|
||||
},
|
||||
distribution: computeDistribution(moodDaily),
|
||||
dimensions: computeMoodDimensionSeries(
|
||||
entries.map((entry) => ({
|
||||
date: entry.date,
|
||||
a1: entry.a1 ?? null,
|
||||
a2: entry.a2 ?? null,
|
||||
a3: entry.a3 ?? null,
|
||||
a4: entry.a4 ?? null,
|
||||
a5: entry.a5 ?? null,
|
||||
})),
|
||||
dayOffsetToBerlinDayKey(now, 0, tz),
|
||||
),
|
||||
weekday,
|
||||
timeOfDay,
|
||||
stability,
|
||||
@@ -624,6 +661,13 @@ export async function fetchMoodAggregates(
|
||||
select: {
|
||||
date: true,
|
||||
score: true,
|
||||
// v1.37 — the five level-A values. Plaintext columns, so the trend is
|
||||
// a plain read rather than a decrypt-per-row.
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
tags: true,
|
||||
moodLoggedAt: true,
|
||||
// v1.9.0 — per-row IANA tz anchors the part-of-day bucketing in
|
||||
@@ -664,6 +708,11 @@ export async function fetchMoodAggregates(
|
||||
return rows.reverse().map((row) => ({
|
||||
date: row.date,
|
||||
score: row.score,
|
||||
a1: row.moodA1,
|
||||
a2: row.stressA2,
|
||||
a3: row.energyA3,
|
||||
a4: row.connectionA4,
|
||||
a5: row.stabilityA5,
|
||||
tags: parseTags(row.tags),
|
||||
moodLoggedAt: row.moodLoggedAt,
|
||||
tz: row.tz,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* v1.37 — per-dimension mood series over the level-A columns.
|
||||
*
|
||||
* A sibling calculator of `mood-aggregates`, following that file's own
|
||||
* structure: pure functions over already-fetched rows, no database, no i18n.
|
||||
* The hub reads the rows once and hands them here, which is what keeps a
|
||||
* second engine from appearing — a parallel `summarize()` of mood that
|
||||
* disagreed with the dashboard's is the failure this project has already
|
||||
* paid for once.
|
||||
*
|
||||
* The rule the whole module is built around: a dimension nobody answered is
|
||||
* absent, not zero and not a flat line through the middle. On a 0-10
|
||||
* self-rating every value in the range is a real answer, so a fabricated one
|
||||
* is indistinguishable from a given one — and five is the answer somebody
|
||||
* gives on a genuinely average day.
|
||||
*/
|
||||
import {
|
||||
MOOD_DIMENSIONS,
|
||||
MOOD_DIMENSION_WINDOWS,
|
||||
type MoodDimensionKey,
|
||||
} from "@/lib/mood/dimensions";
|
||||
import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
|
||||
/** One day's mean for one dimension. */
|
||||
export interface MoodDimensionPoint {
|
||||
/** `YYYY-MM-DD`, the row's own local day key. */
|
||||
date: string;
|
||||
value: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface MoodDimensionSummary {
|
||||
key: MoodDimensionKey;
|
||||
/** True when at least one entry in the window carries a value. */
|
||||
present: boolean;
|
||||
/** Higher is worse — the analytics layer flips through this, never inline. */
|
||||
inverse: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
/** Number of entries carrying a value, across the whole window. */
|
||||
count: number;
|
||||
/** Mean over the trailing 7 / 30 / 90 days; null when that window is empty. */
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg90: number | null;
|
||||
/** The most recent value, and how old it is in whole local days. */
|
||||
latest: number | null;
|
||||
latestDate: string | null;
|
||||
newestDaysAgo: number | null;
|
||||
/** Daily means, oldest first, over the longest window. */
|
||||
series: MoodDimensionPoint[];
|
||||
}
|
||||
|
||||
/** A mood row as this calculator needs it. */
|
||||
export interface MoodDimensionRow {
|
||||
date: string;
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
function meanOf(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sum = values.reduce((acc, v) => acc + v, 0);
|
||||
return round(sum / values.length, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise each of the five dimensions over the rows given.
|
||||
*
|
||||
* `rows` are the window's entries in any order; `todayKey` is the reader's own
|
||||
* local day, already resolved by the caller, so nothing here converts a zone.
|
||||
* Each entry contributes at most one value per dimension, and days with
|
||||
* several entries are averaged — the same daily-mean shape every other mood
|
||||
* surface reports, so a number here can be compared with one there.
|
||||
*/
|
||||
export function computeMoodDimensionSeries(
|
||||
rows: MoodDimensionRow[],
|
||||
todayKey: string,
|
||||
): MoodDimensionSummary[] {
|
||||
const longest = Math.max(...MOOD_DIMENSION_WINDOWS);
|
||||
|
||||
return MOOD_DIMENSIONS.map((dimension) => {
|
||||
const byDay = new Map<string, number[]>();
|
||||
for (const row of rows) {
|
||||
const value = row[dimension.key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
||||
const age = dayKeyAgeInDays(row.date, todayKey);
|
||||
if (age === null || age < 0 || age >= longest) continue;
|
||||
const bucket = byDay.get(row.date);
|
||||
if (bucket) bucket.push(value);
|
||||
else byDay.set(row.date, [value]);
|
||||
}
|
||||
|
||||
const series: MoodDimensionPoint[] = [...byDay.entries()]
|
||||
.map(([date, values]) => ({
|
||||
date,
|
||||
value: meanOf(values) as number,
|
||||
samples: values.length,
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const count = [...byDay.values()].reduce((acc, v) => acc + v.length, 0);
|
||||
|
||||
const windowMean = (days: number): number | null => {
|
||||
const values: number[] = [];
|
||||
for (const point of series) {
|
||||
const age = dayKeyAgeInDays(point.date, todayKey);
|
||||
if (age === null || age >= days) continue;
|
||||
values.push(point.value);
|
||||
}
|
||||
return meanOf(values);
|
||||
};
|
||||
|
||||
const newest = series.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
key: dimension.key,
|
||||
// Presence is decided by whether anybody answered, never by whether the
|
||||
// mean happens to be zero.
|
||||
present: count > 0,
|
||||
inverse: dimension.inverse,
|
||||
min: dimension.min,
|
||||
max: dimension.max,
|
||||
count,
|
||||
avg7: windowMean(7),
|
||||
avg30: windowMean(30),
|
||||
avg90: windowMean(90),
|
||||
latest: newest?.value ?? null,
|
||||
latestDate: newest?.date ?? null,
|
||||
newestDaysAgo: newest ? dayKeyAgeInDays(newest.date, todayKey) : null,
|
||||
series,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
||||
import { getScoreForMood } from "@/lib/validations/mood";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMessage,
|
||||
@@ -648,6 +649,10 @@ export async function logMcpMood(input: {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// v1.37 — pleasantness derived from the same label the score comes
|
||||
// from. The tool still takes a 1-5 score; that is its input and it is
|
||||
// unchanged.
|
||||
moodA1: deriveA1(mood),
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note),
|
||||
source: MCP_SOURCE,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MOOD_DIMENSIONS,
|
||||
MOOD_DIMENSION_MAX,
|
||||
MOOD_DIMENSION_MIN,
|
||||
getMoodDimension,
|
||||
} from "@/lib/mood/dimensions";
|
||||
|
||||
describe("MOOD_DIMENSIONS", () => {
|
||||
it("carries the five level-A dimensions in capture order", () => {
|
||||
expect(MOOD_DIMENSIONS.map((d) => d.key)).toEqual([
|
||||
"a1",
|
||||
"a2",
|
||||
"a3",
|
||||
"a4",
|
||||
"a5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds each dimension to its own column", () => {
|
||||
expect(MOOD_DIMENSIONS.map((d) => d.column)).toEqual([
|
||||
"moodA1",
|
||||
"stressA2",
|
||||
"energyA3",
|
||||
"connectionA4",
|
||||
"stabilityA5",
|
||||
]);
|
||||
const columns = new Set(MOOD_DIMENSIONS.map((d) => d.column));
|
||||
expect(columns.size).toBe(MOOD_DIMENSIONS.length);
|
||||
});
|
||||
|
||||
it("runs 0..10 on every dimension", () => {
|
||||
for (const d of MOOD_DIMENSIONS) {
|
||||
expect(d.min).toBe(MOOD_DIMENSION_MIN);
|
||||
expect(d.max).toBe(MOOD_DIMENSION_MAX);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks exactly one dimension inverse, and it is stress", () => {
|
||||
const inverse = MOOD_DIMENSIONS.filter((d) => d.inverse);
|
||||
expect(inverse.map((d) => d.key)).toEqual(["a2"]);
|
||||
});
|
||||
|
||||
it("gives every dimension a distinct label and two distinct anchors", () => {
|
||||
const keys: string[] = [];
|
||||
for (const d of MOOD_DIMENSIONS) {
|
||||
expect(d.labelKey).toBe(`mood.dimension.${d.key}.label`);
|
||||
expect(d.lowAnchorKey).toBe(`mood.dimension.${d.key}.low`);
|
||||
expect(d.highAnchorKey).toBe(`mood.dimension.${d.key}.high`);
|
||||
keys.push(d.labelKey, d.lowAnchorKey, d.highAnchorKey);
|
||||
}
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("resolves a dimension by wire key and refuses anything else", () => {
|
||||
expect(getMoodDimension("a3")?.column).toBe("energyA3");
|
||||
expect(getMoodDimension("a6")).toBeUndefined();
|
||||
expect(getMoodDimension("moodA1")).toBeUndefined();
|
||||
expect(getMoodDimension("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
||||
import { getScoreForMood } from "@/lib/validations/mood";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
@@ -79,6 +80,10 @@ export async function logTelegramMood(input: {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// v1.37 — pleasantness derived from the same label the score comes
|
||||
// from, so a mood tapped in the chat carries the value a mood typed
|
||||
// into the web form would. Nothing here can answer the other four.
|
||||
moodA1: deriveA1(mood),
|
||||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(input.note ?? null),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The five level-A self-state dimensions of a mood entry.
|
||||
*
|
||||
* A mood entry has carried one 1–5 value (`MoodEntry.score`) since the first
|
||||
* release. That value keeps its meaning and its scale — eleven surfaces read
|
||||
* it on the assumption that it runs 1..5 — and the five dimensions below ride
|
||||
* beside it as their own 0–10 columns. The two coexist by design: `score` is
|
||||
* "how was the day, in one word", the dimensions are "how was the day, in the
|
||||
* terms the day was actually made of".
|
||||
*
|
||||
* Each dimension is stored literally, exactly as the user set it against the
|
||||
* anchors they read. A2 (stress) is inverse-oriented — a higher value means a
|
||||
* worse day — and it is stored that way rather than flipped at write time. An
|
||||
* analytics surface that needs "up is better" flips the sign through
|
||||
* `inverse` on this table; nothing flips inline. The same rule already governs
|
||||
* rated mood factors (`MoodTag.inverse`), and it exists because a stored value
|
||||
* that disagrees with the label the user saw is unrecoverable afterwards.
|
||||
*
|
||||
* This module is pure: it holds i18n keys and column names, and imports
|
||||
* neither Prisma nor the message bundles. The German wording behind every key
|
||||
* lives in `messages/de.json` and its five siblings.
|
||||
*/
|
||||
|
||||
/** Wire/DTO key of a level-A dimension. */
|
||||
export type MoodDimensionKey = "a1" | "a2" | "a3" | "a4" | "a5";
|
||||
|
||||
/** `MoodEntry` column a level-A dimension is stored in. */
|
||||
export type MoodDimensionColumn =
|
||||
"moodA1" | "stressA2" | "energyA3" | "connectionA4" | "stabilityA5";
|
||||
|
||||
export interface MoodDimension {
|
||||
/** Wire key (`a1`…`a5`) accepted by the create schema and echoed on reads. */
|
||||
key: MoodDimensionKey;
|
||||
/** Prisma field on `MoodEntry` this dimension is stored in. */
|
||||
column: MoodDimensionColumn;
|
||||
/** i18n key of the dimension's own name. */
|
||||
labelKey: string;
|
||||
/** i18n key of the wording at the bottom of the scale. */
|
||||
lowAnchorKey: string;
|
||||
/** i18n key of the wording at the top of the scale. */
|
||||
highAnchorKey: string;
|
||||
/**
|
||||
* A higher stored value means a worse day. Exactly one dimension carries
|
||||
* this today (A2, stress), and the unit test pins that count, so a second
|
||||
* one cannot be flipped quietly.
|
||||
*/
|
||||
inverse: boolean;
|
||||
min: 0;
|
||||
max: 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions in capture order — the order the sliders render in and the
|
||||
* order every per-dimension surface iterates. Concept order, not alphabetical:
|
||||
* pleasantness first because it is the one the quick check-in already answers.
|
||||
*/
|
||||
export const MOOD_DIMENSIONS: readonly MoodDimension[] = [
|
||||
{
|
||||
key: "a1",
|
||||
column: "moodA1",
|
||||
labelKey: "mood.dimension.a1.label",
|
||||
lowAnchorKey: "mood.dimension.a1.low",
|
||||
highAnchorKey: "mood.dimension.a1.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a2",
|
||||
column: "stressA2",
|
||||
labelKey: "mood.dimension.a2.label",
|
||||
lowAnchorKey: "mood.dimension.a2.low",
|
||||
highAnchorKey: "mood.dimension.a2.high",
|
||||
inverse: true,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a3",
|
||||
column: "energyA3",
|
||||
labelKey: "mood.dimension.a3.label",
|
||||
lowAnchorKey: "mood.dimension.a3.low",
|
||||
highAnchorKey: "mood.dimension.a3.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a4",
|
||||
column: "connectionA4",
|
||||
labelKey: "mood.dimension.a4.label",
|
||||
lowAnchorKey: "mood.dimension.a4.low",
|
||||
highAnchorKey: "mood.dimension.a4.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "a5",
|
||||
column: "stabilityA5",
|
||||
labelKey: "mood.dimension.a5.label",
|
||||
lowAnchorKey: "mood.dimension.a5.low",
|
||||
highAnchorKey: "mood.dimension.a5.high",
|
||||
inverse: false,
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Windows the per-dimension trend reports, in days. Concept §10.1.
|
||||
*
|
||||
* Here rather than beside the calculator that averages over them, because the
|
||||
* chart offering the choice needs the same three numbers and cannot import
|
||||
* that module: it reaches Prisma two hops down, and a client component that
|
||||
* pulled it in would drag the database client into the browser bundle. This
|
||||
* module is pure by construction, which is what makes it the one place both
|
||||
* ends can agree on.
|
||||
*/
|
||||
export const MOOD_DIMENSION_WINDOWS = [7, 30, 90] as const;
|
||||
export type MoodDimensionWindow = (typeof MOOD_DIMENSION_WINDOWS)[number];
|
||||
|
||||
/** Lowest value any dimension accepts. */
|
||||
export const MOOD_DIMENSION_MIN = 0;
|
||||
/** Highest value any dimension accepts. */
|
||||
export const MOOD_DIMENSION_MAX = 10;
|
||||
|
||||
const BY_KEY = new Map<string, MoodDimension>(
|
||||
MOOD_DIMENSIONS.map((d) => [d.key, d]),
|
||||
);
|
||||
|
||||
/** The dimension carrying this wire key, or `undefined` for anything else. */
|
||||
export function getMoodDimension(key: string): MoodDimension | undefined {
|
||||
return BY_KEY.get(key);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The one place a mood write resolves its five level-A values.
|
||||
*
|
||||
* Five writers reach `MoodEntry`, and every one of them sends exactly what it
|
||||
* sent before this existed: a five-point label and nothing else. The server
|
||||
* derives pleasantness (A1) from that label so a quick check-in is a full
|
||||
* entry rather than a degraded one, and so a Telegram row, an imported row and
|
||||
* a row typed into the web form all carry the same value for the same day.
|
||||
*
|
||||
* The other four are never derived. There is nothing in a five-point label to
|
||||
* derive stress, energy, connectedness or stability from, and a fabricated
|
||||
* value would read as an answer nobody gave — it would poison the trend and
|
||||
* any later model trained on these columns. They stay NULL until somebody
|
||||
* moves the slider.
|
||||
*/
|
||||
import { getA1ForMood } from "@/lib/validations/mood";
|
||||
|
||||
/** Level-A values as a client may send them, all optional. */
|
||||
export interface LevelAInput {
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-A values as they are written to `MoodEntry`.
|
||||
*
|
||||
* A1 is always stated, because it is always derivable from the label. The
|
||||
* other four are present only when the request carried them: an absent key
|
||||
* means the caller said nothing about that dimension, and on an upsert's
|
||||
* `update:` arm saying nothing has to leave the stored answer alone.
|
||||
*/
|
||||
export interface LevelAColumns {
|
||||
moodA1: number;
|
||||
stressA2?: number | null;
|
||||
energyA3?: number | null;
|
||||
connectionA4?: number | null;
|
||||
stabilityA5?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five columns for a write of `mood`, with any explicit client value
|
||||
* winning over the derivation.
|
||||
*
|
||||
* A client that sends only a label gets the derived A1 and says nothing at all
|
||||
* about the other four. A client that sends `a1` gets its own number — that is
|
||||
* what makes the detail sliders a real capture surface rather than a
|
||||
* suggestion the server overrides on the way to the database.
|
||||
*
|
||||
* **A2 to A5 are omitted when the request did not carry them, and that is the
|
||||
* point.** The callers are upserts, so this same object feeds an `update:`
|
||||
* arm: a phone re-posting an entry it holds — the label, the timestamp, the
|
||||
* tags, and no sliders, because its own build has none — must not blank the
|
||||
* stress and energy somebody answered on the web. Absence in a request means
|
||||
* "nothing to say", never "set this to nothing". Clearing an answer is what an
|
||||
* explicit `null` is for, and it travels here as a stated null.
|
||||
*
|
||||
* The asymmetry with A1 is deliberate rather than an oversight. A1 can always
|
||||
* be derived from the label the request already carries, so there is no state
|
||||
* in which restating it loses information; the other four have no such source,
|
||||
* and a value invented for them would be indistinguishable from one somebody
|
||||
* gave — which would poison the trend and anything later trained on it.
|
||||
*
|
||||
* On the `create:` arm an omitted column lands as NULL anyway, so one object
|
||||
* serves both arms. Callers whose write semantics are per-field rather than
|
||||
* whole-row (the edit route) build their own object and use `deriveA1`.
|
||||
*/
|
||||
export function levelAForWrite(
|
||||
mood: string,
|
||||
explicit?: LevelAInput,
|
||||
): LevelAColumns {
|
||||
// Field by field, never a spread of the parsed body.
|
||||
return {
|
||||
// An explicit null falls back to the derivation rather than clearing: an
|
||||
// entry always carries a pleasantness value, because the five-point label
|
||||
// it was saved with always implies one.
|
||||
moodA1: explicit?.a1 ?? getA1ForMood(mood),
|
||||
...(explicit?.a2 === undefined ? {} : { stressA2: explicit.a2 }),
|
||||
...(explicit?.a3 === undefined ? {} : { energyA3: explicit.a3 }),
|
||||
...(explicit?.a4 === undefined ? {} : { connectionA4: explicit.a4 }),
|
||||
...(explicit?.a5 === undefined ? {} : { stabilityA5: explicit.a5 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */
|
||||
export function deriveA1(mood: string): number {
|
||||
return getA1ForMood(mood);
|
||||
}
|
||||
|
||||
/** The five columns as a client reads them back. */
|
||||
export interface LevelAWire {
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
/** The five columns as they sit on a `MoodEntry` row. */
|
||||
export interface LevelARow {
|
||||
moodA1: number | null;
|
||||
stressA2: number | null;
|
||||
energyA3: number | null;
|
||||
connectionA4: number | null;
|
||||
stabilityA5: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a stored row's five columns into the wire keys the client sends.
|
||||
*
|
||||
* A read that answered `moodA1` while the write took `a1` would leave every
|
||||
* form mapping the pair by hand, and one of them would eventually map it
|
||||
* wrong. The names match in both directions.
|
||||
*/
|
||||
export function levelAForWire(row: LevelARow): LevelAWire {
|
||||
return {
|
||||
a1: row.moodA1,
|
||||
a2: row.stressA2,
|
||||
a3: row.energyA3,
|
||||
a4: row.connectionA4,
|
||||
a5: row.stabilityA5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A row shaped for a response: the five columns replaced by their wire keys,
|
||||
* not joined by them.
|
||||
*
|
||||
* The mood routes spread the whole row into their responses, so simply adding
|
||||
* the wire keys left every entry carrying the same five values twice under two
|
||||
* spellings. Two names for one number is a decision nobody made, and the one a
|
||||
* client picks is the one it gets stuck with. This strips the column spelling
|
||||
* and keeps the wire one, which is the spelling the write path accepts.
|
||||
*/
|
||||
export function shapeLevelA<T extends LevelARow>(
|
||||
row: T,
|
||||
): Omit<T, keyof LevelARow> & LevelAWire {
|
||||
const { moodA1, stressA2, energyA3, connectionA4, stabilityA5, ...rest } =
|
||||
row;
|
||||
return {
|
||||
...rest,
|
||||
...levelAForWire({
|
||||
moodA1,
|
||||
stressA2,
|
||||
energyA3,
|
||||
connectionA4,
|
||||
stabilityA5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getA1ForMood,
|
||||
getScoreForMood,
|
||||
moodA1Map,
|
||||
} from "@/lib/validations/mood";
|
||||
|
||||
/**
|
||||
* The five-point label → A1 map is the single derivation point every writer
|
||||
* shares, so every anchor is asserted by value rather than by shape. A test
|
||||
* that only checked monotonicity would pass a map that had drifted a point.
|
||||
*/
|
||||
describe("getA1ForMood", () => {
|
||||
it("maps every five-point label onto its level-A anchor", () => {
|
||||
expect(getA1ForMood("LAUSIG")).toBe(1);
|
||||
expect(getA1ForMood("SCHLECHT")).toBe(3);
|
||||
expect(getA1ForMood("OKAY")).toBe(5);
|
||||
expect(getA1ForMood("GUT")).toBe(7);
|
||||
expect(getA1ForMood("SUPER_GUT")).toBe(9);
|
||||
});
|
||||
|
||||
it("keeps the endpoints inside the scale rather than on it", () => {
|
||||
// 0 and 10 stay reachable only by a value the user set by hand: a mapped
|
||||
// historical entry must not read as more extreme than every future one.
|
||||
expect(getA1ForMood("LAUSIG")).toBeGreaterThan(0);
|
||||
expect(getA1ForMood("SUPER_GUT")).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("answers the same neutral midpoint an unknown label gets from the score map", () => {
|
||||
// getScoreForMood answers 3 on a 1..5 axis for an unknown label — the
|
||||
// midpoint. A1's answer is the midpoint of its own axis, and the two are
|
||||
// asserted against each other so a change to one is loud in the other.
|
||||
expect(getScoreForMood("NOT_A_MOOD")).toBe(3);
|
||||
expect(getA1ForMood("NOT_A_MOOD")).toBe(5);
|
||||
expect(getA1ForMood("")).toBe(5);
|
||||
});
|
||||
|
||||
it("is ordered and evenly spaced across the five labels", () => {
|
||||
const ordered = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"].map(
|
||||
getA1ForMood,
|
||||
);
|
||||
expect(ordered).toEqual([1, 3, 5, 7, 9]);
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
expect(ordered[i] - ordered[i - 1]).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes exactly the five labels and nothing else", () => {
|
||||
expect(Object.keys(moodA1Map()).sort()).toEqual([
|
||||
"GUT",
|
||||
"LAUSIG",
|
||||
"OKAY",
|
||||
"SCHLECHT",
|
||||
"SUPER_GUT",
|
||||
]);
|
||||
});
|
||||
|
||||
it("agrees with the score map on ordering", () => {
|
||||
// Both axes must rank the five labels identically; a disagreement would
|
||||
// mean a row whose score and A1 tell opposite stories.
|
||||
const labels = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"];
|
||||
const byScore = [...labels].sort(
|
||||
(a, b) => getScoreForMood(a) - getScoreForMood(b),
|
||||
);
|
||||
const byA1 = [...labels].sort((a, b) => getA1ForMood(a) - getA1ForMood(b));
|
||||
expect(byA1).toEqual(byScore);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,15 @@ const moodEntrySchema = z
|
||||
date: z.string().min(1),
|
||||
mood: z.string().min(1),
|
||||
score: z.number().int().min(0).max(10),
|
||||
// The five level-A values. Optional and nullable: a file written before
|
||||
// they existed carries none, and an entry whose sliders were never touched
|
||||
// carries nulls. Absent restores as NULL, never as a defaulted midpoint —
|
||||
// a restore that invents an answer is worse than one that admits none.
|
||||
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(),
|
||||
tags: moodEntryTagsSchema,
|
||||
// The free text, in both storage shapes. A portable export carries the
|
||||
// decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus
|
||||
|
||||
@@ -46,6 +46,43 @@ export function getScoreForMood(mood: string): number {
|
||||
return MOOD_SCORE_MAP[mood] ?? 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five-point mood label mapped onto the level-A pleasantness scale (A1).
|
||||
*
|
||||
* The quick check-in asks one question and offers five faces, and it stays a
|
||||
* full-value capture path: its answer produces a real A1 rather than a
|
||||
* degraded one. This is the single place those five numbers exist. A second
|
||||
* copy anywhere in the tree — including inside a migration — is a drift
|
||||
* waiting to happen, which is why the backfill's SQL is read back and compared
|
||||
* against this map by a test rather than trusted to stay in step.
|
||||
*
|
||||
* The endpoints are 1 and 9, not 0 and 10. "LAUSIG" means as bad as this
|
||||
* instrument can express, not as bad as a person can feel; pinning it to 0
|
||||
* would make every mapped historical entry look more extreme than every future
|
||||
* hand-set one and would leave the true extremes unreachable. 5 is the
|
||||
* concept's neutral midpoint and is where "OKAY" belongs.
|
||||
*/
|
||||
const MOOD_A1_MAP: Record<string, number> = {
|
||||
SUPER_GUT: 9,
|
||||
GUT: 7,
|
||||
OKAY: 5,
|
||||
SCHLECHT: 3,
|
||||
LAUSIG: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* A1 for a five-point mood label. An unrecognised label answers the neutral
|
||||
* midpoint, matching what `getScoreForMood` answers for the same input.
|
||||
*/
|
||||
export function getA1ForMood(mood: string): number {
|
||||
return MOOD_A1_MAP[mood] ?? 5;
|
||||
}
|
||||
|
||||
/** Read-only view of the A1 map, for the guards that pin the backfill to it. */
|
||||
export function moodA1Map(): Readonly<Record<string, number>> {
|
||||
return MOOD_A1_MAP;
|
||||
}
|
||||
|
||||
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
|
||||
// Additive alongside the flat free-text `tags`: an entry can carry both.
|
||||
// Bounded so a single create can't fan out an unbounded link set.
|
||||
@@ -69,6 +106,14 @@ const ratedFactor = z.object({
|
||||
});
|
||||
const ratedFactors = z.array(ratedFactor).max(30);
|
||||
|
||||
// v1.37 — the five level-A self-state values, each 0-10. Optional on every
|
||||
// path: a client that sends only a five-point label still writes a complete
|
||||
// entry, because the server derives A1 from the label. A2 to A5 are only ever
|
||||
// what the user set, so an omitted one stays absent rather than defaulting to
|
||||
// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS`
|
||||
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
|
||||
const levelADimension = z.number().int().min(0).max(10);
|
||||
|
||||
export const createMoodEntrySchema = z
|
||||
.object({
|
||||
mood: moodLevelEnum,
|
||||
@@ -83,6 +128,19 @@ export const createMoodEntrySchema = z
|
||||
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
|
||||
// rejected (422) / dropped server-side per the catalog.
|
||||
ratedFactors: ratedFactors.optional(),
|
||||
// v1.37 — the five level-A values the detail sliders capture. Three
|
||||
// states, and they mean three different things, because this create can
|
||||
// land on the `update:` arm of an `externalId` upsert:
|
||||
// absent — nothing to say; a stored answer is left alone
|
||||
// number — the answer, stored literally
|
||||
// null — take the answer back
|
||||
// `a1` is the exception: absent or null, the server derives it from
|
||||
// `mood`, so a one-tap check-in is a full entry and never a degraded one.
|
||||
a1: levelADimension.nullable().optional(),
|
||||
a2: levelADimension.nullable().optional(),
|
||||
a3: levelADimension.nullable().optional(),
|
||||
a4: levelADimension.nullable().optional(),
|
||||
a5: levelADimension.nullable().optional(),
|
||||
// v1.4.30 H-5 — first-class free-text note. Replaces the
|
||||
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
|
||||
// Coach evidence shelf renders cleanly without truncating chips.
|
||||
@@ -118,6 +176,15 @@ export const updateMoodEntrySchema = z.object({
|
||||
// v1.12.0 — full replacement of the rated-factor set when present.
|
||||
// `null` clears every rated link; omit to leave them untouched.
|
||||
ratedFactors: ratedFactors.nullable().optional(),
|
||||
// v1.37 — level-A values on the edit path. Omitted leaves the stored value
|
||||
// alone; an explicit `null` clears it, which is how a user takes back an
|
||||
// answer they did not mean to give. Editing the five-point label without
|
||||
// sending `a1` re-derives A1, the same way it re-derives `score`.
|
||||
a1: levelADimension.nullable().optional(),
|
||||
a2: levelADimension.nullable().optional(),
|
||||
a3: levelADimension.nullable().optional(),
|
||||
a4: levelADimension.nullable().optional(),
|
||||
a5: levelADimension.nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
// v1.17 W1b — same plausibility bound on the edit path.
|
||||
moodLoggedAt: validateEntryInstant(
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* v1.37 — the level-A backfill, read out of the migration and executed.
|
||||
*
|
||||
* The statement that maps a hundred thousand historical rows onto the
|
||||
* pleasantness scale carries its own copy of five numbers, and the runtime map
|
||||
* carries the other. Two copies of the same five numbers is a drift waiting to
|
||||
* happen, so this test reads the arms back out of the `.sql` file and compares
|
||||
* them to `MOOD_A1_MAP` arm by arm — a migration edited without the map, or a
|
||||
* map edited without the migration, fails here.
|
||||
*
|
||||
* The rest of it executes the statement against real Postgres on rows inserted
|
||||
* the way legacy rows actually look (`mood_a1 IS NULL`), and asserts the rows,
|
||||
* not the fact that the statement ran. It runs a second time and asserts
|
||||
* nothing moved: a migration re-applied against a database that has since
|
||||
* taken real answers must leave them alone.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { moodA1Map } from "@/lib/validations/mood";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const MIGRATION_PATH = path.join(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
"0310_mood_level_a_dimensions",
|
||||
"migration.sql",
|
||||
);
|
||||
|
||||
const USER = "user-mood-a1-backfill";
|
||||
|
||||
function migrationSql(): string {
|
||||
return readFileSync(MIGRATION_PATH, "utf8");
|
||||
}
|
||||
|
||||
/** The `UPDATE mood_entries … ;` statement, comments stripped. */
|
||||
function backfillStatement(sql: string): string {
|
||||
const withoutComments = sql
|
||||
.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("--"))
|
||||
.join("\n");
|
||||
const match = withoutComments.match(/UPDATE\s+mood_entries[\s\S]*?;/i);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`No UPDATE statement found in ${MIGRATION_PATH}. The backfill is the point of this test; an absent statement is a failure, not a skip.`,
|
||||
);
|
||||
}
|
||||
return match[0];
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("mood a1 backfill — the statement agrees with the runtime map", () => {
|
||||
it("carries one CASE arm per label, with the value the map answers", () => {
|
||||
const statement = backfillStatement(migrationSql());
|
||||
const arms = [
|
||||
...statement.matchAll(/WHEN\s+'([A-Z_]+)'\s+THEN\s+(\d+)/gi),
|
||||
].map((m) => [m[1], Number(m[2])] as const);
|
||||
|
||||
// An empty match set means the matcher stopped matching, not that the
|
||||
// migration is correct. A guard that cannot fail is worse than none.
|
||||
expect(arms.length).toBeGreaterThan(0);
|
||||
|
||||
const map = moodA1Map();
|
||||
expect(arms.length).toBe(Object.keys(map).length);
|
||||
for (const [label, value] of arms) {
|
||||
expect(
|
||||
map[label],
|
||||
`CASE arm '${label}' has no entry in MOOD_A1_MAP`,
|
||||
).toBe(value);
|
||||
}
|
||||
// And the other direction: a label in the map with no arm would leave
|
||||
// those rows NULL after the backfill claimed to have covered them.
|
||||
for (const label of Object.keys(map)) {
|
||||
expect(
|
||||
arms.map(([l]) => l),
|
||||
`MOOD_A1_MAP entry '${label}' has no CASE arm in the migration`,
|
||||
).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("is guarded on the column being empty, so a re-run cannot overwrite an answer", () => {
|
||||
const statement = backfillStatement(migrationSql());
|
||||
expect(statement.replace(/\s+/g, " ")).toMatch(
|
||||
/WHERE\s+mood_a1\s+IS\s+NULL/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mood a1 backfill — executed against real rows", () => {
|
||||
async function seedLegacyRows() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "mood-a1-backfill",
|
||||
email: "mood-a1-backfill@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
// Inserted through raw SQL so the columns look exactly like a row written
|
||||
// before they existed: every level-A column NULL. Going through Prisma
|
||||
// would let a default or a hook fill one in and the test would be
|
||||
// proving the seed rather than the migration.
|
||||
const rows: Array<[string, string, number, string | null]> = [
|
||||
["legacy-lausig", "LAUSIG", 1, null],
|
||||
["legacy-schlecht", "SCHLECHT", 2, null],
|
||||
["legacy-okay", "OKAY", 3, null],
|
||||
["legacy-gut", "GUT", 4, null],
|
||||
["legacy-super", "SUPER_GUT", 5, null],
|
||||
// A tombstoned row is still the user's history and still restorable.
|
||||
["legacy-deleted", "GUT", 4, "2026-05-01T00:00:00.000Z"],
|
||||
// A label nothing recognises: no arm matches, so it stays NULL rather
|
||||
// than being handed the midpoint.
|
||||
["legacy-unknown", "NOT_A_MOOD", 3, null],
|
||||
];
|
||||
|
||||
for (const [index, [id, mood, score, deletedAt]] of rows.entries()) {
|
||||
// An explicit instant per row, one hour apart. `NOW()` would be the
|
||||
// transaction's own clock, and two inserts landing on the same tick
|
||||
// collide on the `(user_id, date, mood_logged_at)` unique — a seed that
|
||||
// fails on a fast machine and passes on a slow one.
|
||||
const loggedAt = `2026-05-16T0${index}:00:00.000Z`;
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, created_at, updated_at, sync_version, deleted_at)
|
||||
VALUES ($1, $2, '2026-05-16', $3, $4, 'MANUAL', $5::timestamptz, NOW(), NOW(), NOW(), 0, $6::timestamptz)`,
|
||||
id,
|
||||
USER,
|
||||
mood,
|
||||
score,
|
||||
loggedAt,
|
||||
deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
const before = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
|
||||
`SELECT COUNT(*)::bigint AS count FROM mood_entries WHERE mood_a1 IS NULL`,
|
||||
);
|
||||
expect(Number(before[0].count)).toBe(rows.length);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
it("maps every legacy row onto its anchor and leaves the other four empty", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const seeded = await seedLegacyRows();
|
||||
|
||||
const changed = await prisma.$executeRawUnsafe(
|
||||
backfillStatement(migrationSql()),
|
||||
);
|
||||
// Every row but the unknown label — the statement's IN list excludes it.
|
||||
expect(changed).toBe(seeded - 1);
|
||||
|
||||
const after = await prisma.moodEntry.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { id: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
mood: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
},
|
||||
});
|
||||
expect(after).toHaveLength(seeded);
|
||||
|
||||
const byId = new Map(after.map((r) => [r.id, r]));
|
||||
expect(byId.get("legacy-lausig")?.moodA1).toBe(1);
|
||||
expect(byId.get("legacy-schlecht")?.moodA1).toBe(3);
|
||||
expect(byId.get("legacy-okay")?.moodA1).toBe(5);
|
||||
expect(byId.get("legacy-gut")?.moodA1).toBe(7);
|
||||
expect(byId.get("legacy-super")?.moodA1).toBe(9);
|
||||
// A soft-deleted row is backfilled too: it is still in the backup and
|
||||
// still comes back from a restore.
|
||||
expect(byId.get("legacy-deleted")?.moodA1).toBe(7);
|
||||
// An unrecognised label gets nothing rather than a fabricated midpoint.
|
||||
expect(byId.get("legacy-unknown")?.moodA1).toBeNull();
|
||||
|
||||
// Nothing derived the other four, on any row.
|
||||
for (const row of after) {
|
||||
expect(row.stressA2).toBeNull();
|
||||
expect(row.energyA3).toBeNull();
|
||||
expect(row.connectionA4).toBeNull();
|
||||
expect(row.stabilityA5).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, including a value set since the first", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedLegacyRows();
|
||||
const statement = backfillStatement(migrationSql());
|
||||
|
||||
await prisma.$executeRawUnsafe(statement);
|
||||
|
||||
// Somebody answers the slider on one of the backfilled rows, contradicting
|
||||
// the mapped value. A re-run must not take that answer away.
|
||||
await prisma.moodEntry.update({
|
||||
where: { id: "legacy-okay" },
|
||||
data: { moodA1: 8 },
|
||||
});
|
||||
|
||||
const beforeSecond = await prisma.moodEntry.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { id: "asc" },
|
||||
select: { id: true, moodA1: true, updatedAt: true },
|
||||
});
|
||||
|
||||
const changedOnSecondRun = await prisma.$executeRawUnsafe(statement);
|
||||
expect(changedOnSecondRun).toBe(0);
|
||||
|
||||
const afterSecond = await prisma.moodEntry.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { id: "asc" },
|
||||
select: { id: true, moodA1: true, updatedAt: true },
|
||||
});
|
||||
expect(afterSecond).toEqual(beforeSecond);
|
||||
expect(
|
||||
afterSecond.find((r) => r.id === "legacy-okay")?.moodA1,
|
||||
"the hand-set answer survived the re-run",
|
||||
).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -186,6 +186,15 @@ describe("mood backup round trip", () => {
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
// All five level-A values, and deliberately not the ones the label
|
||||
// would derive: a restore that re-derived them instead of carrying
|
||||
// them would look right on this row and be wrong on every row where
|
||||
// the person disagreed with their own five-point pick.
|
||||
moodA1: 4,
|
||||
stressA2: 9,
|
||||
energyA3: 2,
|
||||
connectionA4: 6,
|
||||
stabilityA5: 0,
|
||||
tags: JSON.stringify(["free-text-tag"]),
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(NOTE),
|
||||
@@ -268,6 +277,14 @@ describe("mood backup round trip", () => {
|
||||
expect(restored.updatedAt).toEqual(UPDATED_AT);
|
||||
expect(restored.date).toBe("2026-07-19");
|
||||
expect(restored.score).toBe(3);
|
||||
// Each of the five asserted by value. Zero is included on purpose: it is a
|
||||
// real answer on this scale, and a carrier that treated it as absence
|
||||
// would round-trip it as NULL and pass a truthiness check.
|
||||
expect(restored.moodA1).toBe(4);
|
||||
expect(restored.stressA2).toBe(9);
|
||||
expect(restored.energyA3).toBe(2);
|
||||
expect(restored.connectionA4).toBe(6);
|
||||
expect(restored.stabilityA5).toBe(0);
|
||||
expect(restored.tags).toBe(JSON.stringify(["free-text-tag"]));
|
||||
expect(restored.deletedAt).toBeNull();
|
||||
|
||||
@@ -276,6 +293,11 @@ describe("mood backup round trip", () => {
|
||||
});
|
||||
expect(tombstone.deletedAt).toEqual(DELETED_AT);
|
||||
expect(tombstone.syncVersion).toBe(5);
|
||||
// The tombstone was written without level-A values and comes back without
|
||||
// them. A restore filling these in would put an answer nobody gave into a
|
||||
// row the person had already deleted.
|
||||
expect(tombstone.moodA1).toBeNull();
|
||||
expect(tombstone.stressA2).toBeNull();
|
||||
|
||||
const links = await prisma.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: "mood-rt-entry" },
|
||||
@@ -296,6 +318,146 @@ describe("mood backup round trip", () => {
|
||||
expect(byKey.get("rt_sleep_quality")!.rating).toBe(4);
|
||||
});
|
||||
|
||||
it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await seedOwner(prisma);
|
||||
|
||||
// A row as it stands today, with answered sliders.
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
id: "mood-rt-live",
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
moodA1: 4,
|
||||
stressA2: 9,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: LOGGED_AT,
|
||||
},
|
||||
});
|
||||
|
||||
// Restoring a file written before these columns existed. The restore
|
||||
// deletes the owner's mood partition and rebuilds it from the file, so
|
||||
// the row that comes back is the file's row and carries nothing the file
|
||||
// did not hold. This is the disaster-recovery contract and it is asserted
|
||||
// here so nobody reads the omit-on-update below as protecting a live row:
|
||||
// by the time that arm runs there are no live rows left.
|
||||
const legacyPayload = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: OWNER_ID,
|
||||
appSettings: null,
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-rt-live",
|
||||
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_LEVEL_A_WIPE",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const after = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-live" },
|
||||
});
|
||||
// Not 4 and not 9: the file said nothing about them and the file is what
|
||||
// was restored. Nothing was derived to fill the gap either.
|
||||
expect(after.moodA1).toBeNull();
|
||||
expect(after.stressA2).toBeNull();
|
||||
});
|
||||
|
||||
it("lets a second entry for the same row add without erasing", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await seedOwner(prisma);
|
||||
|
||||
// Two entries in ONE file addressing the same row. The first creates it,
|
||||
// the second lands on the upsert's update arm — the only way that arm is
|
||||
// reachable, since the partition was wiped a moment earlier. A second
|
||||
// entry that mentions nothing about a dimension must not blank what the
|
||||
// first one set, and one that mentions it wins, including a null.
|
||||
const payload = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-08-08T00:00:00.000Z",
|
||||
userId: OWNER_ID,
|
||||
appSettings: null,
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-rt-collide",
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: null,
|
||||
source: "MANUAL",
|
||||
loggedAt: LOGGED_AT.toISOString(),
|
||||
externalId: null,
|
||||
deletedAt: null,
|
||||
a1: 4,
|
||||
a2: 9,
|
||||
a3: 6,
|
||||
factors: [],
|
||||
},
|
||||
{
|
||||
id: "mood-rt-collide",
|
||||
date: "2026-07-19",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: null,
|
||||
source: "MANUAL",
|
||||
loggedAt: LOGGED_AT.toISOString(),
|
||||
externalId: null,
|
||||
deletedAt: null,
|
||||
a1: 7,
|
||||
a3: null,
|
||||
factors: [],
|
||||
},
|
||||
],
|
||||
customMoodTags: [],
|
||||
nutrientDays: [],
|
||||
};
|
||||
|
||||
const response = await restoreFromPayload(
|
||||
prisma,
|
||||
parseBackupPayload(payload),
|
||||
"MOOD_LEVEL_A_COLLIDE",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const after = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-collide" },
|
||||
});
|
||||
expect(after.mood).toBe("GUT");
|
||||
// Stated by the second entry.
|
||||
expect(after.moodA1).toBe(7);
|
||||
// Not mentioned by the second entry: the first entry's answer stands.
|
||||
expect(after.stressA2).toBe(9);
|
||||
// Stated as null by the second entry: cleared.
|
||||
expect(after.energyA3).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a file written in the shape that predates these fields", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
@@ -338,6 +500,7 @@ describe("mood backup round trip", () => {
|
||||
expect(parsed.moodEntries[0].structuredTags).toEqual([]);
|
||||
expect(parsed.moodEntries[0].tz).toBeUndefined();
|
||||
expect(parsed.moodEntries[0].syncVersion).toBeUndefined();
|
||||
expect(parsed.moodEntries[0].a1).toBeUndefined();
|
||||
|
||||
const response = await restoreFromPayload(
|
||||
prisma,
|
||||
@@ -360,6 +523,14 @@ describe("mood backup round trip", () => {
|
||||
expect(restored.score).toBe(4);
|
||||
expect(restored.externalId).toBe("moodlog-legacy");
|
||||
expect(restored.deletedAt).toBeNull();
|
||||
// A file from before these columns carries no answer for them, and the
|
||||
// restore does not invent one. `GUT` would derive to 7; a restore that
|
||||
// derived instead of carrying would write a number this file never held.
|
||||
expect(restored.moodA1).toBeNull();
|
||||
expect(restored.stressA2).toBeNull();
|
||||
expect(restored.energyA3).toBeNull();
|
||||
expect(restored.connectionA4).toBeNull();
|
||||
expect(restored.stabilityA5).toBeNull();
|
||||
|
||||
const links = await prisma.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: "mood-rt-legacy" },
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* v1.37 — the level-A blocks the assistant actually receives.
|
||||
*
|
||||
* This calls `extractFeatures`, the shipped composition, against real rows in
|
||||
* real Postgres. The unit test beside it (`mood-staleness.test.ts`) rebuilds
|
||||
* the block shape from the pure calculator, which proves the calculator and
|
||||
* the freshness rule agree — but it never imports `features.ts`, so on its own
|
||||
* it would stay green if the builder hardcoded `current: true`, dropped
|
||||
* `staleNotice`, or stopped attaching `dimensions` at all. Both ends and the
|
||||
* pipe between them: this is the pipe.
|
||||
*
|
||||
* The fixture is the one the phase exists for. A person taps a face every
|
||||
* morning and opens the sliders once a week, so pleasantness is from today and
|
||||
* stress is six days old. One of those may back a present-tense sentence and
|
||||
* the other may not.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "user-mood-dimension-features";
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The reader's own local today, resolved the way the builder resolves it. */
|
||||
function todayKey(): string {
|
||||
return userDayKey(new Date(), TZ);
|
||||
}
|
||||
|
||||
async function seedUser() {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "mood-dimension-features",
|
||||
email: "mood-dimension-features@example.test",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One mood entry `daysAgo` days back, carrying whichever level-A values are
|
||||
* given. Written through Prisma with every column stated, so the row looks
|
||||
* exactly like one a writer would produce.
|
||||
*/
|
||||
async function seedEntry(
|
||||
daysAgo: number,
|
||||
values: {
|
||||
mood: string;
|
||||
score: number;
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
},
|
||||
) {
|
||||
const loggedAt = new Date(Date.now() - daysAgo * DAY_MS);
|
||||
await getPrismaClient().moodEntry.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
date: shiftDateKey(todayKey(), -daysAgo),
|
||||
tz: TZ,
|
||||
mood: values.mood,
|
||||
score: values.score,
|
||||
moodA1: values.a1 ?? null,
|
||||
stressA2: values.a2 ?? null,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("level-A blocks reach the feature set with their own ages", () => {
|
||||
it("marks today's pleasantness current and a six-day-old stress stale", async () => {
|
||||
await seedUser();
|
||||
// Six days ago: a full entry, sliders and all.
|
||||
await seedEntry(6, { mood: "SCHLECHT", score: 2, a1: 3, a2: 9 });
|
||||
// Since then, face-only check-ins. Pleasantness keeps moving; stress does
|
||||
// not, because nobody opened the section again.
|
||||
await seedEntry(2, { mood: "OKAY", score: 3, a1: 5 });
|
||||
await seedEntry(0, { mood: "GUT", score: 4, a1: 7 });
|
||||
|
||||
const features = await extractFeatures(USER, false);
|
||||
|
||||
// The block exists at all — a dropped `dimensions` key fails right here.
|
||||
expect(features.mood).toBeDefined();
|
||||
const dimensions = features.mood?.dimensions;
|
||||
expect(
|
||||
dimensions,
|
||||
"features.mood carries no dimensions block; the assistant would see none of the five",
|
||||
).toBeDefined();
|
||||
|
||||
// Only the two that were answered. A block for a question nobody was
|
||||
// asked invites a sentence about it.
|
||||
expect(dimensions!.map((d) => d.key).sort()).toEqual(["a1", "a2"]);
|
||||
|
||||
const a1 = dimensions!.find((d) => d.key === "a1")!;
|
||||
expect(a1.latest).toBe(7);
|
||||
expect(a1.newestDaysAgo).toBe(0);
|
||||
expect(a1.current).toBe(true);
|
||||
// Nothing to warn about while it is current.
|
||||
expect(a1.staleNotice).toBeUndefined();
|
||||
expect(a1.count).toBe(3);
|
||||
expect(a1.inverse).toBe(false);
|
||||
|
||||
const a2 = dimensions!.find((d) => d.key === "a2")!;
|
||||
expect(a2.latest).toBe(9);
|
||||
expect(a2.newestDaysAgo).toBe(6);
|
||||
// The claim under test: nothing may say "your stress is high today".
|
||||
expect(a2.current).toBe(false);
|
||||
expect(
|
||||
a2.staleNotice,
|
||||
"a stale dimension ships without the instruction that makes it safe to read",
|
||||
).toBeTruthy();
|
||||
expect(a2.staleNotice).toContain(shiftDateKey(todayKey(), -6));
|
||||
expect(a2.staleNotice!.toLowerCase()).toContain("do not describe");
|
||||
// Stored literally: stress is inverse-oriented and says so rather than
|
||||
// arriving pre-flipped.
|
||||
expect(a2.inverse).toBe(true);
|
||||
expect(a2.count).toBe(1);
|
||||
|
||||
// The block-level asOf the snapshot stamps on top of this comes from
|
||||
// coverage.newestDaysAgo — the newest ENTRY, which is today, because a
|
||||
// face was tapped this morning. Read as covering the dimensions it would
|
||||
// make the six-day-old stress look current, so the block says which age
|
||||
// governs which claim.
|
||||
expect(features.mood?.coverage.newestDaysAgo).toBe(0);
|
||||
expect(
|
||||
features.mood?.dimensionsAsOfNote,
|
||||
"the block carries two ages that disagree and nothing says which one governs the dimensions",
|
||||
).toBeTruthy();
|
||||
expect(features.mood?.dimensionsAsOfNote).toContain("newestDaysAgo");
|
||||
});
|
||||
|
||||
it("never ships a latest without an age beside it, and names the scale", async () => {
|
||||
await seedUser();
|
||||
await seedEntry(1, { mood: "GUT", score: 4, a1: 8, a2: 2 });
|
||||
|
||||
const features = await extractFeatures(USER, false);
|
||||
const dimensions = features.mood?.dimensions ?? [];
|
||||
expect(dimensions.length).toBeGreaterThan(0);
|
||||
|
||||
for (const block of dimensions) {
|
||||
expect(block.latest).not.toBeNull();
|
||||
// The structural half of the fix: a `latest` with no age is exactly
|
||||
// what produced a reading from last week stated as this morning's.
|
||||
expect(block.newestDaysAgo).not.toBeNull();
|
||||
expect(block.latestDate).not.toBeNull();
|
||||
expect(typeof block.current).toBe("boolean");
|
||||
// And the wording behind the numbers, so a 7 is not read as 7 out of 5.
|
||||
expect(block.scale).toMatch(/0-10/);
|
||||
expect(block.scale.length).toBeGreaterThan(20);
|
||||
}
|
||||
|
||||
// Yesterday still backs a present-tense claim.
|
||||
expect(dimensions.every((d) => d.current)).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the block entirely for an account that has only ever tapped a face", async () => {
|
||||
await seedUser();
|
||||
// Rows written before the columns existed look exactly like this.
|
||||
await seedEntry(0, { mood: "GUT", score: 4 });
|
||||
|
||||
const features = await extractFeatures(USER, false);
|
||||
// The mood aggregate is still there — the five-point axis is unaffected.
|
||||
expect(features.mood).toBeDefined();
|
||||
expect(features.mood?.latest).toBe(4);
|
||||
// But nothing claims a self-state that was never recorded — and no note
|
||||
// explaining a block that is not there.
|
||||
expect(features.mood?.dimensions).toBeUndefined();
|
||||
expect(features.mood?.dimensionsAsOfNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the legacy scale line untouched", async () => {
|
||||
await seedUser();
|
||||
await seedEntry(0, { mood: "GUT", score: 4, a1: 7 });
|
||||
|
||||
const features = await extractFeatures(USER, false);
|
||||
// Sent to the model verbatim and describing `score`, not the new axis.
|
||||
// A phase that widened this string would have changed a sentence the
|
||||
// model has been reading correctly for years.
|
||||
expect(features.mood?.scale).toBe(
|
||||
"1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* v1.37 — every mood writer resolves the same pleasantness value.
|
||||
*
|
||||
* Five paths write a `MoodEntry`, and only one of them is the web form. If
|
||||
* the derivation lived at the call site, a mood tapped in a chat and the same
|
||||
* mood typed into the form would drift apart the first time one of the five
|
||||
* was edited without the others. This test posts the identical mood through
|
||||
* all five and asserts the rows agree, against real Postgres and the real
|
||||
* handlers:
|
||||
*
|
||||
* 1. POST /api/mood-entries (web + iOS single)
|
||||
* 2. POST /api/mood-entries/bulk (iOS backfill)
|
||||
* 3. logTelegramMood (the bot)
|
||||
* 4. POST /api/import (JSON restore)
|
||||
* 5. logMcpMood (the assistant tool)
|
||||
*
|
||||
* It also pins the three rules the columns exist for: a re-post refreshes the
|
||||
* value rather than leaving a stale one beside a fresh mood, an explicit
|
||||
* client value beats the derivation, and nothing ever invents A2 to A5.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "user-mood-writers-agree";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
async function createUserSession() {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "mood-writers-agree",
|
||||
email: "mood-writers-agree@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
function jsonRequest(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
});
|
||||
|
||||
describe("mood writers agree on the derived level-A value", () => {
|
||||
it("writes the same moodA1 through all five writers for the same mood", async () => {
|
||||
await createUserSession();
|
||||
|
||||
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
|
||||
const { POST: postBulk } =
|
||||
await import("@/app/api/mood-entries/bulk/route");
|
||||
const { POST: postImport } = await import("@/app/api/import/route");
|
||||
const { logTelegramMood } = await import("@/lib/mood/create-from-telegram");
|
||||
const { logMcpMood } = await import("@/lib/mcp/writes");
|
||||
|
||||
// 1 — single create.
|
||||
const single = await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
externalId: "writer-single",
|
||||
}),
|
||||
);
|
||||
expect(single.status).toBe(201);
|
||||
|
||||
// 2 — bulk backfill. The request shape carries no level-A field and the
|
||||
// server derives it, which is why the published contract does not move:
|
||||
// this batch path is the one in `docs/api/openapi.yaml`, and it is
|
||||
// byte-identical. The unpublished single routes DO answer five new keys
|
||||
// on their reads, which is additive and which the client ignores.
|
||||
const bulk = await postBulk(
|
||||
jsonRequest("/api/mood-entries/bulk", {
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T09:00:00.000Z",
|
||||
externalId: "writer-bulk",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(bulk.status).toBe(200);
|
||||
|
||||
// 3 — the bot. Its input is the 1-5 score; it resolves the label itself.
|
||||
const telegram = await logTelegramMood({
|
||||
userId: USER,
|
||||
score: 4,
|
||||
tz: "Europe/Berlin",
|
||||
externalId: "telegram:mood:1:2:4",
|
||||
});
|
||||
expect(telegram.created).toBe(true);
|
||||
|
||||
// 4 — the JSON import. Deliberately carries a `score` that disagrees with
|
||||
// its own label: this is the one writer whose score comes out of the file
|
||||
// rather than out of the label, and the self-assessment column has to
|
||||
// follow the label the user picked, not the number the file asserts.
|
||||
const imported = await postImport(
|
||||
jsonRequest("/api/import", {
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-16",
|
||||
mood: "GUT",
|
||||
score: 2,
|
||||
loggedAt: "2026-05-16T10:00:00.000Z",
|
||||
externalId: "writer-import",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(imported.status).toBe(200);
|
||||
|
||||
// 5 — the assistant tool.
|
||||
const mcp = await logMcpMood({
|
||||
userId: USER,
|
||||
score: 4,
|
||||
tz: "Europe/Berlin",
|
||||
idempotencyKey: "writer-mcp-1",
|
||||
});
|
||||
expect(mcp.status).toBe("written");
|
||||
|
||||
const rows = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { source: "asc" },
|
||||
select: {
|
||||
source: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Five writers, five rows — and one of them is the bulk path, which must
|
||||
// not have been folded into the single path's row.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows.map((r) => r.source).sort()).toEqual([
|
||||
"IMPORT",
|
||||
"MANUAL",
|
||||
"MANUAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
]);
|
||||
|
||||
for (const row of rows) {
|
||||
expect(row.mood).toBe("GUT");
|
||||
// GUT sits at 7 on the level-A scale. Asserted by value on every row,
|
||||
// not by comparing the rows to each other: five rows agreeing on a
|
||||
// wrong number would pass a same-value assertion.
|
||||
expect(row.moodA1).toBe(7);
|
||||
// Nothing derives the other four, on any path.
|
||||
expect(row.stressA2).toBeNull();
|
||||
expect(row.energyA3).toBeNull();
|
||||
expect(row.connectionA4).toBeNull();
|
||||
expect(row.stabilityA5).toBeNull();
|
||||
}
|
||||
|
||||
// The imported row keeps the file's own score while its level-A value
|
||||
// follows the label — the disagreement the file carried is not copied
|
||||
// into the new column.
|
||||
const importedRow = rows.find((r) => r.source === "IMPORT");
|
||||
expect(importedRow?.score).toBe(2);
|
||||
expect(importedRow?.moodA1).toBe(7);
|
||||
});
|
||||
|
||||
it("refreshes moodA1 when an externalId is re-posted with a different mood", async () => {
|
||||
await createUserSession();
|
||||
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
|
||||
const { POST: postBulk } =
|
||||
await import("@/app/api/mood-entries/bulk/route");
|
||||
|
||||
await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "LAUSIG",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
externalId: "repost-single",
|
||||
}),
|
||||
);
|
||||
await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T08:10:00.000Z",
|
||||
externalId: "repost-single",
|
||||
}),
|
||||
);
|
||||
|
||||
await postBulk(
|
||||
jsonRequest("/api/mood-entries/bulk", {
|
||||
entries: [
|
||||
{
|
||||
mood: "LAUSIG",
|
||||
moodLoggedAt: "2026-05-16T11:00:00.000Z",
|
||||
externalId: "repost-bulk",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await postBulk(
|
||||
jsonRequest("/api/mood-entries/bulk", {
|
||||
entries: [
|
||||
{
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T11:10:00.000Z",
|
||||
externalId: "repost-bulk",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { externalId: "asc" },
|
||||
select: { externalId: true, mood: true, score: true, moodA1: true },
|
||||
});
|
||||
|
||||
// One row per externalId — the re-post updated in place, as before.
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(row.mood).toBe("SUPER_GUT");
|
||||
expect(row.score).toBe(5);
|
||||
// The update arm of both upserts restates the value. Without it the row
|
||||
// would carry LAUSIG's 1 beside SUPER_GUT's 5.
|
||||
expect(row.moodA1).toBe(9);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a dimension the re-post said nothing about exactly where it was", async () => {
|
||||
await createUserSession();
|
||||
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
|
||||
const { POST: postBulk } =
|
||||
await import("@/app/api/mood-entries/bulk/route");
|
||||
|
||||
// Somebody answers the sliders on the web.
|
||||
await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
externalId: "preserve-single",
|
||||
a2: 9,
|
||||
a3: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// The phone re-posts the same entry it holds: label, timestamp, id, and no
|
||||
// sliders, because its build has none. This must not blank what the web
|
||||
// wrote. Absence in a request means "nothing to say", not "set to
|
||||
// nothing" — a request that cleared four columns by staying silent would
|
||||
// destroy answers on every sync round.
|
||||
await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:05:00.000Z",
|
||||
externalId: "preserve-single",
|
||||
}),
|
||||
);
|
||||
|
||||
const afterRepost = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: USER, externalId: "preserve-single" },
|
||||
});
|
||||
expect(afterRepost.mood).toBe("GUT");
|
||||
// Pleasantness DOES follow the label: the request carried one, and a
|
||||
// stale A1 beside a changed face would contradict the entry itself.
|
||||
expect(afterRepost.moodA1).toBe(7);
|
||||
// The four the request said nothing about are untouched.
|
||||
expect(afterRepost.stressA2).toBe(9);
|
||||
expect(afterRepost.energyA3).toBe(2);
|
||||
expect(afterRepost.connectionA4).toBeNull();
|
||||
expect(afterRepost.stabilityA5).toBeNull();
|
||||
|
||||
// An explicit null is how an answer is taken back — the other route out of
|
||||
// the same three-state contract.
|
||||
await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:06:00.000Z",
|
||||
externalId: "preserve-single",
|
||||
a2: null,
|
||||
}),
|
||||
);
|
||||
const afterClear = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: USER, externalId: "preserve-single" },
|
||||
});
|
||||
expect(afterClear.stressA2).toBeNull();
|
||||
// Clearing one says nothing about the others.
|
||||
expect(afterClear.energyA3).toBe(2);
|
||||
|
||||
// The batch path answers the same way, and always has: it carries no
|
||||
// level-A input at all, so every re-import preserves.
|
||||
await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T12:00:00.000Z",
|
||||
externalId: "preserve-bulk",
|
||||
a4: 6,
|
||||
}),
|
||||
);
|
||||
await postBulk(
|
||||
jsonRequest("/api/mood-entries/bulk", {
|
||||
entries: [
|
||||
{
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T12:30:00.000Z",
|
||||
externalId: "preserve-bulk",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const afterBulk = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: USER, externalId: "preserve-bulk" },
|
||||
});
|
||||
expect(afterBulk.mood).toBe("SUPER_GUT");
|
||||
expect(afterBulk.moodA1).toBe(9);
|
||||
expect(afterBulk.connectionA4).toBe(6);
|
||||
});
|
||||
|
||||
it("lets an explicit client value win over the derivation, and stores the other four literally", async () => {
|
||||
await createUserSession();
|
||||
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
|
||||
|
||||
const res = await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T12:00:00.000Z",
|
||||
// A day that felt middling overall but was genuinely stressful: the
|
||||
// whole reason five values beat one.
|
||||
a1: 4,
|
||||
a2: 9,
|
||||
a3: 2,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as {
|
||||
data: Record<string, unknown> & {
|
||||
a1: number;
|
||||
a2: number;
|
||||
a3: number;
|
||||
a4: null;
|
||||
a5: null;
|
||||
};
|
||||
};
|
||||
// The response echoes the values under the keys the request used.
|
||||
expect(json.data.a1).toBe(4);
|
||||
expect(json.data.a2).toBe(9);
|
||||
expect(json.data.a5).toBeNull();
|
||||
// And under those keys ONLY. The routes spread the whole row, so simply
|
||||
// adding the wire keys shipped every value twice under two spellings; a
|
||||
// client would then have picked one and been stuck with it.
|
||||
const keys = Object.keys(json.data as Record<string, unknown>);
|
||||
expect(keys).not.toContain("moodA1");
|
||||
expect(keys).not.toContain("stressA2");
|
||||
expect(keys).not.toContain("energyA3");
|
||||
expect(keys).not.toContain("connectionA4");
|
||||
expect(keys).not.toContain("stabilityA5");
|
||||
|
||||
const row = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: USER },
|
||||
});
|
||||
// Not 5 — OKAY's derived value lost to the number the user set.
|
||||
expect(row.moodA1).toBe(4);
|
||||
// Stored literally, higher meaning more stress. A value flipped on the
|
||||
// way in could never be recovered.
|
||||
expect(row.stressA2).toBe(9);
|
||||
expect(row.energyA3).toBe(2);
|
||||
// Untouched sliders write nothing at all.
|
||||
expect(row.connectionA4).toBeNull();
|
||||
expect(row.stabilityA5).toBeNull();
|
||||
// The five-point axis is untouched by any of this.
|
||||
expect(row.score).toBe(3);
|
||||
});
|
||||
|
||||
it("re-derives A1 when an entry's mood is corrected, and clears a value on an explicit null", async () => {
|
||||
await createUserSession();
|
||||
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
|
||||
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
|
||||
|
||||
const created = await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "SCHLECHT",
|
||||
moodLoggedAt: "2026-05-16T13:00:00.000Z",
|
||||
a2: 8,
|
||||
}),
|
||||
);
|
||||
const createdJson = (await created.json()) as { data: { id: string } };
|
||||
const id = createdJson.data.id;
|
||||
|
||||
const corrected = await PUT(
|
||||
new NextRequest(`http://localhost/api/mood-entries/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mood: "GUT", a2: null }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(corrected.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().moodEntry.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
expect(row.mood).toBe("GUT");
|
||||
expect(row.score).toBe(4);
|
||||
// The label moved, so pleasantness moved with it — the same way `score`
|
||||
// does. A stale 3 here would contradict the entry's own label.
|
||||
expect(row.moodA1).toBe(7);
|
||||
// An explicit null takes back an answer.
|
||||
expect(row.stressA2).toBeNull();
|
||||
});
|
||||
|
||||
it("will not empty pleasantness, on either route", async () => {
|
||||
await createUserSession();
|
||||
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
|
||||
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
|
||||
|
||||
// An entry always carries a five-point label, so it always implies a
|
||||
// pleasantness value. A null asks for one that cannot exist, and both
|
||||
// routes answer it the same way: derive from the label. The capture
|
||||
// surfaces therefore offer no clear control for this one field, which is
|
||||
// the UI half of the same rule.
|
||||
const created = await postSingle(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T14:00:00.000Z",
|
||||
a1: null,
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const createdJson = (await created.json()) as {
|
||||
data: { id: string; a1: number };
|
||||
};
|
||||
expect(createdJson.data.a1).toBe(9);
|
||||
const id = createdJson.data.id;
|
||||
|
||||
const cleared = await PUT(
|
||||
new NextRequest(`http://localhost/api/mood-entries/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ a1: null }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(cleared.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().moodEntry.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
// Derived from the entry's own stored label, not emptied.
|
||||
expect(row.moodA1).toBe(9);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user