From 058d1d7a69646842af1acba7f857a9efc6481abd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 12 Jun 2026 22:03:01 +0200
Subject: [PATCH] feat(mood): tags get groups, icons and a home of their own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/settings/mood manages the whole tag vocabulary: own groups (encrypted
labels, twelve per account), custom tags with a searchable icon picker
over a 75-icon catalog, hide and unhide of catalogue tags, archive
semantics that never touch historical entries (hard delete only behind
an explicit second confirm), and drag-or-arrow ordering of groups and
tags shared by the picker. One GET serves the fully ordered tree —
groups, placements, hidden and archived state — so the entry form, the
settings section and a native client all read the same resolved layout.
The picker finally renders custom tag labels (they were ignored since
v1.13.0) and offers inline tag creation per group. The mood-tag API
enters the OpenAPI contract for the first time, and the key-rotation
script now covers both encrypted tag and group labels — custom tag
labels had been missing from it since v1.13.0.
---
docs/api/openapi.yaml | 809 ++++++++++++++++++
messages/de.json | 9 +
messages/en.json | 9 +
messages/es.json | 9 +
messages/fr.json | 9 +
messages/it.json | 9 +
messages/pl.json | 9 +
.../0152_v1170_mood_tag_groups/migration.sql | 29 +
prisma/schema.prisma | 21 +-
scripts/rotate-encryption-key.ts | 44 +
.../mood/tags/__tests__/custom-crud.test.ts | 63 ++
.../mood/tags/__tests__/groups-crud.test.ts | 230 +++++
.../api/mood/tags/__tests__/layout.test.ts | 121 +++
src/app/api/mood/tags/__tests__/route.test.ts | 173 +++-
src/app/api/mood/tags/custom/[key]/route.ts | 22 +-
src/app/api/mood/tags/custom/route.ts | 21 +-
src/app/api/mood/tags/groups/[key]/route.ts | 152 ++++
src/app/api/mood/tags/groups/route.ts | 85 ++
src/app/api/mood/tags/layout/route.ts | 109 +++
src/app/api/mood/tags/route.ts | 230 +++--
src/app/mood/page.tsx | 35 +-
src/app/settings/[section]/page.tsx | 2 +
.../__tests__/mood-tag-icon-picker.test.tsx | 100 +++
.../__tests__/mood-tag-picker-custom.test.tsx | 152 ++++
.../manage/__tests__/catalog-helpers.test.ts | 212 +++++
.../mood/manage/archived-tags-card.tsx | 199 +++++
.../mood/manage/tag-editor-sheet.tsx | 236 +++++
.../mood/manage/tag-groups-card.tsx | 603 +++++++++++++
.../mood/manage/tag-manager-card.tsx | 641 ++++++++++++++
.../mood/manage/use-mood-tag-manage.ts | 279 ++++++
src/components/mood/mood-tag-icon-picker.tsx | 190 ++++
src/components/mood/mood-tag-icons.ts | 73 ++
src/components/mood/mood-tag-picker.tsx | 109 ++-
.../settings/__tests__/mood-section.test.tsx | 252 ++++++
.../__tests__/settings-shell.test.tsx | 11 +
src/components/settings/mood-section.tsx | 103 +++
.../settings/section-placeholder.tsx | 2 +
src/components/settings/section-slugs.ts | 4 +
src/components/settings/settings-shell.tsx | 6 +
src/lib/mood/__tests__/custom-tags.test.ts | 82 ++
src/lib/mood/__tests__/tag-layout.test.ts | 180 ++++
src/lib/mood/custom-tags.ts | 92 +-
src/lib/mood/icon-catalog.ts | 136 +++
src/lib/mood/tag-groups.ts | 27 +
src/lib/mood/tag-layout.ts | 222 +++++
src/lib/openapi/routes/index.ts | 2 +
src/lib/openapi/routes/mood.ts | 436 ++++++++++
src/lib/query-keys/mood.ts | 17 +
tests/integration/mood-tag-groups.test.ts | 322 +++++++
tests/integration/setup.ts | 22 +
50 files changed, 6781 insertions(+), 129 deletions(-)
create mode 100644 prisma/migrations/0152_v1170_mood_tag_groups/migration.sql
create mode 100644 src/app/api/mood/tags/__tests__/groups-crud.test.ts
create mode 100644 src/app/api/mood/tags/__tests__/layout.test.ts
create mode 100644 src/app/api/mood/tags/groups/[key]/route.ts
create mode 100644 src/app/api/mood/tags/groups/route.ts
create mode 100644 src/app/api/mood/tags/layout/route.ts
create mode 100644 src/components/mood/__tests__/mood-tag-icon-picker.test.tsx
create mode 100644 src/components/mood/__tests__/mood-tag-picker-custom.test.tsx
create mode 100644 src/components/mood/manage/__tests__/catalog-helpers.test.ts
create mode 100644 src/components/mood/manage/archived-tags-card.tsx
create mode 100644 src/components/mood/manage/tag-editor-sheet.tsx
create mode 100644 src/components/mood/manage/tag-groups-card.tsx
create mode 100644 src/components/mood/manage/tag-manager-card.tsx
create mode 100644 src/components/mood/manage/use-mood-tag-manage.ts
create mode 100644 src/components/mood/mood-tag-icon-picker.tsx
create mode 100644 src/components/settings/__tests__/mood-section.test.tsx
create mode 100644 src/components/settings/mood-section.tsx
create mode 100644 src/lib/mood/__tests__/tag-layout.test.ts
create mode 100644 src/lib/mood/icon-catalog.ts
create mode 100644 src/lib/mood/tag-groups.ts
create mode 100644 src/lib/mood/tag-layout.ts
create mode 100644 src/lib/openapi/routes/mood.ts
create mode 100644 tests/integration/mood-tag-groups.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 64d8de8a3..9a0cd391a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4453,6 +4453,358 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/mood/tags:
+ get:
+ tags:
+ - Mood
+ summary: Effective per-user mood-tag tree (v1.8.5 / v1.13.0 / v1.17.0)
+ description: "The fully-resolved, ordered Category → Tag tree: seeded catalogue + the caller's custom tags and groups,
+ per-user layout (group order + placements) applied server-side, hidden catalogue tags omitted. `include` is a
+ comma list: `hidden` (hidden catalogue tags, `hidden:true`), `archived` (own inactive custom tags,
+ `archived:true`), `usage` (per-tag `usageCount`). With any flag set, the caller's own empty groups are kept; the
+ plain read drops empty categories."
+ parameters:
+ - in: query
+ name: include
+ schema:
+ description: Comma list of `hidden`, `archived`, `usage`.
+ type: string
+ description: Comma list of `hidden`, `archived`, `usage`.
+ responses:
+ "200":
+ description: The effective tag tree.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagTreeEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/mood/tags/custom:
+ post:
+ tags:
+ - Mood
+ summary: Create a custom mood tag (v1.13.0 / v1.17.0)
+ description: "Mints a `custom:` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the
+ chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign
+ `categoryKey`."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 40
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ categoryKey:
+ type: string
+ minLength: 1
+ maxLength: 80
+ required:
+ - label
+ responses:
+ "201":
+ description: Custom tag created.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/mood/tags/custom/{key}:
+ patch:
+ tags:
+ - Mood
+ summary: Update a custom mood tag (v1.13.0 / v1.17.0)
+ description: "Owner-scoped: another user's key or a catalogue key 404s. Rename (re-encrypts), re-icon, archive/restore
+ via `isActive`, or move groups via `categoryKey`."
+ parameters:
+ - in: path
+ name: key
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 40
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ isActive:
+ type: boolean
+ categoryKey:
+ type: string
+ minLength: 1
+ maxLength: 80
+ responses:
+ "200":
+ description: Custom tag updated.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodCustomTagPatchEnvelope"
+ "401": *a1
+ "404": &a5
+ description: Not found / not owned by the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ delete:
+ tags:
+ - Mood
+ summary: Archive or purge a custom mood tag (v1.13.0)
+ description: "Default: soft-deactivates (`isActive:false`) — history intact, restorable via PATCH. `?purge=true`:
+ hard-deletes the row; the FK cascade removes its entry links. Owner-scoped 404."
+ parameters:
+ - in: path
+ name: key
+ schema:
+ type: string
+ required: true
+ - in: query
+ name: purge
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ responses:
+ "200":
+ description: Archived (or purged).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope"
+ "401": *a1
+ "404": *a5
+ "422": *a3
+ "429": *a4
+ /api/mood/tags/{key}/hidden:
+ put:
+ tags:
+ - Mood
+ summary: Hide / show a catalogue mood tag (v1.13.0)
+ description: Per-user hide of a CATALOGUE tag (upserts / removes a `mood_tag_hidden` row). 400 on a `custom:` key —
+ custom tags archive via their own `isActive`.
+ parameters:
+ - in: path
+ name: key
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ hidden:
+ type: boolean
+ required:
+ - hidden
+ responses:
+ "200":
+ description: Visibility updated.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagHiddenEnvelope"
+ "400":
+ description: Custom-tag key — use the custom PATCH instead.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "401": *a1
+ "404": *a5
+ "422": *a3
+ "429": *a4
+ /api/mood/tags/groups:
+ post:
+ tags:
+ - Mood
+ summary: Create a custom mood-tag group (v1.17.0)
+ description: Mints a `customcat:` key, encrypts the label at rest, stores a group owned by the caller. 422 over
+ the 12-group cap.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 40
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - label
+ responses:
+ "201":
+ description: Custom group created.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/mood/tags/groups/{key}:
+ patch:
+ tags:
+ - Mood
+ summary: Update a custom mood-tag group (v1.17.0)
+ description: "Owner-scoped: another user's key or a seeded category key 404s. Rename (re-encrypts), re-icon,
+ retire/restore via `isActive`."
+ parameters:
+ - in: path
+ name: key
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 40
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ isActive:
+ type: boolean
+ responses:
+ "200":
+ description: Custom group updated.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagGroupPatchEnvelope"
+ "401": *a1
+ "404": *a5
+ "422": *a3
+ "429": *a4
+ delete:
+ tags:
+ - Mood
+ summary: Delete a custom mood-tag group (v1.17.0)
+ description: "Non-destructive: the group's custom tags re-home to the seeded `custom` category, catalogue-tag placements
+ evaporate back to their seeded category, the layout drops the group, then the row soft-deactivates (default) or
+ hard-deletes (`?purge=true`). No tag and no entry link is deleted."
+ parameters:
+ - in: path
+ name: key
+ schema:
+ type: string
+ required: true
+ - in: query
+ name: purge
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ responses:
+ "200":
+ description: Group deleted; `rehomedCount` custom tags moved.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope"
+ "401": *a1
+ "404": *a5
+ "422": *a3
+ "429": *a4
+ /api/mood/tags/layout:
+ get:
+ tags:
+ - Mood
+ summary: Read the per-user mood-tag layout (v1.17.0)
+ description: "Returns the stored blob merged over defaults: `groupOrder` resolved against the caller's effective
+ category set (seeded + own groups), `placements` as stored."
+ responses:
+ "200":
+ description: Resolved layout.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagLayoutGetEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ put:
+ tags:
+ - Mood
+ summary: Update the per-user mood-tag layout (v1.17.0)
+ description: "Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded:
+ ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ groupOrder:
+ maxItems: 50
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 80
+ placements:
+ type: object
+ propertyNames:
+ type: string
+ minLength: 1
+ maxLength: 80
+ additionalProperties:
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 80
+ responses:
+ "200":
+ description: Merged + resolved layout.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MoodTagLayoutPutEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -13213,6 +13565,463 @@ components:
- interpretation
- lagDays
additionalProperties: false
+ MoodTagTreeEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ categories:
+ type: array
+ items:
+ $ref: "#/components/schemas/MoodTagCategoryDTO"
+ required:
+ - categories
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagCategoryDTO:
+ type: object
+ properties:
+ key:
+ type: string
+ labelKey:
+ anyOf:
+ - type: string
+ - type: "null"
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ custom:
+ type: boolean
+ tags:
+ type: array
+ items:
+ $ref: "#/components/schemas/MoodTagDTO"
+ required:
+ - key
+ - labelKey
+ - label
+ - icon
+ - custom
+ - tags
+ additionalProperties: false
+ description: "One group of the effective tree, already per-user ordered (layout applied server-side). `custom: true` =
+ the caller's own group (render `label`); seeded groups resolve `labelKey`."
+ MoodTagDTO:
+ type: object
+ properties:
+ key:
+ type: string
+ labelKey:
+ anyOf:
+ - type: string
+ - type: "null"
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ kind:
+ type: string
+ enum:
+ - BINARY
+ - RATED
+ scaleMin:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ scaleMax:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ inverse:
+ type: boolean
+ custom:
+ type: boolean
+ hidden:
+ description: Catalogue tags only, present when `include` contains `hidden`.
+ type: boolean
+ archived:
+ description: Custom tags only, present when `include` contains `archived`.
+ type: boolean
+ usageCount:
+ description: Live-entry link count for this user, present when `include` contains `usage`.
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - key
+ - labelKey
+ - label
+ - icon
+ - kind
+ - scaleMin
+ - scaleMax
+ - inverse
+ - custom
+ additionalProperties: false
+ description: One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale.
+ MoodCustomTagCreatedEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ labelKey:
+ type: "null"
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ kind:
+ type: string
+ enum:
+ - BINARY
+ - RATED
+ scaleMin:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ scaleMax:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ inverse:
+ type: boolean
+ custom:
+ type: boolean
+ const: true
+ required:
+ - key
+ - labelKey
+ - label
+ - icon
+ - kind
+ - scaleMin
+ - scaleMax
+ - inverse
+ - custom
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodCustomTagPatchEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ labelKey:
+ type: "null"
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ kind:
+ type: string
+ enum:
+ - BINARY
+ - RATED
+ scaleMin:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ scaleMax:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ inverse:
+ type: boolean
+ custom:
+ type: boolean
+ const: true
+ isActive:
+ type: boolean
+ required:
+ - key
+ - labelKey
+ - label
+ - icon
+ - kind
+ - scaleMin
+ - scaleMax
+ - inverse
+ - custom
+ - isActive
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodCustomTagDeleteEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ purged:
+ type: boolean
+ required:
+ - key
+ - purged
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagHiddenEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ hidden:
+ type: boolean
+ required:
+ - key
+ - hidden
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagGroupCreatedEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ labelKey:
+ type: "null"
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ custom:
+ type: boolean
+ const: true
+ required:
+ - key
+ - labelKey
+ - label
+ - icon
+ - custom
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagGroupPatchEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ labelKey:
+ type: "null"
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ icon:
+ anyOf:
+ - type: string
+ - type: "null"
+ custom:
+ type: boolean
+ const: true
+ isActive:
+ type: boolean
+ required:
+ - key
+ - labelKey
+ - label
+ - icon
+ - custom
+ - isActive
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagGroupDeleteEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ key:
+ type: string
+ purged:
+ type: boolean
+ rehomedCount:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - key
+ - purged
+ - rehomedCount
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagLayoutGetEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/MoodTagLayoutResolved"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MoodTagLayoutResolved:
+ type: object
+ properties:
+ groupOrder:
+ type: array
+ items:
+ type: string
+ placements:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: array
+ items:
+ type: string
+ required:
+ - groupOrder
+ - placements
+ additionalProperties: false
+ description: "The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category
+ set, `placements` as stored."
+ MoodTagLayoutPutEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/MoodTagLayoutResolved"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
MedicationScheduleOutput:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index 5b09c708a..f2dd7099d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -729,6 +729,15 @@
"logIntake": "Einnahme erfassen",
"newMedication": "Neues Medikament"
},
+ "takeAllDue": {
+ "button": "Alle fälligen einnehmen",
+ "dialogTitle": "Fällige Medikamente einnehmen",
+ "dialogDescription": "Diese Medikamente sind jetzt fällig. Bestätige, um sie alle als genommen zu erfassen.",
+ "confirm": "Alle einnehmen ({count})",
+ "successToast": "{count} Einnahmen erfasst",
+ "partialToast": "{taken} erfasst, {failed} fehlgeschlagen – bitte erneut versuchen",
+ "failedToast": "Keine Einnahme erfasst – bitte erneut versuchen"
+ },
"logIntake": {
"dialogTitle": "Einnahme erfassen",
"dialogDescription": "Erfasse eine Dosis für ein vorhandenes Medikament. Du kannst sie auf ein vergangenes Datum und eine vergangene Uhrzeit zurückdatieren.",
diff --git a/messages/en.json b/messages/en.json
index 6fdcb076b..06cbc409d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -729,6 +729,15 @@
"logIntake": "Log an intake",
"newMedication": "New medication"
},
+ "takeAllDue": {
+ "button": "Take all due",
+ "dialogTitle": "Take due medications",
+ "dialogDescription": "These medications are due now. Confirm to record all of them as taken.",
+ "confirm": "Take all ({count})",
+ "successToast": "{count} intakes recorded",
+ "partialToast": "{taken} recorded, {failed} failed – please try again",
+ "failedToast": "No intake recorded – please try again"
+ },
"logIntake": {
"dialogTitle": "Log an intake",
"dialogDescription": "Record a dose against an existing medication. You can backdate it to a past date and time.",
diff --git a/messages/es.json b/messages/es.json
index 6b89d58f1..03b2ec170 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -729,6 +729,15 @@
"logIntake": "Registrar una toma",
"newMedication": "Nuevo medicamento"
},
+ "takeAllDue": {
+ "button": "Tomar todas las pendientes",
+ "dialogTitle": "Tomar medicamentos pendientes",
+ "dialogDescription": "Estos medicamentos están pendientes ahora. Confirma para registrarlos todos como tomados.",
+ "confirm": "Tomar todos ({count})",
+ "successToast": "{count} tomas registradas",
+ "partialToast": "{taken} registradas, {failed} fallidas – inténtalo de nuevo",
+ "failedToast": "No se registró ninguna toma – inténtalo de nuevo"
+ },
"logIntake": {
"dialogTitle": "Registrar una toma",
"dialogDescription": "Registra una dosis de un medicamento existente. Puedes posfecharla a una fecha y hora pasadas.",
diff --git a/messages/fr.json b/messages/fr.json
index ac6e4091e..45039c071 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -729,6 +729,15 @@
"logIntake": "Enregistrer une prise",
"newMedication": "Nouveau médicament"
},
+ "takeAllDue": {
+ "button": "Prendre toutes les doses dues",
+ "dialogTitle": "Prendre les médicaments dus",
+ "dialogDescription": "Ces médicaments sont à prendre maintenant. Confirmez pour les enregistrer tous comme pris.",
+ "confirm": "Tout prendre ({count})",
+ "successToast": "{count} prises enregistrées",
+ "partialToast": "{taken} enregistrées, {failed} échouées – veuillez réessayer",
+ "failedToast": "Aucune prise enregistrée – veuillez réessayer"
+ },
"logIntake": {
"dialogTitle": "Enregistrer une prise",
"dialogDescription": "Enregistrez une dose pour un médicament existant. Vous pouvez l’antidater à une date et une heure passées.",
diff --git a/messages/it.json b/messages/it.json
index eb6685894..0e5059568 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -729,6 +729,15 @@
"logIntake": "Registra un'assunzione",
"newMedication": "Nuovo farmaco"
},
+ "takeAllDue": {
+ "button": "Assumi tutte le dosi in scadenza",
+ "dialogTitle": "Assumi i farmaci in scadenza",
+ "dialogDescription": "Questi farmaci sono da assumere adesso. Conferma per registrarli tutti come assunti.",
+ "confirm": "Assumi tutti ({count})",
+ "successToast": "{count} assunzioni registrate",
+ "partialToast": "{taken} registrate, {failed} non riuscite – riprova",
+ "failedToast": "Nessuna assunzione registrata – riprova"
+ },
"logIntake": {
"dialogTitle": "Registra un'assunzione",
"dialogDescription": "Registra una dose per un farmaco esistente. Puoi retrodatarla a una data e un'ora passate.",
diff --git a/messages/pl.json b/messages/pl.json
index ec1670839..7864d6bfa 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -729,6 +729,15 @@
"logIntake": "Zarejestruj przyjęcie",
"newMedication": "Nowy lek"
},
+ "takeAllDue": {
+ "button": "Przyjmij wszystkie zaległe",
+ "dialogTitle": "Przyjmij zaległe leki",
+ "dialogDescription": "Te leki są teraz do przyjęcia. Potwierdź, aby zarejestrować je wszystkie jako przyjęte.",
+ "confirm": "Przyjmij wszystkie ({count})",
+ "successToast": "Zarejestrowano przyjęcia: {count}",
+ "partialToast": "Zarejestrowano: {taken}, nieudane: {failed} – spróbuj ponownie",
+ "failedToast": "Nie zarejestrowano żadnego przyjęcia – spróbuj ponownie"
+ },
"logIntake": {
"dialogTitle": "Zarejestruj przyjęcie",
"dialogDescription": "Zarejestruj dawkę istniejącego leku. Możesz wstawić ją wstecznie na przeszłą datę i godzinę.",
diff --git a/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql b/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql
new file mode 100644
index 000000000..9b6b53c7d
--- /dev/null
+++ b/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql
@@ -0,0 +1,29 @@
+-- v1.17.0 — per-user custom mood-tag groups + per-user tag layout.
+--
+-- Additive, non-destructive (mirrors 0127_v1130_custom_mood_tags):
+-- 1. `mood_tag_categories` gains `user_id` (NULL = global seeded category,
+-- set = a user's custom group carrying a `customcat:` key) and
+-- `label_encrypted` (AES-256-GCM ciphertext of a custom group's
+-- free-text label; NULL for seeded rows, which resolve `label_key`
+-- against the locale).
+-- 2. `users` gains `mood_tag_layout_json` — the per-user presentation blob
+-- (group order + per-group tag placement), display-only, following the
+-- `medication_list_layout_json` per-surface-column convention.
+--
+-- Idempotent: ADD COLUMN IF NOT EXISTS, constraint adds guarded by
+-- duplicate_object, CREATE INDEX IF NOT EXISTS.
+
+-- ─── 1. mood_tag_categories: owner + encrypted custom label ──────────
+ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "user_id" TEXT;
+ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT;
+
+DO $$ BEGIN
+ ALTER TABLE "mood_tag_categories"
+ ADD CONSTRAINT "mood_tag_categories_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+CREATE INDEX IF NOT EXISTS "mood_tag_categories_user_id_idx" ON "mood_tag_categories"("user_id");
+
+-- ─── 2. users: per-user mood-tag presentation blob ───────────────────
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mood_tag_layout_json" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d32c7affd..02bfb1745 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -145,6 +145,13 @@ model User {
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["", ...] }
medicationListLayoutJson Json? @map("medication_list_layout_json")
+ // v1.17.0 — per-user mood-tag presentation: group order + per-group tag
+ // placement (catalogue tags "moved" into a group live here only — their
+ // `categoryId` never changes). Display-only, preserve-when-absent PUT,
+ // unknown keys dropped at read. Null = seeded defaults.
+ // Shape: { "groupOrder": ["", ...],
+ // "placements": { "": ["", ...] } }
+ moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.7.0 unit pref — global metric/imperial display preference.
@@ -402,6 +409,7 @@ model User {
fitbitOAuthStates FitbitOAuthState[]
moodEntries MoodEntry[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
+ moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
@@ -2371,8 +2379,19 @@ model MoodTagCategory {
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
- tags MoodTag[]
+ /// v1.17.0 — owner of a per-user custom group. NULL = the global seeded
+ /// catalogue (resolves `labelKey` against the locale). Set = a user's own
+ /// group, carrying a `customcat:` key and a free-text label held in
+ /// `labelEncrypted` (mirrors the MoodTag custom-tag precedent).
+ userId String? @map("user_id")
+ /// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label
+ /// (user prose, possibly health-adjacent). NULL for seeded rows.
+ labelEncrypted String? @map("label_encrypted")
+ tags MoodTag[]
+ user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
@@map("mood_tag_categories")
}
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 9b14c2aab..a2eefaa4f 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -230,6 +230,50 @@ async function main() {
results.push(r);
}
+ // ───── MoodTag.labelEncrypted ─────
+ // v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text
+ // label (catalogue rows carry NULL and are skipped by `shouldRotate`).
+ const moodTags = await prisma.moodTag.findMany({
+ where: { labelEncrypted: { not: null } },
+ select: { id: true, labelEncrypted: true },
+ });
+ results.push(
+ await rotateField(
+ "MoodTag",
+ "labelEncrypted",
+ moodTags,
+ (t) => t.labelEncrypted,
+ async (id, ciphertext) => {
+ await prisma.moodTag.update({
+ where: { id },
+ data: { labelEncrypted: ciphertext },
+ });
+ },
+ ),
+ );
+
+ // ───── MoodTagCategory.labelEncrypted ─────
+ // v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's
+ // free-text label (seeded rows carry NULL and are skipped).
+ const moodTagCategories = await prisma.moodTagCategory.findMany({
+ where: { labelEncrypted: { not: null } },
+ select: { id: true, labelEncrypted: true },
+ });
+ results.push(
+ await rotateField(
+ "MoodTagCategory",
+ "labelEncrypted",
+ moodTagCategories,
+ (c) => c.labelEncrypted,
+ async (id, ciphertext) => {
+ await prisma.moodTagCategory.update({
+ where: { id },
+ data: { labelEncrypted: ciphertext },
+ });
+ },
+ ),
+ );
+
// ───── NotificationChannel.config ─────
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
// Skipping these on rotation would leave channels permanently undecryptable
diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts
index 161ff255e..e9d3a3ded 100644
--- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts
+++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts
@@ -14,6 +14,7 @@ const db = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
+ moodTagCategory: { findFirst: vi.fn() },
moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() },
}));
@@ -74,6 +75,42 @@ describe("POST /api/mood/tags/custom", () => {
expect(res.status).toBe(422);
expect(db.moodTag.create).not.toHaveBeenCalled();
});
+
+ it("defaults to the seeded custom category without a categoryKey (v1.13 contract)", async () => {
+ db.moodTag.count.mockResolvedValue(0);
+ db.moodTag.create.mockResolvedValue({
+ key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false,
+ });
+ await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }));
+ expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
+ expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("mtc_custom");
+ });
+
+ it("resolves categoryKey to a seeded OR own group (v1.17.0) and 422s an unknown one", async () => {
+ db.moodTag.count.mockResolvedValue(0);
+ db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
+ db.moodTag.create.mockResolvedValue({
+ key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false,
+ });
+ const ok = await POST(
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:g1" }),
+ );
+ expect(ok.status).toBe(201);
+ expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("cg1");
+ // Resolution is owner-scoped: seeded (userId null) OR the caller's own.
+ const catWhere = db.moodTagCategory.findFirst.mock.calls[0][0].where;
+ expect(catWhere).toEqual({
+ key: "customcat:g1",
+ isActive: true,
+ OR: [{ userId: null }, { userId: "user-1" }],
+ });
+
+ db.moodTagCategory.findFirst.mockResolvedValue(null);
+ const bad = await POST(
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:theirs" }),
+ );
+ expect(bad.status).toBe(422);
+ });
});
describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
@@ -94,6 +131,32 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
expect(db.moodTag.findFirst).not.toHaveBeenCalled();
});
+ it("moves a custom tag to another group via categoryKey (v1.17.0)", async () => {
+ db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
+ db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
+ db.moodTag.update.mockResolvedValue({
+ key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5,
+ inverse: false, isActive: true, labelEncrypted: "enc:X",
+ });
+ const res = await PATCH(
+ jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "customcat:g1" }),
+ params("custom:x"),
+ );
+ expect(res.status).toBe(200);
+ expect(db.moodTag.update.mock.calls[0][0].data).toEqual({ categoryId: "cg1" });
+ });
+
+ it("422s a move to an unknown / foreign group without writing", async () => {
+ db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
+ db.moodTagCategory.findFirst.mockResolvedValue(null);
+ const res = await PATCH(
+ jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "ghost" }),
+ params("custom:x"),
+ );
+ expect(res.status).toBe(422);
+ expect(db.moodTag.update).not.toHaveBeenCalled();
+ });
+
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
const soft = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"), params("custom:abc"));
diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts
new file mode 100644
index 000000000..b4441dc31
--- /dev/null
+++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts
@@ -0,0 +1,230 @@
+/**
+ * v1.17.0 — custom mood-tag group CRUD guards: create cap, ownership 404 on
+ * edit/delete, and the non-destructive delete (re-home own tags → seeded
+ * `custom`, strip the group from the layout blob, soft-deactivate by default
+ * vs hard-delete on ?purge=true).
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const db = vi.hoisted(() => {
+ const base = {
+ moodTagCategory: {
+ count: vi.fn(),
+ create: vi.fn(),
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ moodTag: { updateMany: vi.fn() },
+ user: { findUnique: vi.fn(), update: vi.fn() },
+ $transaction: vi.fn(),
+ };
+ // Interactive tx: hand the same mock surface to the callback.
+ base.$transaction.mockImplementation(
+ async (fn: (tx: typeof base) => Promise) => fn(base),
+ );
+ return base;
+});
+
+vi.mock("@/lib/db", () => ({
+ prisma: db,
+ toJson: (v: T) => v,
+}));
+vi.mock("@/lib/crypto", () => ({
+ encrypt: (s: string) => `enc:${s}`,
+ decrypt: (s: string) => s.replace(/^enc:/, ""),
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
+}));
+
+import { POST } from "../groups/route";
+import { PATCH, DELETE } from "../groups/[key]/route";
+import { getSession } from "@/lib/auth/session";
+import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags";
+
+const SESSION_OK = {
+ session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "t", role: "USER" as const },
+};
+
+function jsonReq(url: string, method: string, body: unknown) {
+ return new NextRequest(url, {
+ method,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+const params = (key: string) => ({ params: Promise.resolve({ key }) });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ db.$transaction.mockImplementation(
+ async (fn: (tx: typeof db) => Promise) => fn(db),
+ );
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+});
+
+describe("POST /api/mood/tags/groups", () => {
+ it("creates a group and returns it (custom:true, decrypted label)", async () => {
+ db.moodTagCategory.count.mockResolvedValue(2);
+ db.moodTagCategory.create.mockResolvedValue({
+ key: "customcat:abc",
+ icon: "Stethoscope",
+ });
+ const res = await POST(
+ jsonReq("http://localhost/api/mood/tags/groups", "POST", {
+ label: "Therapie",
+ icon: "Stethoscope",
+ }),
+ );
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as {
+ data: { key: string; label: string; labelKey: null; custom: boolean };
+ };
+ expect(body.data).toMatchObject({
+ key: "customcat:abc",
+ label: "Therapie",
+ labelKey: null,
+ custom: true,
+ });
+ const data = db.moodTagCategory.create.mock.calls[0][0].data;
+ // Label is stored encrypted, never plaintext; owner pinned from session.
+ expect(data.labelEncrypted).toBe("enc:Therapie");
+ expect(data.userId).toBe("user-1");
+ expect(data.key.startsWith("customcat:")).toBe(true);
+ });
+
+ it("rejects over the per-user cap with 422", async () => {
+ db.moodTagCategory.count.mockResolvedValue(12);
+ const res = await POST(
+ jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "X" }),
+ );
+ expect(res.status).toBe(422);
+ expect(db.moodTagCategory.create).not.toHaveBeenCalled();
+ });
+});
+
+describe("PATCH /api/mood/tags/groups/:key", () => {
+ it("404s a seeded category key without touching the DB", async () => {
+ const res = await PATCH(
+ jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", { label: "x" }),
+ params("feelings"),
+ );
+ expect(res.status).toBe(404);
+ expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("404s when the key is not the caller's own group", async () => {
+ db.moodTagCategory.findFirst.mockResolvedValue(null);
+ const res = await PATCH(
+ jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "x" }),
+ params("customcat:x"),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("renames (re-encrypts) and returns the updated group", async () => {
+ db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
+ db.moodTagCategory.update.mockResolvedValue({
+ key: "customcat:x",
+ icon: null,
+ isActive: true,
+ labelEncrypted: "enc:Neu",
+ });
+ const res = await PATCH(
+ jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "Neu" }),
+ params("customcat:x"),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { label: string; isActive: boolean } };
+ expect(body.data).toMatchObject({ label: "Neu", isActive: true });
+ expect(db.moodTagCategory.update.mock.calls[0][0].data).toEqual({
+ labelEncrypted: "enc:Neu",
+ });
+ });
+});
+
+describe("DELETE /api/mood/tags/groups/:key", () => {
+ beforeEach(() => {
+ db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
+ db.moodTag.updateMany.mockResolvedValue({ count: 2 });
+ db.user.findUnique.mockResolvedValue({
+ moodTagLayoutJson: {
+ groupOrder: ["customcat:x", "feelings"],
+ placements: { "customcat:x": ["happy"], feelings: ["sad"] },
+ },
+ });
+ });
+
+ it("re-homes own tags, strips the layout, soft-deactivates by default", async () => {
+ const res = await DELETE(
+ new NextRequest("http://localhost/api/mood/tags/groups/customcat:x"),
+ params("customcat:x"),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { purged: boolean; rehomedCount: number };
+ };
+ expect(body.data).toMatchObject({ purged: false, rehomedCount: 2 });
+
+ // Own custom tags re-home to the seeded custom category.
+ expect(db.moodTag.updateMany).toHaveBeenCalledWith({
+ where: { categoryId: "cg1", userId: "user-1" },
+ data: { categoryId: CUSTOM_CATEGORY_ID },
+ });
+ // Layout blob drops the group from order + placements, keeps the rest.
+ expect(db.user.update).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: {
+ moodTagLayoutJson: {
+ groupOrder: ["feelings"],
+ placements: { feelings: ["sad"] },
+ },
+ },
+ });
+ // Default = retire, never delete.
+ expect(db.moodTagCategory.update).toHaveBeenCalledWith({
+ where: { id: "cg1" },
+ data: { isActive: false },
+ });
+ expect(db.moodTagCategory.delete).not.toHaveBeenCalled();
+ });
+
+ it("hard-deletes the emptied group row on ?purge=true", async () => {
+ const res = await DELETE(
+ new NextRequest("http://localhost/api/mood/tags/groups/customcat:x?purge=true"),
+ params("customcat:x"),
+ );
+ expect(res.status).toBe(200);
+ expect(db.moodTagCategory.delete).toHaveBeenCalledWith({
+ where: { id: "cg1" },
+ });
+ expect(db.moodTagCategory.update).not.toHaveBeenCalled();
+ // Re-home still ran first — purge removes the group, never the tags.
+ expect(db.moodTag.updateMany).toHaveBeenCalled();
+ });
+
+ it("404s a non-custom key and a foreign group", async () => {
+ const seeded = await DELETE(
+ new NextRequest("http://localhost/api/mood/tags/groups/custom"),
+ params("custom"),
+ );
+ expect(seeded.status).toBe(404);
+
+ db.moodTagCategory.findFirst.mockResolvedValue(null);
+ const foreign = await DELETE(
+ new NextRequest("http://localhost/api/mood/tags/groups/customcat:theirs"),
+ params("customcat:theirs"),
+ );
+ expect(foreign.status).toBe(404);
+ expect(db.moodTag.updateMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/mood/tags/__tests__/layout.test.ts b/src/app/api/mood/tags/__tests__/layout.test.ts
new file mode 100644
index 000000000..044b1dacb
--- /dev/null
+++ b/src/app/api/mood/tags/__tests__/layout.test.ts
@@ -0,0 +1,121 @@
+/**
+ * v1.17.0 — `GET/PUT /api/mood/tags/layout`: resolved groupOrder over the
+ * effective category set, preserve-when-absent merge on PUT, and the Zod
+ * bounds on the blob.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const db = vi.hoisted(() => ({
+ user: { findUnique: vi.fn(), update: vi.fn() },
+ moodTagCategory: { findMany: vi.fn() },
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: db,
+ toJson: (v: T) => v,
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
+}));
+
+import { GET, PUT } from "../layout/route";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "t", role: "USER" as const },
+};
+
+const CATEGORY_KEYS = [
+ { key: "feelings" },
+ { key: "custom" },
+ { key: "customcat:g1" },
+];
+
+function putReq(body: unknown) {
+ return new NextRequest("http://localhost/api/mood/tags/layout", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ db.moodTagCategory.findMany.mockResolvedValue(CATEGORY_KEYS);
+ db.user.findUnique.mockResolvedValue({ moodTagLayoutJson: null });
+});
+
+describe("GET /api/mood/tags/layout", () => {
+ it("returns seeded defaults when nothing is stored", async () => {
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { groupOrder: string[]; placements: Record };
+ };
+ expect(body.data).toEqual({
+ groupOrder: ["feelings", "custom", "customcat:g1"],
+ placements: {},
+ });
+ });
+
+ it("resolves the stored order: layout first, unknown dropped, missing appended", async () => {
+ db.user.findUnique.mockResolvedValue({
+ moodTagLayoutJson: {
+ groupOrder: ["customcat:g1", "ghost", "feelings"],
+ placements: { "customcat:g1": ["happy"] },
+ },
+ });
+ const res = await GET();
+ const body = (await res.json()) as {
+ data: { groupOrder: string[]; placements: Record };
+ };
+ expect(body.data.groupOrder).toEqual(["customcat:g1", "feelings", "custom"]);
+ expect(body.data.placements).toEqual({ "customcat:g1": ["happy"] });
+ });
+});
+
+describe("PUT /api/mood/tags/layout", () => {
+ it("merges preserve-when-absent: a groupOrder-only PUT keeps stored placements", async () => {
+ db.user.findUnique.mockResolvedValue({
+ moodTagLayoutJson: { placements: { feelings: ["sad"] } },
+ });
+ const res = await PUT(putReq({ groupOrder: ["custom", "feelings"] }));
+ expect(res.status).toBe(200);
+ expect(db.user.update).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: {
+ moodTagLayoutJson: {
+ groupOrder: ["custom", "feelings"],
+ placements: { feelings: ["sad"] },
+ },
+ },
+ });
+ const body = (await res.json()) as {
+ data: { groupOrder: string[]; placements: Record };
+ };
+ expect(body.data.groupOrder).toEqual(["custom", "feelings", "customcat:g1"]);
+ expect(body.data.placements).toEqual({ feelings: ["sad"] });
+ });
+
+ it("422s an over-bound blob without writing", async () => {
+ const res = await PUT(
+ putReq({ groupOrder: Array.from({ length: 51 }, (_, i) => `g${i}`) }),
+ );
+ expect(res.status).toBe(422);
+ expect(db.user.update).not.toHaveBeenCalled();
+ });
+
+ it("422s a malformed body (non-array groupOrder)", async () => {
+ const res = await PUT(putReq({ groupOrder: "feelings" }));
+ expect(res.status).toBe(422);
+ expect(db.user.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts
index 6e300d3c2..96c11e5e8 100644
--- a/src/app/api/mood/tags/__tests__/route.test.ts
+++ b/src/app/api/mood/tags/__tests__/route.test.ts
@@ -1,9 +1,11 @@
/**
- * v1.12.0 / v1.13.0 — `GET /api/mood/tags` exposes the rated-factor metadata
- * (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the v1.13.0 effective
+ * v1.12.0 / v1.13.0 / v1.17.0 — `GET /api/mood/tags` exposes the rated-factor
+ * metadata (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the effective
* per-user set: own custom tags merged in (label decrypted, `custom: true`),
* hidden catalogue tags omitted by default and surfaced under
- * `?include=hidden`.
+ * `?include=hidden`. v1.17.0 layers custom groups, the layout blob (group
+ * order + placements), `include=archived` (own inactive customs) and
+ * `include=usage` (per-tag live-entry counts) on top.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
@@ -11,12 +13,16 @@ import { NextRequest } from "next/server";
const categoryFindMany = vi.fn();
const tagFindMany = vi.fn();
const hiddenFindMany = vi.fn();
+const userFindUnique = vi.fn();
+const linkGroupBy = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) },
moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) },
moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) },
+ user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
+ moodEntryTagLink: { groupBy: (...a: unknown[]) => linkGroupBy(...a) },
},
}));
@@ -52,16 +58,40 @@ const SESSION_OK = {
};
const CATEGORIES = [
- { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile" },
- { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag" },
+ { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile", userId: null, labelEncrypted: null },
+ { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag", userId: null, labelEncrypted: null },
];
+const OWN_GROUP = {
+ id: "cg1",
+ key: "customcat:g1",
+ labelKey: "customcat:g1",
+ icon: "Stethoscope",
+ userId: "user-1",
+ labelEncrypted: "enc:Therapie",
+};
+
const TAGS = [
- { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null },
- { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null },
- { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: "user-1", labelEncrypted: "enc:Migräne" },
+ { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null },
+ { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null },
+ { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: "user-1", labelEncrypted: "enc:Migräne" },
];
+const ARCHIVED_TAG = {
+ id: "t_arch",
+ categoryId: "mtc_custom",
+ key: "custom:arch",
+ labelKey: "custom:arch",
+ icon: null,
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
+ isActive: false,
+ userId: "user-1",
+ labelEncrypted: "enc:Alt",
+};
+
interface TagOut {
key: string;
labelKey: string | null;
@@ -72,9 +102,19 @@ interface TagOut {
scaleMax: number;
inverse: boolean;
hidden?: boolean;
+ archived?: boolean;
+ usageCount?: number;
+}
+interface CategoryOut {
+ key: string;
+ labelKey: string | null;
+ label: string | null;
+ icon: string | null;
+ custom: boolean;
+ tags: TagOut[];
}
interface Body {
- data: { categories: Array<{ key: string; tags: TagOut[] }> };
+ data: { categories: CategoryOut[] };
}
function req(url = "http://localhost/api/mood/tags") {
@@ -87,6 +127,8 @@ beforeEach(() => {
categoryFindMany.mockResolvedValue(CATEGORIES);
tagFindMany.mockResolvedValue(TAGS);
hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]);
+ userFindUnique.mockResolvedValue({ moodTagLayoutJson: null });
+ linkGroupBy.mockResolvedValue([]);
});
function flat(body: Body): Record {
@@ -126,6 +168,9 @@ describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => {
// Tag query scopes to catalogue OR the caller's own customs.
const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
+
+ // Plain picker read never pays the usage groupBy.
+ expect(linkGroupBy).not.toHaveBeenCalled();
});
it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => {
@@ -137,3 +182,113 @@ describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => {
expect(tags["custom:abc"].hidden).toBeUndefined();
});
});
+
+describe("GET /api/mood/tags — groups + layout + archived + usage (v1.17.0)", () => {
+ it("returns own custom groups with decrypted label and custom:true on categories", async () => {
+ categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
+ tagFindMany.mockResolvedValue([
+ ...TAGS,
+ { ...TAGS[2], id: "t_c2", categoryId: "cg1", key: "custom:ing1", labelKey: "custom:ing1", labelEncrypted: "enc:Sitzung" },
+ ]);
+ const res = await GET(req());
+ const body = (await res.json()) as Body;
+ const group = body.data.categories.find((c) => c.key === "customcat:g1");
+ expect(group).toMatchObject({
+ custom: true,
+ label: "Therapie",
+ labelKey: null,
+ icon: "Stethoscope",
+ });
+ expect(group?.tags.map((t) => t.key)).toEqual(["custom:ing1"]);
+ // Seeded categories stay labelKey-driven.
+ const feelings = body.data.categories.find((c) => c.key === "feelings");
+ expect(feelings).toMatchObject({
+ custom: false,
+ label: null,
+ labelKey: "mood.tagCategory.feelings",
+ });
+ // Category query scopes to seeded OR own groups.
+ const catWhere = categoryFindMany.mock.calls[0]?.[0]?.where;
+ expect(catWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
+ });
+
+ it("applies the layout blob: group order + catalogue-tag placement", async () => {
+ categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
+ userFindUnique.mockResolvedValue({
+ moodTagLayoutJson: {
+ groupOrder: ["customcat:g1", "custom", "feelings"],
+ placements: { "customcat:g1": ["happy"] },
+ },
+ });
+ const res = await GET(req());
+ const body = (await res.json()) as Body;
+ // `feelings` ends empty (happy placed away, sad hidden) → dropped.
+ expect(body.data.categories.map((c) => c.key)).toEqual([
+ "customcat:g1",
+ "custom",
+ ]);
+ // Catalogue tag `happy` rendered inside the user's group (placement,
+ // not a categoryId change) — gone from its home category.
+ const group = body.data.categories.find((c) => c.key === "customcat:g1");
+ expect(group?.tags.map((t) => t.key)).toEqual(["happy"]);
+ const feelings = body.data.categories.find((c) => c.key === "feelings");
+ expect(feelings).toBeUndefined(); // only hidden `sad` left → dropped
+ });
+
+ it("drops a stale layout placement (hidden tag) silently", async () => {
+ categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
+ userFindUnique.mockResolvedValue({
+ moodTagLayoutJson: {
+ placements: { "customcat:g1": ["sad", "ghost"] }, // sad is hidden
+ },
+ });
+ const res = await GET(req());
+ const body = (await res.json()) as Body;
+ expect(
+ body.data.categories.find((c) => c.key === "customcat:g1"),
+ ).toBeUndefined(); // nothing visible placed → empty → dropped (plain read)
+ });
+
+ it("returns archived own customs with archived:true under include=archived and keeps empty own groups", async () => {
+ categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
+ tagFindMany.mockResolvedValue([...TAGS, ARCHIVED_TAG]);
+ const res = await GET(
+ req("http://localhost/api/mood/tags?include=hidden,archived"),
+ );
+ const body = (await res.json()) as Body;
+ const tags = flat(body);
+ expect(tags["custom:arch"]).toMatchObject({
+ custom: true,
+ archived: true,
+ label: "Alt",
+ });
+ expect(tags["custom:abc"]).toMatchObject({ archived: false });
+ // Catalogue rows never carry the archived flag.
+ expect(tags.happy.archived).toBeUndefined();
+ // The archived read lifts isActive only for the caller's own rows.
+ const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
+ expect(tagWhere).toEqual({
+ OR: [{ userId: null, isActive: true }, { userId: "user-1" }],
+ });
+ // Empty own group kept on a management read.
+ expect(
+ body.data.categories.find((c) => c.key === "customcat:g1"),
+ ).toMatchObject({ custom: true, tags: [] });
+ });
+
+ it("emits usageCount per tag under include=usage (live entries only)", async () => {
+ linkGroupBy.mockResolvedValue([
+ { moodTagId: "t_happy", _count: { _all: 7 } },
+ ]);
+ const res = await GET(req("http://localhost/api/mood/tags?include=usage"));
+ const tags = flat((await res.json()) as Body);
+ expect(tags.happy.usageCount).toBe(7);
+ expect(tags["custom:abc"].usageCount).toBe(0);
+ expect(linkGroupBy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ by: ["moodTagId"],
+ where: { moodEntry: { userId: "user-1", deletedAt: null } },
+ }),
+ );
+ });
+});
diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts
index 697489bbf..3b28228e7 100644
--- a/src/app/api/mood/tags/custom/[key]/route.ts
+++ b/src/app/api/mood/tags/custom/[key]/route.ts
@@ -15,20 +15,27 @@ import {
decryptCustomLabel,
isCustomTagKey,
} from "@/lib/mood/custom-tags";
+import { resolveCategoryKeyForUser } from "@/lib/mood/tag-groups";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ key: string }> };
/**
- * v1.13.0 — update / delete a per-user custom mood tag.
+ * v1.13.0 / v1.17.0 — update / delete a per-user custom mood tag.
*
* Both handlers resolve the `custom:`-prefixed key against the CALLER's own
* rows only — another user's key (or a catalogue key) is a 404, so a tag can
* never be edited or removed across the ownership boundary.
*/
-/** `PATCH /api/mood/tags/custom/:key` — rename / recolour / (de)activate. */
+/**
+ * `PATCH /api/mood/tags/custom/:key` — rename / re-icon / (de)activate /
+ * move to another group (v1.17.0 `categoryKey`: any seeded category or an
+ * own `customcat:` group; unknown / foreign → 422). The move is a real
+ * `categoryId` update — custom tags belong relationally, only catalogue
+ * tags "move" via the layout blob.
+ */
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -48,6 +55,16 @@ export const PATCH = apiHandler(
});
if (!owned) return apiError("Custom tag not found", 404);
+ let categoryId: string | undefined;
+ if (parsed.data.categoryKey !== undefined) {
+ const resolved = await resolveCategoryKeyForUser(
+ parsed.data.categoryKey,
+ user.id,
+ );
+ if (!resolved) return apiError("Unknown category", 422);
+ categoryId = resolved;
+ }
+
const updated = await prisma.moodTag.update({
where: { id: owned.id },
data: {
@@ -58,6 +75,7 @@ export const PATCH = apiHandler(
...(parsed.data.isActive !== undefined
? { isActive: parsed.data.isActive }
: {}),
+ ...(categoryId !== undefined ? { categoryId } : {}),
},
select: {
key: true,
diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts
index d5cf6d30e..4aece227e 100644
--- a/src/app/api/mood/tags/custom/route.ts
+++ b/src/app/api/mood/tags/custom/route.ts
@@ -16,15 +16,20 @@ import {
CUSTOM_CATEGORY_ID,
MAX_CUSTOM_TAGS_PER_USER,
} from "@/lib/mood/custom-tags";
+import { resolveCategoryKeyForUser } from "@/lib/mood/tag-groups";
export const dynamic = "force-dynamic";
/**
- * v1.13.0 — create a per-user custom mood tag.
+ * v1.13.0 / v1.17.0 — create a per-user custom mood tag.
*
* `POST /api/mood/tags/custom` — body `{ label, icon?, categoryKey? }`. Mints
* a `custom:` key, encrypts the label at rest, and stores a BINARY tag
- * under the global `custom` category owned by the caller. Capped per user.
+ * owned by the caller. `categoryKey` (v1.17.0) picks the home group — any
+ * seeded category key or one of the caller's own `customcat:` groups; an
+ * unknown or foreign key is a 422. Omitted → the seeded `custom` category
+ * (the v1.13.0 behaviour, byte-identical for existing clients). Capped per
+ * user.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -46,10 +51,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
+ let categoryId = CUSTOM_CATEGORY_ID;
+ if (parsed.data.categoryKey !== undefined) {
+ const resolved = await resolveCategoryKeyForUser(
+ parsed.data.categoryKey,
+ user.id,
+ );
+ if (!resolved) return apiError("Unknown category", 422);
+ categoryId = resolved;
+ }
+
const key = mintCustomTagKey();
const created = await prisma.moodTag.create({
data: {
- categoryId: CUSTOM_CATEGORY_ID,
+ categoryId,
key,
// Catalogue rows resolve `labelKey` against the locale; a custom tag
// renders its decrypted `label` instead, so labelKey just mirrors the
diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts
new file mode 100644
index 000000000..cc94d9483
--- /dev/null
+++ b/src/app/api/mood/tags/groups/[key]/route.ts
@@ -0,0 +1,152 @@
+import { NextRequest } from "next/server";
+
+import { prisma, toJson } from "@/lib/db";
+import {
+ apiSuccess,
+ apiError,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import {
+ updateCustomGroupSchema,
+ encryptCustomLabel,
+ decryptCustomLabel,
+ isCustomCategoryKey,
+ CUSTOM_CATEGORY_ID,
+} from "@/lib/mood/custom-tags";
+import {
+ parseStoredMoodTagLayout,
+ stripGroupFromLayout,
+} from "@/lib/mood/tag-layout";
+
+export const dynamic = "force-dynamic";
+
+type RouteParams = { params: Promise<{ key: string }> };
+
+/**
+ * v1.17.0 — update / delete a per-user custom mood-tag group.
+ *
+ * Both handlers resolve the `customcat:`-prefixed key against the CALLER's
+ * own rows only — another user's key (or a seeded category key) is a 404,
+ * so a group can never be edited or removed across the ownership boundary.
+ */
+
+/** `PATCH /api/mood/tags/groups/:key` — rename / re-icon / (de)activate. */
+export const PATCH = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { key } = await params;
+ if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404);
+
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = updateCustomGroupSchema.safeParse(rawJsonBody);
+ if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
+
+ const owned = await prisma.moodTagCategory.findFirst({
+ where: { key, userId: user.id },
+ select: { id: true },
+ });
+ if (!owned) return apiError("Custom group not found", 404);
+
+ const updated = await prisma.moodTagCategory.update({
+ where: { id: owned.id },
+ data: {
+ ...(parsed.data.label !== undefined
+ ? { labelEncrypted: encryptCustomLabel(parsed.data.label) }
+ : {}),
+ ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}),
+ ...(parsed.data.isActive !== undefined
+ ? { isActive: parsed.data.isActive }
+ : {}),
+ },
+ select: {
+ key: true,
+ icon: true,
+ isActive: true,
+ labelEncrypted: true,
+ },
+ });
+
+ annotate({ action: { name: "mood.tag.group.update" } });
+
+ return apiSuccess({
+ key: updated.key,
+ labelKey: null,
+ label: decryptCustomLabel(updated.labelEncrypted),
+ icon: updated.icon,
+ isActive: updated.isActive,
+ custom: true,
+ });
+ },
+);
+
+/**
+ * `DELETE /api/mood/tags/groups/:key` — non-destructive by construction:
+ * the group's own custom tags re-home to the seeded `custom` category
+ * (catalogue-tag placements simply evaporate back to their seeded category
+ * when the layout entry is stripped), the layout blob drops the group, and
+ * the row soft-deactivates. `?purge=true` hard-deletes the (now empty)
+ * group row instead. No tag and no entry link is ever deleted here.
+ */
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { key } = await params;
+ if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404);
+
+ const owned = await prisma.moodTagCategory.findFirst({
+ where: { key, userId: user.id },
+ select: { id: true },
+ });
+ if (!owned) return apiError("Custom group not found", 404);
+
+ const purge = request.nextUrl.searchParams.get("purge") === "true";
+
+ const rehomedCount = await prisma.$transaction(async (tx) => {
+ // 1. Re-home the caller's custom tags living in this group. Only own
+ // tags can reference an own group (the categoryKey resolvers
+ // enforce it), so this empties the group.
+ const rehomed = await tx.moodTag.updateMany({
+ where: { categoryId: owned.id, userId: user.id },
+ data: { categoryId: CUSTOM_CATEGORY_ID },
+ });
+
+ // 2. Strip the group from the layout blob (groupOrder + placements).
+ const userRow = await tx.user.findUnique({
+ where: { id: user.id },
+ select: { moodTagLayoutJson: true },
+ });
+ const layout = parseStoredMoodTagLayout(userRow?.moodTagLayoutJson);
+ if (layout.groupOrder !== undefined || layout.placements !== undefined) {
+ await tx.user.update({
+ where: { id: user.id },
+ data: { moodTagLayoutJson: toJson(stripGroupFromLayout(layout, key)) },
+ });
+ }
+
+ // 3. Retire (default) or hard-delete (purge) the empty group row.
+ if (purge) {
+ await tx.moodTagCategory.delete({ where: { id: owned.id } });
+ } else {
+ await tx.moodTagCategory.update({
+ where: { id: owned.id },
+ data: { isActive: false },
+ });
+ }
+
+ return rehomed.count;
+ });
+
+ annotate({
+ action: { name: "mood.tag.group.delete" },
+ meta: { purge, rehomed_count: rehomedCount },
+ });
+
+ return apiSuccess({ key, purged: purge, rehomedCount });
+ },
+);
diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts
new file mode 100644
index 000000000..b0b385809
--- /dev/null
+++ b/src/app/api/mood/tags/groups/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import {
+ apiSuccess,
+ apiError,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import {
+ createCustomGroupSchema,
+ mintCustomCategoryKey,
+ encryptCustomLabel,
+ MAX_CUSTOM_GROUPS_PER_USER,
+} from "@/lib/mood/custom-tags";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * v1.17.0 — create a per-user custom mood-tag group.
+ *
+ * `POST /api/mood/tags/groups` — body `{ label, icon? }`. Mints a
+ * `customcat:` key, encrypts the label at rest, and stores a
+ * category owned by the caller. Capped per user. The group is
+ * presentation-plus-home only: entry writes are key-based and never see
+ * categories, so no group change can break `tagKeys` / `ratedFactors`
+ * ingestion.
+ */
+export const POST = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = createCustomGroupSchema.safeParse(rawJsonBody);
+ if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
+
+ const activeCount = await prisma.moodTagCategory.count({
+ where: { userId: user.id, isActive: true },
+ });
+ if (activeCount >= MAX_CUSTOM_GROUPS_PER_USER) {
+ return apiError(
+ `Custom group limit reached (${MAX_CUSTOM_GROUPS_PER_USER})`,
+ 422,
+ );
+ }
+
+ const key = mintCustomCategoryKey();
+ const created = await prisma.moodTagCategory.create({
+ data: {
+ key,
+ // Seeded rows resolve `labelKey` against the locale; a custom group
+ // renders its decrypted `label` instead, so labelKey just mirrors the
+ // key for a stable, non-empty value.
+ labelKey: key,
+ labelEncrypted: encryptCustomLabel(parsed.data.label),
+ icon: parsed.data.icon ?? null,
+ // After the seeded set (the `custom` category sits at 100) so a fresh
+ // group appends rather than jumping the seeded categories; the
+ // per-user layout blob owns the real display order.
+ sortOrder: 101 + activeCount,
+ userId: user.id,
+ },
+ select: { key: true, icon: true },
+ });
+
+ annotate({
+ action: { name: "mood.tag.group.create" },
+ meta: { icon: created.icon },
+ });
+
+ return apiSuccess(
+ {
+ key: created.key,
+ labelKey: null,
+ label: parsed.data.label,
+ icon: created.icon,
+ custom: true,
+ },
+ 201,
+ );
+});
diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts
new file mode 100644
index 000000000..fee4b79d4
--- /dev/null
+++ b/src/app/api/mood/tags/layout/route.ts
@@ -0,0 +1,109 @@
+import { NextRequest } from "next/server";
+
+import { prisma, toJson } from "@/lib/db";
+import {
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import {
+ moodTagLayoutSchema,
+ parseStoredMoodTagLayout,
+ mergeMoodTagLayout,
+ resolveGroupOrder,
+ type MoodTagLayout,
+} from "@/lib/mood/tag-layout";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * v1.17.0 — per-user mood-tag layout (group order + tag placements).
+ *
+ * GET returns the stored blob merged over defaults: `groupOrder` fully
+ * resolved against the user's effective category set (layout order first,
+ * unmentioned categories appended in seeded order) + the raw `placements`.
+ * PUT updates with preserve-when-absent semantics — a groupOrder-only PUT
+ * keeps the stored placements and vice versa. Mirrors
+ * `/api/medications/layout`; the blob lives on its own `User` column
+ * (`mood_tag_layout_json`) per the per-surface-column convention.
+ *
+ * Display-only: keys here are opaque, unknown / stale keys are dropped at
+ * read time by `GET /api/mood/tags`, so the schema bounds size and shape
+ * but does not assert ownership of every key.
+ */
+
+async function resolveLayoutResponse(
+ userId: string,
+ layout: MoodTagLayout,
+): Promise<{
+ groupOrder: string[];
+ placements: Record;
+}> {
+ const categories = await prisma.moodTagCategory.findMany({
+ where: { isActive: true, OR: [{ userId: null }, { userId }] },
+ orderBy: { sortOrder: "asc" },
+ select: { key: true },
+ });
+ return {
+ groupOrder: resolveGroupOrder(
+ categories.map((c) => c.key),
+ layout.groupOrder,
+ ),
+ placements: layout.placements ?? {},
+ };
+}
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ const row = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { moodTagLayoutJson: true },
+ });
+ const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson);
+ return apiSuccess(await resolveLayoutResponse(user.id, stored));
+});
+
+export const PUT = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = moodTagLayoutSchema.safeParse(rawJsonBody);
+ if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
+
+ // Preserve-when-absent: a PUT carrying only `groupOrder` must not wipe
+ // the stored placements, and a placements-only PUT must not reset the
+ // group order.
+ const row = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { moodTagLayoutJson: true },
+ });
+ const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson);
+ const merged: MoodTagLayout = mergeMoodTagLayout(stored, {
+ ...(parsed.data.groupOrder !== undefined
+ ? { groupOrder: parsed.data.groupOrder }
+ : {}),
+ ...(parsed.data.placements !== undefined
+ ? { placements: parsed.data.placements }
+ : {}),
+ });
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { moodTagLayoutJson: toJson(merged) },
+ });
+
+ annotate({
+ action: { name: "mood.tag.layout.update" },
+ meta: {
+ group_order_count: merged.groupOrder?.length ?? 0,
+ placement_group_count: Object.keys(merged.placements ?? {}).length,
+ },
+ });
+
+ return apiSuccess(await resolveLayoutResponse(user.id, merged));
+});
diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts
index c4589267d..d5b784515 100644
--- a/src/app/api/mood/tags/route.ts
+++ b/src/app/api/mood/tags/route.ts
@@ -5,110 +5,196 @@ import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { decryptCustomLabel } from "@/lib/mood/custom-tags";
+import {
+ parseStoredMoodTagLayout,
+ resolveMoodTagPlacement,
+} from "@/lib/mood/tag-layout";
export const dynamic = "force-dynamic";
/**
- * v1.8.5 / v1.13.0 — the EFFECTIVE per-user mood-tag taxonomy.
+ * v1.8.5 / v1.13.0 / v1.17.0 — the EFFECTIVE per-user mood-tag taxonomy.
*
- * Returns the active Category → Tag tree the mood-logging form renders as a
- * pick-from-catalogue surface. The catalogue is global reference data (seeded
- * by migration); v1.13.0 layers two per-user adjustments on top so the read
- * is now user-scoped:
- * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys)
- * appear under the seeded `custom` category, their label decrypted;
+ * Returns the fully-resolved, ordered Category → Tag tree the mood-logging
+ * form renders as a pick-from-catalogue surface — for web AND iOS, in one
+ * read. The catalogue is global reference data (seeded by migration);
+ * layered per-user adjustments:
+ * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys),
+ * label decrypted;
+ * - the user's own custom groups (`mood_tag_categories.user_id = me`,
+ * `customcat:` keys), label decrypted, `custom: true` on the DTO;
+ * - the per-user layout blob (`User.moodTagLayoutJson`): group display
+ * order + per-group tag placement. A CATALOGUE tag "moved" into a group
+ * is placement only — its `categoryId` never changes, and entry writes
+ * are key-based, so no grouping change can break `tagKeys` /
+ * `ratedFactors` ingestion;
* - catalogue tags the user hid (`mood_tag_hidden`) are omitted — unless
- * `?include=hidden` is set, in which case they're returned with
- * `hidden: true` so the management screen can toggle them back on.
+ * `include` contains `hidden`, in which case they return `hidden: true`.
+ *
+ * `?include=` is a comma list (v1.17.0; the bare v1.13 `hidden` still
+ * works): `hidden` (hidden catalogue tags), `archived` (the user's own
+ * inactive custom tags, `archived: true`), `usage` (per-tag `usageCount`
+ * over the user's live entries — management read only). When any flag is
+ * set the user's own EMPTY groups are kept (the management screen needs
+ * them); the plain picker read keeps dropping empty categories.
*
* Every tag carries `custom: boolean` + `label: string | null`: render
* `label` for a custom tag, resolve `labelKey` against the locale for a
- * catalogue tag. Backward-compatible — a client that ignores the new fields
- * just sees the same categories → tags shape.
+ * catalogue tag. Backward-compatible — a v1.13 client ignoring the new
+ * fields sees the same tree, now per-user ordered.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const includeHidden =
- request.nextUrl.searchParams.get("include") === "hidden";
- const [categories, tags, hiddenRows] = await Promise.all([
- prisma.moodTagCategory.findMany({
- where: { isActive: true },
- orderBy: { sortOrder: "asc" },
- select: { id: true, key: true, labelKey: true, icon: true },
- }),
- // Catalogue tags (user_id NULL) + this user's own custom tags. Another
- // user's customs are excluded by construction.
- prisma.moodTag.findMany({
- where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] },
- orderBy: { sortOrder: "asc" },
- select: {
- id: true,
- categoryId: true,
- key: true,
- labelKey: true,
- icon: true,
- kind: true,
- scaleMin: true,
- scaleMax: true,
- inverse: true,
- userId: true,
- labelEncrypted: true,
- },
- }),
- prisma.moodTagHidden.findMany({
- where: { userId: user.id },
- select: { moodTagId: true },
- }),
- ]);
+ const includeFlags = new Set(
+ (request.nextUrl.searchParams.get("include") ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ );
+ const includeHidden = includeFlags.has("hidden");
+ const includeArchived = includeFlags.has("archived");
+ const includeUsage = includeFlags.has("usage");
+ const managementRead = includeFlags.size > 0;
+
+ const [categories, tags, hiddenRows, layoutRow, usageRows] =
+ await Promise.all([
+ // Seeded categories + this user's own custom groups.
+ prisma.moodTagCategory.findMany({
+ where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] },
+ orderBy: { sortOrder: "asc" },
+ select: {
+ id: true,
+ key: true,
+ labelKey: true,
+ icon: true,
+ userId: true,
+ labelEncrypted: true,
+ },
+ }),
+ // Catalogue tags (user_id NULL) + this user's own custom tags. Another
+ // user's customs are excluded by construction. `archived` lifts the
+ // isActive pin for the user's OWN rows only — retired catalogue tags
+ // (0126 precedent) never resurface.
+ prisma.moodTag.findMany({
+ where: includeArchived
+ ? { OR: [{ userId: null, isActive: true }, { userId: user.id }] }
+ : { isActive: true, OR: [{ userId: null }, { userId: user.id }] },
+ orderBy: { sortOrder: "asc" },
+ select: {
+ id: true,
+ categoryId: true,
+ key: true,
+ labelKey: true,
+ icon: true,
+ kind: true,
+ scaleMin: true,
+ scaleMax: true,
+ inverse: true,
+ isActive: true,
+ userId: true,
+ labelEncrypted: true,
+ },
+ }),
+ prisma.moodTagHidden.findMany({
+ where: { userId: user.id },
+ select: { moodTagId: true },
+ }),
+ prisma.user.findUnique({
+ where: { id: user.id },
+ select: { moodTagLayoutJson: true },
+ }),
+ includeUsage
+ ? prisma.moodEntryTagLink.groupBy({
+ by: ["moodTagId"],
+ _count: { _all: true },
+ where: { moodEntry: { userId: user.id, deletedAt: null } },
+ })
+ : Promise.resolve(
+ [] as Array<{ moodTagId: string; _count: { _all: number } }>,
+ ),
+ ]);
const hiddenIds = new Set(hiddenRows.map((r) => r.moodTagId));
+ const usageByTagId = new Map(
+ usageRows.map((r) => [r.moodTagId, r._count._all]),
+ );
+ const categoryKeyById = new Map(categories.map((c) => [c.id, c.key]));
- const tagsByCategory = new Map();
- for (const t of tags) {
+ // Visible tag rows (global sortOrder preserved by the query).
+ const visibleTags = tags.filter((t) => {
const isCustom = t.userId !== null;
const isHidden = !isCustom && hiddenIds.has(t.id);
- if (isHidden && !includeHidden) continue;
- const list = tagsByCategory.get(t.categoryId) ?? [];
- list.push(t);
- tagsByCategory.set(t.categoryId, list);
- }
+ if (isHidden && !includeHidden) return false;
+ return categoryKeyById.has(t.categoryId);
+ });
- const responseCategories = categories
- .map((c) => {
- const catTags = (tagsByCategory.get(c.id) ?? []).map((t) => {
- const isCustom = t.userId !== null;
- return {
- key: t.key,
- labelKey: isCustom ? null : t.labelKey,
- label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null,
- icon: t.icon,
- kind: t.kind,
- scaleMin: t.scaleMin,
- scaleMax: t.scaleMax,
- inverse: t.inverse,
- custom: isCustom,
- ...(includeHidden && !isCustom
- ? { hidden: hiddenIds.has(t.id) }
- : {}),
- };
- });
+ const layout = parseStoredMoodTagLayout(layoutRow?.moodTagLayoutJson);
+ const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({
+ categoryKeysInSeededOrder: categories.map((c) => c.key),
+ tags: visibleTags.map((t) => ({
+ key: t.key,
+ homeCategoryKey: categoryKeyById.get(t.categoryId) as string,
+ })),
+ layout,
+ });
+
+ const tagByKey = new Map(visibleTags.map((t) => [t.key, t]));
+ const categoryByKey = new Map(categories.map((c) => [c.key, c]));
+
+ const responseCategories = orderedCategoryKeys
+ .map((categoryKey) => {
+ const c = categoryByKey.get(categoryKey);
+ if (!c) return null;
+ const isCustomCategory = c.userId !== null;
+ const catTags = (tagKeysByCategory.get(categoryKey) ?? [])
+ .map((tagKey) => tagByKey.get(tagKey))
+ .filter((t) => t !== undefined)
+ .map((t) => {
+ const isCustom = t.userId !== null;
+ return {
+ key: t.key,
+ labelKey: isCustom ? null : t.labelKey,
+ label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null,
+ icon: t.icon,
+ kind: t.kind,
+ scaleMin: t.scaleMin,
+ scaleMax: t.scaleMax,
+ inverse: t.inverse,
+ custom: isCustom,
+ ...(includeHidden && !isCustom
+ ? { hidden: hiddenIds.has(t.id) }
+ : {}),
+ ...(includeArchived && isCustom
+ ? { archived: !t.isActive }
+ : {}),
+ ...(includeUsage
+ ? { usageCount: usageByTagId.get(t.id) ?? 0 }
+ : {}),
+ };
+ });
return {
key: c.key,
- labelKey: c.labelKey,
+ labelKey: isCustomCategory ? null : c.labelKey,
+ label: isCustomCategory ? decryptCustomLabel(c.labelEncrypted) : null,
icon: c.icon,
+ custom: isCustomCategory,
tags: catTags,
};
})
+ .filter((c) => c !== null)
// Drop a category with no visible tags (e.g. `custom` for a user with
- // none yet) so the picker grid stays tight.
- .filter((c) => c.tags.length > 0);
+ // none yet) so the picker grid stays tight — EXCEPT the user's own
+ // groups on a management read, which must surface even when empty.
+ .filter((c) => c.tags.length > 0 || (managementRead && c.custom));
annotate({
action: { name: "mood.tags.catalog.read" },
meta: {
category_count: responseCategories.length,
include_hidden: includeHidden,
+ include_archived: includeArchived,
+ include_usage: includeUsage,
},
});
diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx
index f630e8338..31865a6d3 100644
--- a/src/app/mood/page.tsx
+++ b/src/app/mood/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
+import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
@@ -9,7 +10,7 @@ import { MoodList } from "@/components/mood/mood-list";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
-import { Plus, Loader2 } from "lucide-react";
+import { Plus, Loader2, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
@@ -66,10 +67,34 @@ export default function MoodPage() {
{t("mood.subtitle")}
-
+
+ {/* v1.17 — the wrench is the one customize entry point: it
+ links to /settings/mood, which owns the tag groups, custom
+ tags, visibility, and picker order. Same glyph, slot (left
+ of the add button) and responsive 44-px mobile tap floor
+ as the medications header. */}
+
+
+
,
api: ApiSection,
diff --git a/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx b/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx
new file mode 100644
index 000000000..cbdbe6c36
--- /dev/null
+++ b/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx
@@ -0,0 +1,100 @@
+/**
+ * v1.17 — searchable icon picker over the shared curated catalog.
+ * The filter is a pure function (name + English keyword aids,
+ * case-insensitive, undrawable names excluded) so the search contract
+ * is pinned without DOM events; the SSR render covers the radiogroup
+ * grid, the selected-tile accent, and the localised search affordances.
+ */
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ MOOD_TAG_ICON_CATALOG,
+ type MoodTagIconCatalogEntry,
+} from "@/lib/mood/icon-catalog";
+import { isMoodTagIconName } from "../mood-tag-icons";
+import { MoodTagIconPicker, filterIconCatalog } from "../mood-tag-icon-picker";
+
+describe("filterIconCatalog", () => {
+ it("returns the full drawable catalog for an empty query", () => {
+ const all = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "");
+ expect(all.length).toBeGreaterThan(40);
+ expect(all.every((entry) => isMoodTagIconName(entry.name))).toBe(true);
+ });
+
+ it("matches on the icon name, case-insensitive", () => {
+ const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "dumbbell");
+ expect(hits.map((entry) => entry.name)).toContain("Dumbbell");
+ });
+
+ it("matches on keyword aids", () => {
+ const byKeyword = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "happy");
+ expect(byKeyword.length).toBeGreaterThan(0);
+ expect(
+ byKeyword.some((entry) =>
+ entry.keywords.some((k) => k.includes("happy")),
+ ) ||
+ byKeyword.some((entry) => entry.name.toLowerCase().includes("happy")),
+ ).toBe(true);
+ });
+
+ it("returns nothing for a miss", () => {
+ expect(filterIconCatalog(MOOD_TAG_ICON_CATALOG, "zzzznope")).toEqual([]);
+ });
+
+ it("excludes catalog names the client bundle cannot draw", () => {
+ const withGhost: MoodTagIconCatalogEntry[] = [
+ ...MOOD_TAG_ICON_CATALOG,
+ { name: "NotARealIcon", keywords: ["ghost"], group: "misc" },
+ ];
+ const filtered = filterIconCatalog(withGhost, "");
+ expect(filtered.some((entry) => entry.name === "NotARealIcon")).toBe(false);
+ });
+
+ it("every shipped catalog entry resolves to a real glyph (allowlist ⊆ client map)", () => {
+ for (const entry of MOOD_TAG_ICON_CATALOG) {
+ expect(
+ isMoodTagIconName(entry.name),
+ `unmapped icon: ${entry.name}`,
+ ).toBe(true);
+ }
+ });
+});
+
+describe(" — SSR", () => {
+ function render(value: string | null, locale: "en" | "de" = "en"): string {
+ return renderToStaticMarkup(
+
+ {}} />
+ ,
+ );
+ }
+
+ it("renders the search input + one radio tile per drawable catalog entry", () => {
+ const html = render(null);
+ expect(html).toContain('type="search"');
+ const tiles = html.match(/data-slot="mood-icon-tile"/g);
+ expect(tiles?.length ?? 0).toBe(
+ filterIconCatalog(MOOD_TAG_ICON_CATALOG, "").length,
+ );
+ });
+
+ it("marks the current value with the selected accent + aria-checked", () => {
+ const html = render("Heart");
+ expect(html).toMatch(
+ /data-icon="Heart"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-icon="Heart"/,
+ );
+ });
+
+ it("resolves the German search placeholder", () => {
+ const html = render(null, "de");
+ expect(html).toContain("Symbole durchsuchen…");
+ });
+
+ it("renders localised group sub-headers", () => {
+ const html = render(null, "de");
+ expect(html).toContain("Gefühle");
+ expect(html).toContain("Aktivitäten");
+ });
+});
diff --git a/src/components/mood/__tests__/mood-tag-picker-custom.test.tsx b/src/components/mood/__tests__/mood-tag-picker-custom.test.tsx
new file mode 100644
index 000000000..835e99511
--- /dev/null
+++ b/src/components/mood/__tests__/mood-tag-picker-custom.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * v1.17 — picker-side management-suite additions:
+ * - a custom tag renders its decrypted `label` (the v1.13 API field
+ * the web picker previously ignored — the raw `custom:` key
+ * used to paint through the `t()` fallback);
+ * - every group carries a trailing ghost "+" tile that opens the
+ * inline create sheet, plus a synthetic Custom bootstrap group when
+ * the plain read carries no custom node yet;
+ * - render order is exactly the server-resolved order (no client
+ * sorting).
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+interface FixtureCatalog {
+ categories: unknown[];
+}
+
+const queryState: { catalog: FixtureCatalog | null } = { catalog: null };
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
+ if (
+ Array.isArray(queryKey) &&
+ queryKey[0] === "mood-tag-catalog" &&
+ queryKey.length === 1
+ ) {
+ return { data: queryState.catalog, isLoading: false };
+ }
+ return { data: null, isLoading: false };
+ },
+ useQueryClient: () => ({
+ setQueryData: vi.fn(),
+ getQueryData: vi.fn(),
+ cancelQueries: vi.fn(),
+ invalidateQueries: vi.fn(),
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock("@/hooks/use-is-mobile", () => ({
+ useIsMobile: () => false,
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ isAuthenticated: true, isLoading: false }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { MoodTagPicker } from "../mood-tag-picker";
+
+const CATALOG = {
+ categories: [
+ {
+ key: "feelings",
+ labelKey: "mood.tagCategory.feelings",
+ label: null,
+ icon: "Smile",
+ custom: false,
+ tags: [
+ {
+ key: "happy",
+ labelKey: "mood.tag.happy",
+ label: null,
+ icon: "Smile",
+ kind: "BINARY",
+ scaleMin: 0,
+ scaleMax: 1,
+ inverse: false,
+ custom: false,
+ },
+ ],
+ },
+ {
+ key: "custom",
+ labelKey: "mood.tagCategory.custom",
+ label: null,
+ icon: "SlidersHorizontal",
+ custom: false,
+ tags: [
+ {
+ key: "custom:11111111-aaaa",
+ labelKey: "custom:11111111-aaaa",
+ label: "Gartenarbeit",
+ icon: "Leaf",
+ kind: "BINARY",
+ scaleMin: 0,
+ scaleMax: 1,
+ inverse: false,
+ custom: true,
+ },
+ ],
+ },
+ ],
+};
+
+function render(locale: "en" | "de" = "de"): string {
+ return renderToStaticMarkup(
+
+ {}} />
+ ,
+ );
+}
+
+beforeEach(() => {
+ queryState.catalog = CATALOG;
+});
+
+describe(" — custom labels + inline create", () => {
+ it("renders a custom tag by its decrypted label, never the raw key", () => {
+ const html = render();
+ expect(html).toContain("Gartenarbeit");
+ expect(html).not.toContain("custom:11111111-aaaa");
+ });
+
+ it("keeps resolving catalogue tags through their i18n key", () => {
+ const html = render("en");
+ expect(html).toContain("happy");
+ expect(html).not.toContain("mood.tag.happy");
+ });
+
+ it("renders a trailing + tile per group with the localised aria-label", () => {
+ const html = render();
+ const tiles = html.match(/data-slot="mood-tag-add-tile"/g);
+ expect(tiles?.length ?? 0).toBe(2); // one per group
+ expect(html).toContain("Tag hinzufügen");
+ });
+
+ it("synthesizes the Custom bootstrap group when the plain read has no custom node", () => {
+ queryState.catalog = { categories: [CATALOG.categories[0]] };
+ const html = render();
+ expect(html).toContain('data-slot="mood-tag-custom-bootstrap"');
+ expect(html).toContain("Eigene"); // mood.tagCategory.custom (de)
+ });
+
+ it("does NOT synthesize the bootstrap group when the custom node exists", () => {
+ const html = render();
+ expect(html).not.toContain('data-slot="mood-tag-custom-bootstrap"');
+ });
+
+ it("renders groups and tags in exactly the server order", () => {
+ const html = render();
+ expect(html.indexOf("Feelings") === -1).toBe(true); // de locale
+ const feelings = html.indexOf("Gefühle");
+ const custom = html.indexOf("Gartenarbeit");
+ expect(feelings).toBeGreaterThanOrEqual(0);
+ expect(custom).toBeGreaterThan(feelings);
+ });
+});
diff --git a/src/components/mood/manage/__tests__/catalog-helpers.test.ts b/src/components/mood/manage/__tests__/catalog-helpers.test.ts
new file mode 100644
index 000000000..4e360af13
--- /dev/null
+++ b/src/components/mood/manage/__tests__/catalog-helpers.test.ts
@@ -0,0 +1,212 @@
+/**
+ * v1.17 — pure catalog-tree surgery behind the mood-tag management
+ * cards. The optimistic-update path applies these to the cached manage
+ * read before the PATCH/PUT settles, so their contracts (unknown keys
+ * tolerated, archived rows preserved, placement maps complete) are
+ * pinned here without any DOM.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ buildGroupOrder,
+ buildPlacements,
+ moveTagToGroup,
+ removeTag,
+ reorderGroupTags,
+ reorderGroups,
+ setTagArchived,
+ setTagHidden,
+ type ManageCatalog,
+ type ManageTag,
+} from "../use-mood-tag-manage";
+import {
+ buildVisiblePlacements,
+ mergeBack,
+ visibleCatalog,
+} from "../tag-manager-card";
+import { archivedTags } from "../archived-tags-card";
+import { moveGroupKey } from "../tag-groups-card";
+import { insertTagIntoCatalog } from "../tag-editor-sheet";
+
+function tag(key: string, overrides: Partial = {}): ManageTag {
+ return {
+ key,
+ labelKey: `mood.tag.${key}`,
+ label: null,
+ icon: "Tag",
+ kind: "BINARY",
+ scaleMin: 0,
+ scaleMax: 1,
+ inverse: false,
+ custom: false,
+ ...overrides,
+ };
+}
+
+function catalog(): ManageCatalog {
+ return {
+ categories: [
+ {
+ key: "feelings",
+ labelKey: "mood.tagCategory.feelings",
+ label: null,
+ icon: "Smile",
+ custom: false,
+ tags: [tag("happy"), tag("stressed", { hidden: true })],
+ },
+ {
+ key: "customcat:g1",
+ labelKey: "customcat:g1",
+ label: "Garten",
+ icon: "Leaf",
+ custom: true,
+ tags: [
+ tag("custom:1", { custom: true, label: "Gartenarbeit" }),
+ tag("custom:2", {
+ custom: true,
+ label: "Altes Tag",
+ archived: true,
+ usageCount: 5,
+ }),
+ ],
+ },
+ ],
+ };
+}
+
+describe("setTagHidden / setTagArchived", () => {
+ it("flips only the addressed tag", () => {
+ const next = setTagHidden(catalog(), "happy", true);
+ expect(next.categories[0]!.tags[0]!.hidden).toBe(true);
+ expect(next.categories[0]!.tags[1]!.hidden).toBe(true); // untouched
+ expect(next.categories[1]!.tags[0]!.hidden).toBeUndefined();
+ });
+
+ it("archive flag round-trips", () => {
+ const archived = setTagArchived(catalog(), "custom:1", true);
+ expect(archived.categories[1]!.tags[0]!.archived).toBe(true);
+ const restored = setTagArchived(archived, "custom:1", false);
+ expect(restored.categories[1]!.tags[0]!.archived).toBe(false);
+ });
+});
+
+describe("removeTag", () => {
+ it("drops the row from its group and leaves everything else", () => {
+ const next = removeTag(catalog(), "custom:2");
+ expect(next.categories[1]!.tags.map((t) => t.key)).toEqual(["custom:1"]);
+ expect(next.categories[0]!.tags).toHaveLength(2);
+ });
+});
+
+describe("moveTagToGroup", () => {
+ it("appends the tag to the target group", () => {
+ const next = moveTagToGroup(catalog(), "happy", "customcat:g1");
+ expect(next.categories[0]!.tags.map((t) => t.key)).toEqual(["stressed"]);
+ expect(next.categories[1]!.tags.map((t) => t.key)).toEqual([
+ "custom:1",
+ "custom:2",
+ "happy",
+ ]);
+ });
+
+ it("is a no-op for an unknown tag key", () => {
+ const before = catalog();
+ expect(moveTagToGroup(before, "nope", "customcat:g1")).toEqual(before);
+ });
+});
+
+describe("reorderGroupTags / reorderGroups", () => {
+ it("applies the given order and keeps unknown keys last", () => {
+ const next = reorderGroupTags(catalog(), "feelings", [
+ "stressed",
+ "ghost",
+ "happy",
+ ]);
+ expect(next.categories[0]!.tags.map((t) => t.key)).toEqual([
+ "stressed",
+ "happy",
+ ]);
+ });
+
+ it("keeps unmentioned tags after the placed block", () => {
+ const next = reorderGroupTags(catalog(), "feelings", ["stressed"]);
+ expect(next.categories[0]!.tags.map((t) => t.key)).toEqual([
+ "stressed",
+ "happy",
+ ]);
+ });
+
+ it("reorders groups and keeps unmentioned groups after the placed block", () => {
+ const next = reorderGroups(catalog(), ["customcat:g1"]);
+ expect(next.categories.map((c) => c.key)).toEqual([
+ "customcat:g1",
+ "feelings",
+ ]);
+ });
+});
+
+describe("buildPlacements / buildGroupOrder", () => {
+ it("emits the complete map in display order", () => {
+ expect(buildPlacements(catalog())).toEqual({
+ feelings: ["happy", "stressed"],
+ "customcat:g1": ["custom:1", "custom:2"],
+ });
+ expect(buildGroupOrder(catalog())).toEqual(["feelings", "customcat:g1"]);
+ });
+});
+
+describe("visibleCatalog / archivedTags / buildVisiblePlacements", () => {
+ it("splits archived rows out of the visible projection", () => {
+ const visible = visibleCatalog(catalog());
+ expect(visible.categories[1]!.tags.map((t) => t.key)).toEqual(["custom:1"]);
+ expect(archivedTags(catalog()).map((t) => t.key)).toEqual(["custom:2"]);
+ });
+
+ it("placement map of the visible tree excludes archived keys", () => {
+ expect(buildVisiblePlacements(catalog())).toEqual({
+ feelings: ["happy", "stressed"],
+ "customcat:g1": ["custom:1"],
+ });
+ });
+});
+
+describe("mergeBack", () => {
+ it("re-attaches archived rows after a visible-tree edit", () => {
+ const full = catalog();
+ const moved = moveTagToGroup(visibleCatalog(full), "happy", "customcat:g1");
+ const merged = mergeBack(full, moved);
+ expect(merged.categories[1]!.tags.map((t) => t.key)).toEqual([
+ "custom:1",
+ "happy",
+ "custom:2",
+ ]);
+ });
+});
+
+describe("moveGroupKey", () => {
+ it("swaps within bounds and refuses to cross either end", () => {
+ expect(moveGroupKey(["a", "b", "c"], "b", -1)).toEqual(["b", "a", "c"]);
+ expect(moveGroupKey(["a", "b", "c"], "a", -1)).toEqual(["a", "b", "c"]);
+ expect(moveGroupKey(["a", "b", "c"], "c", 1)).toEqual(["a", "b", "c"]);
+ });
+});
+
+describe("insertTagIntoCatalog", () => {
+ it("appends the fresh DTO to its group", () => {
+ const created = tag("custom:3", { custom: true, label: "Neu" });
+ const next = insertTagIntoCatalog(catalog(), "customcat:g1", created);
+ expect(next.categories[1]!.tags.map((t) => t.key)).toEqual([
+ "custom:1",
+ "custom:2",
+ "custom:3",
+ ]);
+ });
+
+ it("leaves the tree untouched when the group node is absent (empty group dropped by the plain read)", () => {
+ const before = catalog();
+ const created = tag("custom:3", { custom: true });
+ expect(insertTagIntoCatalog(before, "customcat:none", created)).toEqual(
+ before,
+ );
+ });
+});
diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx
new file mode 100644
index 000000000..989fd401f
--- /dev/null
+++ b/src/components/mood/manage/archived-tags-card.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { ArchiveRestore, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { moodTagIcon } from "../mood-tag-icons";
+import {
+ invalidateMoodTagCaches,
+ removeTag,
+ setTagArchived,
+ snapshotManageCache,
+ tagDisplayName,
+ updateManageCache,
+ type ManageCatalog,
+ type ManageTag,
+} from "./use-mood-tag-manage";
+
+/**
+ * v1.17 — archived custom tags for `/settings/mood`. Archiving keeps
+ * every historical entry resolving the tag's label (the 0126 retire
+ * precedent: reads join by id without the active filter) — the card
+ * copy says so explicitly. Restore flips `isActive` back (the server
+ * re-checks the 50-tag cap). The hard delete sits behind an explicit
+ * confirm dialog that names the number of past entries the purge will
+ * strip the tag from; the FK cascade removes those links for good.
+ */
+
+interface ArchivedTagsCardProps {
+ catalog: ManageCatalog;
+}
+
+/** Every archived custom tag across the tree, in tree order. */
+export function archivedTags(catalog: ManageCatalog): ManageTag[] {
+ return catalog.categories.flatMap((category) =>
+ category.tags.filter((tag) => tag.archived === true),
+ );
+}
+
+export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const [purgeTag, setPurgeTag] = useState(null);
+
+ const archived = archivedTags(catalog);
+
+ async function restore(tag: ManageTag) {
+ const rollback = await snapshotManageCache(queryClient);
+ updateManageCache(queryClient, (c) => setTagArchived(c, tag.key, false));
+ try {
+ await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
+ isActive: true,
+ });
+ toast.success(t("common.saved"));
+ } catch (err) {
+ rollback();
+ if (err instanceof ApiError && err.status === 422) {
+ toast.error(t("mood.manage.tagLimitReached"));
+ } else {
+ toast.error(err instanceof ApiError ? err.message : t("common.error"));
+ }
+ } finally {
+ void invalidateMoodTagCaches(queryClient);
+ }
+ }
+
+ async function purge(tag: ManageTag) {
+ const rollback = await snapshotManageCache(queryClient);
+ updateManageCache(queryClient, (c) => removeTag(c, tag.key));
+ try {
+ await apiDelete(
+ `/api/mood/tags/custom/${encodeURIComponent(tag.key)}?purge=true`,
+ );
+ toast.success(t("mood.manage.purged"));
+ } catch (err) {
+ rollback();
+ toast.error(err instanceof ApiError ? err.message : t("common.error"));
+ } finally {
+ void invalidateMoodTagCaches(queryClient);
+ }
+ }
+
+ return (
+
);
})}
+
+ {/* Bootstrap node for the seeded Custom group: before the first
+ custom tag exists the plain read carries no `custom` category,
+ so the inline-create entry point renders synthetically. */}
+ {!hasCustomGroup && (
+