feat(mood): rebuild logging as a 5-face hero with tag picker and factor ratings

Replace the score-number radio + free-text form with an icon-first
"How are you?" hero that matches the native client: a row of five mood
faces in best-on-the-left order is the primary input, and picking one
reveals a brief annotate panel (time, the category-grouped tag picker,
factor ratings, and the note field).

The tag picker now reads the catalog's `kind` / `scaleMin` / `scaleMax`
metadata. BINARY tags stay icon-above-label toggle tiles sent as
`tagKeys`; RATED factors (work / social / sleep quality / stress, and
the 1..2 Yes/No conflict factor) render a compact 1..scaleMax segmented
control and are sent as a parallel `ratedFactors: [{ key, rating }]`
array. The entries list / history stays reachable below the hero.

Add the mood-face icon map (Laugh / Smile / Meh / Frown / Angry) and the
`heroQuestion`, `factorYes`, `factorNo`, `factorRatingOption` strings
across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 23:23:14 +02:00
1 parent 2bb810852e
commit e9eb7dc704
10 files changed
+315 -54

No files matched your search

+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
@@ -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. `<MoodList>` — the delete mutation surfaces a toast on failure
* (it previously had no `onError`, so a rejected DELETE was silent).
* 2. `<MoodForm>` — Reset confirms first when the form holds typed
* input, and resets immediately when pristine.
* 3. `<MoodForm>` — 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(
/<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,",
);
// 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"');
+59 -12
View File
@@ -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<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.
@@ -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) {
</div>
);
// 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 (
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label id="mood-level-label">{t("mood.moodLevel")}</Label>
{/* 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. */}
<div className="space-y-2" data-slot="mood-face-hero">
<Label id="mood-level-label" className="text-base">
{t("mood.heroQuestion")}
</Label>
<div
role="radiogroup"
aria-labelledby="mood-level-label"
className="grid grid-cols-5 gap-2"
className="grid grid-cols-5 gap-1.5 sm:gap-2"
>
{MOOD_LEVELS.map((level, index) => {
const isSelected = mood === level.value;
const FaceIcon = moodFaceIcon(level.value);
return (
<button
key={level.value}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={t(level.labelKey)}
data-slot="mood-face"
data-mood={level.value}
onClick={() => setMood(level.value)}
{...getMoodRadioProps(index)}
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-center transition-colors ${
className={`flex flex-col items-center gap-1.5 rounded-xl border p-2 text-center transition-colors sm:p-3 ${
isSelected
? "border-primary bg-primary/10 text-primary border-2"
: "border-border hover:bg-accent"
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<span className="text-lg font-semibold tabular-nums">
{level.score}
</span>
<FaceIcon
className="h-7 w-7 sm:h-8 sm:w-8"
aria-hidden="true"
/>
<span className="text-[10px] leading-tight sm:text-xs">
{t(level.labelKey)}
</span>
@@ -280,6 +316,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
</div>
{moodPicked && (
<div className="space-y-4 border-t pt-4" data-slot="mood-annotate-panel">
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
@@ -363,8 +401,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
</div>
{/* 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`. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")}{" "}
@@ -372,7 +412,12 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
({t("common.optional")})
</span>
</Label>
<MoodTagPicker selected={tagKeys} onToggle={toggleTagKey} />
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
@@ -411,6 +456,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
rows={3}
/>
</div>
</div>
)}
{error && (
<div
+22
View File
@@ -1,5 +1,6 @@
import {
AlertTriangle,
Angry,
Apple,
BedDouble,
BookOpen,
@@ -22,6 +23,7 @@ import {
HeartPulse,
HelpCircle,
Home,
Laugh,
LogOut,
Meh,
Moon,
@@ -103,3 +105,23 @@ export function moodTagIcon(name: string | null | undefined): LucideIcon {
if (!name) return Tag;
return MOOD_TAG_ICONS[name] ?? Tag;
}
/**
* v1.12.0 — face icon per mood enum for the "How are you?" 5-face hero.
*
* The logging surface renders these five faces best-on-the-left
* (`SUPER_GUT` … `LAUSIG`) as the primary mood input. Monochrome by
* default; the selection accent is applied by the caller (the one
* sanctioned tint), matching the iOS `Mood1…Mood5` imageset order.
*/
const MOOD_FACE_ICONS: Record<string, LucideIcon> = {
SUPER_GUT: Laugh,
GUT: Smile,
OKAY: Meh,
SCHLECHT: Frown,
LAUSIG: Angry,
};
export function moodFaceIcon(mood: string): LucideIcon {
return MOOD_FACE_ICONS[mood] ?? Meh;
}
+144 -29
View File
@@ -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 (
<div className="space-y-3" data-slot="mood-tag-picker">
<div className="space-y-4" data-slot="mood-tag-picker">
{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 (
<div key={category.key} className="space-y-1.5">
<div key={category.key} className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<CategoryIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t(category.labelKey)}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{category.tags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
return (
<button
key={tag.key}
type="button"
onClick={() => onToggle(tag.key)}
aria-pressed={isActive}
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</button>
);
})}
</div>
{binaryTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{binaryTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
return (
<button
key={tag.key}
type="button"
onClick={() => onToggle(tag.key)}
aria-pressed={isActive}
data-slot="mood-tag-tile"
className={`flex min-h-16 w-[4.5rem] flex-col items-center justify-center gap-1 rounded-xl border p-2 text-center transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<TagIcon className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] leading-tight">{label}</span>
</button>
);
})}
</div>
)}
{ratedTags.length > 0 && onRateFactor && (
<div className="space-y-2" data-slot="mood-factor-ratings">
{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 (
<div
key={tag.key}
className="flex items-center justify-between gap-3"
data-slot="mood-factor-rating"
>
<span
id={groupLabelId}
className="text-foreground/85 flex items-center gap-1.5 text-xs"
>
<TagIcon className="h-4 w-4" aria-hidden="true" />
{label}
</span>
<div
role="radiogroup"
aria-labelledby={groupLabelId}
className="flex items-center gap-1"
>
{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 (
<button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={a11yLabel}
// Re-tap the active step to clear the score.
onClick={() =>
onRateFactor(tag.key, isActive ? null : value)
}
className={`flex h-8 items-center justify-center rounded-md border px-2 text-xs tabular-nums transition-colors ${
isBinaryScale ? "min-w-12" : "min-w-8"
} ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
{optionLabel}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}