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

+46 -2
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
"pageInfo": "Seite {page} von {total}",
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen."
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
"note": "Notiz",
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
"tagPicker": "Tags",
"tagCategory": {
"feelings": "Gefühle",
"sleep": "Schlaf",
"health": "Gesundheit",
"social": "Soziales",
"work": "Arbeit"
},
"tag": {
"happy": "glücklich",
"excited": "aufgeregt",
"grateful": "dankbar",
"relaxed": "entspannt",
"content": "zufrieden",
"tired": "müde",
"unsure": "unsicher",
"bored": "gelangweilt",
"tense": "angespannt",
"angry": "wütend",
"stressed": "gestresst",
"sad": "traurig",
"sleptWell": "gut geschlafen",
"sleptOk": "mäßig geschlafen",
"sleptPoorly": "schlecht geschlafen",
"earlyNight": "früh im Bett",
"workedOut": "trainiert",
"ateWell": "gesund gegessen",
"hydrated": "Wasser getrunken",
"walked": "spazieren",
"alcohol": "Alkohol",
"family": "Familie",
"friends": "Freunde",
"party": "Party",
"alone": "allein",
"productive": "produktiv",
"overtime": "Überstunden",
"dayOff": "Feierabend",
"travel": "Reise",
"sickDay": "Krankheitstag"
}
},
"medications": {
"title": "Medikamente",
@@ -2326,7 +2368,9 @@
"source": "{n} gepaarte Tage · r = {r}",
"emptyTitle": "Noch nicht genug gepaarte Daten",
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen."
}
},
"structuredTagsTitle": "Tag-Auswertung",
"notesTimelineTitle": "Notizen"
}
},
"thresholds": {
+46 -2
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
"pageInfo": "Page {page} of {total}",
"loginRequired": "Please sign in to view mood entries."
"loginRequired": "Please sign in to view mood entries.",
"note": "Note",
"notePlaceholder": "Add a note about how you're feeling…",
"tagPicker": "Tags",
"tagCategory": {
"feelings": "Feelings",
"sleep": "Sleep",
"health": "Health",
"social": "Social",
"work": "Work"
},
"tag": {
"happy": "happy",
"excited": "excited",
"grateful": "grateful",
"relaxed": "relaxed",
"content": "content",
"tired": "tired",
"unsure": "unsure",
"bored": "bored",
"tense": "tense",
"angry": "angry",
"stressed": "stressed",
"sad": "sad",
"sleptWell": "slept well",
"sleptOk": "slept okay",
"sleptPoorly": "slept poorly",
"earlyNight": "early night",
"workedOut": "worked out",
"ateWell": "ate well",
"hydrated": "hydrated",
"walked": "walked",
"alcohol": "alcohol",
"family": "family",
"friends": "friends",
"party": "party",
"alone": "alone",
"productive": "productive",
"overtime": "overtime",
"dayOff": "day off",
"travel": "travel",
"sickDay": "sick day"
}
},
"medications": {
"title": "Medications",
@@ -2326,7 +2368,9 @@
"source": "{n} paired days · r = {r}",
"emptyTitle": "Not enough paired data yet",
"emptyDescription": "Log mood and this metric on the same days to see a correlation."
}
},
"structuredTagsTitle": "Tag breakdown",
"notesTimelineTitle": "Notes"
}
},
"thresholds": {
+46 -2
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
"pageInfo": "Página {page} de {total}",
"loginRequired": "Inicia sesión para ver tus estados de ánimo."
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
"note": "Nota",
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
"tagPicker": "Etiquetas",
"tagCategory": {
"feelings": "Sentimientos",
"sleep": "Sueño",
"health": "Salud",
"social": "Social",
"work": "Trabajo"
},
"tag": {
"happy": "feliz",
"excited": "emocionado",
"grateful": "agradecido",
"relaxed": "relajado",
"content": "satisfecho",
"tired": "cansado",
"unsure": "inseguro",
"bored": "aburrido",
"tense": "tenso",
"angry": "enfadado",
"stressed": "estresado",
"sad": "triste",
"sleptWell": "dormí bien",
"sleptOk": "dormí regular",
"sleptPoorly": "dormí mal",
"earlyNight": "me acosté pronto",
"workedOut": "entrené",
"ateWell": "comí bien",
"hydrated": "bebí agua",
"walked": "caminé",
"alcohol": "alcohol",
"family": "familia",
"friends": "amigos",
"party": "fiesta",
"alone": "solo",
"productive": "productivo",
"overtime": "horas extra",
"dayOff": "día libre",
"travel": "viaje",
"sickDay": "día de baja"
}
},
"medications": {
"title": "Medicamentos",
@@ -2326,7 +2368,9 @@
"source": "{n} días emparejados · r = {r}",
"emptyTitle": "Aún no hay suficientes datos emparejados",
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación."
}
},
"structuredTagsTitle": "Desglose de etiquetas",
"notesTimelineTitle": "Notas"
}
},
"thresholds": {
+46 -2
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
"pageInfo": "Page {page} sur {total}",
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur."
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
"note": "Note",
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
"tagPicker": "Étiquettes",
"tagCategory": {
"feelings": "Émotions",
"sleep": "Sommeil",
"health": "Santé",
"social": "Social",
"work": "Travail"
},
"tag": {
"happy": "heureux",
"excited": "enthousiaste",
"grateful": "reconnaissant",
"relaxed": "détendu",
"content": "satisfait",
"tired": "fatigué",
"unsure": "incertain",
"bored": "ennuyé",
"tense": "tendu",
"angry": "en colère",
"stressed": "stressé",
"sad": "triste",
"sleptWell": "bien dormi",
"sleptOk": "sommeil moyen",
"sleptPoorly": "mal dormi",
"earlyNight": "couché tôt",
"workedOut": "fait du sport",
"ateWell": "bien mangé",
"hydrated": "bu de l'eau",
"walked": "marché",
"alcohol": "alcool",
"family": "famille",
"friends": "amis",
"party": "fête",
"alone": "seul",
"productive": "productif",
"overtime": "heures sup",
"dayOff": "jour de repos",
"travel": "voyage",
"sickDay": "jour de maladie"
}
},
"medications": {
"title": "Médicaments",
@@ -2326,7 +2368,9 @@
"source": "{n} jours appariés · r = {r}",
"emptyTitle": "Pas encore assez de données appariées",
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation."
}
},
"structuredTagsTitle": "Répartition des étiquettes",
"notesTimelineTitle": "Notes"
}
},
"thresholds": {
+46 -2
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
"pageInfo": "Pagina {page} di {total}",
"loginRequired": "Accedi per vedere le voci dell’umore."
"loginRequired": "Accedi per vedere le voci dell’umore.",
"note": "Nota",
"notePlaceholder": "Aggiungi una nota su come ti senti…",
"tagPicker": "Etichette",
"tagCategory": {
"feelings": "Emozioni",
"sleep": "Sonno",
"health": "Salute",
"social": "Sociale",
"work": "Lavoro"
},
"tag": {
"happy": "felice",
"excited": "entusiasta",
"grateful": "grato",
"relaxed": "rilassato",
"content": "soddisfatto",
"tired": "stanco",
"unsure": "incerto",
"bored": "annoiato",
"tense": "teso",
"angry": "arrabbiato",
"stressed": "stressato",
"sad": "triste",
"sleptWell": "dormito bene",
"sleptOk": "dormito così così",
"sleptPoorly": "dormito male",
"earlyNight": "a letto presto",
"workedOut": "allenato",
"ateWell": "mangiato bene",
"hydrated": "bevuto acqua",
"walked": "camminato",
"alcohol": "alcol",
"family": "famiglia",
"friends": "amici",
"party": "festa",
"alone": "da solo",
"productive": "produttivo",
"overtime": "straordinari",
"dayOff": "giorno libero",
"travel": "viaggio",
"sickDay": "giorno di malattia"
}
},
"medications": {
"title": "Farmaci",
@@ -2326,7 +2368,9 @@
"source": "{n} giorni appaiati · r = {r}",
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione."
}
},
"structuredTagsTitle": "Analisi etichette",
"notesTimelineTitle": "Note"
}
},
"thresholds": {
+46 -2
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
"pageInfo": "Strona {page} z {total}",
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju."
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
"note": "Notatka",
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
"tagPicker": "Tagi",
"tagCategory": {
"feelings": "Uczucia",
"sleep": "Sen",
"health": "Zdrowie",
"social": "Społeczne",
"work": "Praca"
},
"tag": {
"happy": "szczęśliwy",
"excited": "podekscytowany",
"grateful": "wdzięczny",
"relaxed": "zrelaksowany",
"content": "zadowolony",
"tired": "zmęczony",
"unsure": "niepewny",
"bored": "znudzony",
"tense": "spięty",
"angry": "zły",
"stressed": "zestresowany",
"sad": "smutny",
"sleptWell": "dobrze spałem",
"sleptOk": "sen średni",
"sleptPoorly": "źle spałem",
"earlyNight": "wcześnie spać",
"workedOut": "trening",
"ateWell": "zdrowo zjadłem",
"hydrated": "piłem wodę",
"walked": "spacer",
"alcohol": "alkohol",
"family": "rodzina",
"friends": "znajomi",
"party": "impreza",
"alone": "sam",
"productive": "produktywny",
"overtime": "nadgodziny",
"dayOff": "dzień wolny",
"travel": "podróż",
"sickDay": "dzień chorobowy"
}
},
"medications": {
"title": "Leki",
@@ -2326,7 +2368,9 @@
"source": "{n} sparowanych dni · r = {r}",
"emptyTitle": "Za mało sparowanych danych",
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację."
}
},
"structuredTagsTitle": "Podział tagów",
"notesTimelineTitle": "Notatki"
}
},
"thresholds": {
@@ -0,0 +1,252 @@
-- v1.8.5 — structured mood-tag taxonomy.
--
-- Adds a global Category -> Tag catalog plus a per-entry join, modelled
-- on the standalone mood diary's structured taxonomy. Purely additive:
-- the legacy flat `mood_entries.tags` JSON column is untouched and keeps
-- working for every existing row, the GLP-1 quick-tag chips, and the
-- Telegram / MoodLog sync. The structured taxonomy is a second, richer
-- capture surface; the tag-frequency / lift breakdown folds the two
-- axes together.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales) rather than a hard-coded label, so the seeded
-- catalog renders localised everywhere. The seed at the bottom uses
-- deterministic ids + ON CONFLICT upsert so re-running is idempotent.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "mood_entry_tag_links";
-- DROP TABLE IF EXISTS "mood_tags";
-- DROP TABLE IF EXISTS "mood_tag_categories";
-- CreateTable
CREATE TABLE "mood_tag_categories" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "mood_tag_categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mood_tags" (
"id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "mood_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mood_entry_tag_links" (
"mood_entry_id" TEXT NOT NULL,
"mood_tag_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mood_entry_tag_links_pkey" PRIMARY KEY ("mood_entry_id","mood_tag_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "mood_tag_categories_key_key" ON "mood_tag_categories"("key");
-- CreateIndex
CREATE UNIQUE INDEX "mood_tags_key_key" ON "mood_tags"("key");
-- CreateIndex
CREATE INDEX "mood_tags_category_id_sort_order_idx" ON "mood_tags"("category_id", "sort_order");
-- CreateIndex
CREATE INDEX "mood_entry_tag_links_mood_tag_id_idx" ON "mood_entry_tag_links"("mood_tag_id");
-- AddForeignKey
ALTER TABLE "mood_tags" ADD CONSTRAINT "mood_tags_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "mood_tag_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_entry_id_fkey" FOREIGN KEY ("mood_entry_id") REFERENCES "mood_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_tag_id_fkey" FOREIGN KEY ("mood_tag_id") REFERENCES "mood_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed the default taxonomy (idempotent upsert by `key`). Mirrors
-- `src/lib/mood/tag-taxonomy.ts` — keep the two in sync if either
-- changes.
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_feelings', 'feelings', 'mood.tagCategory.feelings', 'Heart', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_happy', 'mtc_feelings', 'happy', 'mood.tag.happy', 'Smile', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_excited', 'mtc_feelings', 'excited', 'mood.tag.excited', 'Zap', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_grateful', 'mtc_feelings', 'grateful', 'mood.tag.grateful', 'HandHeart', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_relaxed', 'mtc_feelings', 'relaxed', 'mood.tag.relaxed', 'CloudSun', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_content', 'mtc_feelings', 'content', 'mood.tag.content', 'ThumbsUp', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_tired', 'mtc_feelings', 'tired', 'mood.tag.tired', 'Moon', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_unsure', 'mtc_feelings', 'unsure', 'mood.tag.unsure', 'HelpCircle', 6, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_bored', 'mtc_feelings', 'bored', 'mood.tag.bored', 'Meh', 7, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_tense', 'mtc_feelings', 'tense', 'mood.tag.tense', 'AlertTriangle', 8, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_angry', 'mtc_feelings', 'angry', 'mood.tag.angry', 'Flame', 9, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_stressed', 'mtc_feelings', 'stressed', 'mood.tag.stressed', 'Brain', 10, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_sad', 'mtc_feelings', 'sad', 'mood.tag.sad', 'Frown', 11, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_sleep', 'sleep', 'mood.tagCategory.sleep', 'BedDouble', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_well', 'mtc_sleep', 'slept_well', 'mood.tag.sleptWell', 'Moon', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_ok', 'mtc_sleep', 'slept_ok', 'mood.tag.sleptOk', 'CloudMoon', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_poorly', 'mtc_sleep', 'slept_poorly', 'mood.tag.sleptPoorly', 'MoonStar', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_early_night', 'mtc_sleep', 'early_night', 'mood.tag.earlyNight', 'Clock', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_health', 'health', 'mood.tagCategory.health', 'HeartPulse', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_worked_out', 'mtc_health', 'worked_out', 'mood.tag.workedOut', 'Dumbbell', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_ate_well', 'mtc_health', 'ate_well', 'mood.tag.ateWell', 'Apple', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_hydrated', 'mtc_health', 'hydrated', 'mood.tag.hydrated', 'GlassWater', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_walked', 'mtc_health', 'walked', 'mood.tag.walked', 'Footprints', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_alcohol', 'mtc_health', 'alcohol', 'mood.tag.alcohol', 'Wine', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_social', 'social', 'mood.tagCategory.social', 'Users', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_family', 'mtc_social', 'family', 'mood.tag.family', 'Home', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_friends', 'mtc_social', 'friends', 'mood.tag.friends', 'Users', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_party', 'mtc_social', 'party', 'mood.tag.party', 'PartyPopper', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_alone', 'mtc_social', 'alone', 'mood.tag.alone', 'User', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_work', 'work', 'mood.tagCategory.work', 'Briefcase', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_productive', 'mtc_work', 'productive', 'mood.tag.productive', 'CheckCircle', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_overtime', 'mtc_work', 'overtime', 'mood.tag.overtime', 'Clock', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_day_off', 'mtc_work', 'day_off', 'mood.tag.dayOff', 'LogOut', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_travel', 'mtc_work', 'travel', 'mood.tag.travel', 'Plane', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_sick_day', 'mtc_work', 'sick_day', 'mood.tag.sickDay', 'Thermometer', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
+80
View File
@@ -1560,6 +1560,13 @@ model MoodEntry {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// v1.8.5 — structured-tag links. Additive alongside the legacy flat
// `tags` JSON string: an entry can carry both (a free-text tag the user
// typed AND a structured tag they picked from the taxonomy). The
// aggregates read whichever is present; existing rows keep their flat
// `tags` untouched.
tagLinks MoodEntryTagLink[]
@@unique([userId, date, moodLoggedAt])
@@index([userId, date])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
@@ -1568,6 +1575,79 @@ model MoodEntry {
@@map("mood_entries")
}
// ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ────────────────────────
//
// Structured, icon-bearing tag catalog modelled on the standalone mood
// diary's Category → TagItem shape. Global reference data (one shared
// catalog for the deployment, not per-user): a self-hoster's account
// inherits the seeded categories the same way the metric-type enum is
// shared. `MoodEntryTagLink` is the per-entry join.
//
// Backward-compat: this is purely ADDITIVE. The legacy flat
// `MoodEntry.tags` (a JSON string array of free-text keys) keeps working
// unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and
// every existing row stay on the flat axis. The structured taxonomy is a
// second, richer capture surface that the tag-frequency / lift breakdown
// folds in next to the flat tags (`mood-aggregates.ts`).
//
// `labelKey` carries an i18n key (resolved client-side against all six
// locales) rather than a hard-coded German label, so the seeded catalog
// renders localised everywhere. `icon` is a Lucide icon name rendered by
// the capture UI + breakdown.
model MoodTagCategory {
id String @id @default(cuid())
/// Stable machine key (e.g. `feelings`). Unique across the catalog so
/// the seed migration can upsert idempotently.
key String @unique
/// i18n message key for the category label (`mood.tagCategory.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the category header.
icon String?
sortOrder Int @default(0) @map("sort_order")
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
tags MoodTag[]
@@map("mood_tag_categories")
}
model MoodTag {
id String @id @default(cuid())
categoryId String @map("category_id")
/// Stable machine key (e.g. `happy`). Unique across the catalog.
key String @unique
/// i18n message key for the tag label (`mood.tag.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the tag chip.
icon String?
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
links MoodEntryTagLink[]
@@index([categoryId, sortOrder])
@@map("mood_tags")
}
// Join: which structured tags a mood entry carries. Composite PK keeps
// the pairing unique; cascade on both sides so deleting an entry (or a
// catalog tag) cleans up its links. Distinct from the flat `tags` JSON
// column on `MoodEntry`.
model MoodEntryTagLink {
moodEntryId String @map("mood_entry_id")
moodTagId String @map("mood_tag_id")
createdAt DateTime @default(now()) @map("created_at")
moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade)
moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade)
@@id([moodEntryId, moodTagId])
@@index([moodTagId])
@@map("mood_entry_tag_links")
}
// ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ─────
//
// Idempotency guard for the daily 22:00 MOOD_REMINDER push. The
+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 })