mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
2bb810852e
commit
e9eb7dc704
10 files changed
+315
-54
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user