From 079be9d5af9927b0190451ddbcd6fa3d98d2465d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:44:51 +0200 Subject: [PATCH 1/3] feat(ui): add collapsible SheetSection with summary badge slot --- .../ui/__tests__/sheet-section.test.tsx | 74 ++++++++++++ src/components/ui/sheet-section.tsx | 107 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/components/ui/__tests__/sheet-section.test.tsx create mode 100644 src/components/ui/sheet-section.tsx diff --git a/src/components/ui/__tests__/sheet-section.test.tsx b/src/components/ui/__tests__/sheet-section.test.tsx new file mode 100644 index 000000000..b3f719c71 --- /dev/null +++ b/src/components/ui/__tests__/sheet-section.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SheetSection, SheetSectionCount } from "../sheet-section"; + +describe("", () => { + it("renders the title and summary slot on the trigger row", () => { + const html = renderToStaticMarkup( + summary-badge}> +

section body

+
, + ); + expect(html).toContain("More tags"); + expect(html).toContain("summary-badge"); + }); + + it("renders children inside an open section", () => { + // Radix Collapsible omits the content subtree while collapsed, so the + // body is asserted on an expanded section. + const html = renderToStaticMarkup( + +

section body

+
, + ); + expect(html).toContain("section body"); + }); + + it("starts collapsed by default and open when defaultOpen", () => { + const closed = renderToStaticMarkup( + +

x

+
, + ); + const open = renderToStaticMarkup( + +

x

+
, + ); + expect(closed).toContain('data-state="closed"'); + expect(open).toContain('data-state="open"'); + }); + + it("hides the leading icon from assistive tech", () => { + const html = renderToStaticMarkup( + }> +

x

+
, + ); + expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]* { + const html = renderToStaticMarkup( + +

y

+
, + ); + expect(html).toContain("motion-reduce:animate-none"); + expect(html).toContain("motion-reduce:transition-none"); + }); +}); + +describe("", () => { + it("renders the count when positive", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("3"); + expect(html).toContain('data-slot="sheet-section-count"'); + }); + + it("renders nothing for zero or negative counts", () => { + expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toBe(""); + }); +}); diff --git a/src/components/ui/sheet-section.tsx b/src/components/ui/sheet-section.tsx new file mode 100644 index 000000000..1eadf6028 --- /dev/null +++ b/src/components/ui/sheet-section.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; +import { ChevronDown } from "lucide-react"; +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +/** + * v1.17.0 — `SheetSection` is the collapsible disclosure primitive that + * powers the sectioned single-sheet add flows (mood, cycle). Each section + * carries a title, an optional leading icon, and a `summary` slot that + * communicates what is selected inside it while collapsed (e.g. a count + * badge) — so a closed section never hides its contents silently. + * + * Built on Radix Collapsible. The chevron rotates and the content height + * animates open/closed via the `tw-animate-css` `collapsible-down/up` + * keyframes; both motions are suppressed under `prefers-reduced-motion`. + * Matches the shadcn new-york + zinc token language used across the app. + */ + +interface SheetSectionProps { + /** Section heading shown on the always-visible trigger row. */ + title: string; + /** + * A node rendered on the trigger row's trailing edge — typically a count + * badge that reports what is selected inside while the section is + * collapsed. Stays visible whether the section is open or closed. + */ + summary?: React.ReactNode; + /** Optional leading icon (a Lucide component or any node). */ + icon?: React.ReactNode; + /** Whether the section starts expanded. Defaults to collapsed. */ + defaultOpen?: boolean; + /** Optional className merged onto the section wrapper. */ + className?: string; + children: React.ReactNode; +} + +export function SheetSection({ + title, + summary, + icon, + defaultOpen = false, + className, + children, +}: SheetSectionProps) { + return ( + + + {icon ? ( + + ) : null} + + {title} + + {summary ? ( + + {summary} + + ) : null} + + +
{children}
+
+
+ ); +} + +/** + * A compact "(N)" count badge for a `SheetSection` summary slot. Renders + * nothing when `count <= 0`, so a section with no selections shows a bare + * chevron rather than a "(0)". + */ +export function SheetSectionCount({ count }: { count: number }) { + if (count <= 0) return null; + return ( + + {count} + + ); +} From 9e1e285fe40d2a59d43bab4b2b0f89627582bd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:50:11 +0200 Subject: [PATCH 2/3] feat(mood): sectioned add sheet with quick-tag row and disclosure --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- .../mood/__tests__/recent-tags.test.ts | 73 ++++ src/components/mood/mood-form.tsx | 333 +++++++++++------- src/components/mood/mood-quick-tags.tsx | 135 +++++++ src/components/mood/mood-tag-picker.tsx | 30 +- src/components/mood/recent-tags.ts | 137 +++++++ src/components/ui/sheet-section.tsx | 11 +- 12 files changed, 611 insertions(+), 150 deletions(-) create mode 100644 src/components/mood/__tests__/recent-tags.test.ts create mode 100644 src/components/mood/mood-quick-tags.tsx create mode 100644 src/components/mood/recent-tags.ts diff --git a/messages/de.json b/messages/de.json index cfea1b804..d52a6fa23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Wetter", "iconGroupPlaces": "Orte", "iconGroupMisc": "Sonstiges" - } + }, + "sectionMoreTags": "Weitere Tags", + "sectionFactors": "Faktoren", + "sectionNoteDetails": "Notiz & Details", + "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", + "quickTagsExpand": "Weitere" }, "medications": { "title": "Medikamente", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..626c41fde 100644 --- a/messages/en.json +++ b/messages/en.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Weather", "iconGroupPlaces": "Places", "iconGroupMisc": "Other" - } + }, + "sectionMoreTags": "More tags", + "sectionFactors": "Factors", + "sectionNoteDetails": "Note & details", + "quickTagsHelp": "Your frequent tags — one tap", + "quickTagsExpand": "More" }, "medications": { "title": "Medications", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..83cf1f912 100644 --- a/messages/es.json +++ b/messages/es.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Tiempo", "iconGroupPlaces": "Lugares", "iconGroupMisc": "Otros" - } + }, + "sectionMoreTags": "Más etiquetas", + "sectionFactors": "Factores", + "sectionNoteDetails": "Nota y detalles", + "quickTagsHelp": "Tus etiquetas frecuentes: un toque", + "quickTagsExpand": "Más" }, "medications": { "title": "Medicamentos", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..e49bd86fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Météo", "iconGroupPlaces": "Lieux", "iconGroupMisc": "Autres" - } + }, + "sectionMoreTags": "Plus de tags", + "sectionFactors": "Facteurs", + "sectionNoteDetails": "Note et détails", + "quickTagsHelp": "Vos tags fréquents — en un geste", + "quickTagsExpand": "Plus" }, "medications": { "title": "Médicaments", diff --git a/messages/it.json b/messages/it.json index 04730edac..6aa821428 100644 --- a/messages/it.json +++ b/messages/it.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Meteo", "iconGroupPlaces": "Luoghi", "iconGroupMisc": "Altro" - } + }, + "sectionMoreTags": "Altri tag", + "sectionFactors": "Fattori", + "sectionNoteDetails": "Nota e dettagli", + "quickTagsHelp": "I tuoi tag frequenti: un tocco", + "quickTagsExpand": "Altro" }, "medications": { "title": "Farmaci", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..ecfb13c1c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Pogoda", "iconGroupPlaces": "Miejsca", "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" }, "medications": { "title": "Leki", diff --git a/src/components/mood/__tests__/recent-tags.test.ts b/src/components/mood/__tests__/recent-tags.test.ts new file mode 100644 index 000000000..f9d9390ac --- /dev/null +++ b/src/components/mood/__tests__/recent-tags.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { promoteRecentTags, selectQuickTagKeys } from "../recent-tags"; + +describe("promoteRecentTags", () => { + it("prepends a freshly-used key, most-recent first", () => { + expect(promoteRecentTags(["a", "b"], ["c"])).toEqual(["c", "a", "b"]); + }); + + it("moves an already-present key to the front without duplicating", () => { + expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual([ + "b", + "a", + "c", + ]); + }); + + it("keeps the latest of a multi-key use at the front", () => { + // The last key in `usedKeys` is the most recent → leads the list. + expect(promoteRecentTags([], ["x", "y", "z"])).toEqual(["z", "y", "x"]); + }); + + it("de-duplicates within the used keys", () => { + expect(promoteRecentTags(["a"], ["a", "a"])).toEqual(["a"]); + }); + + it("drops empty keys", () => { + expect(promoteRecentTags(["a"], ["", "b"])).toEqual(["b", "a"]); + }); + + it("caps the history length", () => { + const existing = Array.from({ length: 30 }, (_, i) => `k${i}`); + const result = promoteRecentTags(existing, ["new"]); + expect(result.length).toBe(24); + expect(result[0]).toBe("new"); + }); +}); + +describe("selectQuickTagKeys", () => { + const catalog = ["a", "b", "c", "d", "e"]; + + it("returns the MRU keys that still exist in the catalog", () => { + expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("drops MRU keys no longer in the catalog", () => { + expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual([ + "b", + "a", + ]); + }); + + it("falls back to the first catalog keys with no history", () => { + expect(selectQuickTagKeys([], catalog, 3)).toEqual(["a", "b", "c"]); + }); + + it("respects the limit", () => { + expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual([ + "e", + "d", + ]); + }); + + it("never duplicates when topping up from the catalog", () => { + const result = selectQuickTagKeys(["b"], catalog, 5); + expect(new Set(result).size).toBe(result.length); + expect(result[0]).toBe("b"); + }); +}); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index aa0fd6001..d827cf790 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -24,13 +24,27 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; -import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; +import { + ListChecks, + Loader2, + MoreHorizontal, + Plus, + RotateCcw, + SlidersHorizontal, + StickyNote, +} from "lucide-react"; 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 { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { + SheetSection, + SheetSectionCount, +} from "@/components/ui/sheet-section"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { MoodQuickTags } from "./mood-quick-tags"; +import { useRecentTags } from "./recent-tags"; import { moodFaceIcon } from "./mood-tag-icons"; // v1.12.0 — best-on-the-left face order for the "How are you?" hero, @@ -116,6 +130,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { // fields the user actively fills count toward "dirty"; the timestamp // always carries an auto-populated default, so it is excluded. const [resetConfirmOpen, setResetConfirmOpen] = useState(false); + // v1.17.0 — MRU tag history powers the always-open Quick row; tapping the + // Quick row's "+" expands the full "More tags" section (controlled so the + // chip can open it). + const { recent, recordUse } = useRecentTags(); + const [moreTagsOpen, setMoreTagsOpen] = useState(false); const isDirty = mood !== "" || tagsInput.trim() !== "" || @@ -193,7 +212,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { moodLoggedAt: timestamp, }); + // v1.17.0 — promote the saved structured tags to the Quick row's MRU + // history so the next entry surfaces them as one-tap chips. + if (tagKeys.length > 0) recordUse(tagKeys); resetForm(); + setMoreTagsOpen(false); await invalidateKeys(queryClient, moodDependentKeys); toast.success(t("common.saved")); onSuccess?.(); @@ -259,6 +282,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { // primary input and a fast log is a single tap + Save. const moodPicked = mood !== ""; + // v1.17.0 — the "Note & details" badge counts the free-text tags entered + // plus the note (when present), so a collapsed section communicates what + // it holds. The auto-populated timestamp is excluded (it always carries a + // default, so it would never read as "empty"). + const freeTextTagCount = tagsInput + .split(",") + .map((s) => s.trim()) + .filter(Boolean).length; + const detailsCount = freeTextTagCount + (note.trim() ? 1 : 0); + return (
{/* v1.12.0 — "How are you?" 5-face hero. The five faces are a @@ -318,152 +351,182 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { {moodPicked && ( -
-
- - setMoodLoggedAt(e.target.value)} - // v1.17 W1b — match the server bound: no future instant. - max={getDefaultMoodLoggedAtValue()} - required - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - /> -
+
+ {/* v1.17.0 — always-open Quick row: MRU one-tap tags + a "+" that + expands the full "More tags" section. The common case stays a + face + a couple of taps + Save. */} + setMoreTagsOpen(true)} + /> -
-
- - - {t("mood.tagsHelp")} - -
- setTagsInput(e.target.value)} - placeholder={t("mood.tagsPlaceholder")} - enterKeyHint="done" - autoCapitalize="none" - autoComplete="off" + {/* v1.17.0 — "More tags": the full structured-tag taxonomy + (binary tiles + custom). Controlled so the Quick row's "+" can + open it; the badge reports how many tags are selected. */} + } + open={moreTagsOpen} + onOpenChange={setMoreTagsOpen} + summary={} + > + - {/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip - appends the localised label to the free-text tag list. - Always visible for now (cheap UX; the Coach side-effect - aggregator filters on the canonical English tag set so the - German labels still register correctly). */} -
-

- {t("medications.sideEffectTagsHelp")} -

-
- {GLP1_SIDE_EFFECT_KEYS.map((key) => { - const label = t(key); - const tags = tagsInput - .split(",") - .map((p) => p.trim().toLowerCase()); - const isActive = tags.includes(label.toLowerCase()); - return ( - - ); - })} -
-
-
+ - {/* 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.17.0 — "Factors": the FACTOR-kind segmented ratings, kept + exactly as the existing segmented control. */} + } + summary={} + > -
+ - {/* v1.8.5 (C1) — free-text note. */} -
-
-