mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(mood): structured tag taxonomy + note capture and timeline
Add the Category -> Tag taxonomy the mood surface was missing and the free-text note the web form could never write, then surface both on the Insights mood page. Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog plus a MoodEntryTagLink join, seeded with a default five-category set (i18n-keyed labels, Lucide icons). Purely additive — the legacy flat MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram / MoodLog sync are untouched; an entry can carry both axes. Capture: the mood-logging form and the edit sheet gain a Textarea for the note (model + API already accepted it) and an icon-chip picker over the taxonomy catalog (GET /api/mood/tags). Create / update resolve the picked keys to links; unknown keys drop silently against the catalog. Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and a notes timeline join the existing distribution / weekday / flat-tag sections. The aggregate extension also fixes the flat-tag axis, which read the raw JSON string and so never populated. i18n across all six locales. Tests cover the taxonomy aggregation, the note round-trip, the structured-tag schema, and the notes timeline.
This commit is contained in:
1 parent
b99a015266
commit
cf649f72ce
25 files changed
+1688
-18
No files matched your search
@@ -24,6 +24,14 @@ import {
|
||||
MoodCorrelationCards,
|
||||
type MoodMetricCorrelationData,
|
||||
} from "./mood-correlation-cards";
|
||||
import {
|
||||
MoodStructuredTagBreakdown,
|
||||
type MoodStructuredTagRow,
|
||||
} from "./mood-structured-tag-breakdown";
|
||||
import {
|
||||
MoodNotesTimeline,
|
||||
type MoodNoteEntry,
|
||||
} from "./mood-notes-timeline";
|
||||
|
||||
/**
|
||||
* v1.8.5 — additional Mood Insights sections.
|
||||
@@ -46,6 +54,8 @@ interface MoodInsightsResponse {
|
||||
distribution: MoodDistributionRow[];
|
||||
weekday: MoodWeekdayRow[];
|
||||
tags: MoodTagRow[];
|
||||
structuredTags: MoodStructuredTagRow[];
|
||||
notesTimeline: MoodNoteEntry[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
steps: MoodMetricCorrelationData;
|
||||
@@ -99,6 +109,8 @@ export function MoodInsightsSections() {
|
||||
);
|
||||
|
||||
const hasTags = data.tags.length > 0;
|
||||
const hasStructuredTags = data.structuredTags.length > 0;
|
||||
const hasNotes = data.notesTimeline.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -119,6 +131,12 @@ export function MoodInsightsSections() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{hasStructuredTags && (
|
||||
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
|
||||
<MoodStructuredTagBreakdown tags={data.structuredTags} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<SectionCard title={t("insights.mood.tagsTitle")}>
|
||||
<MoodTagBreakdown tags={data.tags} />
|
||||
@@ -138,6 +156,12 @@ export function MoodInsightsSections() {
|
||||
pulse={data.correlations.pulse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasNotes && (
|
||||
<SectionCard title={t("insights.mood.notesTimelineTitle")}>
|
||||
<MoodNotesTimeline notes={data.notesTimeline} />
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
|
||||
|
||||
/**
|
||||
* v1.8.5 (C1) — notes timeline.
|
||||
*
|
||||
* Chronological feed of recent mood entries that carry a free-text note,
|
||||
* each with its score, mood label, note text, and tag chips. Renders the
|
||||
* note as a plain React text child (no markdown library — the project
|
||||
* forbids one for XSS reasons).
|
||||
*/
|
||||
|
||||
export interface MoodNoteEntry {
|
||||
date: string;
|
||||
loggedAt: string;
|
||||
score: number;
|
||||
mood: string | null;
|
||||
note: string;
|
||||
tags: string[];
|
||||
structuredTagLabelKeys: string[];
|
||||
}
|
||||
|
||||
export function MoodNotesTimeline({ notes }: { notes: MoodNoteEntry[] }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ul className="space-y-3" data-slot="mood-notes-timeline">
|
||||
{notes.map((entry) => {
|
||||
const moodLabel =
|
||||
entry.mood && MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.loggedAt}-${entry.date}`}
|
||||
className="border-border/60 border-l-2 pl-3"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{entry.score}
|
||||
</span>
|
||||
{moodLabel && <span>({moodLabel})</span>}
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(entry.loggedAt)}</span>
|
||||
</div>
|
||||
<p className="text-foreground/90 mt-0.5 text-sm whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
{(entry.structuredTagLabelKeys.length > 0 ||
|
||||
entry.tags.length > 0) && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{entry.structuredTagLabelKeys.map((labelKey) => (
|
||||
<span
|
||||
key={labelKey}
|
||||
className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
))}
|
||||
{entry.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="border-border/70 text-muted-foreground rounded-full border px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured-tag breakdown.
|
||||
*
|
||||
* Ranked icon-bearing rows over the taxonomy tags the user picked,
|
||||
* grouped under their category, with the per-tag average mood ("lift")
|
||||
* as a coloured score. Distinct from the flat free-text breakdown:
|
||||
* structured tags carry icons + categories from the curated catalog.
|
||||
*/
|
||||
|
||||
export interface MoodStructuredTagRow {
|
||||
key: string;
|
||||
categoryKey: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
avgScore: number;
|
||||
}
|
||||
|
||||
function colorForScore(score: number): string {
|
||||
if (score < 2) return "var(--dracula-red)";
|
||||
if (score < 3) return "var(--dracula-orange)";
|
||||
if (score < 3.5) return "var(--dracula-yellow)";
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
export function MoodStructuredTagBreakdown({
|
||||
tags,
|
||||
}: {
|
||||
tags: MoodStructuredTagRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
|
||||
|
||||
return (
|
||||
<ul className="space-y-2" data-slot="mood-structured-tag-breakdown">
|
||||
{tags.map((row) => {
|
||||
const Icon = moodTagIcon(row.icon);
|
||||
return (
|
||||
<li key={row.key} className="flex items-center gap-2 text-sm">
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-foreground w-28 shrink-0 truncate">
|
||||
{t(row.labelKey)}
|
||||
</span>
|
||||
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${(row.count / maxCount) * 100}%`,
|
||||
backgroundColor: colorForScore(row.avgScore),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-right tabular-nums">
|
||||
{row.count}
|
||||
</span>
|
||||
<span
|
||||
className="w-10 shrink-0 text-right text-xs font-medium tabular-nums"
|
||||
style={{ color: colorForScore(row.avgScore) }}
|
||||
title={t("insights.mood.tagAvgMood")}
|
||||
>
|
||||
{row.avgScore.toFixed(1)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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";
|
||||
@@ -18,6 +19,7 @@ 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";
|
||||
|
||||
const MOOD_LEVELS = [
|
||||
{ value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" },
|
||||
@@ -77,10 +79,22 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
|
||||
});
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
// 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.
|
||||
const [note, setNote] = useState("");
|
||||
const [moodLoggedAt, setMoodLoggedAt] = useState(getDefaultMoodLoggedAtValue);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggleTagKey(key: string) {
|
||||
setTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
@@ -93,6 +107,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setError(null);
|
||||
}
|
||||
@@ -110,12 +126,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
const res = await fetch("/api/mood-entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : undefined,
|
||||
moodLoggedAt: timestamp,
|
||||
}),
|
||||
});
|
||||
@@ -308,6 +328,36 @@ 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. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<MoodTagPicker selected={tagKeys} onToggle={toggleTagKey} />
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 {
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from "@/lib/mood/labels";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
|
||||
// Re-export the score map under the legacy local name to keep the
|
||||
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
|
||||
@@ -76,6 +78,9 @@ interface MoodEntry {
|
||||
mood: string;
|
||||
score: number;
|
||||
tags: string[];
|
||||
// v1.8.5 — structured-tag keys + free-text note.
|
||||
tagKeys: string[];
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
}
|
||||
@@ -127,8 +132,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
onSelect: (index) => setEditMood(MOOD_LEVELS_LIST[index]!),
|
||||
});
|
||||
const [editTagsInput, setEditTagsInput] = useState("");
|
||||
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
function toggleEditTagKey(key: string) {
|
||||
setEditTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
|
||||
const editFormId = useId();
|
||||
@@ -194,17 +207,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
id,
|
||||
mood,
|
||||
tags,
|
||||
tagKeys,
|
||||
note,
|
||||
moodLoggedAt,
|
||||
}: {
|
||||
id: string;
|
||||
mood: string;
|
||||
tags: string[] | null;
|
||||
tagKeys: string[];
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
const res = await fetch(`/api/mood-entries/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mood, tags, moodLoggedAt }),
|
||||
body: JSON.stringify({ mood, tags, tagKeys, note, moodLoggedAt }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
@@ -228,6 +245,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setEditing(entry);
|
||||
setEditMood(entry.mood);
|
||||
setEditTagsInput(entry.tags.join(", "));
|
||||
setEditTagKeys(entry.tagKeys ?? []);
|
||||
setEditNote(entry.note ?? "");
|
||||
setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt));
|
||||
setEditError(null);
|
||||
}
|
||||
@@ -266,11 +285,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const trimmedNote = editNote.trim();
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
id: editing.id,
|
||||
mood: editMood,
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
tagKeys: editTagKeys,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
moodLoggedAt: measuredDate.toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -395,6 +418,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
@@ -467,6 +495,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
{entry.tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 truncate text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -603,6 +636,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 — structured-tag taxonomy picker. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")} ({t("common.optional")})
|
||||
</Label>
|
||||
<MoodTagPicker
|
||||
selected={editTagKeys}
|
||||
onToggle={toggleEditTagKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
{t("mood.note")} ({t("common.optional")})
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-mood-note"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Apple,
|
||||
BedDouble,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Flame,
|
||||
Footprints,
|
||||
Frown,
|
||||
GlassWater,
|
||||
HandHeart,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Meh,
|
||||
Moon,
|
||||
MoonStar,
|
||||
PartyPopper,
|
||||
Plane,
|
||||
Smile,
|
||||
Tag,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
User,
|
||||
Users,
|
||||
Wine,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* v1.8.5 — icon-name → Lucide component map for the structured mood-tag
|
||||
* taxonomy. The catalog stores Lucide icon names as strings (so the
|
||||
* server stays icon-library-agnostic); this map resolves them on the
|
||||
* client. Mirrors the static-map convention used by the workout / sport
|
||||
* icon lookups. `Tag` is the fallback for an unmapped name.
|
||||
*/
|
||||
const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
|
||||
AlertTriangle,
|
||||
Apple,
|
||||
BedDouble,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Flame,
|
||||
Footprints,
|
||||
Frown,
|
||||
GlassWater,
|
||||
HandHeart,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Meh,
|
||||
Moon,
|
||||
MoonStar,
|
||||
PartyPopper,
|
||||
Plane,
|
||||
Smile,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
User,
|
||||
Users,
|
||||
Wine,
|
||||
Zap,
|
||||
};
|
||||
|
||||
export function moodTagIcon(name: string | null | undefined): LucideIcon {
|
||||
if (!name) return Tag;
|
||||
return MOOD_TAG_ICONS[name] ?? Tag;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
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 { moodTagIcon } from "./mood-tag-icons";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured mood-tag capture surface.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* deployment that cleared the taxonomy degrades to the free-text field.
|
||||
*/
|
||||
|
||||
interface CatalogTag {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
interface CatalogCategory {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
tags: CatalogTag[];
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
categories: CatalogCategory[];
|
||||
}
|
||||
|
||||
export function MoodTagPicker({
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
selected: string[];
|
||||
onToggle: (tagKey: string) => void;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.moodTagCatalog(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/mood/tags");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as CatalogResponse;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
// The catalog only changes on a migration / admin edit.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-24 w-full rounded-lg" />;
|
||||
}
|
||||
|
||||
if (!data || data.categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="mood-tag-picker">
|
||||
{data.categories.map((category) => {
|
||||
const CategoryIcon = moodTagIcon(category.icon);
|
||||
return (
|
||||
<div key={category.key} className="space-y-1.5">
|
||||
<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-8 items-center gap-1.5 rounded-full border px-2.5 py-1 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user