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:
Marc-André Bombeck committed 2026-06-01 20:25:42 +02:00
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>
);
}
+50
View File
@@ -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}
+60 -1
View File
@@ -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"
+83
View File
@@ -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;
}
+112
View File
@@ -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>
);
}