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 });
});