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

+8
View File
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { replaceTagLinks } from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -138,6 +139,13 @@ export const PUT = apiHandler(
data: updateData,
});
// v1.8.5 — full replacement of the structured-tag link set when
// `tagKeys` is present in the body. `null` clears every link; an
// omitted field leaves the links untouched.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, data.tagKeys ?? []);
}
await auditLog("moodEntry.update", {
userId: user.id,
ipAddress: getClientIp(request),
+20 -2
View File
@@ -22,6 +22,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
import { createTagLinks } from "@/lib/mood/tag-links";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -90,6 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { [sortBy]: sortDir },
take: limit,
skip: offset,
// v1.8.5 — include the structured-tag link keys so the edit form
// can pre-populate the taxonomy picker.
include: {
tagLinks: {
select: { moodTag: { select: { key: true } } },
},
},
}),
prisma.moodEntry.count({ where }),
]);
@@ -99,9 +107,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { total, limit, offset },
});
const entriesWithParsedTags = entries.map((e) => ({
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
...e,
tags: parseTags(e.tags),
// v1.8.5 — flat list of structured-tag keys attached to the entry.
tagKeys: tagLinks.map((link) => link.moodTag.key),
}));
return apiSuccess({
@@ -147,7 +157,7 @@ async function postMoodEntry(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
const { mood, tags, tagKeys, note, moodLoggedAt, source } = parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -171,6 +181,14 @@ async function postMoodEntry(request: NextRequest) {
},
});
// v1.8.5 — write the structured-tag links. Unknown keys are dropped
// inside the helper (the catalog is the source of truth). Best-effort
// is wrong here — the links are user-intended content, not a cache —
// so a failure surfaces as a 5xx via the outer try/catch.
if (tagKeys && tagKeys.length > 0) {
await createTagLinks(entry.id, tagKeys);
}
await auditLog("moodEntry.create", {
userId: user.id,
ipAddress: getClientIp(request),
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
/**
* v1.8.5 — structured mood-tag taxonomy catalog.
*
* Returns the active Category -> Tag tree the mood-logging form renders
* as a pick-from-catalog capture surface. Global reference data (one
* shared catalog for the deployment, seeded by migration 0101), so the
* read carries no per-user filter and no encryption. The `labelKey`
* fields resolve client-side against the active locale.
*/
export const GET = apiHandler(async () => {
// Auth-gated like every app route — the catalog is not public, but it
// is identical for every authenticated user.
await requireAuth();
const categories = await prisma.moodTagCategory.findMany({
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: {
key: true,
labelKey: true,
icon: true,
tags: {
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: { key: true, labelKey: true, icon: true },
},
},
});
annotate({
action: { name: "mood.tags.catalog.read" },
meta: { category_count: categories.length },
});
return apiSuccess({ categories });
});
@@ -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>
);
}
@@ -6,11 +6,14 @@ import {
computeInTargetPct,
computeMoodAggregates,
computeMoodMetricCorrelation,
computeNotesTimeline,
computeStructuredTagSummary,
computeTagSummary,
computeWeekdayAverages,
selectHeatmapWindow,
type CrossMetricMeasurement,
type MoodAggregateEntry,
type StructuredTagRef,
} from "../mood-aggregates";
const dayMs = 24 * 60 * 60 * 1000;
@@ -78,6 +81,79 @@ describe("computeTagSummary", () => {
});
});
describe("computeStructuredTagSummary", () => {
function entryWithStructured(
offset: number,
score: number,
structuredTags: StructuredTagRef[],
): MoodAggregateEntry {
return { ...entry(offset, score, null), structuredTags };
}
it("ranks structured tags by frequency, keeps singletons, and groups by category", () => {
const entries: MoodAggregateEntry[] = [
entryWithStructured(1, 5, [
{ key: "happy", categoryKey: "feelings", labelKey: "mood.tag.happy", icon: "Smile" },
{ key: "worked_out", categoryKey: "health", labelKey: "mood.tag.workedOut", icon: "Dumbbell" },
]),
entryWithStructured(2, 3, [
{ key: "happy", categoryKey: "feelings", labelKey: "mood.tag.happy", icon: "Smile" },
]),
entryWithStructured(3, 1, [
{ key: "stressed", categoryKey: "feelings", labelKey: "mood.tag.stressed", icon: "Brain" },
]),
];
const rows = computeStructuredTagSummary(entries, NOW);
const happy = rows.find((r) => r.key === "happy");
expect(happy).toEqual({
key: "happy",
categoryKey: "feelings",
labelKey: "mood.tag.happy",
icon: "Smile",
count: 2,
avgScore: 4,
});
// singletons are kept for the structured breakdown (curated catalog,
// not noisy free text).
expect(rows.find((r) => r.key === "stressed")?.count).toBe(1);
expect(rows.find((r) => r.key === "worked_out")?.categoryKey).toBe("health");
// ranked by frequency desc.
expect(rows[0].key).toBe("happy");
});
it("excludes entries older than the window and ignores entries without structured tags", () => {
const entries: MoodAggregateEntry[] = [
entryWithStructured(1, 5, [
{ key: "happy", categoryKey: "feelings", labelKey: "mood.tag.happy", icon: "Smile" },
]),
entryWithStructured(200, 5, [
{ key: "sad", categoryKey: "feelings", labelKey: "mood.tag.sad", icon: "Frown" },
]),
entry(2, 4, ["flat-only"]), // no structuredTags → ignored here
];
const rows = computeStructuredTagSummary(entries, NOW, 90);
expect(rows.map((r) => r.key)).toEqual(["happy"]);
});
});
describe("computeNotesTimeline", () => {
it("returns noted entries newest-first, skips empty notes, and caps at the limit", () => {
const entries: MoodAggregateEntry[] = [
{ ...entry(5, 2, ["x"]), note: "oldest", mood: "SCHLECHT" },
{ ...entry(3, 4, null), note: " ", mood: "GUT" }, // blank → skipped
{ ...entry(2, 5, null), note: "middle", mood: "SUPER_GUT" },
{ ...entry(1, 3, null), note: "newest", mood: "OKAY" },
];
const timeline = computeNotesTimeline(entries, 2);
expect(timeline.map((n) => n.note)).toEqual(["newest", "middle"]);
expect(timeline[0]).toMatchObject({ score: 3, mood: "OKAY", note: "newest" });
});
it("returns an empty array when no entry carries a note", () => {
expect(computeNotesTimeline([entry(1, 4, ["x"])])).toEqual([]);
});
});
describe("computeInTargetPct", () => {
it("returns the share of the newest 30 daily buckets in the green band", () => {
const daily = [
+197 -2
View File
@@ -28,6 +28,24 @@ import { round, summarizeSeries } from "@/lib/insights/status-shared";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Parse the flat `MoodEntry.tags` JSON string into a string array. The
* column stores a JSON-encoded array (or null); the read path must
* decode it before `computeTagSummary`'s `Array.isArray` check, otherwise
* the flat-tag axis silently collapses to empty (the raw string is never
* an array). Defensive against malformed JSON — returns `[]` on a parse
* failure rather than throwing.
*/
function parseTags(tags: string | null): string[] {
if (!tags) return [];
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [];
}
}
/**
* Minimal daily-bucket shape the aggregates read. `DailyBucket` (from
* `bucket-series`) carries an extra `n` field; the helpers only ever
@@ -36,6 +54,23 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
*/
type DailyPoint = Pick<DailyBucket, "dayOffset" | "value">;
/**
* A structured tag attached to a mood entry, resolved from the catalog
* (`mood_tags` + its parent `mood_tag_categories`) via the
* `mood_entry_tag_links` join. Carried alongside the legacy flat `tags`
* string so the breakdown can surface both axes.
*/
export interface StructuredTagRef {
/** Stable tag key (e.g. `happy`). */
key: string;
/** Parent category key (e.g. `feelings`). */
categoryKey: string;
/** i18n message key for the tag label. */
labelKey: string;
/** Lucide icon name, or null. */
icon: string | null;
}
/** Raw mood row shape the aggregates operate on. */
export interface MoodAggregateEntry {
/** TZ-anchored day key (YYYY-MM-DD). */
@@ -46,6 +81,59 @@ export interface MoodAggregateEntry {
tags: unknown;
/** Exact log timestamp — carries time-of-day. */
moodLoggedAt: Date;
/**
* v1.8.5 — structured tags from the taxonomy join. Additive next to
* the flat `tags`: an entry can carry both. Optional so the legacy
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
/** v1.8.5 — free-text note (C1). */
note?: string | null;
/** Mood enum (for the notes timeline label / colour). */
mood?: string;
}
// --- Notes timeline (v1.8.5 C1 — qualitative reflection feed) ---
export interface NoteTimelineEntry {
/** TZ-anchored day key. */
date: string;
/** ISO timestamp of the log. */
loggedAt: string;
score: number;
mood: string | null;
note: string;
/** Flat free-text tags on the entry. */
tags: string[];
/** Structured-tag labels (i18n keys) on the entry. */
structuredTagLabelKeys: string[];
}
/**
* Recent entries that carry a free-text note, newest first. Bounded to
* `limit` so the cached aggregate payload stays small. Entries without a
* note are skipped — the timeline is the qualitative-reflection feed.
*/
export function computeNotesTimeline(
entries: MoodAggregateEntry[],
limit = 20,
): NoteTimelineEntry[] {
return entries
.filter((entry) => typeof entry.note === "string" && entry.note.trim())
.slice()
.reverse() // entries arrive oldest-first; show newest first
.slice(0, limit)
.map((entry) => ({
date: entry.date,
loggedAt: entry.moodLoggedAt.toISOString(),
score: entry.score,
mood: entry.mood ?? null,
note: entry.note!.trim(),
tags: Array.isArray(entry.tags) ? (entry.tags as string[]) : [],
structuredTagLabelKeys: (entry.structuredTags ?? []).map(
(tag) => tag.labelKey,
),
}));
}
/** Cross-metric row shape (WEIGHT / BLOOD_PRESSURE_SYS / PULSE / …). */
@@ -147,6 +235,71 @@ export function computeTagSummary(
}));
}
// --- Structured-tag frequency + per-tag avg score (v1.8.5 taxonomy) ---
export interface StructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
icon: string | null;
count: number;
avgScore: number;
}
/**
* Frequency + per-tag average mood for the structured taxonomy tags,
* over the same recency window as the flat-tag summary.
*
* Unlike `computeTagSummary` (flat free text), singletons are KEPT — a
* structured tag comes from a curated catalog the user deliberately
* picked, so a single occurrence is signal, not noise. Ranked by
* frequency desc, then label key for a stable order. The category key
* rides along so the UI can group the bars by category with icons.
*/
export function computeStructuredTagSummary(
entries: MoodAggregateEntry[],
now: Date,
windowDays = 90,
): StructuredTagRow[] {
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const byKey = new Map<
string,
{
categoryKey: string;
labelKey: string;
icon: string | null;
count: number;
scoreSum: number;
}
>();
for (const entry of entries) {
if (entry.moodLoggedAt.getTime() < cutoff) continue;
if (!entry.structuredTags) continue;
for (const tag of entry.structuredTags) {
const current = byKey.get(tag.key) ?? {
categoryKey: tag.categoryKey,
labelKey: tag.labelKey,
icon: tag.icon,
count: 0,
scoreSum: 0,
};
current.count += 1;
current.scoreSum += entry.score;
byKey.set(tag.key, current);
}
}
return Array.from(byKey.entries())
.map(([key, stats]) => ({
key,
categoryKey: stats.categoryKey,
labelKey: stats.labelKey,
icon: stats.icon,
count: stats.count,
avgScore: round(stats.scoreSum / stats.count, 2),
}))
.sort((a, b) => b.count - a.count || a.labelKey.localeCompare(b.labelKey));
}
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
/**
@@ -346,6 +499,10 @@ export interface MoodAggregates {
distribution: DistributionRow[];
weekday: WeekdayRow[];
tags: TagSummaryRow[];
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
structuredTags: StructuredTagRow[];
/** v1.8.5 — recent notes timeline (qualitative-reflection feed). */
notesTimeline: NoteTimelineEntry[];
correlations: {
sleep: MoodMetricCorrelation;
steps: MoodMetricCorrelation;
@@ -424,6 +581,8 @@ export function computeMoodAggregates(args: {
distribution: computeDistribution(moodDaily),
weekday: computeWeekdayAverages(moodDaily, now),
tags: computeTagSummary(entries, now),
structuredTags: computeStructuredTagSummary(entries, now),
notesTimeline: computeNotesTimeline(entries),
correlations: {
sleep: computeMoodMetricCorrelation(
moodDaily,
@@ -473,9 +632,45 @@ export async function fetchMoodAggregates(
where: { userId, deletedAt: null, moodLoggedAt: { gte: windowCutoff } },
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: { date: true, score: true, tags: true, moodLoggedAt: true },
select: {
date: true,
score: true,
tags: true,
moodLoggedAt: true,
mood: true,
note: true,
// v1.8.5 — pull the structured-tag links + their catalog rows so
// the breakdown can fold structured tags next to the flat ones.
tagLinks: {
select: {
moodTag: {
select: {
key: true,
labelKey: true,
icon: true,
category: { select: { key: true } },
},
},
},
},
},
})
.then((rows) => rows.reverse());
.then((rows) =>
rows.reverse().map((row) => ({
date: row.date,
score: row.score,
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
mood: row.mood,
note: row.note,
structuredTags: row.tagLinks.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
})),
})),
);
const measurements = await prisma.measurement
.findMany({
+73
View File
@@ -0,0 +1,73 @@
import { prisma } from "@/lib/db";
/**
* v1.8.5 — structured-tag link helpers.
*
* Resolve a list of catalog tag keys (`mood_tags.key`) to their ids and
* write the `mood_entry_tag_links` join for a mood entry. The catalog is
* the source of truth: unknown keys are dropped silently so a stale
* client can never mint a link to a tag the deployment doesn't carry.
*/
/**
* Resolve catalog tag keys to ids, dropping unknown / inactive keys.
* Returns the ids in catalog order (deduped).
*/
export async function resolveTagKeysToIds(keys: string[]): Promise<string[]> {
const unique = Array.from(new Set(keys));
if (unique.length === 0) return [];
const rows = await prisma.moodTag.findMany({
where: { key: { in: unique }, isActive: true },
select: { id: true },
});
return rows.map((row) => row.id);
}
/**
* Create the structured-tag links for a freshly-created entry. No-op on
* an empty / all-unknown key set.
*/
export async function createTagLinks(
moodEntryId: string,
keys: string[],
): Promise<void> {
const tagIds = await resolveTagKeysToIds(keys);
if (tagIds.length === 0) return;
await prisma.moodEntryTagLink.createMany({
data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })),
skipDuplicates: true,
});
}
/**
* Replace the full structured-tag link set for an entry. `keys` is the
* desired set; the helper deletes links no longer present and inserts
* the new ones, leaving unchanged links in place. Passing an empty array
* clears every link.
*/
export async function replaceTagLinks(
moodEntryId: string,
keys: string[],
): Promise<void> {
const desiredIds = new Set(await resolveTagKeysToIds(keys));
const existing = await prisma.moodEntryTagLink.findMany({
where: { moodEntryId },
select: { moodTagId: true },
});
const existingIds = new Set(existing.map((row) => row.moodTagId));
const toDelete = [...existingIds].filter((id) => !desiredIds.has(id));
const toCreate = [...desiredIds].filter((id) => !existingIds.has(id));
if (toDelete.length > 0) {
await prisma.moodEntryTagLink.deleteMany({
where: { moodEntryId, moodTagId: { in: toDelete } },
});
}
if (toCreate.length > 0) {
await prisma.moodEntryTagLink.createMany({
data: toCreate.map((moodTagId) => ({ moodEntryId, moodTagId })),
skipDuplicates: true,
});
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* v1.8.5 — default mood-tag taxonomy.
*
* The canonical Category → Tag catalog seeded into `mood_tag_categories`
* + `mood_tags` by migration `0101_v185_mood_tag_taxonomy`. Modelled on
* the standalone mood diary's structured taxonomy but keyed on i18n
* message keys (resolved against all six locales) rather than hard-coded
* labels, so the catalog renders localised everywhere.
*
* The ids are deterministic string literals so the migration seed is
* idempotent (re-running upserts by `key`) and so the catalog rows are
* stable across deployments. Keys are stable machine identifiers; the
* `labelKey` is the i18n key the capture UI + breakdown resolve.
*
* This is purely additive next to the legacy flat `MoodEntry.tags` JSON
* column — the structured taxonomy is a second capture surface, not a
* replacement.
*/
export interface MoodTagSeed {
/** Stable machine key, unique across the catalog. */
key: string;
/** i18n message key `mood.tag.<key>`. */
labelKey: string;
/** Lucide icon name. */
icon: string;
}
export interface MoodTagCategorySeed {
key: string;
/** i18n message key `mood.tagCategory.<key>`. */
labelKey: string;
/** Lucide icon name for the category header. */
icon: string;
tags: MoodTagSeed[];
}
/**
* The seeded taxonomy. Order within the arrays is the `sortOrder` used
* for the catalog rows (category index, tag index within its category).
*/
export const MOOD_TAG_TAXONOMY: MoodTagCategorySeed[] = [
{
key: "feelings",
labelKey: "mood.tagCategory.feelings",
icon: "Heart",
tags: [
{ key: "happy", labelKey: "mood.tag.happy", icon: "Smile" },
{ key: "excited", labelKey: "mood.tag.excited", icon: "Zap" },
{ key: "grateful", labelKey: "mood.tag.grateful", icon: "HandHeart" },
{ key: "relaxed", labelKey: "mood.tag.relaxed", icon: "CloudSun" },
{ key: "content", labelKey: "mood.tag.content", icon: "ThumbsUp" },
{ key: "tired", labelKey: "mood.tag.tired", icon: "Moon" },
{ key: "unsure", labelKey: "mood.tag.unsure", icon: "HelpCircle" },
{ key: "bored", labelKey: "mood.tag.bored", icon: "Meh" },
{ key: "tense", labelKey: "mood.tag.tense", icon: "AlertTriangle" },
{ key: "angry", labelKey: "mood.tag.angry", icon: "Flame" },
{ key: "stressed", labelKey: "mood.tag.stressed", icon: "Brain" },
{ key: "sad", labelKey: "mood.tag.sad", icon: "Frown" },
],
},
{
key: "sleep",
labelKey: "mood.tagCategory.sleep",
icon: "BedDouble",
tags: [
{ key: "slept_well", labelKey: "mood.tag.sleptWell", icon: "Moon" },
{ key: "slept_ok", labelKey: "mood.tag.sleptOk", icon: "CloudMoon" },
{ key: "slept_poorly", labelKey: "mood.tag.sleptPoorly", icon: "MoonStar" },
{ key: "early_night", labelKey: "mood.tag.earlyNight", icon: "Clock" },
],
},
{
key: "health",
labelKey: "mood.tagCategory.health",
icon: "HeartPulse",
tags: [
{ key: "worked_out", labelKey: "mood.tag.workedOut", icon: "Dumbbell" },
{ key: "ate_well", labelKey: "mood.tag.ateWell", icon: "Apple" },
{ key: "hydrated", labelKey: "mood.tag.hydrated", icon: "GlassWater" },
{ key: "walked", labelKey: "mood.tag.walked", icon: "Footprints" },
{ key: "alcohol", labelKey: "mood.tag.alcohol", icon: "Wine" },
],
},
{
key: "social",
labelKey: "mood.tagCategory.social",
icon: "Users",
tags: [
{ key: "family", labelKey: "mood.tag.family", icon: "Home" },
{ key: "friends", labelKey: "mood.tag.friends", icon: "Users" },
{ key: "party", labelKey: "mood.tag.party", icon: "PartyPopper" },
{ key: "alone", labelKey: "mood.tag.alone", icon: "User" },
],
},
{
key: "work",
labelKey: "mood.tagCategory.work",
icon: "Briefcase",
tags: [
{ key: "productive", labelKey: "mood.tag.productive", icon: "CheckCircle" },
{ key: "overtime", labelKey: "mood.tag.overtime", icon: "Clock" },
{ key: "day_off", labelKey: "mood.tag.dayOff", icon: "LogOut" },
{ key: "travel", labelKey: "mood.tag.travel", icon: "Plane" },
{ key: "sick_day", labelKey: "mood.tag.sickDay", icon: "Thermometer" },
],
},
];
/** Flat list of every seeded tag key (for validation / lookups). */
export const MOOD_TAG_KEYS: string[] = MOOD_TAG_TAXONOMY.flatMap((c) =>
c.tags.map((tagSeed) => tagSeed.key),
);
+8
View File
@@ -60,6 +60,14 @@ export const queryKeys = {
* `moodDependentKeys` fan-out below.
*/
moodInsights: () => ["mood-insights"] as const,
/**
* v1.8.5 — structured mood-tag taxonomy catalog (global reference
* data, identical for every user). Read by the mood-logging form's
* tag-category capture surface. Not invalidated on a mood write — the
* catalog only changes on a migration / admin edit, so a long
* `staleTime` is fine.
*/
moodTagCatalog: () => ["mood-tag-catalog"] as const,
/**
* v1.7.0 W6 — unified dashboard first-paint snapshot. One client cell
+46 -1
View File
@@ -1,5 +1,50 @@
import { describe, expect, it } from "vitest";
import { moodLogCredentialsSchema } from "../moodlog";
import {
moodLogCredentialsSchema,
createMoodEntrySchema,
updateMoodEntrySchema,
} from "../moodlog";
describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
const base = { mood: "GUT", moodLoggedAt: "2026-06-01T12:00:00.000Z" };
it("round-trips the note field", () => {
const r = createMoodEntrySchema.safeParse({ ...base, note: "felt great" });
expect(r.success).toBe(true);
if (r.success) expect(r.data.note).toBe("felt great");
});
it("accepts structured tagKeys alongside flat tags", () => {
const r = createMoodEntrySchema.safeParse({
...base,
tags: ["nausea"],
tagKeys: ["happy", "worked_out"],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.tagKeys).toEqual(["happy", "worked_out"]);
expect(r.data.tags).toEqual(["nausea"]);
}
});
it("rejects an over-long note and an over-large tagKeys set", () => {
expect(
createMoodEntrySchema.safeParse({ ...base, note: "x".repeat(501) }).success,
).toBe(false);
expect(
createMoodEntrySchema.safeParse({
...base,
tagKeys: Array.from({ length: 31 }, (_, i) => `t${i}`),
}).success,
).toBe(false);
});
it("allows update to clear tagKeys with null", () => {
const r = updateMoodEntrySchema.safeParse({ tagKeys: null });
expect(r.success).toBe(true);
if (r.success) expect(r.data.tagKeys).toBeNull();
});
});
describe("moodLogCredentialsSchema SSRF guard", () => {
it("accepts a public moodLog URL", () => {
+13
View File
@@ -78,9 +78,19 @@ export function getScoreForMood(mood: string): number {
return MOOD_SCORE_MAP[mood] ?? 3;
}
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
// Additive alongside the flat free-text `tags`: an entry can carry both.
// Bounded so a single create can't fan out an unbounded link set.
const structuredTagKeys = z.array(z.string().max(60)).max(30);
export const createMoodEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.8.5 — structured-tag keys from the taxonomy. Server resolves each
// key to a `MoodTag` row and writes the `MoodEntryTagLink` join;
// unknown keys are dropped silently (the catalog is the source of
// truth, a stale client can't mint a tag).
tagKeys: structuredTagKeys.optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
@@ -92,6 +102,9 @@ export const createMoodEntrySchema = z.object({
export const updateMoodEntrySchema = z.object({
mood: moodLevelEnum.optional(),
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
// v1.8.5 — full replacement of the structured-tag set when present.
// `null` clears every link; omit to leave links untouched.
tagKeys: structuredTagKeys.nullable().optional(),
note: z.string().max(500).nullable().optional(),
moodLoggedAt: z.iso
.datetime({ offset: true })