mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
b99a015266
commit
cf649f72ce
25 files changed
+1688
-18
No files matched your search
+46
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user