feat(mood): sectioned add sheet with quick-tag row and disclosure

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 18:50:11 +02:00
1 parent 079be9d5af
commit 9e1e285fe4
12 files changed
+611 -150

No files matched your search

+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
@@ -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");
});
});
+198 -135
View File
@@ -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 (
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
{/* 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) {
</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
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => 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}
/>
</div>
<div className="space-y-3" data-slot="mood-annotate-panel">
{/* 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. */}
<MoodQuickTags
selected={tagKeys}
onToggle={toggleTagKey}
recent={recent}
onExpand={() => setMoreTagsOpen(true)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</div>
<Input
id="tags"
value={tagsInput}
onChange={(e) => 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. */}
<SheetSection
title={t("mood.sectionMoreTags")}
icon={<ListChecks />}
open={moreTagsOpen}
onOpenChange={setMoreTagsOpen}
summary={<SheetSectionCount count={tagKeys.length} />}
>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
mode="binary"
/>
{/* 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). */}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{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 (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter(
(p) => p.toLowerCase() !== label.toLowerCase(),
);
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`inline-flex min-h-11 items-center 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"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
</div>
</div>
</div>
</SheetSection>
{/* 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")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
exactly as the existing segmented control. */}
<SheetSection
title={t("mood.sectionFactors")}
icon={<SlidersHorizontal />}
summary={<SheetSectionCount count={ratedFactors.length} />}
>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
mode="rated"
/>
</div>
</SheetSection>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
the note field, and the timestamp. The badge counts whichever
of these carry input. */}
<SheetSection
title={t("mood.sectionNoteDetails")}
icon={<StickyNote />}
summary={<SheetSectionCount count={detailsCount} />}
>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no longer
truncates silently. Turns destructive (warns) as the input
approaches the limit. */}
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
<Input
id="tags"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
enterKeyHint="done"
autoCapitalize="none"
autoComplete="off"
/>
{/* 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). */}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{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 (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter(
(p) => p.toLowerCase() !== label.toLowerCase(),
);
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`inline-flex min-h-11 items-center 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"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
</div>
</div>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no
longer truncates silently. Turns destructive (warns) as the
input approaches the limit. */}
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => 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}
/>
</div>
</SheetSection>
</div>
)}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { moodTagIcon } from "./mood-tag-icons";
import { selectQuickTagKeys } from "./recent-tags";
/**
* v1.17.0 — the always-open Quick row at the top of the mood add-sheet.
*
* Surfaces the caller's most-recently-used binary tags (falling back to the
* first catalogue tags on a fresh account) as one-tap chips, so the common
* case is a face + a couple of familiar taps + Save. A trailing "+" expands
* the full "More tags" section for everything else.
*
* Reads the same `/api/mood/tags` catalogue as `MoodTagPicker` (shared query
* key → one network round-trip). Renders nothing while the catalogue is
* empty so a cleared taxonomy degrades gracefully.
*/
interface CatalogTag {
key: string;
labelKey: string;
label?: string | null;
icon: string | null;
kind: "BINARY" | "RATED";
}
interface CatalogCategory {
tags: CatalogTag[];
}
interface CatalogResponse {
categories: CatalogCategory[];
}
const QUICK_LIMIT = 8;
export function MoodQuickTags({
selected,
onToggle,
recent,
onExpand,
}: {
/** Selected binary tag keys (shared with `MoodTagPicker`). */
selected: string[];
onToggle: (tagKey: string) => void;
/** MRU tag keys, most-recent first (from `useRecentTags`). */
recent: readonly string[];
/** Opens the full "More tags" section. */
onExpand: () => void;
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
queryFn: async () => apiGet<CatalogResponse>("/api/mood/tags"),
enabled: isAuthenticated,
staleTime: 60 * 60 * 1000,
});
if (isLoading) {
return <Skeleton className="h-11 w-full rounded-lg" />;
}
if (!data || data.categories.length === 0) return null;
// Flatten the catalogue to binary tags only; the Quick row never carries
// rated factors (those live under the "Factors" section).
const binaryTags = data.categories
.flatMap((c) => c.tags)
.filter((tag) => tag.kind !== "RATED");
if (binaryTags.length === 0) return null;
const tagByKey = new Map(binaryTags.map((tag) => [tag.key, tag]));
const catalogKeys = binaryTags.map((tag) => tag.key);
const selectedSet = new Set(selected);
// Show the MRU/fallback quick keys, but always include any currently
// selected key so a tap from the full picker stays visible up here too.
const quickKeys = selectQuickTagKeys(recent, catalogKeys, QUICK_LIMIT);
const orderedKeys = [
...quickKeys,
...selected.filter((k) => tagByKey.has(k) && !quickKeys.includes(k)),
];
return (
<div className="space-y-1.5" data-slot="mood-quick-tags">
<p className="text-muted-foreground text-xs">{t("mood.quickTagsHelp")}</p>
<div className="flex flex-wrap gap-1.5">
{orderedKeys.map((key) => {
const tag = tagByKey.get(key);
if (!tag) return null;
const TagIcon = moodTagIcon(tag.icon);
const label = tag.label ?? t(tag.labelKey);
const isActive = selectedSet.has(key);
return (
<button
key={key}
type="button"
onClick={() => onToggle(key)}
aria-pressed={isActive}
data-slot="mood-quick-tag"
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>
);
})}
<button
type="button"
onClick={onExpand}
aria-label={t("mood.quickTagsExpand")}
title={t("mood.quickTagsExpand")}
data-slot="mood-quick-tags-expand"
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded-full border border-dashed px-3 py-2 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{t("mood.quickTagsExpand")}
</button>
</div>
</div>
);
}
+23 -7
View File
@@ -77,6 +77,7 @@ export function MoodTagPicker({
onToggle,
ratedFactors = [],
onRateFactor,
mode = "all",
}: {
/** Selected binary tag keys (sent as `tagKeys`). */
selected: string[];
@@ -89,6 +90,13 @@ export function MoodTagPicker({
* when omitted the picker only surfaces binary tags.
*/
onRateFactor?: (key: string, rating: number | null) => void;
/**
* v1.17.0 — which tag kinds to surface. The sectioned add-sheet renders
* the binary tiles under "More tags" (`mode="binary"`) and the segmented
* factor ratings under "Factors" (`mode="rated"`) as two separate
* sections; the default `"all"` keeps the legacy interleaved layout.
*/
mode?: "all" | "binary" | "rated";
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -116,6 +124,8 @@ export function MoodTagPicker({
const selectedSet = new Set(selected);
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
const showBinary = mode !== "rated";
const showRated = mode !== "binary";
const groupOptions = data.categories.map((category) => ({
key: category.key,
@@ -131,10 +141,15 @@ export function MoodTagPicker({
<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");
const binaryTags = showBinary
? category.tags.filter((tag) => tag.kind !== "RATED")
: [];
const ratedTags = showRated
? 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.
// `onRateFactor` is wired, or a mode that filters all tags out) 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;
@@ -147,7 +162,7 @@ export function MoodTagPicker({
<span>{category.label ?? t(category.labelKey)}</span>
</div>
{
{showBinary && (
<div className="flex flex-wrap gap-2">
{binaryTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
@@ -178,7 +193,7 @@ export function MoodTagPicker({
onClick={() => setCreateGroupKey(category.key)}
/>
</div>
}
)}
{ratedTags.length > 0 && onRateFactor && (
<div className="space-y-2" data-slot="mood-factor-ratings">
@@ -262,8 +277,9 @@ export function MoodTagPicker({
{/* Bootstrap node for the seeded Custom group: before the first
custom tag exists the plain read carries no `custom` category,
so the inline-create entry point renders synthetically. */}
{!hasCustomGroup && (
so the inline-create entry point renders synthetically. Only the
binary-tag surfaces it (custom tags are binary). */}
{showBinary && !hasCustomGroup && (
<div className="space-y-2" data-slot="mood-tag-custom-bootstrap">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
/**
* v1.17.0 — most-recently-used mood-tag tracking for the Quick row.
*
* The Quick row surfaces the caller's handful of most-recently-used binary
* tags as one-tap chips so the common case (a face + a couple of familiar
* tags + Save) is genuinely fast. The MRU order is a small, client-only
* convenience persisted in `localStorage`; it never touches the API and a
* cleared store simply degrades to "no quick chips yet".
*
* The selection logic is split out as pure functions so it is unit-testable
* without a DOM.
*/
const STORAGE_KEY = "healthlog:mood:recent-tags";
const MAX_RECENT = 24;
/**
* Promote `usedKeys` to the front of the existing MRU list (most-recent
* first), de-duplicated, capped at `MAX_RECENT`. A key already present is
* moved to the front rather than duplicated. Order within `usedKeys` is
* preserved (first item ends up most-recent).
*/
export function promoteRecentTags(
existing: readonly string[],
usedKeys: readonly string[],
): string[] {
const seen = new Set<string>();
const next: string[] = [];
// Most-recent first: the latest-used keys lead, then prior history.
for (const key of [...usedKeys].reverse().concat(existing)) {
if (!key || seen.has(key)) continue;
seen.add(key);
next.push(key);
if (next.length >= MAX_RECENT) break;
}
return next;
}
/**
* Resolve the ordered Quick-row keys for a render: the MRU keys that still
* exist in the live catalog (intersected so a deleted tag drops out), capped
* at `limit`. When the user has no history yet, fall back to the first
* `limit` catalog keys so the Quick row is never empty on a fresh account.
*/
export function selectQuickTagKeys(
recent: readonly string[],
catalogKeys: readonly string[],
limit: number,
): string[] {
const catalogSet = new Set(catalogKeys);
const fromHistory = recent.filter((k) => catalogSet.has(k));
if (fromHistory.length >= limit) return fromHistory.slice(0, limit);
// Top up with catalog keys not already chosen so the row stays full.
const chosen = new Set(fromHistory);
const topped = [...fromHistory];
for (const key of catalogKeys) {
if (topped.length >= limit) break;
if (!chosen.has(key)) {
chosen.add(key);
topped.push(key);
}
}
return topped.slice(0, limit);
}
const EMPTY: string[] = [];
// Cache the parsed snapshot so `useSyncExternalStore` gets a stable
// reference between reads (a fresh array every call would loop forever).
let snapshot: string[] = EMPTY;
let snapshotRaw: string | null | undefined;
function readStore(): string[] {
if (typeof window === "undefined") return EMPTY;
let raw: string | null = null;
try {
raw = window.localStorage.getItem(STORAGE_KEY);
} catch {
return EMPTY;
}
if (raw === snapshotRaw) return snapshot;
snapshotRaw = raw;
if (!raw) {
snapshot = EMPTY;
return snapshot;
}
try {
const parsed: unknown = JSON.parse(raw);
snapshot = Array.isArray(parsed)
? parsed.filter((x): x is string => typeof x === "string")
: EMPTY;
} catch {
snapshot = EMPTY;
}
return snapshot;
}
const listeners = new Set<() => void>();
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
function emit() {
for (const cb of listeners) cb();
}
/**
* React hook over the MRU store. Reads the persisted keys via
* `useSyncExternalStore` (SSR-safe: the server snapshot is empty and
* hydration stays stable), and exposes a `recordUse` to promote
* freshly-saved tag keys to the front.
*/
export function useRecentTags(): {
recent: string[];
recordUse: (usedKeys: readonly string[]) => void;
} {
const recent = useSyncExternalStore(subscribe, readStore, () => EMPTY);
const recordUse = useCallback((usedKeys: readonly string[]) => {
if (usedKeys.length === 0 || typeof window === "undefined") return;
const next = promoteRecentTags(readStore(), usedKeys);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* storage full / disabled — nothing to promote */
return;
}
// Invalidate the cached snapshot and notify subscribers to re-read.
snapshotRaw = undefined;
emit();
}, []);
return { recent, recordUse };
}
+9 -2
View File
@@ -30,8 +30,12 @@ interface SheetSectionProps {
summary?: React.ReactNode;
/** Optional leading icon (a Lucide component or any node). */
icon?: React.ReactNode;
/** Whether the section starts expanded. Defaults to collapsed. */
/** Whether the section starts expanded (uncontrolled). Defaults to closed. */
defaultOpen?: boolean;
/** Controlled open state. When set, pair with `onOpenChange`. */
open?: boolean;
/** Controlled open-state change handler. */
onOpenChange?: (open: boolean) => void;
/** Optional className merged onto the section wrapper. */
className?: string;
children: React.ReactNode;
@@ -42,12 +46,15 @@ export function SheetSection({
summary,
icon,
defaultOpen = false,
open,
onOpenChange,
className,
children,
}: SheetSectionProps) {
return (
<CollapsiblePrimitive.Root
defaultOpen={defaultOpen}
// Controlled when `open` is provided, otherwise uncontrolled.
{...(open === undefined ? { defaultOpen } : { open, onOpenChange })}
data-slot="sheet-section"
className={cn("border-border/60 border-t pt-3", className)}
>