diff --git a/messages/de.json b/messages/de.json index 592c64139..2db6f697a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -469,6 +469,10 @@ "addEntry": "Hinzufügen", "editEntry": "Eintrag bearbeiten", "moodLevel": "Stimmung", + "heroQuestion": "Wie geht's dir?", + "factorYes": "Ja", + "factorNo": "Nein", + "factorRatingOption": "{label}: {value} von {max}", "tags": "Tags", "tagsPlaceholder": "z.B. Sport, Arbeit, Familie", "tagsHelp": "Kommagetrennt eingeben", diff --git a/messages/en.json b/messages/en.json index 80ce80aa3..3218a3471 100644 --- a/messages/en.json +++ b/messages/en.json @@ -469,6 +469,10 @@ "addEntry": "Add", "editEntry": "Edit entry", "moodLevel": "Mood", + "heroQuestion": "How are you?", + "factorYes": "Yes", + "factorNo": "No", + "factorRatingOption": "{label}: {value} of {max}", "tags": "Tags", "tagsPlaceholder": "e.g. Exercise, Work, Family", "tagsHelp": "Comma-separated", diff --git a/messages/es.json b/messages/es.json index 2d17d3ee3..1a11cfbfd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -469,6 +469,10 @@ "addEntry": "Añadir", "editEntry": "Editar entrada", "moodLevel": "Estado de ánimo", + "heroQuestion": "¿Cómo estás?", + "factorYes": "Sí", + "factorNo": "No", + "factorRatingOption": "{label}: {value} de {max}", "tags": "Etiquetas", "tagsPlaceholder": "p. ej. deporte, trabajo, familia", "tagsHelp": "Separa con comas", diff --git a/messages/fr.json b/messages/fr.json index 1b3be05a7..0637c7c0f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -469,6 +469,10 @@ "addEntry": "Ajouter", "editEntry": "Modifier l’entrée", "moodLevel": "Humeur", + "heroQuestion": "Comment vas-tu ?", + "factorYes": "Oui", + "factorNo": "Non", + "factorRatingOption": "{label} : {value} sur {max}", "tags": "Étiquettes", "tagsPlaceholder": "p. ex. sport, travail, famille", "tagsHelp": "Sépare par des virgules", diff --git a/messages/it.json b/messages/it.json index 020d94744..8ad148028 100644 --- a/messages/it.json +++ b/messages/it.json @@ -469,6 +469,10 @@ "addEntry": "Aggiungi", "editEntry": "Modifica voce", "moodLevel": "Umore", + "heroQuestion": "Come stai?", + "factorYes": "Sì", + "factorNo": "No", + "factorRatingOption": "{label}: {value} di {max}", "tags": "Tag", "tagsPlaceholder": "es. sport, lavoro, famiglia", "tagsHelp": "Separa con virgole", diff --git a/messages/pl.json b/messages/pl.json index 0e026652e..c8a3910a7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -469,6 +469,10 @@ "addEntry": "Dodaj", "editEntry": "Edytuj wpis", "moodLevel": "Nastrój", + "heroQuestion": "Jak się masz?", + "factorYes": "Tak", + "factorNo": "Nie", + "factorRatingOption": "{label}: {value} z {max}", "tags": "Tagi", "tagsPlaceholder": "np. sport, praca, rodzina", "tagsHelp": "Oddziel przecinkami", diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index f026d8032..12de71804 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -1,17 +1,17 @@ /** - * v1.11.5 — QoL-Lows coverage for the mood form + list fixes. + * v1.11.5 / v1.12.0 — coverage for the mood form + list. * * Project convention is SSR-only component tests (`renderToStaticMarkup`, * no `@testing-library/react`) plus source-string structural assertions - * for the interactive plumbing that an SSR mount can't exercise. The - * three fixes pinned here: + * for the interactive plumbing that an SSR mount can't exercise. * - * 1. `` — the delete mutation surfaces a toast on failure - * (it previously had no `onError`, so a rejected DELETE was silent). - * 2. `` — Reset confirms first when the form holds typed - * input, and resets immediately when pristine. - * 3. `` — the note field shows a live character counter so - * the `maxLength` cap no longer truncates without feedback. + * v1.12.0 rebuilt the logging surface into the "How are you?" 5-face + * hero: the five mood faces are the primary input (rendered on a + * pristine form), and the annotate panel — time, tags, factor ratings, + * the note field + its counter — reveals only after a face is picked. + * A pristine SSR mount therefore shows the hero but not the counter; + * the counter / reset / note plumbing is pinned via source-string + * assertions below. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -44,13 +44,43 @@ const listSrc = readFileSync( "utf8", ); -describe("MoodForm — note character counter (v1.11.5)", () => { - it("renders the counter at 0/500 for a pristine form", () => { +describe("MoodForm — 5-face hero (v1.12.0)", () => { + it("renders the five mood faces as a radiogroup on a pristine form", () => { const html = renderForm(); - expect(html).toContain('data-testid="mood-note-counter"'); - expect(html).toContain("0/500"); + // The "How are you?" hero is the primary input — present before any + // mood is picked. + expect(html).toContain('data-slot="mood-face-hero"'); + expect(html).toContain("How are you?"); + // Best-on-the-left order: every enum value renders one face button. + for (const mood of [ + "SUPER_GUT", + "GUT", + "OKAY", + "SCHLECHT", + "LAUSIG", + ]) { + expect(html).toContain(`data-mood="${mood}"`); + } + // The first face is SUPER_GUT (best on the left). + expect(html.indexOf('data-mood="SUPER_GUT"')).toBeLessThan( + html.indexOf('data-mood="LAUSIG"'), + ); }); + it("hides the annotate panel (and note counter) until a face is picked", () => { + // A pristine SSR mount cannot pick a mood, so the panel stays closed. + const html = renderForm(); + expect(html).not.toContain('data-slot="mood-annotate-panel"'); + expect(html).not.toContain('data-testid="mood-note-counter"'); + // The panel is gated on `moodPicked` (mood !== ""). + expect(formSrc).toContain("const moodPicked = mood !== \"\""); + expect(formSrc).toMatch( + /moodPicked &&[\s\S]*data-slot="mood-annotate-panel"/, + ); + }); +}); + +describe("MoodForm — note character counter (v1.11.5)", () => { it("ties the textarea cap to the shared NOTE_MAX_LENGTH constant", () => { expect(formSrc).toContain("const NOTE_MAX_LENGTH = 500"); expect(formSrc).toContain("maxLength={NOTE_MAX_LENGTH}"); @@ -67,6 +97,7 @@ 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"); expect(formSrc).toContain('note.trim() !== ""'); // The `isDirty` expression itself must not read `moodLoggedAt`. const isDirtyExpr = formSrc.slice( @@ -94,6 +125,28 @@ 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,", + ); + // The binary structured-tag contract stays byte-identical. + expect(formSrc).toContain( + "tagKeys: tagKeys.length > 0 ? tagKeys : undefined,", + ); + }); +}); + 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"'); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 6f31d611c..44ca476e1 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -29,8 +29,13 @@ import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; -import { MoodTagPicker } from "./mood-tag-picker"; +import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { moodFaceIcon } from "./mood-tag-icons"; +// v1.12.0 — best-on-the-left face order for the "How are you?" hero, +// mirroring the iOS `Mood1…Mood5` imageset order. The numeric `score` +// is the server's 1..5 scale; the face icon resolves through +// `moodFaceIcon`. const MOOD_LEVELS = [ { value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" }, { value: "GUT", score: 4, labelKey: "mood.levelGut" }, @@ -96,6 +101,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { 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. @@ -111,6 +119,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { mood !== "" || tagsInput.trim() !== "" || tagKeys.length > 0 || + ratedFactors.length > 0 || note.trim() !== ""; function toggleTagKey(key: string) { @@ -119,6 +128,15 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { ); } + // 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; @@ -132,6 +150,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { setMood(""); setTagsInput(""); setTagKeys([]); + setRatedFactors([]); setNote(""); setMoodLoggedAt(getDefaultMoodLoggedAtValue()); setError(null); @@ -170,6 +189,8 @@ 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, moodLoggedAt: timestamp, }), @@ -243,34 +264,49 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { ); + // v1.12.0 — the annotate panel (tags / factors / note / time) reveals + // only after a mood face is picked, so the icon-first hero is the + // primary input and a fast log is a single tap + Save. + const moodPicked = mood !== ""; + return (
-
- + {/* v1.12.0 — "How are you?" 5-face hero. The five faces are a + radiogroup (best-on-the-left order); tapping one picks the mood + and reveals the annotate panel below. */} +
+
{MOOD_LEVELS.map((level, index) => { const isSelected = mood === level.value; + const FaceIcon = moodFaceIcon(level.value); return (
+ {moodPicked && ( +
- {/* v1.8.5 — structured-tag taxonomy picker. Additive next to the - free-text input above; an entry can carry both axes. */} + {/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next + to the free-text input above; an entry can carry both axes. The + picker also surfaces the FACTOR-kind tags as 1..scaleMax rating + controls, lifted here as `ratedFactors`. */}
- +
{/* v1.8.5 (C1) — free-text note. */} @@ -411,6 +456,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { rows={3} />
+
+ )} {error && (
= { + SUPER_GUT: Laugh, + GUT: Smile, + OKAY: Meh, + SCHLECHT: Frown, + LAUSIG: Angry, +}; + +export function moodFaceIcon(mood: string): LucideIcon { + return MOOD_FACE_ICONS[mood] ?? Meh; +} diff --git a/src/components/mood/mood-tag-picker.tsx b/src/components/mood/mood-tag-picker.tsx index 25139e103..0bc636320 100644 --- a/src/components/mood/mood-tag-picker.tsx +++ b/src/components/mood/mood-tag-picker.tsx @@ -9,12 +9,19 @@ import { useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "./mood-tag-icons"; /** - * v1.8.5 — structured mood-tag capture surface. + * v1.8.5 / v1.12.0 — structured mood-tag capture surface (Daylio-style). * * Loads the global Category -> Tag catalog from `/api/mood/tags` and - * renders one labelled group per category with icon chips the user can - * toggle. Selection is a controlled set of tag keys lifted to the - * parent form, which sends them as `tagKeys` on create / update. + * renders one labelled group per category. Two tag kinds are surfaced: + * + * - `BINARY` — an icon-above-label toggle tile. Multi-select across + * categories; the selection is a controlled set of tag keys lifted + * to the parent form, which sends them as `tagKeys` on create. + * - `RATED` — a 1..scaleMax segmented control (a Yes/No pair when + * `scaleMax === 2`). The score is lifted as a `{ key, rating }` + * entry and sent as `ratedFactors`. `inverse` factors (higher = + * worse day) carry the same control; the inversion is a read-side + * concern handled server-side. * * Additive next to the legacy free-text tag input: a user can use * either or both. Renders nothing if the catalog is empty so a @@ -25,6 +32,10 @@ interface CatalogTag { key: string; labelKey: string; icon: string | null; + kind: "BINARY" | "RATED"; + scaleMin: number; + scaleMax: number; + inverse: boolean; } interface CatalogCategory { @@ -38,12 +49,28 @@ interface CatalogResponse { categories: CatalogCategory[]; } +export interface RatedFactor { + key: string; + rating: number; +} + export function MoodTagPicker({ selected, onToggle, + ratedFactors = [], + onRateFactor, }: { + /** Selected binary tag keys (sent as `tagKeys`). */ selected: string[]; onToggle: (tagKey: string) => void; + /** Current rated-factor scores (sent as `ratedFactors`). */ + ratedFactors?: RatedFactor[]; + /** + * Sets (or, when `rating === null`, clears) a rated factor's score. + * Required for the RATED segmented controls to render interactively; + * when omitted the picker only surfaces binary tags. + */ + onRateFactor?: (key: string, rating: number | null) => void; }) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -70,40 +97,128 @@ export function MoodTagPicker({ } const selectedSet = new Set(selected); + const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating])); return ( -
+
{data.categories.map((category) => { const CategoryIcon = moodTagIcon(category.icon); + const binaryTags = category.tags.filter((tag) => tag.kind !== "RATED"); + const ratedTags = category.tags.filter((tag) => tag.kind === "RATED"); + // Self-gate an empty category (e.g. a RATED-only category when no + // `onRateFactor` is wired) so the picker never shows a bare heading. + if (binaryTags.length === 0 && ratedTags.length === 0) return null; + if (ratedTags.length > 0 && !onRateFactor && binaryTags.length === 0) { + return null; + } + return ( -
+
-
- {category.tags.map((tag) => { - const TagIcon = moodTagIcon(tag.icon); - const isActive = selectedSet.has(tag.key); - const label = t(tag.labelKey); - return ( - - ); - })} -
+ + {binaryTags.length > 0 && ( +
+ {binaryTags.map((tag) => { + const TagIcon = moodTagIcon(tag.icon); + const isActive = selectedSet.has(tag.key); + const label = t(tag.labelKey); + return ( + + ); + })} +
+ )} + + {ratedTags.length > 0 && onRateFactor && ( +
+ {ratedTags.map((tag) => { + const TagIcon = moodTagIcon(tag.icon); + const label = t(tag.labelKey); + const current = ratingByKey.get(tag.key); + const isBinaryScale = tag.scaleMax - tag.scaleMin === 1; + const steps: number[] = []; + for (let v = tag.scaleMin; v <= tag.scaleMax; v++) { + steps.push(v); + } + const groupLabelId = `mood-factor-${tag.key}`; + return ( +
+ + +
+ {steps.map((value) => { + const isActive = current === value; + const optionLabel = isBinaryScale + ? value === tag.scaleMax + ? t("mood.factorYes") + : t("mood.factorNo") + : String(value); + const a11yLabel = isBinaryScale + ? optionLabel + : t("mood.factorRatingOption", { + label, + value: String(value), + max: String(tag.scaleMax), + }); + return ( + + ); + })} +
+
+ ); + })} +
+ )}
); })}