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 (