feat(mood): capture the five values without lengthening the quick check-in

- the five faces are untouched: pick one, save, done in a tap
- a closed section below them opens onto five sliders; picking a face
  suggests a pleasantness value and moving that slider takes the
  suggestion over, while the face selection stays where it is
- an untouched slider writes nothing, and clearing one takes the answer
  back rather than leaving a middle value behind
- the list's edit dialog shows and changes the same five, so nothing is
  written on one screen and invisible on the other
- correcting an entry no longer re-derives pleasantness when the label
  did not move, which used to overwrite a hand-set value on any edit
- factor ratings are no longer offered on a new entry and nothing was
  deleted: existing ratings still render, edit and feed the crosstab
- six locales, and the dimension table's keys are declared dynamic
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 01:24:04 +02:00
1 parent 7eaede257e
commit 97582dedc0
13 files changed
+530 -67

No files matched your search

+33 -2
View File
@@ -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",
+33 -2
View File
@@ -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",
+33 -2
View File
@@ -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",
+33 -2
View File
@@ -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",
+33 -2
View File
@@ -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",
+33 -2
View File
@@ -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",
+10
View File
@@ -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.
@@ -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
+7 -2
View File
@@ -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
@@ -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(
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
);
});
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
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("<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\)/,
);
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)");
});
});
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("<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
+186
View File
@@ -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<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")}
onClear={() => {
if (dimension.key === "a1") onA1Touched?.();
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">