mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
7eaede257e
commit
97582dedc0
13 files changed
+530
-67
No files matched your search
+33
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,7 +44,14 @@ import {
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelACreatePayload,
|
||||
seedA1FromMood,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
@@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
|
||||
count: MOOD_LEVELS.length,
|
||||
selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood),
|
||||
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
|
||||
onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value),
|
||||
});
|
||||
// v1.37 — the five level-A values. Pleasantness follows the picked face
|
||||
// until the user moves it themselves; the other four start and stay empty
|
||||
// unless somebody answers them.
|
||||
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [a1Touched, setA1Touched] = useState(false);
|
||||
const [levelAOpen, setLevelAOpen] = useState(false);
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
|
||||
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
|
||||
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// v1.8.5 (C1) — first-class free-text note. The model + API already
|
||||
// accepted `note`; the web form was the only surface that couldn't
|
||||
// write it.
|
||||
@@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood !== "" ||
|
||||
tagsInput.trim() !== "" ||
|
||||
tagKeys.length > 0 ||
|
||||
ratedFactors.length > 0 ||
|
||||
// A level-A value the user set counts as typed input: Reset must ask
|
||||
// before it discards an answer that took five slider moves.
|
||||
Object.values(levelA).some((v) => v !== null) ||
|
||||
note.trim() !== "";
|
||||
|
||||
// v1.37 — picking a face suggests a pleasantness value; the suggestion
|
||||
// stands until the user moves that slider, after which the two are
|
||||
// independent. Moving the slider never changes which face is lit.
|
||||
function pickMood(value: string) {
|
||||
setMood(value);
|
||||
setLevelA((prev) => seedA1FromMood(prev, value, a1Touched));
|
||||
}
|
||||
|
||||
function toggleTagKey(key: string) {
|
||||
setTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.12.0 — set or clear a rated factor's score. `rating === null`
|
||||
// removes the factor from the set (re-tapping the active step clears).
|
||||
function rateFactor(key: string, rating: number | null) {
|
||||
setRatedFactors((prev) => {
|
||||
const rest = prev.filter((f) => f.key !== key);
|
||||
return rating === null ? rest : [...rest, { key, rating }];
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
@@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setLevelA(EMPTY_LEVEL_A);
|
||||
setA1Touched(false);
|
||||
setLevelAOpen(false);
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setRatedFactors([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setError(null);
|
||||
@@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
|
||||
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : undefined,
|
||||
// v1.37 — only the values somebody answered. An untouched slider
|
||||
// sends nothing, and the server writes NULL rather than a middle.
|
||||
...levelACreatePayload(levelA),
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
@@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
aria-label={t(level.labelKey)}
|
||||
data-slot="mood-face"
|
||||
data-mood={level.value}
|
||||
onClick={() => setMood(level.value)}
|
||||
onClick={() => pickMood(level.value)}
|
||||
{...getMoodRadioProps(index)}
|
||||
// v1.12 — selected-state unified with the tag tiles and
|
||||
// factor steps: `border-primary bg-primary/15 text-primary`
|
||||
@@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
|
||||
exactly as the existing segmented control. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionFactors")}
|
||||
icon={<SlidersHorizontal />}
|
||||
summary={<SheetSectionCount count={ratedFactors.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
mode="rated"
|
||||
/>
|
||||
</SheetSection>
|
||||
{/* v1.37 — the five level-A values, closed by default. The rated
|
||||
factors that used to sit here are no longer offered on a new
|
||||
entry: a 1-5 score for "work stress" and a 0-10 stress
|
||||
self-state are different questions, and asking both invites
|
||||
two answers to one thing. Existing ratings keep working
|
||||
everywhere they already do, including the edit dialog, and no
|
||||
catalogue row was removed. */}
|
||||
<MoodLevelASection
|
||||
value={levelA}
|
||||
onChange={setLevelA}
|
||||
onA1Touched={() => setA1Touched(true)}
|
||||
open={levelAOpen}
|
||||
onOpenChange={setLevelAOpen}
|
||||
/>
|
||||
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
and the note field. The badge counts whichever of these carry
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user