diff --git a/.gitleaks.toml b/.gitleaks.toml index 1b98529ce..b5d9433d0 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -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: ""` as a token. + '''mood\.dimension\.''', ] # Specific commit fingerprints from prior false-positive findings (kept for reproducibility). diff --git a/CHANGELOG.md b/CHANGELOG.md index 540c25071..4966d947b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f0e9130f..36fb7684e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", diff --git a/messages/de.json b/messages/de.json index 6f369713c..4a6829640 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index c1ed221a0..6f5441023 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index f224a86cd..2f7a782d3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index a9a274f54..37c665bbd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index c153dfd5d..47bef8d2b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index fd5b1d90f..3b9101d7b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql new file mode 100644 index 000000000..e07fec8ee --- /dev/null +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -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'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7adb641be..affc5e70c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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:"]` 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. diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 1feac2a38..cb5a0d316 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -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 diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 4f3597843..a6196cf0c 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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 diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index f1c32149f..e81f51a54 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index b6f1c4412..c3f09b24c 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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 diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index a8f3bd3d2..938be2a60 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { 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 { 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 { // the same row. mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e92baa7d1..cc3ef97c8 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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 diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 6fc198934..788235dc0 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -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"; diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx new file mode 100644 index 000000000..2b06f7ca8 --- /dev/null +++ b/src/components/insights/mood/mood-dimension-trends.tsx @@ -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 = { + 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(30); + + const present = dimensions.filter((d) => d.present); + const missing = dimensions.filter((d) => !d.present); + + const chartData = useMemo(() => { + const byDate = new Map>(); + 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 ( +

+ {t("insights.mood.dimensions.none")} +

+ ); + } + + return ( +
+
+ {MOOD_DIMENSION_WINDOWS.map((option) => ( + + ))} +
+ +
+ + + + value.slice(5)} + minTickGap={24} + /> + + [ + String(value ?? ""), + labelFor(String(name)), + ]} + /> + {present.map((summary) => ( + + ))} + + +
+ +
    + {present.map((summary) => { + const mean = meanForWindow(summary, window); + const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key); + return ( +
  • + + + + {mean === null + ? t("insights.mood.dimensions.noneInWindow") + : mean} + +
  • + ); + })} +
+ + {missing.length > 0 ? ( +

+ {t("insights.mood.dimensions.notRecorded", { + list: missing.map((d) => labelFor(d.key)).join(", "), + })} +

+ ) : null} +
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index de4e6c7d4..19c64ac55 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -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: () => ( + + ), + }, +); 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. */} + {/* 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 && ( + + + + )} +
{ 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( - / { + 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(" 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(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(" { it("surfaces a toast on a rejected delete instead of failing silently", () => { expect(listSrc).toContain('import { toast } from "sonner"'); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce89a80e8..7c779cf26 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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(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([]); - // 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([]); // 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) { /> - {/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept - exactly as the existing segmented control. */} - } - summary={} - > - - + {/* 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. */} + 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 diff --git a/src/components/mood/mood-level-a-fields.tsx b/src/components/mood/mood-level-a-fields.tsx new file mode 100644 index 000000000..2269908ee --- /dev/null +++ b/src/components/mood/mood-level-a-fields.tsx @@ -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> { + const out: Partial> = {}; + 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 ( + } + open={open} + onOpenChange={onOpenChange} + summary={} + > +
+

+ {t("mood.sectionLevelAHelp")} +

+ {MOOD_DIMENSIONS.map((dimension) => { + const current = value[dimension.key]; + const low = t(dimension.lowAnchorKey); + const high = t(dimension.highAnchorKey); + return ( + onChange({ ...value, [dimension.key]: null }) + } + onValueChange={(next) => { + if (dimension.key === "a1") onA1Touched?.(); + onChange({ ...value, [dimension.key]: next }); + }} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 7cd920d1c..94b5d7cc9 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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([]); const [editRatedFactors, setEditRatedFactors] = useState([]); + // 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(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 = {}) { />
+ {/* 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. */} + + {/* v1.8.5 (C1) — free-text note. */}