diff --git a/messages/de.json b/messages/de.json index 24ebef389..19818c8d9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -510,7 +510,8 @@ "sleep": "Schlaf", "health": "Gesundheit", "social": "Soziales", - "work": "Arbeit" + "work": "Arbeit", + "hobbies": "Hobbys" }, "tag": { "happy": "glücklich", @@ -542,7 +543,15 @@ "overtime": "Überstunden", "dayOff": "Feierabend", "travel": "Reise", - "sickDay": "Krankheitstag" + "sickDay": "Krankheitstag", + "movies": "Filme", + "reading": "gelesen", + "gaming": "gezockt", + "music": "Musik", + "outdoors": "draußen", + "fastFood": "Fast Food", + "noSweets": "keine Süßigkeiten", + "bigMeal": "üppige Mahlzeit" } }, "medications": { diff --git a/messages/en.json b/messages/en.json index e0832a629..c8106b970 100644 --- a/messages/en.json +++ b/messages/en.json @@ -510,7 +510,8 @@ "sleep": "Sleep", "health": "Health", "social": "Social", - "work": "Work" + "work": "Work", + "hobbies": "Hobbies" }, "tag": { "happy": "happy", @@ -542,7 +543,15 @@ "overtime": "overtime", "dayOff": "day off", "travel": "travel", - "sickDay": "sick day" + "sickDay": "sick day", + "movies": "movies", + "reading": "reading", + "gaming": "gaming", + "music": "music", + "outdoors": "outdoors", + "fastFood": "fast food", + "noSweets": "no sweets", + "bigMeal": "big meal" } }, "medications": { diff --git a/messages/es.json b/messages/es.json index 0537ec304..14e6b084b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -510,7 +510,8 @@ "sleep": "Sueño", "health": "Salud", "social": "Social", - "work": "Trabajo" + "work": "Trabajo", + "hobbies": "Aficiones" }, "tag": { "happy": "feliz", @@ -542,7 +543,15 @@ "overtime": "horas extra", "dayOff": "día libre", "travel": "viaje", - "sickDay": "día de baja" + "sickDay": "día de baja", + "movies": "películas", + "reading": "lectura", + "gaming": "videojuegos", + "music": "música", + "outdoors": "aire libre", + "fastFood": "comida rápida", + "noSweets": "sin dulces", + "bigMeal": "comilona" } }, "medications": { diff --git a/messages/fr.json b/messages/fr.json index ef6105d02..03116b03c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -510,7 +510,8 @@ "sleep": "Sommeil", "health": "Santé", "social": "Social", - "work": "Travail" + "work": "Travail", + "hobbies": "Loisirs" }, "tag": { "happy": "heureux", @@ -542,7 +543,15 @@ "overtime": "heures sup", "dayOff": "jour de repos", "travel": "voyage", - "sickDay": "jour de maladie" + "sickDay": "jour de maladie", + "movies": "films", + "reading": "lecture", + "gaming": "jeux vidéo", + "music": "musique", + "outdoors": "plein air", + "fastFood": "fast-food", + "noSweets": "sans sucreries", + "bigMeal": "gros repas" } }, "medications": { diff --git a/messages/it.json b/messages/it.json index aaff084e5..841aaa8bb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -510,7 +510,8 @@ "sleep": "Sonno", "health": "Salute", "social": "Sociale", - "work": "Lavoro" + "work": "Lavoro", + "hobbies": "Hobby" }, "tag": { "happy": "felice", @@ -542,7 +543,15 @@ "overtime": "straordinari", "dayOff": "giorno libero", "travel": "viaggio", - "sickDay": "giorno di malattia" + "sickDay": "giorno di malattia", + "movies": "film", + "reading": "lettura", + "gaming": "videogiochi", + "music": "musica", + "outdoors": "all'aperto", + "fastFood": "fast food", + "noSweets": "niente dolci", + "bigMeal": "pasto abbondante" } }, "medications": { diff --git a/messages/pl.json b/messages/pl.json index 7d2b48da2..584b5c7b7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -510,7 +510,8 @@ "sleep": "Sen", "health": "Zdrowie", "social": "Społeczne", - "work": "Praca" + "work": "Praca", + "hobbies": "Hobby" }, "tag": { "happy": "szczęśliwy", @@ -542,7 +543,15 @@ "overtime": "nadgodziny", "dayOff": "dzień wolny", "travel": "podróż", - "sickDay": "dzień chorobowy" + "sickDay": "dzień chorobowy", + "movies": "filmy", + "reading": "czytanie", + "gaming": "gry", + "music": "muzyka", + "outdoors": "na dworze", + "fastFood": "fast food", + "noSweets": "bez słodyczy", + "bigMeal": "obfity posiłek" } }, "medications": { diff --git a/prisma/migrations/0118_v1120_mood_tag_hobbies_nutrition/migration.sql b/prisma/migrations/0118_v1120_mood_tag_hobbies_nutrition/migration.sql new file mode 100644 index 000000000..fe2155038 --- /dev/null +++ b/prisma/migrations/0118_v1120_mood_tag_hobbies_nutrition/migration.sql @@ -0,0 +1,62 @@ +-- v1.12.0 — deepen the structured mood-tag taxonomy. +-- +-- Purely additive on top of migration 0101's catalog: one new `hobbies` +-- category (movies / reading / gaming / music / outdoors) plus a set of +-- nutrition-detail tags appended to the existing `health` category +-- (`fast_food` / `no_sweets` / `big_meal`, alongside the original +-- `ate_well`). No table changes, no per-user data touched — this only +-- seeds new global catalog rows. +-- +-- `label_key` columns carry an i18n message key (resolved client-side +-- against all six locales), same convention as the original seed. The +-- inserts use deterministic ids + ON CONFLICT upsert by `key` so a +-- re-run is idempotent, mirroring 0101 exactly. + +-- ─── New category: hobbies ─────────────────────────────────────────── +INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") +VALUES ('mtc_hobbies', 'hobbies', 'mood.tagCategory.hobbies', 'Palette', 5, 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_movies', 'mtc_hobbies', 'movies', 'mood.tag.movies', 'Film', 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_reading', 'mtc_hobbies', 'reading', 'mood.tag.reading', 'BookOpen', 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_gaming', 'mtc_hobbies', 'gaming', 'mood.tag.gaming', 'Gamepad2', 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_music', 'mtc_hobbies', 'music', 'mood.tag.music', 'Music', 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_outdoors', 'mtc_hobbies', 'outdoors', 'mood.tag.outdoors', 'Trees', 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"; + +-- ─── Nutrition-detail tags appended to the existing `health` category ─ +-- Mirrors the existing `ate_well` (sort_order 1) under `mtc_health`; the +-- original health tags occupy sort_order 0-4, so these slot in at 5-7. +INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") +VALUES ('mt_fast_food', 'mtc_health', 'fast_food', 'mood.tag.fastFood', 'Pizza', 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_no_sweets', 'mtc_health', 'no_sweets', 'mood.tag.noSweets', 'CandyOff', 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_big_meal', 'mtc_health', 'big_meal', 'mood.tag.bigMeal', 'UtensilsCrossed', 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"; diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 489e29c02..1e255d225 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -15,6 +15,14 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/lib/mood/tag-links", () => ({ + createTagLinks: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/moodlog/push", () => ({ + pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -52,6 +60,8 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { createTagLinks } from "@/lib/mood/tag-links"; +import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -71,6 +81,15 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(createTagLinks).mockResolvedValue(undefined); + // `vi.resetAllMocks()` above wipes the factory default, so the + // best-effort reverse-sync push must be re-stubbed to a resolved + // promise — the route calls `.catch()` on its return value. + vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({ + pushed: 0, + skipped: 0, + status: "skipped", + }); }); describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { @@ -153,3 +172,55 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { expect(res.status).toBe(422); }); }); + +describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { + beforeEach(() => { + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ + id: "entry-1", + } as never); + }); + + it("persists structured tag links for an entry carrying tagKeys", async () => { + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: ["movies", "gaming"], + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(createTagLinks).toHaveBeenCalledTimes(1); + expect(createTagLinks).toHaveBeenCalledWith("entry-1", ["movies", "gaming"]); + }); + + it("skips the tag-link write when an entry sends no tagKeys", async () => { + const res = await POST( + postReq({ + entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], + }), + ); + expect(res.status).toBe(200); + expect(createTagLinks).not.toHaveBeenCalled(); + }); + + it("strips an over-long tagKeys array at the schema boundary (422)", async () => { + const res = await POST( + postReq({ + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: Array.from({ length: 31 }, (_, i) => `k${i}`), + }, + ], + }), + ); + expect(res.status).toBe(422); + expect(createTagLinks).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index c65608b8b..f5fd6ad95 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -47,6 +47,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"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -55,6 +56,18 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; const bulkEntrySchema = z.object({ mood: moodLevelEnum, tags: z.array(z.string().max(50)).max(20).optional(), + /** + * v1.12.0 — structured-tag keys from the catalog (`mood_tags.key`), + * mirroring the single-entry `POST /api/mood-entries` contract. + * Without this the bulk path Zod-stripped the field, so iOS-sent + * taxonomy links were silently dropped on the adopt-on-pair backfill. + * The 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). Bounds match the single-entry + * `structuredTagKeys` schema so one entry can't fan out an unbounded + * link set. + */ + tagKeys: z.array(z.string().max(60)).max(30).optional(), note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), @@ -204,6 +217,17 @@ async function postBulk(request: NextRequest): Promise { }, }); + // v1.12.0 — persist structured-tag links, mirroring the + // single-entry `createTagLinks` path. Additive + idempotent: + // `createTagLinks` resolves keys against the catalog (dropping + // unknown keys) and `skipDuplicates` on the join insert keeps a + // re-posted entry from minting duplicate links. Runs for both + // fresh and re-posted (upserted) rows so a backfill that adds tag + // keys on a second pass still lands them. + if (entry.tagKeys && entry.tagKeys.length > 0) { + await createTagLinks(result.id, entry.tagKeys); + } + if (existing) { duplicates += 1; results.push({ index: i, status: "duplicate", id: result.id }); diff --git a/src/components/mood/mood-tag-icons.ts b/src/components/mood/mood-tag-icons.ts index 1b8146ac9..aabb3884e 100644 --- a/src/components/mood/mood-tag-icons.ts +++ b/src/components/mood/mood-tag-icons.ts @@ -2,16 +2,20 @@ import { AlertTriangle, Apple, BedDouble, + BookOpen, Brain, Briefcase, + CandyOff, CheckCircle, Clock, CloudMoon, CloudSun, Dumbbell, + Film, Flame, Footprints, Frown, + Gamepad2, GlassWater, HandHeart, Heart, @@ -22,14 +26,19 @@ import { Meh, Moon, MoonStar, + Music, + Palette, PartyPopper, + Pizza, Plane, Smile, Tag, Thermometer, ThumbsUp, + Trees, User, Users, + UtensilsCrossed, Wine, Zap, type LucideIcon, @@ -46,16 +55,20 @@ const MOOD_TAG_ICONS: Record = { AlertTriangle, Apple, BedDouble, + BookOpen, Brain, Briefcase, + CandyOff, CheckCircle, Clock, CloudMoon, CloudSun, Dumbbell, + Film, Flame, Footprints, Frown, + Gamepad2, GlassWater, HandHeart, Heart, @@ -66,13 +79,18 @@ const MOOD_TAG_ICONS: Record = { Meh, Moon, MoonStar, + Music, + Palette, PartyPopper, + Pizza, Plane, Smile, Thermometer, ThumbsUp, + Trees, User, Users, + UtensilsCrossed, Wine, Zap, }; diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts index b3fe39246..57999a785 100644 --- a/tests/integration/mood-entries-bulk.test.ts +++ b/tests/integration/mood-entries-bulk.test.ts @@ -153,6 +153,61 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => { expect(stored[0].score).toBe(5); }); + it("persists structured tagKeys as MoodEntryTagLink rows (v1.12.0)", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + // Two valid catalog keys (one from the new hobbies category, + // one nutrition-detail key) plus one unknown key that must be + // dropped silently. + tagKeys: ["movies", "fast_food", "not_a_real_tag"], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { inserted: number } }; + expect(json.data.inserted).toBe(1); + + const entry = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + const links = await getPrismaClient().moodEntryTagLink.findMany({ + where: { moodEntryId: entry.id }, + select: { moodTag: { select: { key: true } } }, + }); + const keys = links.map((l) => l.moodTag.key).sort(); + // The unknown key is dropped; the two catalog keys round-trip. + expect(keys).toEqual(["fast_food", "movies"]); + }); + + it("ignores tagKeys that resolve to no catalog rows", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + tagKeys: ["totally_unknown_key"], + }, + ], + }), + ); + expect(res.status).toBe(200); + const entry = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + const linkCount = await getPrismaClient().moodEntryTagLink.count({ + where: { moodEntryId: entry.id }, + }); + expect(linkCount).toBe(0); + }); + it("rejects an over-cap batch with 422 and the documented error code", async () => { const { POST } = await import("@/app/api/mood-entries/bulk/route"); const entries = Array.from({ length: 501 }, (_, i) => ({