Merge pull request #735 from MBombeck/feat/mood-level-a

Five dimensions behind the mood check-in
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 04:29:27 +02:00
commit 8045540b7e
47 files changed
+3667 -74

No files matched your search

+4
View File
@@ -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).
+25
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+52
View File
@@ -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
+11
View File
@@ -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,
+33 -2
View File
@@ -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
+8
View File
@@ -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),
+31 -2
View File
@@ -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
+1
View File
@@ -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"');
+47 -35
View File
@@ -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
+194
View File
@@ -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>
);
}
+36
View File
@@ -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">
+120
View File
@@ -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"');
});
});
+167
View File
@@ -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 };
+48
View File
@@ -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([
"",
"",
"",
"",
"",
]);
});
});
+22
View File
@@ -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";
+15
View File
@@ -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 ?? "",
}));
}
+14
View File
@@ -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);
});
});
+186
View File
@@ -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,
+49
View File
@@ -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,
+138
View File
@@ -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,
};
});
}
+5
View File
@@ -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,
+62
View File
@@ -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();
});
});
+5
View File
@@ -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),
+135
View File
@@ -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);
}
+152
View File
@@ -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);
});
});
+9
View File
@@ -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
+67
View File
@@ -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(
+229
View File
@@ -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);
});
});