diff --git a/messages/de.json b/messages/de.json index dbfd00e78..f53fb4e6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 83ec368d5..f64dbe583 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 00e26dad5..1c96e71c4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 91fc51b2c..8063ccc6f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 9dbe62040..2b00fb2b2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 51cb4985b..a34c6ad4e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/prisma/migrations/0101_v185_mood_tag_taxonomy/migration.sql b/prisma/migrations/0101_v185_mood_tag_taxonomy/migration.sql new file mode 100644 index 000000000..3afb5449f --- /dev/null +++ b/prisma/migrations/0101_v185_mood_tag_taxonomy/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b7060a1dd..e03a08b5f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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.`). + 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.`). + 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 diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index c68f8d21c..fd2849322 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 5afdfba91..8f3dad697 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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), diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts new file mode 100644 index 000000000..6cf10e667 --- /dev/null +++ b/src/app/api/mood/tags/route.ts @@ -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 }); +}); diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 7e5187919..db1b9bc6e 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -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 (
@@ -119,6 +131,12 @@ export function MoodInsightsSections() {
+ {hasStructuredTags && ( + + + + )} + {hasTags && ( @@ -138,6 +156,12 @@ export function MoodInsightsSections() { pulse={data.correlations.pulse} /> + + {hasNotes && ( + + + + )} ); } diff --git a/src/components/insights/mood/mood-notes-timeline.tsx b/src/components/insights/mood/mood-notes-timeline.tsx new file mode 100644 index 000000000..136cdbcfd --- /dev/null +++ b/src/components/insights/mood/mood-notes-timeline.tsx @@ -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 ( +
    + {notes.map((entry) => { + const moodLabel = + entry.mood && MOOD_LABEL_KEYS[entry.mood] + ? t(MOOD_LABEL_KEYS[entry.mood]) + : entry.mood; + return ( +
  • +
    + + {entry.score} + + {moodLabel && ({moodLabel})} + · + {formatDateTime(entry.loggedAt)} +
    +

    + {entry.note} +

    + {(entry.structuredTagLabelKeys.length > 0 || + entry.tags.length > 0) && ( +
    + {entry.structuredTagLabelKeys.map((labelKey) => ( + + {t(labelKey)} + + ))} + {entry.tags.map((tag) => ( + + {tag} + + ))} +
    + )} +
  • + ); + })} +
+ ); +} diff --git a/src/components/insights/mood/mood-structured-tag-breakdown.tsx b/src/components/insights/mood/mood-structured-tag-breakdown.tsx new file mode 100644 index 000000000..423cb31ac --- /dev/null +++ b/src/components/insights/mood/mood-structured-tag-breakdown.tsx @@ -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 ( +
    + {tags.map((row) => { + const Icon = moodTagIcon(row.icon); + return ( +
  • +
  • + ); + })} +
+ ); +} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index cf7956f68..a42ce14e1 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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([]); + // 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(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) { + {/* v1.8.5 — structured-tag taxonomy picker. Additive next to the + free-text input above; an entry can carry both axes. */} +
+ + +
+ + {/* v1.8.5 (C1) — free-text note. */} +
+ +