diff --git a/messages/de.json b/messages/de.json index 455bad74f..b22c5e078 100644 --- a/messages/de.json +++ b/messages/de.json @@ -885,11 +885,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", diff --git a/messages/en.json b/messages/en.json index 2041766c0..107c00816 100644 --- a/messages/en.json +++ b/messages/en.json @@ -885,11 +885,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", diff --git a/messages/es.json b/messages/es.json index bafa72e6e..fbd062826 100644 --- a/messages/es.json +++ b/messages/es.json @@ -885,11 +885,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", diff --git a/messages/fr.json b/messages/fr.json index 5324f563a..632b225ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -885,11 +885,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", diff --git a/messages/it.json b/messages/it.json index 38af4fc16..3a558c56d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -885,11 +885,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", diff --git a/messages/pl.json b/messages/pl.json index 702ad81c5..872cad733 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -885,11 +885,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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 169a853d9..c8aef7520 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4213,6 +4213,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/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f6b01ba4d..9e7ea9565 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -128,8 +128,13 @@ export const PUT = apiHandler( 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. - // An explicit `a1` in the same request overrides it below. - updateData.moodA1 = deriveA1(data.mood); + // 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 diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 8670b5575..6073a01e6 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -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,38 @@ 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( - / { - expect(formSrc).toContain( - "ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,", - ); +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("offers the five level-A sliders and sends only the answered ones", () => { + expect(formSrc).toContain(" setA1Touched(true)}"); + // Nothing here defaults a value: the state starts empty and stays empty. + expect(formSrc).toContain("useState(EMPTY_LEVEL_A)"); + }); }); 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 +166,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..8b1f15cda --- /dev/null +++ b/src/components/mood/mood-level-a-fields.tsx @@ -0,0 +1,186 @@ +"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 ( + { + if (dimension.key === "a1") onA1Touched?.(); + 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. */}