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 (