feat(mood): tags get groups, icons and a home of their own

/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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:03:01 +02:00
1 parent 778c3a53a4
commit 058d1d7a69
50 files changed
+6781 -129

No files matched your search

+809
View File
@@ -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:<uuid>` 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:<uuid>` 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:
+9
View File
@@ -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.",
+9
View File
@@ -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.",
+9
View File
@@ -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.",
+9
View File
@@ -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.",
+9
View File
@@ -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.",
+9
View File
@@ -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ę.",
@@ -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:<uuid>` 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;
+20 -1
View File
@@ -145,6 +145,13 @@ model User {
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
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": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
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:<uuid>` 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")
}
+44
View File
@@ -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
@@ -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"));
@@ -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<unknown>) => fn(base),
);
return base;
});
vi.mock("@/lib/db", () => ({
prisma: db,
toJson: <T,>(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<unknown>) => 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();
});
});
@@ -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: <T,>(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<string, string[]> };
};
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<string, string[]> };
};
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<string, string[]> };
};
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();
});
});
+164 -9
View File
@@ -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<string, TagOut> {
@@ -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 } },
}),
);
});
});
+20 -2
View File
@@ -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,
+18 -3
View File
@@ -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:<uuid>` 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
+152
View File
@@ -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 });
},
);
+85
View File
@@ -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:<uuid>` 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,
);
});
+109
View File
@@ -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<string, string[]>;
}> {
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));
});
+158 -72
View File
@@ -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<string, typeof tags>();
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,
},
});
+30 -5
View File
@@ -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")}
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
<div className="flex shrink-0 items-center gap-2">
{/* 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. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
</div>
<ResponsiveSheet
+2
View File
@@ -11,6 +11,7 @@ import { ExportSection } from "@/components/settings/export-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
@@ -53,6 +54,7 @@ const SECTION_COMPONENTS: Record<
dashboard: DashboardSection,
insights: InsightsSection,
medications: MedicationsSection,
mood: MoodSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
@@ -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("<MoodTagIconPicker> — SSR", () => {
function render(value: string | null, locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodTagIconPicker value={value} onChange={() => {}} />
</I18nProvider>,
);
}
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");
});
});
@@ -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:<uuid>` 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(
<I18nProvider initialLocale={locale}>
<MoodTagPicker selected={[]} onToggle={() => {}} />
</I18nProvider>,
);
}
beforeEach(() => {
queryState.catalog = CATALOG;
});
describe("<MoodTagPicker> — 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</span>");
});
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);
});
});
@@ -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> = {}): 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,
);
});
});
@@ -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<ManageTag | null>(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 (
<div className="space-y-3" data-slot="mood-archived-tags-card">
<p className="text-muted-foreground text-xs">
{t("mood.manage.archivedDescription")}
</p>
{archived.length === 0 ? (
<p
className="text-muted-foreground text-sm"
data-slot="mood-archived-empty"
>
{t("mood.manage.archivedEmpty")}
</p>
) : (
<div className="space-y-2">
{archived.map((tag) => {
const Icon = moodTagIcon(tag.icon);
const name = tagDisplayName(tag, t);
return (
<div
key={tag.key}
data-slot="mood-archived-row"
data-tag={tag.key}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span
className="text-foreground/75 min-w-0 flex-1 truncate text-sm"
title={name}
>
{name}
</span>
{typeof tag.usageCount === "number" && tag.usageCount > 0 && (
<Badge variant="secondary" className="tabular-nums">
{t("mood.manage.usageCount", {
count: String(tag.usageCount),
})}
</Badge>
)}
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void restore(tag)}
>
<ArchiveRestore className="h-4 w-4" aria-hidden="true" />
{t("mood.manage.restore")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive size-11 sm:size-9"
onClick={() => setPurgeTag(tag)}
aria-label={`${t("mood.manage.purgeAction")} — ${name}`}
title={t("mood.manage.purgeAction")}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
})}
</div>
)}
<AlertDialog
open={purgeTag !== null}
onOpenChange={(open) => {
if (!open) setPurgeTag(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("mood.manage.purgeTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.manage.purgeBody", {
count: String(purgeTag?.usageCount ?? 0),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const tag = purgeTag;
setPurgeTag(null);
if (tag) void purge(tag);
}}
>
{t("mood.manage.purgeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,236 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MoodTagIconPicker } from "../mood-tag-icon-picker";
import {
invalidateMoodTagCaches,
type ManageCatalog,
type ManageTag,
} from "./use-mood-tag-manage";
/**
* v1.17 — create / edit sheet for a custom mood tag, shared by the
* settings tag manager and the picker's inline "+" tile. Label (40-char
* counter, mirrors the server Zod bound), curated icon picker, group
* select (any seeded category or one of the caller's own groups — the
* widened `categoryKey` contract).
*
* Create POSTs `/api/mood/tags/custom` and inserts the 201 DTO into the
* picker cache immediately (so an inline create lands in the form
* without a refetch round-trip), then invalidates the
* `["mood-tag-catalog"]` prefix. Edit PATCHes `/custom/[key]`
* (label / icon / categoryKey move).
*/
const LABEL_MAX_LENGTH = 40;
export interface TagEditorGroupOption {
key: string;
name: string;
}
export interface TagEditorSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Group choices: every category of the effective tree, display-named. */
groups: TagEditorGroupOption[];
/** Preselected group for create mode (inline "+" tile passes its group). */
initialGroupKey?: string;
/** Present → edit mode for this custom tag. */
tag?: {
key: string;
label: string;
icon: string | null;
groupKey: string;
};
/** Fires with the server DTO after a successful create. */
onCreated?: (tag: ManageTag) => void;
}
/** Append a fresh tag DTO to its group in a cached catalog tree. */
export function insertTagIntoCatalog(
catalog: ManageCatalog,
groupKey: string,
tag: ManageTag,
): ManageCatalog {
let inserted = false;
const categories = catalog.categories.map((category) => {
if (category.key !== groupKey) return category;
inserted = true;
return { ...category, tags: [...category.tags, tag] };
});
// The plain picker read drops empty categories, so a first tag in a
// fresh group has no node to land in — the prefix invalidation that
// follows fetches the tree with the group present.
return inserted ? { categories } : catalog;
}
export function TagEditorSheet({
open,
onOpenChange,
groups,
initialGroupKey,
tag,
onCreated,
}: TagEditorSheetProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const labelInputId = useId();
const groupSelectId = useId();
// Hosts mount this sheet conditionally (`{open && <TagEditorSheet …>}`),
// so the field state seeds fresh from props on every open — no
// re-seeding effect needed.
const isEdit = tag !== undefined;
const [label, setLabel] = useState(tag?.label ?? "");
const [icon, setIcon] = useState<string | null>(tag?.icon ?? "Tag");
const [groupKey, setGroupKey] = useState(
tag?.groupKey ?? initialGroupKey ?? "custom",
);
const [saving, setSaving] = useState(false);
const trimmed = label.trim();
const valid = trimmed.length > 0 && trimmed.length <= LABEL_MAX_LENGTH;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid || saving) return;
setSaving(true);
try {
if (isEdit) {
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
...(trimmed !== tag.label ? { label: trimmed } : {}),
...(icon !== tag.icon ? { icon } : {}),
...(groupKey !== tag.groupKey ? { categoryKey: groupKey } : {}),
});
toast.success(t("common.saved"));
} else {
const created = await apiPost<ManageTag>("/api/mood/tags/custom", {
label: trimmed,
icon,
categoryKey: groupKey,
});
// Land the fresh tag in the picker cache immediately so the
// inline-create flow pre-selects it without a refetch beat.
if (created) {
queryClient.setQueryData<ManageCatalog>(
queryKeys.moodTagCatalog(),
(current) =>
current
? insertTagIntoCatalog(current, groupKey, created)
: current,
);
onCreated?.(created);
}
toast.success(t("common.saved"));
}
onOpenChange(false);
void invalidateMoodTagCaches(queryClient);
} catch (err) {
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 {
setSaving(false);
}
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t("common.edit") : t("mood.manage.newTag")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || !valid}>
{saving && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
data-slot="mood-tag-editor"
>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={labelInputId}>{t("mood.manage.tagLabel")}</Label>
<span
className={`text-xs tabular-nums ${
label.length >= LABEL_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(label.length),
max: String(LABEL_MAX_LENGTH),
})}
</span>
</div>
<Input
id={labelInputId}
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={LABEL_MAX_LENGTH}
autoFocus
autoComplete="off"
autoCapitalize="sentences"
enterKeyHint="done"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label htmlFor={groupSelectId}>{t("mood.manage.tagGroup")}</Label>
<NativeSelect
id={groupSelectId}
value={groupKey}
onChange={(e) => setGroupKey(e.target.value)}
>
{groups.map((group) => (
<option key={group.key} value={group.key}>
{group.name}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-2">
<Label>{t("mood.manage.tagIcon")}</Label>
<MoodTagIconPicker value={icon} onChange={setIcon} />
</div>
</form>
</ResponsiveSheet>
);
}
@@ -0,0 +1,603 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
FolderPlus,
GripVertical,
Loader2,
MoreHorizontal,
Pencil,
Trash2,
type LucideIcon,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
ApiError,
apiDelete,
apiPatch,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "../mood-tag-icons";
import { MoodTagIconPicker } from "../mood-tag-icon-picker";
import {
buildGroupOrder,
categoryDisplayName,
invalidateMoodTagCaches,
reorderGroups,
snapshotManageCache,
updateManageCache,
type ManageCatalog,
} from "./use-mood-tag-manage";
/**
* v1.17 — group management card for `/settings/mood`. Lists every
* group of the effective tree (seeded + own) in picker order and reuses
* the medication-order-editor interaction grammar: a drag handle
* (@dnd-kit, pointer + keyboard sensors) buffers a draft flushed by an
* explicit Save, while the per-row arrow buttons write through
* immediately (one optimistic layout PUT per press). Own groups carry a
* kebab with rename (label + icon) and delete; seeded groups reorder
* only — they are shared reference data.
*
* Group delete is non-destructive by contract: the server re-homes the
* group's custom tags back to the seeded Custom group and catalogue
* placements evaporate to their original category; the confirm dialog
* says exactly that.
*/
const GROUP_LABEL_MAX = 40;
interface TagGroupsCardProps {
catalog: ManageCatalog;
}
/** Arrow move bounded to the list ends (same contract as medications). */
export function moveGroupKey(
keys: readonly string[],
key: string,
delta: -1 | 1,
): string[] {
const idx = keys.indexOf(key);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= keys.length) return [...keys];
return reorderById(
keys.map((id, order) => ({ id, order })),
key,
keys[target],
).map((row) => row.id);
}
export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dragHintId = useId();
const [draftOrder, setDraftOrder] = useState<string[] | null>(null);
const [saving, setSaving] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [deleteKey, setDeleteKey] = useState<string | null>(null);
const serverOrder = buildGroupOrder(catalog);
const order = draftOrder ?? serverOrder;
const byKey = new Map(catalog.categories.map((c) => [c.key, c]));
const dirty = draftOrder !== null;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
async function putGroupOrder(nextOrder: string[]): Promise<boolean> {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, (current) =>
reorderGroups(current, nextOrder),
);
try {
await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder });
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
/** Arrow press: immediate optimistic PUT unless a drag draft is open. */
function moveByArrow(key: string, delta: -1 | 1) {
const next = moveGroupKey(order, key, delta);
if (next.every((v, i) => v === order[i])) return;
if (dirty) {
setDraftOrder(next);
} else {
void putGroupOrder(next);
}
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
setDraftOrder(
reorderById(
order.map((id, idx) => ({ id, order: idx })),
String(active.id),
String(over.id),
).map((row) => row.id),
);
}
async function saveDraft() {
if (!draftOrder) return;
setSaving(true);
const ok = await putGroupOrder(draftOrder);
setSaving(false);
if (ok) setDraftOrder(null);
}
const editing = editingKey ? byKey.get(editingKey) : undefined;
const deleting = deleteKey ? byKey.get(deleteKey) : undefined;
const customGroupCount = catalog.categories.filter((c) => c.custom).length;
return (
<div className="space-y-3" data-slot="mood-tag-groups-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
{t("mood.manage.groupsDescription")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => {
setEditingKey(null);
setEditorOpen(true);
}}
>
<FolderPlus className="h-4 w-4" aria-hidden="true" />
{t("mood.manage.newGroup")}
</Button>
</div>
{customGroupCount === 0 && (
<p
className="text-muted-foreground text-sm"
data-slot="mood-groups-empty"
>
{t("mood.manage.groupEmpty")}
</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{order.map((key, index) => {
const group = byKey.get(key);
if (!group) return null;
return (
<SortableGroupRow
key={key}
groupKey={key}
name={categoryDisplayName(group, t)}
Icon={moodTagIcon(group.icon)}
custom={group.custom === true}
tagCount={group.tags.length}
index={index}
total={order.length}
dragHintId={dragHintId}
disabled={saving}
onMove={moveByArrow}
onEdit={() => {
setEditingKey(key);
setEditorOpen(true);
}}
onDelete={() => setDeleteKey(key)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDraftOrder(null)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("mood.manage.orderSave")}
</Button>
</div>
)}
{editorOpen && (
<GroupEditorSheet
open
onOpenChange={(next) => {
if (!next) setEditorOpen(false);
}}
group={
editing && editing.custom
? {
key: editing.key,
label: categoryDisplayName(editing, t),
icon: editing.icon,
}
: undefined
}
/>
)}
<AlertDialog
open={deleteKey !== null}
onOpenChange={(open) => {
if (!open) setDeleteKey(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("mood.manage.groupDeleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.manage.groupDeleteBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const key = deleting?.key;
setDeleteKey(null);
if (!key) return;
void (async () => {
try {
await apiDelete(
`/api/mood/tags/groups/${encodeURIComponent(key)}`,
);
toast.success(t("mood.manage.groupDeleted"));
} catch (err) {
toast.error(
err instanceof ApiError ? err.message : t("common.error"),
);
} finally {
void invalidateMoodTagCaches(queryClient);
}
})();
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
interface SortableGroupRowProps {
groupKey: string;
name: string;
/** Resolved by the host's map callback (react-hooks/static-components). */
Icon: LucideIcon;
custom: boolean;
tagCount: number;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
onMove: (key: string, delta: -1 | 1) => void;
onEdit: () => void;
onDelete: () => void;
}
function SortableGroupRow({
groupKey,
name,
Icon,
custom,
tagCount,
index,
total,
dragHintId,
disabled,
onMove,
onEdit,
onDelete,
}: SortableGroupRowProps) {
const { t } = useTranslations();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: groupKey });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="mood-group-row"
data-group={groupKey}
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
data-slot="mood-group-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span
className="min-w-0 flex-1 truncate text-sm font-medium"
title={name}
>
{name}
</span>
<Badge variant="outline" className="tabular-nums">
{tagCount}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
{custom && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
interface GroupEditorSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Present → rename mode for this custom group. */
group?: { key: string; label: string; icon: string | null };
}
function GroupEditorSheet({
open,
onOpenChange,
group,
}: GroupEditorSheetProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const labelInputId = useId();
// The host mounts this sheet conditionally, so the field state seeds
// fresh from props on every open — no re-seeding effect needed.
const isEdit = group !== undefined;
const [label, setLabel] = useState(group?.label ?? "");
const [icon, setIcon] = useState<string | null>(group?.icon ?? "Tag");
const [saving, setSaving] = useState(false);
const trimmed = label.trim();
const valid = trimmed.length > 0 && trimmed.length <= GROUP_LABEL_MAX;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid || saving) return;
setSaving(true);
try {
if (isEdit) {
await apiPatch(
`/api/mood/tags/groups/${encodeURIComponent(group.key)}`,
{
...(trimmed !== group.label ? { label: trimmed } : {}),
...(icon !== group.icon ? { icon } : {}),
},
);
toast.success(t("mood.manage.groupRenamed"));
} else {
await apiPost("/api/mood/tags/groups", { label: trimmed, icon });
toast.success(t("mood.manage.groupCreated"));
}
onOpenChange(false);
void invalidateMoodTagCaches(queryClient);
} catch (err) {
if (err instanceof ApiError && err.status === 422) {
toast.error(t("mood.manage.groupLimitReached"));
} else {
toast.error(err instanceof ApiError ? err.message : t("common.error"));
}
} finally {
setSaving(false);
}
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t("common.edit") : t("mood.manage.newGroup")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || !valid}>
{saving && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
data-slot="mood-group-editor"
>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={labelInputId}>{t("mood.manage.groupLabel")}</Label>
<span
className={`text-xs tabular-nums ${
label.length >= GROUP_LABEL_MAX
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(label.length),
max: String(GROUP_LABEL_MAX),
})}
</span>
</div>
<Input
id={labelInputId}
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={GROUP_LABEL_MAX}
autoFocus
autoComplete="off"
autoCapitalize="sentences"
enterKeyHint="done"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label>{t("mood.manage.groupIcon")}</Label>
<MoodTagIconPicker value={icon} onChange={setIcon} />
</div>
</form>
</ResponsiveSheet>
);
}
@@ -0,0 +1,641 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Archive,
ArrowDown,
ArrowUp,
Eye,
EyeOff,
FolderInput,
GripVertical,
Loader2,
MoreHorizontal,
Pencil,
Plus,
type LucideIcon,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "../mood-tag-icons";
import { TagEditorSheet } from "./tag-editor-sheet";
import {
categoryDisplayName,
invalidateMoodTagCaches,
moveTagToGroup,
reorderGroupTags,
setTagArchived,
setTagHidden,
snapshotManageCache,
tagDisplayName,
updateManageCache,
type ManageCatalog,
type ManageCategory,
type ManageTag,
} from "./use-mood-tag-manage";
/**
* v1.17 — per-group tag management for `/settings/mood`. Mirrors the
* picker's grouping; every row carries: icon, label (custom label first,
* i18n key second), a scale badge for RATED factors, the historical
* usage count, an eye toggle (catalogue tag → per-user hide via
* `PUT /[key]/hidden`; custom tag → archive via `PATCH … isActive`),
* and a kebab with edit (custom only), move-to-group, and archive.
*
* Ordering follows the medication-order-editor grammar: drag buffers a
* draft (explicit Save, one layout PUT), arrows write through
* immediately (optimistic, rollback on error). Moving a catalogue tag
* between groups is a per-user PLACEMENT (layout blob) — the shared
* catalogue row itself never changes group; a custom tag moves for real
* (`categoryKey` PATCH) with the placement map updated alongside so the
* resolved order stays deterministic.
*/
interface TagManagerCardProps {
catalog: ManageCatalog;
}
/** The card manages visible (non-archived) tags; archived rows live in the archived card. */
export function visibleCatalog(catalog: ManageCatalog): ManageCatalog {
return {
categories: catalog.categories.map((category) => ({
...category,
tags: category.tags.filter((tag) => tag.archived !== true),
})),
};
}
/** Placement map of the visible tree: every group → its visible tag keys in order. */
export function buildVisiblePlacements(
catalog: ManageCatalog,
): Record<string, string[]> {
const placements: Record<string, string[]> = {};
for (const category of visibleCatalog(catalog).categories) {
placements[category.key] = category.tags.map((tag) => tag.key);
}
return placements;
}
export function TagManagerCard({ catalog }: TagManagerCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dragHintId = useId();
const [drafts, setDrafts] = useState<Record<string, string[]>>({});
const [saving, setSaving] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editorTag, setEditorTag] = useState<
| { key: string; label: string; icon: string | null; groupKey: string }
| undefined
>(undefined);
const visible = visibleCatalog(catalog);
const dirty = Object.keys(drafts).length > 0;
const groupOptions = visible.categories.map((category) => ({
key: category.key,
name: categoryDisplayName(category, t),
}));
function orderedKeys(category: ManageCategory): string[] {
return drafts[category.key] ?? category.tags.map((tag) => tag.key);
}
/** Apply current drafts onto the visible tree, then PUT the placement map. */
async function putPlacements(nextCatalog: ManageCatalog): Promise<boolean> {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog));
try {
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(nextCatalog),
});
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
/** Arrow press: immediate optimistic PUT unless a drag session is open. */
function moveByArrow(groupKey: string, tagKey: string, delta: -1 | 1) {
const category = visible.categories.find((c) => c.key === groupKey);
if (!category) return;
const keys = orderedKeys(category);
const idx = keys.indexOf(tagKey);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= keys.length) return;
const next = reorderById(
keys.map((id, order) => ({ id, order })),
tagKey,
keys[target],
).map((row) => row.id);
if (dirty) {
setDrafts((prev) => ({ ...prev, [groupKey]: next }));
} else {
void putPlacements(reorderGroupTags(visible, groupKey, next));
}
}
function handleDragEnd(groupKey: string, event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const category = visible.categories.find((c) => c.key === groupKey);
if (!category) return;
const keys = orderedKeys(category);
const next = reorderById(
keys.map((id, order) => ({ id, order })),
String(active.id),
String(over.id),
).map((row) => row.id);
setDrafts((prev) => ({ ...prev, [groupKey]: next }));
}
async function saveDrafts() {
setSaving(true);
let next = visible;
for (const [groupKey, keys] of Object.entries(drafts)) {
next = reorderGroupTags(next, groupKey, keys);
}
const ok = await putPlacements(next);
setSaving(false);
if (ok) setDrafts({});
}
/** Eye toggle — catalogue hide/show or custom archive. */
async function toggleVisibility(tag: ManageTag) {
const rollback = await snapshotManageCache(queryClient);
try {
if (tag.custom) {
updateManageCache(queryClient, (c) => setTagArchived(c, tag.key, true));
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
isActive: false,
});
} else {
const nextHidden = tag.hidden !== true;
updateManageCache(queryClient, (c) =>
setTagHidden(c, tag.key, nextHidden),
);
await apiPut(`/api/mood/tags/${encodeURIComponent(tag.key)}/hidden`, {
hidden: nextHidden,
});
}
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
/** Move a tag to another group (custom: relational; catalogue: placement). */
async function moveToGroup(tag: ManageTag, targetGroupKey: string) {
const moved = moveTagToGroup(visible, tag.key, targetGroupKey);
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, moved));
try {
if (tag.custom) {
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
categoryKey: targetGroupKey,
});
}
// Both kinds pin the resulting per-group order in the layout blob
// so the resolved tree matches what the user just saw happen.
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(moved),
});
toast.success(t("common.saved"));
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
return (
<div className="space-y-4" data-slot="mood-tag-manager-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
{t("mood.manage.tagsDescription")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => {
setEditorTag(undefined);
setEditorOpen(true);
}}
>
<Plus className="h-4 w-4" aria-hidden="true" />
{t("mood.manage.newTag")}
</Button>
</div>
{visible.categories.length === 0 && (
<p className="text-muted-foreground text-sm">{t("common.noData")}</p>
)}
{visible.categories.map((category) => {
const keys = orderedKeys(category);
const byKey = new Map(category.tags.map((tag) => [tag.key, tag]));
return (
<TagGroupSection
key={category.key}
heading={categoryDisplayName(category, t)}
HeadingIcon={moodTagIcon(category.icon)}
groupKey={category.key}
keys={keys}
byKey={byKey}
dragHintId={dragHintId}
disabled={saving}
groupOptions={groupOptions}
onDragEnd={handleDragEnd}
onMove={moveByArrow}
onToggleVisibility={(tag) => void toggleVisibility(tag)}
onMoveToGroup={(tag, target) => void moveToGroup(tag, target)}
onEdit={(tag) => {
setEditorTag({
key: tag.key,
label: tagDisplayName(tag, t),
icon: tag.icon,
groupKey: category.key,
});
setEditorOpen(true);
}}
/>
);
})}
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDrafts({})}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={() => void saveDrafts()} disabled={saving}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("mood.manage.orderSave")}
</Button>
</div>
)}
{editorOpen && (
<TagEditorSheet
open
onOpenChange={(next) => {
if (!next) setEditorOpen(false);
}}
groups={groupOptions}
tag={editorTag}
/>
)}
</div>
);
}
/**
* Write an updated VISIBLE tree back over the full manage tree without
* losing the archived rows the visible projection filtered out.
*/
export function mergeBack(
full: ManageCatalog,
nextVisible: ManageCatalog,
): ManageCatalog {
const archivedByGroup = new Map<string, ManageTag[]>();
for (const category of full.categories) {
archivedByGroup.set(
category.key,
category.tags.filter((tag) => tag.archived === true),
);
}
return {
categories: nextVisible.categories.map((category) => ({
...category,
tags: [...category.tags, ...(archivedByGroup.get(category.key) ?? [])],
})),
};
}
interface TagGroupSectionProps {
heading: string;
/** Resolved by the host's map callback (react-hooks/static-components). */
HeadingIcon: LucideIcon;
groupKey: string;
keys: string[];
byKey: ReadonlyMap<string, ManageTag>;
dragHintId: string;
disabled: boolean;
groupOptions: { key: string; name: string }[];
onDragEnd: (groupKey: string, event: DragEndEvent) => void;
onMove: (groupKey: string, tagKey: string, delta: -1 | 1) => void;
onToggleVisibility: (tag: ManageTag) => void;
onMoveToGroup: (tag: ManageTag, targetGroupKey: string) => void;
onEdit: (tag: ManageTag) => void;
}
function TagGroupSection({
heading,
HeadingIcon,
groupKey,
keys,
byKey,
dragHintId,
disabled,
groupOptions,
onDragEnd,
onMove,
onToggleVisibility,
onMoveToGroup,
onEdit,
}: TagGroupSectionProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const rows = keys
.map((key) => byKey.get(key))
.filter((tag): tag is ManageTag => tag !== undefined);
return (
<div
className="space-y-2"
data-slot="mood-tag-manage-group"
data-group={groupKey}
>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<HeadingIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{heading}</span>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => onDragEnd(groupKey, event)}
>
<SortableContext items={keys} strategy={verticalListSortingStrategy}>
{rows.map((tag, index) => (
<SortableTagRow
key={tag.key}
tag={tag}
Icon={moodTagIcon(tag.icon)}
groupKey={groupKey}
index={index}
total={rows.length}
dragHintId={dragHintId}
disabled={disabled}
groupOptions={groupOptions}
onMove={onMove}
onToggleVisibility={onToggleVisibility}
onMoveToGroup={onMoveToGroup}
onEdit={onEdit}
/>
))}
</SortableContext>
</DndContext>
</div>
);
}
interface SortableTagRowProps {
tag: ManageTag;
/** Resolved by the host's map callback (react-hooks/static-components). */
Icon: LucideIcon;
groupKey: string;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
groupOptions: { key: string; name: string }[];
onMove: (groupKey: string, tagKey: string, delta: -1 | 1) => void;
onToggleVisibility: (tag: ManageTag) => void;
onMoveToGroup: (tag: ManageTag, targetGroupKey: string) => void;
onEdit: (tag: ManageTag) => void;
}
function SortableTagRow({
tag,
Icon,
groupKey,
index,
total,
dragHintId,
disabled,
groupOptions,
onMove,
onToggleVisibility,
onMoveToGroup,
onEdit,
}: SortableTagRowProps) {
const { t } = useTranslations();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.key });
const name = tagDisplayName(tag, t);
const isHidden = tag.hidden === true;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="mood-tag-manage-row"
data-tag={tag.key}
data-hidden={isHidden ? "true" : undefined}
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
} ${isHidden ? "opacity-60" : ""}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
data-slot="mood-tag-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={name}>
{name}
</span>
{tag.kind === "RATED" && (
<Badge variant="outline" className="tabular-nums">
{tag.scaleMin}–{tag.scaleMax}
</Badge>
)}
{typeof tag.usageCount === "number" && tag.usageCount > 0 && (
<Badge variant="secondary" className="tabular-nums">
{t("mood.manage.usageCount", { count: String(tag.usageCount) })}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onToggleVisibility(tag)}
disabled={disabled}
aria-pressed={tag.custom ? undefined : isHidden}
aria-label={`${
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
} — ${name}`}
title={
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
}
>
{tag.custom ? (
<Archive className="h-4 w-4" aria-hidden="true" />
) : isHidden ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{tag.custom && (
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="mr-2 h-4 w-4" />
{t("mood.manage.moveToGroup")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{groupOptions
.filter((group) => group.key !== groupKey)
.map((group) => (
<DropdownMenuItem
key={group.key}
onClick={() => onMoveToGroup(tag, group.key)}
>
{group.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{tag.custom && (
<DropdownMenuItem onClick={() => onToggleVisibility(tag)}>
<Archive className="mr-2 h-4 w-4" />
{t("mood.manage.archive")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,279 @@
"use client";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.17 — shared data layer for the mood-tag management surface
* (`/settings/mood`). One catalog read + one layout read feed every
* card; both fire in parallel at section mount so the section never
* waterfalls.
*
* Wire contract (frozen for the v1.17 management suite):
* - `GET /api/mood/tags?include=hidden,archived,usage` — the fully
* resolved per-user Category → Tag tree, including hidden catalogue
* tags (`hidden: true`), the caller's archived custom tags
* (`archived: true`), per-tag historical link counts
* (`usageCount`), the caller's own custom groups (`custom: true`
* on the category) and empty own groups (kept when an `include`
* flag is set, dropped on the plain picker read).
* - `GET /api/mood/tags/layout` — the per-user presentation blob
* (`groupOrder` + `placements`), merged over the seeded defaults
* server-side. Display-only; a PUT is preserve-when-absent.
*
* Cache discipline: the manage read rides under the
* `["mood-tag-catalog"]` prefix (see `queryKeys.moodTagManage`), so a
* single prefix invalidation after any management mutation refreshes
* both this read and the picker's plain read.
*/
export interface ManageTag {
key: string;
labelKey: string;
/** Decrypted custom label; null/absent for catalogue tags. */
label?: string | null;
icon: string | null;
kind: "BINARY" | "RATED";
scaleMin: number;
scaleMax: number;
inverse: boolean;
custom?: boolean;
/** Catalogue tag hidden for this user (`mood_tag_hidden`). */
hidden?: boolean;
/** Custom tag soft-deactivated (history intact). */
archived?: boolean;
/** Historical entry-link count; present on the `usage` include. */
usageCount?: number;
}
export interface ManageCategory {
key: string;
labelKey: string;
/** Decrypted custom group label; null/absent for seeded categories. */
label?: string | null;
icon: string | null;
custom?: boolean;
tags: ManageTag[];
}
export interface ManageCatalog {
categories: ManageCategory[];
}
export interface MoodTagLayout {
groupOrder?: string[];
placements?: Record<string, string[]>;
}
/** Per-user ceilings — mirror the server's 422 caps for friendly copy. */
export const MAX_CUSTOM_TAGS = 50;
export const MAX_CUSTOM_GROUPS = 12;
export function useMoodTagManage(enabled: boolean) {
return useQuery({
queryKey: queryKeys.moodTagManage(),
queryFn: async () =>
apiGet<ManageCatalog>("/api/mood/tags?include=hidden,archived,usage"),
enabled,
});
}
export function useMoodTagLayout(enabled: boolean) {
return useQuery({
queryKey: queryKeys.moodTagLayout(),
queryFn: async () => apiGet<MoodTagLayout>("/api/mood/tags/layout"),
enabled,
});
}
/**
* Invalidate every mood-tag read after a management mutation: the
* `["mood-tag-catalog"]` prefix catches both the picker read and the
* manage read; the layout key rides along because group/order writes
* change the resolved tree AND the blob.
*/
export function invalidateMoodTagCaches(
queryClient: QueryClient,
): Promise<unknown> {
return Promise.allSettled([
queryClient.invalidateQueries({ queryKey: queryKeys.moodTagCatalog() }),
queryClient.invalidateQueries({ queryKey: queryKeys.moodTagLayout() }),
]);
}
/**
* Cancel in-flight catalog fetches and snapshot the manage cache so an
* optimistic update can roll back on error. Returns the restore thunk.
*/
export async function snapshotManageCache(
queryClient: QueryClient,
): Promise<() => void> {
await queryClient.cancelQueries({ queryKey: queryKeys.moodTagCatalog() });
const manage = queryClient.getQueryData<ManageCatalog>(
queryKeys.moodTagManage(),
);
return () => {
queryClient.setQueryData(queryKeys.moodTagManage(), manage);
};
}
/** Apply a pure updater to the manage cache (no-op when not yet loaded). */
export function updateManageCache(
queryClient: QueryClient,
updater: (catalog: ManageCatalog) => ManageCatalog,
): void {
queryClient.setQueryData<ManageCatalog>(
queryKeys.moodTagManage(),
(current) => (current ? updater(current) : current),
);
}
/* ------------------------------------------------------------------ */
/* Pure catalog-tree surgery — exported for unit tests. */
/* ------------------------------------------------------------------ */
/** Display name for a category: custom label first, i18n key second. */
export function categoryDisplayName(
category: Pick<ManageCategory, "label" | "labelKey">,
t: (key: string) => string,
): string {
return category.label ?? t(category.labelKey);
}
/** Display name for a tag: custom label first, i18n key second. */
export function tagDisplayName(
tag: Pick<ManageTag, "label" | "labelKey">,
t: (key: string) => string,
): string {
return tag.label ?? t(tag.labelKey);
}
/** Flip a tag's `hidden` flag in place (catalogue eye-toggle). */
export function setTagHidden(
catalog: ManageCatalog,
tagKey: string,
hidden: boolean,
): ManageCatalog {
return mapTag(catalog, tagKey, (tag) => ({ ...tag, hidden }));
}
/** Flip a custom tag's `archived` flag in place (archive / restore). */
export function setTagArchived(
catalog: ManageCatalog,
tagKey: string,
archived: boolean,
): ManageCatalog {
return mapTag(catalog, tagKey, (tag) => ({ ...tag, archived }));
}
/** Remove a tag from the tree entirely (purge). */
export function removeTag(
catalog: ManageCatalog,
tagKey: string,
): ManageCatalog {
return {
categories: catalog.categories.map((category) => ({
...category,
tags: category.tags.filter((tag) => tag.key !== tagKey),
})),
};
}
/** Move a tag to another group, appended after that group's tags. */
export function moveTagToGroup(
catalog: ManageCatalog,
tagKey: string,
groupKey: string,
): ManageCatalog {
let moved: ManageTag | undefined;
const stripped = catalog.categories.map((category) => {
const hit = category.tags.find((tag) => tag.key === tagKey);
if (hit) moved = hit;
return {
...category,
tags: category.tags.filter((tag) => tag.key !== tagKey),
};
});
if (!moved) return catalog;
return {
categories: stripped.map((category) =>
category.key === groupKey
? { ...category, tags: [...category.tags, moved!] }
: category,
),
};
}
/** Re-order the tags of one group to `orderedKeys` (unknown keys kept last). */
export function reorderGroupTags(
catalog: ManageCatalog,
groupKey: string,
orderedKeys: readonly string[],
): ManageCatalog {
return {
categories: catalog.categories.map((category) => {
if (category.key !== groupKey) return category;
const byKey = new Map(category.tags.map((tag) => [tag.key, tag]));
const placed = orderedKeys
.map((key) => byKey.get(key))
.filter((tag): tag is ManageTag => tag !== undefined);
const placedKeys = new Set(orderedKeys);
const rest = category.tags.filter((tag) => !placedKeys.has(tag.key));
return { ...category, tags: [...placed, ...rest] };
}),
};
}
/** Re-order the groups to `orderedKeys` (unknown keys kept last). */
export function reorderGroups(
catalog: ManageCatalog,
orderedKeys: readonly string[],
): ManageCatalog {
const byKey = new Map(
catalog.categories.map((category) => [category.key, category]),
);
const placed = orderedKeys
.map((key) => byKey.get(key))
.filter((category): category is ManageCategory => category !== undefined);
const placedKeys = new Set(orderedKeys);
const rest = catalog.categories.filter(
(category) => !placedKeys.has(category.key),
);
return { categories: [...placed, ...rest] };
}
/**
* The complete placement map the layout PUT carries: every category →
* its tag keys in current display order. Sending the full map (instead
* of a delta) keeps the stored blob deterministic — the server resolves
* unknown / stale keys by dropping them at read time.
*/
export function buildPlacements(
catalog: ManageCatalog,
): Record<string, string[]> {
const placements: Record<string, string[]> = {};
for (const category of catalog.categories) {
placements[category.key] = category.tags.map((tag) => tag.key);
}
return placements;
}
/** Current group-key order, for the layout PUT's `groupOrder`. */
export function buildGroupOrder(catalog: ManageCatalog): string[] {
return catalog.categories.map((category) => category.key);
}
function mapTag(
catalog: ManageCatalog,
tagKey: string,
fn: (tag: ManageTag) => ManageTag,
): ManageCatalog {
return {
categories: catalog.categories.map((category) => ({
...category,
tags: category.tags.map((tag) => (tag.key === tagKey ? fn(tag) : tag)),
})),
};
}
@@ -0,0 +1,190 @@
"use client";
import { useId, useMemo, useState } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import {
MOOD_TAG_ICON_CATALOG,
type MoodTagIconCatalogEntry,
} from "@/lib/mood/icon-catalog";
import { isMoodTagIconName, moodTagIcon } from "./mood-tag-icons";
/**
* v1.17 — searchable icon picker over the shared curated catalog
* (`src/lib/mood/icon-catalog.ts`: the server half feeds the custom-tag
* icon allowlist, this client half renders the searchable grid). Search
* filters on the icon name plus its English keyword aids; the grid is
* sub-headed by catalog group and behaves as one ARIA radiogroup with a
* roving tab stop, so Arrow keys walk the filtered set and Enter/Space
* picks the focused tile. Tile selection language matches the mood-tag
* tiles (`border-primary bg-primary/15 text-primary`).
*/
/** i18n keys for the catalog's fixed group headers. */
const ICON_GROUP_LABEL_KEYS: Record<string, string> = {
emotions: "mood.manage.iconGroupEmotions",
activities: "mood.manage.iconGroupActivities",
health: "mood.manage.iconGroupHealth",
food: "mood.manage.iconGroupFood",
weather: "mood.manage.iconGroupWeather",
places: "mood.manage.iconGroupPlaces",
misc: "mood.manage.iconGroupMisc",
};
/**
* Pure search filter — name + keyword substring match, case-insensitive.
* Exported for unit tests. Entries the client bundle cannot draw are
* excluded up front so the picker never offers a fallback-glyph tile.
*/
export function filterIconCatalog(
catalog: readonly MoodTagIconCatalogEntry[],
query: string,
): MoodTagIconCatalogEntry[] {
const drawable = catalog.filter((entry) => isMoodTagIconName(entry.name));
const needle = query.trim().toLowerCase();
if (needle.length === 0) return drawable;
return drawable.filter(
(entry) =>
entry.name.toLowerCase().includes(needle) ||
entry.keywords.some((keyword) => keyword.toLowerCase().includes(needle)),
);
}
export interface MoodTagIconPickerProps {
/** Currently selected icon name (null → nothing selected yet). */
value: string | null;
onChange: (iconName: string) => void;
}
export function MoodTagIconPicker({ value, onChange }: MoodTagIconPickerProps) {
const { t } = useTranslations();
const [query, setQuery] = useState("");
const groupLabelId = useId();
const filtered = useMemo(
() => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query),
[query],
);
// One flat roving radiogroup across the (filtered) grid; the group
// sub-headers are presentation only.
const selectedIndex = filtered.findIndex((entry) => entry.name === value);
const { getRadioProps } = useRovingRadioGroup({
count: filtered.length,
selectedIndex,
onSelect: (index) => {
const entry = filtered[index];
if (entry) onChange(entry.name);
},
});
// Group the filtered list while preserving catalog order; remember
// each entry's flat index for the roving wiring.
const groups: {
group: string;
entries: { entry: MoodTagIconCatalogEntry; index: number }[];
}[] = [];
filtered.forEach((entry, index) => {
const last = groups[groups.length - 1];
if (last && last.group === entry.group) {
last.entries.push({ entry, index });
} else {
groups.push({ group: entry.group, entries: [{ entry, index }] });
}
});
return (
<div className="space-y-3" data-slot="mood-tag-icon-picker">
<span id={groupLabelId} className="sr-only">
{t("mood.manage.iconPickerTitle")}
</span>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("mood.manage.iconSearchPlaceholder")}
aria-label={t("mood.manage.iconSearchPlaceholder")}
className="px-9"
autoComplete="off"
autoCapitalize="none"
/>
{query.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setQuery("")}
aria-label={t("mood.manage.iconSearchClear")}
className="absolute top-1/2 right-1 h-8 w-8 -translate-y-1/2"
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
{filtered.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-start gap-2 py-2 text-sm">
<span>{t("mood.manage.iconSearchEmpty")}</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setQuery("")}
>
{t("mood.manage.iconSearchClear")}
</Button>
</div>
) : (
<div
role="radiogroup"
aria-labelledby={groupLabelId}
className="max-h-64 space-y-3 overflow-y-auto pr-1"
>
{groups.map(({ group, entries }) => (
<div key={group} className="space-y-1.5">
<p className="text-muted-foreground text-xs font-medium">
{t(ICON_GROUP_LABEL_KEYS[group] ?? "mood.manage.iconGroupMisc")}
</p>
<div className="flex flex-wrap gap-2">
{entries.map(({ entry, index }) => {
const Icon = moodTagIcon(entry.name);
const isActive = entry.name === value;
return (
<button
key={entry.name}
type="button"
role="radio"
aria-checked={isActive}
aria-label={entry.name}
title={entry.name}
data-slot="mood-icon-tile"
data-icon={entry.name}
onClick={() => onChange(entry.name)}
{...getRadioProps(index)}
className={`focus-visible:ring-ring focus-visible:ring-offset-background flex h-11 w-11 items-center justify-center rounded-xl border transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}
+73
View File
@@ -1,42 +1,70 @@
import {
Activity,
AlertTriangle,
Angry,
Apple,
Baby,
Banknote,
Bath,
Bed,
BedDouble,
Bike,
Book,
BookOpen,
Brain,
Briefcase,
Camera,
CandyOff,
Car,
Cat,
CheckCircle,
Cigarette,
CigaretteOff,
Clock,
Cloud,
CloudMoon,
CloudRain,
CloudSun,
Coffee,
Dog,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
Gift,
GlassWater,
GraduationCap,
HandHeart,
Headphones,
Heart,
HeartPulse,
HelpCircle,
Home,
House,
Laugh,
Leaf,
LogOut,
Meh,
Moon,
MoonStar,
Mountain,
Music,
Palette,
PartyPopper,
Phone,
Pill,
Pizza,
Plane,
ShoppingCart,
SlidersHorizontal,
Smile,
Star,
Stethoscope,
Sun,
Swords,
Syringe,
Tag,
Thermometer,
ThumbsUp,
@@ -55,44 +83,80 @@ import {
* server stays icon-library-agnostic); this map resolves them on the
* client. Mirrors the static-map convention used by the workout / sport
* icon lookups. `Tag` is the fallback for an unmapped name.
*
* v1.17 — extended to a superset of the server's custom-tag icon
* allowlist (`src/lib/mood/icon-catalog.ts`): the allowlist is the
* contract, this map must always cover at least every allowed name so a
* user-picked icon never falls back to the generic glyph on web.
*/
const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
Activity,
AlertTriangle,
Angry,
Apple,
Baby,
Banknote,
Bath,
Bed,
BedDouble,
Bike,
Book,
BookOpen,
Brain,
Briefcase,
Camera,
CandyOff,
Car,
Cat,
CheckCircle,
Cigarette,
CigaretteOff,
Clock,
Cloud,
CloudMoon,
CloudRain,
CloudSun,
Coffee,
Dog,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
Gift,
GlassWater,
GraduationCap,
HandHeart,
Headphones,
Heart,
HeartPulse,
HelpCircle,
Home,
House,
Laugh,
Leaf,
LogOut,
Meh,
Moon,
MoonStar,
Mountain,
Music,
Palette,
PartyPopper,
Phone,
Pill,
Pizza,
Plane,
ShoppingCart,
SlidersHorizontal,
Smile,
Star,
Stethoscope,
Sun,
Swords,
Syringe,
Tag,
Thermometer,
ThumbsUp,
Trees,
@@ -108,6 +172,15 @@ export function moodTagIcon(name: string | null | undefined): LucideIcon {
return MOOD_TAG_ICONS[name] ?? Tag;
}
/**
* v1.17 — every icon name this client can resolve to a real glyph.
* The searchable icon picker filters the shared catalog against this
* set so a catalog entry the bundle can't draw is never offered.
*/
export function isMoodTagIconName(name: string): boolean {
return name in MOOD_TAG_ICONS;
}
/**
* v1.12.0 — face icon per mood enum for the "How are you?" 5-face hero.
*
+104 -5
View File
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plus, Tag } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "./mood-tag-icons";
import { TagEditorSheet } from "./manage/tag-editor-sheet";
import { apiGet } from "@/lib/api/api-fetch";
/**
@@ -27,22 +30,36 @@ import { apiGet } from "@/lib/api/api-fetch";
* Additive next to the legacy free-text tag input: a user can use
* either or both. Renders nothing if the catalog is empty so a
* deployment that cleared the taxonomy degrades to the free-text field.
*
* v1.17 — three management-suite additions: (1) a custom tag renders
* its decrypted `label` (the v1.13 API field the web picker previously
* ignored — a custom tag used to paint its raw key through the `t()`
* fallback); (2) a trailing ghost "+" tile per group opens the shared
* create sheet with that group preselected, and the fresh tag is
* selected in the form right away; (3) the render order is exactly the
* server-resolved per-user order — no client-side sorting.
*/
interface CatalogTag {
key: string;
labelKey: string;
/** Decrypted custom label (v1.13+); null/absent for catalogue tags. */
label?: string | null;
icon: string | null;
kind: "BINARY" | "RATED";
scaleMin: number;
scaleMax: number;
inverse: boolean;
custom?: boolean;
}
interface CatalogCategory {
key: string;
labelKey: string;
/** Decrypted custom group label; null/absent for seeded categories. */
label?: string | null;
icon: string | null;
custom?: boolean;
tags: CatalogTag[];
}
@@ -75,6 +92,9 @@ export function MoodTagPicker({
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
// v1.17 — inline-create sheet state: the group key the tapped "+"
// tile belongs to, or null when closed.
const [createGroupKey, setCreateGroupKey] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
@@ -97,6 +117,16 @@ export function MoodTagPicker({
const selectedSet = new Set(selected);
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
const groupOptions = data.categories.map((category) => ({
key: category.key,
name: category.label ?? t(category.labelKey),
}));
// The plain read drops empty categories, so before the first custom
// tag exists there is no Custom node to hang the "+" tile on —
// synthesize the bootstrap group so inline creation is always
// reachable.
const hasCustomGroup = data.categories.some((c) => c.key === "custom");
return (
<div className="space-y-4" data-slot="mood-tag-picker">
{data.categories.map((category) => {
@@ -114,15 +144,17 @@ export function MoodTagPicker({
<div key={category.key} className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<CategoryIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t(category.labelKey)}</span>
<span>{category.label ?? t(category.labelKey)}</span>
</div>
{binaryTags.length > 0 && (
{
<div className="flex flex-wrap gap-2">
{binaryTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
// v1.17 — a custom tag carries its decrypted label;
// catalogue tags keep resolving their i18n key.
const label = tag.label ?? t(tag.labelKey);
return (
<button
key={tag.key}
@@ -141,14 +173,18 @@ export function MoodTagPicker({
</button>
);
})}
<AddTagTile
label={t("mood.addTagInline")}
onClick={() => setCreateGroupKey(category.key)}
/>
</div>
)}
}
{ratedTags.length > 0 && onRateFactor && (
<div className="space-y-2" data-slot="mood-factor-ratings">
{ratedTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const label = t(tag.labelKey);
const label = tag.label ?? t(tag.labelKey);
const current = ratingByKey.get(tag.key);
const isBinaryScale = tag.scaleMax - tag.scaleMin === 1;
const steps: number[] = [];
@@ -223,6 +259,69 @@ export function MoodTagPicker({
</div>
);
})}
{/* 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 && (
<div className="space-y-2" data-slot="mood-tag-custom-bootstrap">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("mood.tagCategory.custom")}</span>
</div>
<div className="flex flex-wrap gap-2">
<AddTagTile
label={t("mood.addTagInline")}
onClick={() => setCreateGroupKey("custom")}
/>
</div>
</div>
)}
{createGroupKey !== null && (
<TagEditorSheet
open
onOpenChange={(open) => {
if (!open) setCreateGroupKey(null);
}}
groups={
hasCustomGroup
? groupOptions
: [
...groupOptions,
{ key: "custom", name: t("mood.tagCategory.custom") },
]
}
initialGroupKey={createGroupKey}
onCreated={(created) => onToggle(created.key)}
/>
)}
</div>
);
}
/**
* v1.17 — trailing ghost "+" tile: same footprint as a tag tile,
* dashed border, opens the inline create sheet for its group.
*/
function AddTagTile({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
data-slot="mood-tag-add-tile"
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background flex min-h-16 w-[4.5rem] flex-col items-center justify-center gap-1 rounded-xl border border-dashed p-2 text-center transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Plus className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] leading-tight">{label}</span>
</button>
);
}
@@ -0,0 +1,252 @@
/**
* v1.17 — the "Stimmungs-Tags" settings section (`/settings/mood`).
*
* SSR smoke assertions matching the rest of the settings suite: the
* three management cards (groups / tags / archived) render from one
* mocked manage read, custom labels paint decrypted (never the raw
* key), hidden catalogue rows dim, archived custom tags land in the
* archived card with restore + purge affordances, and both locales
* resolve end-to-end. The interactive contracts (optimistic flips,
* reorder PUTs) ride the pure helpers pinned in
* `src/components/mood/manage/__tests__/catalog-helpers.test.ts`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ManageCatalog } from "@/components/mood/manage/use-mood-tag-manage";
const FIXTURE: ManageCatalog = {
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,
hidden: false,
usageCount: 12,
},
{
key: "stressed",
labelKey: "mood.tag.stressed",
label: null,
icon: "AlertTriangle",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: true,
custom: false,
hidden: true,
usageCount: 0,
},
],
},
{
key: "customcat:g1",
labelKey: "customcat:g1",
label: "Garten",
icon: "Leaf",
custom: true,
tags: [
{
key: "custom:1",
labelKey: "custom:1",
label: "Gartenarbeit",
icon: "Leaf",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: true,
archived: false,
usageCount: 3,
},
{
key: "custom:2",
labelKey: "custom:2",
label: "Altes Tag",
icon: "Tag",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: true,
archived: true,
usageCount: 5,
},
],
},
],
};
const queryState: { catalog: ManageCatalog | null; loading: boolean } = {
catalog: FIXTURE,
loading: false,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (
Array.isArray(queryKey) &&
queryKey[0] === "mood-tag-catalog" &&
queryKey[1] === "manage"
) {
return { data: queryState.catalog, isLoading: queryState.loading };
}
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("@/lib/charts/reduced-motion", () => ({
prefersReducedMotion: () => true,
}));
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 { MoodSection } from "../mood-section";
function render(locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodSection />
</I18nProvider>,
);
}
beforeEach(() => {
queryState.catalog = FIXTURE;
queryState.loading = false;
});
describe("<MoodSection> — SSR smoke", () => {
it("renders the section heading + description via i18n (no raw keys)", () => {
const html = render();
expect(html).toContain("settings-section-mood-title");
expect(html).toContain("Groups, tags, and order of the mood picker.");
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("mood.manage.");
});
it("renders the German copy end-to-end", () => {
const html = render("de");
expect(html).toContain("Gruppen, Tags und Reihenfolge der Tag-Auswahl.");
expect(html).toContain("Neue Gruppe");
expect(html).toContain("Neuer Tag");
expect(html).toContain("Archivierte Tags");
});
it("hosts the three management cards from ONE manage read", () => {
const html = render();
expect(html).toContain('data-slot="mood-tag-groups-card"');
expect(html).toContain('data-slot="mood-tag-manager-card"');
expect(html).toContain('data-slot="mood-archived-tags-card"');
});
it("lists every group — seeded by i18n key, custom by decrypted label", () => {
const html = render();
expect(html).toContain("Feelings");
expect(html).toContain("Garten");
// The custom group's raw key must never paint as text (it remains
// present as a `data-group` hook for the reorder wiring).
expect(html).not.toContain("&gt;customcat:g1&lt;");
expect(html).not.toContain(">customcat:g1<");
});
it("seeded groups reorder only — the kebab is reserved for own groups", () => {
const html = render();
const groupRows = html.match(/data-slot="mood-group-row"/g);
expect(groupRows?.length ?? 0).toBe(2);
// One kebab per CUSTOM group row; the seeded row carries none.
const kebabs = html.match(/More options — Garten/g);
expect(kebabs?.length ?? 0).toBeGreaterThanOrEqual(1);
expect(html).not.toContain("More options — Feelings");
});
it("renders custom tags by decrypted label, never the raw key", () => {
const html = render();
expect(html).toContain("Gartenarbeit");
expect(html).not.toContain("custom:1</");
});
it("dims a hidden catalogue row and labels its show-toggle", () => {
const html = render();
expect(html).toMatch(
/data-slot="mood-tag-manage-row"[^>]*data-hidden="true"/,
);
expect(html).toContain("Show tag — stressed");
});
it("surfaces the usage count badge", () => {
const html = render();
expect(html).toContain("12 entries");
});
it("archived card lists the archived custom tag with restore + purge", () => {
const html = render();
expect(html).toContain('data-slot="mood-archived-row"');
expect(html).toContain("Altes Tag");
expect(html).toContain("Restore");
expect(html).toContain("Delete permanently — Altes Tag");
// The active custom tag must NOT appear in the archived card.
const archivedCard = html.slice(
html.indexOf('data-slot="mood-archived-tags-card"'),
);
expect(archivedCard).not.toContain("Gartenarbeit");
});
it("archived wording makes clear history stays (German)", () => {
const html = render("de");
expect(html).toContain(
"Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten",
);
});
it("shows the groups empty-state when the user owns no groups", () => {
queryState.catalog = {
categories: FIXTURE.categories.filter((c) => !c.custom),
};
const html = render();
expect(html).toContain('data-slot="mood-groups-empty"');
expect(html).toContain("No own groups yet.");
});
it("shows the archived empty-state when nothing is archived", () => {
queryState.catalog = {
categories: FIXTURE.categories.map((c) => ({
...c,
tags: c.tags.filter((t) => t.archived !== true),
})),
};
const html = render();
expect(html).toContain('data-slot="mood-archived-empty"');
expect(html).toContain("Nothing archived.");
});
});
@@ -53,6 +53,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.16.10 — `medications` sits between `insights` and `thresholds`:
// the view-preference + manual-order customise surface for
// `/medications`, reached from the page header's Settings2 glyph.
// v1.17 — `mood` sits between `medications` and `thresholds`: the
// mood-tag management surface (groups, custom tags, hide/archive,
// picker order), reached from the /mood page header's wrench glyph.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
@@ -60,6 +63,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"dashboard",
"insights",
"medications",
"mood",
"thresholds",
"sources",
"ai",
@@ -157,6 +161,10 @@ describe("<SettingsShell>", () => {
// between Insights and Targets.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medications");
// v1.17 — the mood-tag management surface sits between Medications
// and Targets.
expect(html).toContain('href="/settings/mood"');
expect(html).toContain("Mood tags");
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
@@ -192,6 +200,9 @@ describe("<SettingsShell>", () => {
// v1.16.10 — the /medications customise surface ("Medikamente").
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medikamente");
// v1.17 — the mood-tag management surface ("Stimmungs-Tags").
expect(html).toContain('href="/settings/mood"');
expect(html).toContain("Stimmungs-Tags");
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { Archive, Folders, Loader2, Tags } from "lucide-react";
import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card";
import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card";
import { TagManagerCard } from "@/components/mood/manage/tag-manager-card";
import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.17 — the "Stimmungs-Tags" settings section (`/settings/mood`),
* following the medications-section card pattern. Hosts the whole
* mood-tag management surface in three blocks:
* 1. Groups — create / rename / delete own groups, drag + arrow
* reorder of the picker's group order.
* 2. Tags — per-group tag list mirroring the picker: hide/show
* catalogue tags, edit / move / archive custom tags, drag + arrow
* reorder, create.
* 3. Archived — archived custom tags with restore and the explicit
* hard-delete path.
*
* One management read (`useMoodTagManage`) feeds all three cards — the
* section never waterfalls; every mutation invalidates the
* `["mood-tag-catalog"]` prefix so the mood form's picker repaints in
* the same tick.
*/
export function MoodSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: catalog, isLoading } = useMoodTagManage(isAuthenticated);
const loading = (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
</div>
);
return (
<section
aria-labelledby="settings-section-mood-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-mood-title" className="sr-only">
{t("settings.sections.mood.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.mood.description")}
</p>
</header>
{/* Groups — picker group order + own groups. */}
<div
id="mood-groups"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Folders className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("mood.manage.groupsTitle")}
</h2>
</div>
{isLoading || !catalog ? loading : <TagGroupsCard catalog={catalog} />}
</div>
{/* Tags — visibility, order, edit, move, create. */}
<div
id="mood-tags"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Tags className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("mood.manage.tagsTitle")}
</h2>
</div>
{isLoading || !catalog ? loading : <TagManagerCard catalog={catalog} />}
</div>
{/* Archived custom tags — restore / hard delete. */}
<div
id="mood-archived"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Archive className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("mood.manage.archivedTitle")}
</h2>
</div>
{isLoading || !catalog ? (
loading
) : (
<ArchivedTagsCard catalog={catalog} />
)}
</div>
</section>
);
}
@@ -22,6 +22,7 @@ import {
Pill,
Settings2,
SlidersHorizontal,
Smile,
Sparkles,
TrendingUp,
User,
@@ -39,6 +40,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
dashboard: LayoutDashboard,
insights: TrendingUp,
medications: Pill,
mood: Smile,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
+4
View File
@@ -17,6 +17,9 @@
// for the /medications list (view preference + manual order), following
// the dashboard / insights pattern of a dedicated settings section
// reached from the page header's Settings2 glyph.
// v1.17 — `mood` sits after `medications`: the mood-tag management
// surface (groups, custom tags, hide/archive, picker order), reached
// from the /mood page header's wrench glyph.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
@@ -24,6 +27,7 @@ export const SETTINGS_SECTION_SLUGS = [
"dashboard",
"insights",
"medications",
"mood",
"thresholds",
"sources",
"ai",
@@ -28,6 +28,7 @@ import {
Pill,
Settings2,
SlidersHorizontal,
Smile,
Sparkles,
TrendingUp,
User,
@@ -106,6 +107,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.medications.title",
icon: Pill,
},
{
slug: "mood",
titleKey: "settings.sections.mood.title",
icon: Smile,
},
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
@@ -11,13 +11,20 @@ vi.mock("@/lib/crypto", () => ({
import {
isCustomTagKey,
mintCustomTagKey,
isCustomCategoryKey,
mintCustomCategoryKey,
encryptCustomLabel,
decryptCustomLabel,
createCustomTagSchema,
updateCustomTagSchema,
createCustomGroupSchema,
updateCustomGroupSchema,
hideCatalogueTagSchema,
CUSTOM_TAG_KEY_PREFIX,
CUSTOM_CATEGORY_KEY_PREFIX,
CUSTOM_TAG_ICON_ALLOWLIST,
} from "@/lib/mood/custom-tags";
import { MOOD_TAG_ICON_CATALOG } from "@/lib/mood/icon-catalog";
import { resolveTagKeysToIds } from "@/lib/mood/tag-links";
describe("custom-tag helpers", () => {
@@ -53,6 +60,81 @@ describe("custom-tag schemas", () => {
expect(hideCatalogueTagSchema.safeParse({ hidden: true }).success).toBe(true);
expect(hideCatalogueTagSchema.safeParse({ hidden: "yes" }).success).toBe(false);
});
it("accepts a categoryKey on create + update (v1.17.0) within bounds", () => {
expect(
createCustomTagSchema.safeParse({ label: "x", categoryKey: "feelings" })
.success,
).toBe(true);
expect(
createCustomTagSchema.safeParse({
label: "x",
categoryKey: `customcat:${"a".repeat(36)}`,
}).success,
).toBe(true);
expect(
createCustomTagSchema.safeParse({
label: "x",
categoryKey: "k".repeat(81),
}).success,
).toBe(false);
// A lone categoryKey satisfies the at-least-one-field update refine.
expect(
updateCustomTagSchema.safeParse({ categoryKey: "custom" }).success,
).toBe(true);
});
});
describe("custom-group helpers + schemas (v1.17.0)", () => {
it("mints prefixed, unique group keys and recognises them", () => {
const a = mintCustomCategoryKey();
const b = mintCustomCategoryKey();
expect(a.startsWith(CUSTOM_CATEGORY_KEY_PREFIX)).toBe(true);
expect(a).not.toBe(b);
expect(isCustomCategoryKey(a)).toBe(true);
expect(isCustomCategoryKey("feelings")).toBe(false);
// A custom TAG key is not a custom GROUP key and vice versa.
expect(isCustomCategoryKey(mintCustomTagKey())).toBe(false);
expect(isCustomTagKey(a)).toBe(false);
});
it("validates group create / update bodies like the tag schemas", () => {
expect(createCustomGroupSchema.safeParse({ label: "Hobbies 2" }).success).toBe(true);
expect(
createCustomGroupSchema.safeParse({ label: "x", icon: "Stethoscope" }).success,
).toBe(true);
expect(
createCustomGroupSchema.safeParse({ label: "x", icon: "NotAnIcon" }).success,
).toBe(false);
expect(createCustomGroupSchema.safeParse({ label: "" }).success).toBe(false);
expect(
createCustomGroupSchema.safeParse({ label: "a".repeat(41) }).success,
).toBe(false);
expect(updateCustomGroupSchema.safeParse({}).success).toBe(false);
expect(updateCustomGroupSchema.safeParse({ isActive: false }).success).toBe(true);
});
});
describe("icon catalog → allowlist seam (v1.17.0)", () => {
it("derives the allowlist from the catalog and keeps the pre-v1.17 names", () => {
expect(CUSTOM_TAG_ICON_ALLOWLIST).toEqual(
MOOD_TAG_ICON_CATALOG.map((e) => e.name),
);
// The v1.13 22-name allowlist must survive — icons already stored on
// rows keep validating.
const legacy = [
"Tag", "Heart", "Smile", "Frown", "Dumbbell", "Moon", "Sun", "Wine",
"Coffee", "House", "Briefcase", "Book", "Music", "Plane", "Car",
"Users", "Pill", "Activity", "Brain", "Cloud", "Star", "Zap",
];
for (const name of legacy) {
expect(CUSTOM_TAG_ICON_ALLOWLIST).toContain(name);
}
// No duplicate names in the catalog (the picker keys on them).
expect(new Set(CUSTOM_TAG_ICON_ALLOWLIST).size).toBe(
CUSTOM_TAG_ICON_ALLOWLIST.length,
);
});
});
describe("resolveTagKeysToIds — custom-tag ownership scoping (v1.13.0)", () => {
+180
View File
@@ -0,0 +1,180 @@
/**
* v1.17.0 — layout-blob schema bounds, preserve-when-absent merge, group
* stripping, and the read-time placement resolution (unknown-key drops,
* duplicate claims, home-category fallback ordering).
*/
import { describe, expect, it } from "vitest";
import {
moodTagLayoutSchema,
parseStoredMoodTagLayout,
mergeMoodTagLayout,
stripGroupFromLayout,
resolveGroupOrder,
resolveMoodTagPlacement,
MOOD_TAG_LAYOUT_MAX_GROUPS,
MOOD_TAG_LAYOUT_MAX_PLACEMENTS,
} from "@/lib/mood/tag-layout";
describe("moodTagLayoutSchema bounds", () => {
it("accepts the empty blob and partial blobs", () => {
expect(moodTagLayoutSchema.safeParse({}).success).toBe(true);
expect(
moodTagLayoutSchema.safeParse({ groupOrder: ["feelings"] }).success,
).toBe(true);
expect(
moodTagLayoutSchema.safeParse({ placements: { feelings: ["happy"] } })
.success,
).toBe(true);
});
it("bounds groupOrder length, placement totals, and key length", () => {
expect(
moodTagLayoutSchema.safeParse({
groupOrder: Array.from(
{ length: MOOD_TAG_LAYOUT_MAX_GROUPS + 1 },
(_, i) => `g${i}`,
),
}).success,
).toBe(false);
expect(
moodTagLayoutSchema.safeParse({
placements: { g1: Array.from({ length: MOOD_TAG_LAYOUT_MAX_PLACEMENTS + 1 }, (_, i) => `t${i}`) },
}).success,
).toBe(false);
expect(
moodTagLayoutSchema.safeParse({ groupOrder: ["k".repeat(81)] }).success,
).toBe(false);
expect(
moodTagLayoutSchema.safeParse({ placements: { g: [""] } }).success,
).toBe(false);
});
});
describe("parseStoredMoodTagLayout", () => {
it("degrades a malformed / legacy blob to the empty layout", () => {
expect(parseStoredMoodTagLayout(null)).toEqual({});
expect(parseStoredMoodTagLayout(undefined)).toEqual({});
expect(parseStoredMoodTagLayout("garbage")).toEqual({});
expect(parseStoredMoodTagLayout({ groupOrder: "not-an-array" })).toEqual({});
expect(parseStoredMoodTagLayout({ groupOrder: ["a"] })).toEqual({
groupOrder: ["a"],
});
});
});
describe("mergeMoodTagLayout — preserve-when-absent", () => {
const stored = { groupOrder: ["a"], placements: { a: ["t1"] } };
it("keeps stored placements on a groupOrder-only PUT and vice versa", () => {
expect(mergeMoodTagLayout(stored, { groupOrder: ["b"] })).toEqual({
groupOrder: ["b"],
placements: { a: ["t1"] },
});
expect(mergeMoodTagLayout(stored, { placements: {} })).toEqual({
groupOrder: ["a"],
placements: {},
});
expect(mergeMoodTagLayout({}, { groupOrder: ["x"] })).toEqual({
groupOrder: ["x"],
});
});
});
describe("stripGroupFromLayout", () => {
it("drops the group from order + its placement bucket, keeps the rest", () => {
const layout = {
groupOrder: ["a", "customcat:x", "b"],
placements: { "customcat:x": ["t1"], b: ["t2"] },
};
expect(stripGroupFromLayout(layout, "customcat:x")).toEqual({
groupOrder: ["a", "b"],
placements: { b: ["t2"] },
});
// Absent fields stay absent (a strip never materialises a blob).
expect(stripGroupFromLayout({}, "customcat:x")).toEqual({});
});
});
describe("resolveGroupOrder", () => {
it("layout order first (unknown dropped, dupes collapsed), missing appended in seeded order", () => {
expect(
resolveGroupOrder(
["feelings", "sleep", "custom"],
["custom", "ghost", "custom", "feelings"],
),
).toEqual(["custom", "feelings", "sleep"]);
expect(resolveGroupOrder(["a", "b"], undefined)).toEqual(["a", "b"]);
});
});
describe("resolveMoodTagPlacement", () => {
const categoryKeys = ["feelings", "custom", "customcat:g1"];
const tags = [
{ key: "happy", homeCategoryKey: "feelings" },
{ key: "sad", homeCategoryKey: "feelings" },
{ key: "custom:a", homeCategoryKey: "custom" },
{ key: "custom:b", homeCategoryKey: "custom" },
];
it("renders placed tags at their slot and un-placed ones after, in sortOrder", () => {
const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({
categoryKeysInSeededOrder: categoryKeys,
tags,
layout: {
groupOrder: ["customcat:g1", "feelings"],
placements: { "customcat:g1": ["sad", "custom:b"] },
},
});
expect(orderedCategoryKeys).toEqual(["customcat:g1", "feelings", "custom"]);
expect(tagKeysByCategory.get("customcat:g1")).toEqual(["sad", "custom:b"]);
expect(tagKeysByCategory.get("feelings")).toEqual(["happy"]);
expect(tagKeysByCategory.get("custom")).toEqual(["custom:a"]);
});
it("drops placements referencing unknown tags or deleted groups", () => {
const { tagKeysByCategory } = resolveMoodTagPlacement({
categoryKeysInSeededOrder: categoryKeys,
tags,
layout: {
placements: {
"customcat:g1": ["ghost-tag", "happy"],
"customcat:deleted": ["sad"],
},
},
});
expect(tagKeysByCategory.get("customcat:g1")).toEqual(["happy"]);
// `sad` was placed into an unknown group → falls back to its home.
expect(tagKeysByCategory.get("feelings")).toEqual(["sad"]);
});
it("resolves a duplicate claim to the first group in display order", () => {
const { tagKeysByCategory } = resolveMoodTagPlacement({
categoryKeysInSeededOrder: categoryKeys,
tags,
layout: {
placements: {
feelings: ["custom:a"],
"customcat:g1": ["custom:a"],
},
},
});
expect(tagKeysByCategory.get("feelings")).toEqual([
"custom:a",
"happy",
"sad",
]);
expect(tagKeysByCategory.get("customcat:g1")).toEqual([]);
});
it("returns the seeded tree untouched for the empty layout", () => {
const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({
categoryKeysInSeededOrder: categoryKeys,
tags,
layout: {},
});
expect(orderedCategoryKeys).toEqual(categoryKeys);
expect(tagKeysByCategory.get("feelings")).toEqual(["happy", "sad"]);
expect(tagKeysByCategory.get("custom")).toEqual(["custom:a", "custom:b"]);
});
});
+60 -32
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { z } from "zod/v4";
import { encrypt, decrypt } from "@/lib/crypto";
import { MOOD_TAG_ICON_NAMES } from "@/lib/mood/icon-catalog";
/**
* v1.13.0 — per-user custom mood-tag helpers (iOS v1.13.0 contract).
@@ -26,36 +27,21 @@ export const CUSTOM_CATEGORY_ID = "mtc_custom";
/** Per-user ceiling on custom tags (422 over the cap). */
export const MAX_CUSTOM_TAGS_PER_USER = 50;
/** v1.17.0 — reserved key prefix for a user-minted custom group. */
export const CUSTOM_CATEGORY_KEY_PREFIX = "customcat:";
/** v1.17.0 — per-user ceiling on custom groups (422 over the cap). */
export const MAX_CUSTOM_GROUPS_PER_USER = 12;
/**
* Allow-list of Lucide icon names a custom tag may use. Kept to names the iOS
* client maps to an SF Symbol (`MoodTagSFSymbol`) so a custom tag never falls
* back to the generic glyph on one platform; iOS extends its map to cover the
* final list. An unknown name → 422. `Tag` is the default.
* Allow-list of Lucide icon names a custom tag or group may use — derived
* from the curated catalog in `icon-catalog.ts` (the shared server/client
* seam). Every name has an iOS SF-Symbol mapping (`MoodTagSFSymbol`) so a
* custom tag never falls back to the generic glyph on one platform; iOS
* extends its map before the catalog grows. An unknown name → 422. `Tag`
* is the default.
*/
export const CUSTOM_TAG_ICON_ALLOWLIST = [
"Tag",
"Heart",
"Smile",
"Frown",
"Dumbbell",
"Moon",
"Sun",
"Wine",
"Coffee",
"House",
"Briefcase",
"Book",
"Music",
"Plane",
"Car",
"Users",
"Pill",
"Activity",
"Brain",
"Cloud",
"Star",
"Zap",
] as const;
export const CUSTOM_TAG_ICON_ALLOWLIST: readonly string[] = MOOD_TAG_ICON_NAMES;
const ICON_SET = new Set<string>(CUSTOM_TAG_ICON_ALLOWLIST);
@@ -69,6 +55,16 @@ export function mintCustomTagKey(): string {
return `${CUSTOM_TAG_KEY_PREFIX}${randomUUID()}`;
}
/** v1.17.0 — is `key` a custom-group key (vs a seeded category slug)? */
export function isCustomCategoryKey(key: string): boolean {
return key.startsWith(CUSTOM_CATEGORY_KEY_PREFIX);
}
/** v1.17.0 — mint a fresh, collision-proof custom-group key. */
export function mintCustomCategoryKey(): string {
return `${CUSTOM_CATEGORY_KEY_PREFIX}${randomUUID()}`;
}
/** Encrypt a custom label for storage in `label_encrypted`. */
export function encryptCustomLabel(label: string): string {
return encrypt(label);
@@ -98,13 +94,20 @@ const iconSchema = z
.refine((v) => ICON_SET.has(v), "Unknown icon")
.nullish();
/**
* v1.17.0 — a category key on the wire: a seeded slug (`feelings`, `custom`,
* …) or a user's own `customcat:<uuid>` group key. The handler resolves it
* against the seeded set OR the caller's own groups; anything else → 422.
*/
const categoryKeySchema = z.string().trim().min(1).max(80);
/** POST body — create a custom tag. */
export const createCustomTagSchema = z.object({
label: labelSchema,
icon: iconSchema,
// Reserved for a future per-tag category choice; v1 pins everything to the
// `custom` category, so a supplied value other than `custom` is rejected.
categoryKey: z.literal(CUSTOM_CATEGORY_KEY).optional(),
// v1.17.0 — home group for the new tag. Omitted → the seeded `custom`
// category (the v1.13.0 behaviour).
categoryKey: categoryKeySchema.optional(),
});
/** PATCH body — update a custom tag (every field optional). */
@@ -113,9 +116,34 @@ export const updateCustomTagSchema = z
label: labelSchema.optional(),
icon: iconSchema,
isActive: z.boolean().optional(),
// v1.17.0 — move the tag to another group (real `categoryId` move).
categoryKey: categoryKeySchema.optional(),
})
.refine(
(v) => v.label !== undefined || v.icon !== undefined || v.isActive !== undefined,
(v) =>
v.label !== undefined ||
v.icon !== undefined ||
v.isActive !== undefined ||
v.categoryKey !== undefined,
"At least one field is required",
);
/** v1.17.0 — POST body — create a custom group. */
export const createCustomGroupSchema = z.object({
label: labelSchema,
icon: iconSchema,
});
/** v1.17.0 — PATCH body — update a custom group (every field optional). */
export const updateCustomGroupSchema = z
.object({
label: labelSchema.optional(),
icon: iconSchema,
isActive: z.boolean().optional(),
})
.refine(
(v) =>
v.label !== undefined || v.icon !== undefined || v.isActive !== undefined,
"At least one field is required",
);
+136
View File
@@ -0,0 +1,136 @@
/**
* v1.17.0 — curated mood-tag icon catalog (the shared server/client seam).
*
* One module, two consumers:
* - the server derives `CUSTOM_TAG_ICON_ALLOWLIST` (custom-tags.ts) from
* the `name` column, so an icon a tag or group may store is exactly an
* entry of this catalog;
* - the client renders the searchable icon picker from the full rows
* (keywords are English-only search aids, not i18n keys; the visible
* label under each icon is the name itself).
*
* Constraints:
* - every `name` is a Lucide icon name the iOS client maps to an SF
* Symbol (`MoodTagSFSymbol`) — extend the iOS map before extending
* this list (see the ios-coord note for the published set);
* - the catalog is a strict superset of the pre-v1.17 22-name allowlist
* AND of the 44-name client map in `mood-tag-icons.ts`, so every icon
* already stored on a row keeps resolving. Never remove a name.
*
* No `lucide-react` import here — the server stays icon-library-agnostic
* (it stores and validates names only).
*/
export type MoodTagIconGroup =
| "emotions"
| "activities"
| "health"
| "food"
| "weather"
| "places"
| "misc";
export interface MoodTagIconCatalogEntry {
/** Lucide icon name — the value stored on `mood_tags.icon` / `mood_tag_categories.icon`. */
name: string;
/** English search aids for the picker's filter input. */
keywords: string[];
/** Picker sub-header bucket. */
group: MoodTagIconGroup;
}
export const MOOD_TAG_ICON_CATALOG: readonly MoodTagIconCatalogEntry[] = [
// ── emotions ─────────────────────────────────────────────────────────
{ name: "Smile", keywords: ["happy", "good", "content"], group: "emotions" },
{ name: "Laugh", keywords: ["joy", "fun", "laughter"], group: "emotions" },
{ name: "Meh", keywords: ["neutral", "okay", "indifferent"], group: "emotions" },
{ name: "Frown", keywords: ["sad", "down", "unhappy"], group: "emotions" },
{ name: "Angry", keywords: ["mad", "anger", "frustration"], group: "emotions" },
{ name: "Heart", keywords: ["love", "romance", "date"], group: "emotions" },
{ name: "HandHeart", keywords: ["kindness", "gratitude", "care"], group: "emotions" },
{ name: "PartyPopper", keywords: ["party", "celebration", "birthday"], group: "emotions" },
{ name: "ThumbsUp", keywords: ["good", "win", "approve"], group: "emotions" },
{ name: "CheckCircle", keywords: ["done", "achievement", "complete"], group: "emotions" },
{ name: "AlertTriangle", keywords: ["stress", "warning", "alert"], group: "emotions" },
{ name: "HelpCircle", keywords: ["uncertainty", "confusion", "question"], group: "emotions" },
{ name: "Swords", keywords: ["conflict", "argument", "fight"], group: "emotions" },
{ name: "Flame", keywords: ["energy", "motivation", "streak"], group: "emotions" },
{ name: "Zap", keywords: ["energy", "electric", "boost"], group: "emotions" },
{ name: "Star", keywords: ["favorite", "special", "highlight"], group: "emotions" },
{ name: "Gift", keywords: ["present", "surprise", "birthday"], group: "emotions" },
// ── activities ───────────────────────────────────────────────────────
{ name: "Dumbbell", keywords: ["gym", "workout", "strength"], group: "activities" },
{ name: "Activity", keywords: ["exercise", "pulse", "movement"], group: "activities" },
{ name: "Footprints", keywords: ["walk", "steps", "stroll"], group: "activities" },
{ name: "Bike", keywords: ["cycling", "bicycle", "ride"], group: "activities" },
{ name: "Music", keywords: ["concert", "song", "instrument"], group: "activities" },
{ name: "Headphones", keywords: ["podcast", "listening", "audio"], group: "activities" },
{ name: "Gamepad2", keywords: ["gaming", "videogame", "play"], group: "activities" },
{ name: "Film", keywords: ["movie", "cinema", "series"], group: "activities" },
{ name: "BookOpen", keywords: ["reading", "book", "novel"], group: "activities" },
{ name: "Book", keywords: ["journal", "study", "diary"], group: "activities" },
{ name: "Briefcase", keywords: ["work", "office", "job"], group: "activities" },
{ name: "GraduationCap", keywords: ["school", "study", "learning"], group: "activities" },
{ name: "Palette", keywords: ["art", "creative", "painting"], group: "activities" },
{ name: "Camera", keywords: ["photo", "photography", "picture"], group: "activities" },
{ name: "Trees", keywords: ["nature", "outdoors", "forest"], group: "activities" },
{ name: "Mountain", keywords: ["hike", "climbing", "summit"], group: "activities" },
{ name: "Plane", keywords: ["travel", "flight", "vacation"], group: "activities" },
{ name: "Car", keywords: ["drive", "commute", "road"], group: "activities" },
{ name: "ShoppingCart", keywords: ["shopping", "groceries", "errands"], group: "activities" },
{ name: "Phone", keywords: ["call", "telephone", "contact"], group: "activities" },
{ name: "Users", keywords: ["friends", "social", "family"], group: "activities" },
{ name: "User", keywords: ["alone", "solo", "me"], group: "activities" },
{ name: "LogOut", keywords: ["out", "leave", "going out"], group: "activities" },
{ name: "Banknote", keywords: ["money", "finance", "spending"], group: "activities" },
{ name: "Clock", keywords: ["time", "late", "schedule"], group: "activities" },
{ name: "SlidersHorizontal", keywords: ["adjust", "factor", "balance"], group: "activities" },
// ── health ───────────────────────────────────────────────────────────
{ name: "Pill", keywords: ["medication", "medicine", "dose"], group: "health" },
{ name: "Stethoscope", keywords: ["doctor", "appointment", "checkup"], group: "health" },
{ name: "Syringe", keywords: ["injection", "vaccine", "shot"], group: "health" },
{ name: "Thermometer", keywords: ["fever", "sick", "temperature"], group: "health" },
{ name: "HeartPulse", keywords: ["heart rate", "cardio", "pulse"], group: "health" },
{ name: "Brain", keywords: ["mental", "focus", "therapy"], group: "health" },
{ name: "Bath", keywords: ["bath", "relax", "selfcare"], group: "health" },
{ name: "Bed", keywords: ["rest", "nap", "lie down"], group: "health" },
{ name: "BedDouble", keywords: ["sleep", "bedtime", "night"], group: "health" },
{ name: "Moon", keywords: ["night", "sleep", "dark"], group: "health" },
{ name: "MoonStar", keywords: ["deep sleep", "night", "dream"], group: "health" },
{ name: "Cigarette", keywords: ["smoking", "tobacco", "nicotine"], group: "health" },
{ name: "CigaretteOff", keywords: ["no smoking", "quit", "smoke-free"], group: "health" },
{ name: "Baby", keywords: ["baby", "kids", "parenting"], group: "health" },
// ── food ─────────────────────────────────────────────────────────────
{ name: "Apple", keywords: ["fruit", "healthy", "snack"], group: "food" },
{ name: "Pizza", keywords: ["fastfood", "junk", "takeout"], group: "food" },
{ name: "UtensilsCrossed", keywords: ["meal", "dinner", "restaurant"], group: "food" },
{ name: "Coffee", keywords: ["caffeine", "espresso", "morning"], group: "food" },
{ name: "Wine", keywords: ["alcohol", "drinks", "glass"], group: "food" },
{ name: "GlassWater", keywords: ["water", "hydration", "drink"], group: "food" },
{ name: "CandyOff", keywords: ["no sugar", "diet", "sweets"], group: "food" },
// ── weather ──────────────────────────────────────────────────────────
{ name: "Sun", keywords: ["sunny", "warm", "bright"], group: "weather" },
{ name: "CloudSun", keywords: ["partly cloudy", "mild", "mixed"], group: "weather" },
{ name: "Cloud", keywords: ["overcast", "grey", "cloudy"], group: "weather" },
{ name: "CloudRain", keywords: ["rain", "wet", "storm"], group: "weather" },
{ name: "CloudMoon", keywords: ["clear night", "evening", "dusk"], group: "weather" },
{ name: "Leaf", keywords: ["autumn", "plant", "season"], group: "weather" },
// ── places ───────────────────────────────────────────────────────────
{ name: "House", keywords: ["home", "house", "indoors"], group: "places" },
{ name: "Home", keywords: ["home", "house", "indoors"], group: "places" },
// ── misc ─────────────────────────────────────────────────────────────
{ name: "Tag", keywords: ["default", "generic", "label"], group: "misc" },
{ name: "Cat", keywords: ["cat", "pet", "animal"], group: "misc" },
{ name: "Dog", keywords: ["dog", "pet", "animal"], group: "misc" },
] as const;
/** Flat name list — the server-side allowlist source. */
export const MOOD_TAG_ICON_NAMES: readonly string[] = MOOD_TAG_ICON_CATALOG.map(
(entry) => entry.name,
);
+27
View File
@@ -0,0 +1,27 @@
import { prisma } from "@/lib/db";
/**
* v1.17.0 — group resolution for the custom-tag write paths.
*
* A `categoryKey` on the wire is either a seeded category slug
* (`feelings`, `custom`, …) or the caller's own `customcat:<uuid>` group
* key. Resolution is owner-scoped by construction: another user's group —
* or an inactive / unknown key — resolves to null, which the handlers
* surface as a 422. This is the structural guard that keeps a custom tag's
* `categoryId` pointing only at shared reference data or the owner's own
* groups (and is what makes group deletion's re-home step sufficient).
*/
export async function resolveCategoryKeyForUser(
categoryKey: string,
userId: string,
): Promise<string | null> {
const category = await prisma.moodTagCategory.findFirst({
where: {
key: categoryKey,
isActive: true,
OR: [{ userId: null }, { userId }],
},
select: { id: true },
});
return category?.id ?? null;
}
+222
View File
@@ -0,0 +1,222 @@
import { z } from "zod/v4";
/**
* v1.17.0 — per-user mood-tag layout blob (`User.moodTagLayoutJson`).
*
* Display-only presentation state, byte-for-byte the
* `medicationListLayoutJson` posture: the blob never carries authority over
* data (a placement referencing a hidden / archived / unknown tag or a
* deleted group is silently dropped at read time), PUT merges
* preserve-when-absent, and unknown keys are tolerated rather than rejected
* so a stale client can never wedge the read.
*
* Shape:
* groupOrder — category keys in display order; unknown dropped, missing
* appended in seeded `sortOrder`.
* placements — categoryKey → ordered tag keys ("what lives where, in
* what order"). A tag key appearing in some `placements[g]`
* renders in group `g` at that index; un-placed tags render
* in their home category after placed ones, in `sortOrder`.
* This is how a CATALOGUE tag "moves" into a user's group —
* its `categoryId` never changes.
*/
/** Upper bound on a single category/tag key on the wire. */
export const MOOD_TAG_LAYOUT_KEY_MAX_LENGTH = 80;
/** Upper bound on `groupOrder` entries (and `placements` record keys). */
export const MOOD_TAG_LAYOUT_MAX_GROUPS = 50;
/** Upper bound on placement tag keys summed across every group. */
export const MOOD_TAG_LAYOUT_MAX_PLACEMENTS = 400;
const layoutKeySchema = z
.string()
.min(1)
.max(MOOD_TAG_LAYOUT_KEY_MAX_LENGTH);
/**
* PUT body / stored-blob schema. Both fields optional so a client can PUT
* exactly the field it changed (preserve-when-absent merge in the handler).
*/
export const moodTagLayoutSchema = z
.object({
groupOrder: z
.array(layoutKeySchema)
.max(MOOD_TAG_LAYOUT_MAX_GROUPS)
.optional(),
placements: z
.record(layoutKeySchema, z.array(layoutKeySchema))
.optional(),
})
.superRefine((value, ctx) => {
if (!value.placements) return;
const groupCount = Object.keys(value.placements).length;
if (groupCount > MOOD_TAG_LAYOUT_MAX_GROUPS) {
ctx.addIssue({
code: "custom",
path: ["placements"],
message: `At most ${MOOD_TAG_LAYOUT_MAX_GROUPS} placement groups`,
});
}
const total = Object.values(value.placements).reduce(
(sum, keys) => sum + keys.length,
0,
);
if (total > MOOD_TAG_LAYOUT_MAX_PLACEMENTS) {
ctx.addIssue({
code: "custom",
path: ["placements"],
message: `At most ${MOOD_TAG_LAYOUT_MAX_PLACEMENTS} placement entries`,
});
}
});
export interface MoodTagLayout {
groupOrder?: string[];
placements?: Record<string, string[]>;
}
/**
* Parse the stored JSON column into a layout. A malformed / legacy blob
* degrades to the empty layout (seeded defaults) rather than failing the
* read — the blob is presentation, never data.
*/
export function parseStoredMoodTagLayout(value: unknown): MoodTagLayout {
const parsed = moodTagLayoutSchema.safeParse(value);
if (!parsed.success) return {};
return {
...(parsed.data.groupOrder !== undefined
? { groupOrder: parsed.data.groupOrder }
: {}),
...(parsed.data.placements !== undefined
? { placements: parsed.data.placements }
: {}),
};
}
/**
* Preserve-when-absent merge: a PUT carrying only `groupOrder` must not wipe
* the stored `placements` and vice versa (the medications-layout contract).
*/
export function mergeMoodTagLayout(
stored: MoodTagLayout,
incoming: MoodTagLayout,
): MoodTagLayout {
return {
...(incoming.groupOrder !== undefined
? { groupOrder: incoming.groupOrder }
: stored.groupOrder !== undefined
? { groupOrder: stored.groupOrder }
: {}),
...(incoming.placements !== undefined
? { placements: incoming.placements }
: stored.placements !== undefined
? { placements: stored.placements }
: {}),
};
}
/**
* Drop every reference to a (deleted) group from the layout: its
* `groupOrder` entry and its `placements` bucket. Placements of OTHER
* groups are untouched — a tag placed elsewhere keeps its slot.
*/
export function stripGroupFromLayout(
layout: MoodTagLayout,
groupKey: string,
): MoodTagLayout {
const next: MoodTagLayout = {};
if (layout.groupOrder !== undefined) {
next.groupOrder = layout.groupOrder.filter((k) => k !== groupKey);
}
if (layout.placements !== undefined) {
const rest = { ...layout.placements };
delete rest[groupKey];
next.placements = rest;
}
return next;
}
/**
* Resolve the display order of category keys: layout order first (unknown
* keys dropped, duplicates collapsed), then every known key the layout does
* not mention, in the seeded order the caller passed.
*/
export function resolveGroupOrder(
categoryKeysInSeededOrder: string[],
groupOrder: string[] | undefined,
): string[] {
const known = new Set(categoryKeysInSeededOrder);
const ordered: string[] = [];
const seen = new Set<string>();
for (const key of groupOrder ?? []) {
if (!known.has(key) || seen.has(key)) continue;
seen.add(key);
ordered.push(key);
}
for (const key of categoryKeysInSeededOrder) {
if (seen.has(key)) continue;
seen.add(key);
ordered.push(key);
}
return ordered;
}
export interface ResolvedMoodTagPlacement {
/** Category keys in final display order. */
orderedCategoryKeys: string[];
/** Tag keys per category key, placed-first then home-category remainder. */
tagKeysByCategory: Map<string, string[]>;
}
/**
* Apply the full layout to the visible tag set.
*
* `tags` must arrive in global `sortOrder` (the un-placed fallback order)
* and contain only the tags the response will render — placements
* referencing anything else (hidden, archived, unknown, another user's)
* are dropped here by construction. A tag key claimed by two groups keeps
* its first claim (group-order wins).
*/
export function resolveMoodTagPlacement(args: {
categoryKeysInSeededOrder: string[];
tags: Array<{ key: string; homeCategoryKey: string }>;
layout: MoodTagLayout;
}): ResolvedMoodTagPlacement {
const { categoryKeysInSeededOrder, tags, layout } = args;
const orderedCategoryKeys = resolveGroupOrder(
categoryKeysInSeededOrder,
layout.groupOrder,
);
const visibleTagKeys = new Set(tags.map((t) => t.key));
const tagKeysByCategory = new Map<string, string[]>(
orderedCategoryKeys.map((key) => [key, []]),
);
// 1. Explicit placements, walked in group display order so a duplicate
// claim resolves deterministically.
const placed = new Set<string>();
for (const categoryKey of orderedCategoryKeys) {
const placement = layout.placements?.[categoryKey];
if (!placement) continue;
const bucket = tagKeysByCategory.get(categoryKey);
if (!bucket) continue;
for (const tagKey of placement) {
if (!visibleTagKeys.has(tagKey) || placed.has(tagKey)) continue;
placed.add(tagKey);
bucket.push(tagKey);
}
}
// 2. Un-placed tags fall back to their home category, after the placed
// ones, keeping global sortOrder. A tag whose home category is not in
// the response (inactive) drops — same posture as the v1.13 read.
for (const tag of tags) {
if (placed.has(tag.key)) continue;
const bucket = tagKeysByCategory.get(tag.homeCategoryKey);
if (!bucket) continue;
bucket.push(tag.key);
}
return { orderedCategoryKeys, tagKeysByCategory };
}
+2
View File
@@ -31,6 +31,7 @@ import { insightsPaths } from "./insights";
import { measurementPaths } from "./measurements";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
import { profilePaths } from "./profile";
import { syncPaths } from "./sync";
import { workoutPaths } from "./workouts";
@@ -51,6 +52,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...coachFeedbackPaths,
...adminDiagnosticPaths,
...insightsPaths,
...moodPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+436
View File
@@ -0,0 +1,436 @@
/**
* OpenAPI route table — mood-tag taxonomy management (catalog read, custom
* tags, custom groups, per-user layout, catalogue hide).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Request schemas come from `src/lib/mood/{custom-tags,tag-layout}.ts`
* where they are shared with the runtime request parsing, so the wire
* contract stays single-source. Response DTOs are declared here mirroring
* the route handlers under `src/app/api/mood/tags/`.
*
* v1.13.0 shipped the custom-tag CRUD + hide surface outside the YAML;
* v1.17.0 adds groups + layout and registers the WHOLE surface so the iOS
* client gets a locked contract.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
createCustomTagSchema,
updateCustomTagSchema,
createCustomGroupSchema,
updateCustomGroupSchema,
hideCatalogueTagSchema,
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// ── Request schemas — annotated for spec emission ────────────────────
createCustomTagSchema.meta({
id: "CreateMoodTagRequest",
description:
"Create a per-user custom mood tag (BINARY only). `label` is encrypted at rest. `icon` must come from the curated allowlist (unknown name → 422). `categoryKey` picks the home group — any seeded category key or one of the caller's own `customcat:` group keys; omitted → the seeded `custom` category. Capped at 50 active custom tags per user (422).",
});
updateCustomTagSchema.meta({
id: "UpdateMoodTagRequest",
description:
"Partial custom-tag edit; at least one field required. `isActive:false` archives (history intact), `isActive:true` restores. `categoryKey` moves the tag to another group (a real categoryId move).",
});
createCustomGroupSchema.meta({
id: "CreateMoodTagGroupRequest",
description:
"Create a per-user custom mood-tag group. `label` is encrypted at rest; `icon` must come from the curated allowlist. Capped at 12 active custom groups per user (422).",
});
updateCustomGroupSchema.meta({
id: "UpdateMoodTagGroupRequest",
description:
"Partial custom-group edit; at least one field required. `isActive:false` retires the group without touching its tags.",
});
hideCatalogueTagSchema.meta({
id: "HideMoodTagRequest",
description:
"Hide / show a CATALOGUE tag for the calling user. Custom tags are hidden via their own `isActive` flag on the custom PATCH instead (this route 400s a `custom:` key).",
});
moodTagLayoutSchema.meta({
id: "MoodTagLayout",
description:
"Per-user mood-tag presentation blob. `groupOrder`: category keys in display order (unknown dropped, missing appended in seeded order). `placements`: categoryKey → ordered tag keys; a placed tag renders in that group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent.",
});
// ── Response DTOs ─────────────────────────────────────────────────────
const moodTagDto = z
.object({
key: z.string(),
labelKey: z.string().nullable(),
label: z.string().nullable(),
icon: z.string().nullable(),
kind: z.enum(["BINARY", "RATED"]),
scaleMin: z.number().int(),
scaleMax: z.number().int(),
inverse: z.boolean(),
custom: z.boolean(),
hidden: z
.boolean()
.optional()
.describe(
"Catalogue tags only, present when `include` contains `hidden`.",
),
archived: z
.boolean()
.optional()
.describe("Custom tags only, present when `include` contains `archived`."),
usageCount: z
.number()
.int()
.optional()
.describe(
"Live-entry link count for this user, present when `include` contains `usage`.",
),
})
.meta({
id: "MoodTagDTO",
description:
"One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale.",
});
const moodTagCategoryDto = z
.object({
key: z.string(),
labelKey: z.string().nullable(),
label: z.string().nullable(),
icon: z.string().nullable(),
custom: z.boolean(),
tags: z.array(moodTagDto),
})
.meta({
id: "MoodTagCategoryDTO",
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`.",
});
const moodTagTreeResponse = z.object({
categories: z.array(moodTagCategoryDto),
});
const moodCustomTagResponse = z.object({
key: z.string(),
labelKey: z.null(),
label: z.string().nullable(),
icon: z.string().nullable(),
kind: z.enum(["BINARY", "RATED"]),
scaleMin: z.number().int(),
scaleMax: z.number().int(),
inverse: z.boolean(),
custom: z.literal(true),
});
const moodCustomTagPatchResponse = moodCustomTagResponse.extend({
isActive: z.boolean(),
});
const moodTagGroupResponse = z.object({
key: z.string(),
labelKey: z.null(),
label: z.string().nullable(),
icon: z.string().nullable(),
custom: z.literal(true),
});
const moodTagGroupPatchResponse = moodTagGroupResponse.extend({
isActive: z.boolean(),
});
const moodTagLayoutResolved = z
.object({
groupOrder: z.array(z.string()),
placements: z.record(z.string(), z.array(z.string())),
})
.meta({
id: "MoodTagLayoutResolved",
description:
"The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category set, `placements` as stored.",
});
const notFoundResponse = {
"404": {
description: "Not found / not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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.",
requestParams: {
query: z.object({
include: z
.string()
.optional()
.describe("Comma list of `hidden`, `archived`, `usage`."),
}),
},
responses: {
"200": {
description: "The effective tag tree.",
content: {
"application/json": {
schema: dataEnvelope(moodTagTreeResponse, "MoodTagTreeEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/mood/tags/custom": {
post: {
tags: ["Mood"],
summary: "Create a custom mood tag (v1.13.0 / v1.17.0)",
description:
"Mints a `custom:<uuid>` 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: createCustomTagSchema } },
},
responses: {
"201": {
description: "Custom tag created.",
content: {
"application/json": {
schema: dataEnvelope(
moodCustomTagResponse,
"MoodCustomTagCreatedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/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`.",
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomTagSchema } },
},
responses: {
"200": {
description: "Custom tag updated.",
content: {
"application/json": {
schema: dataEnvelope(
moodCustomTagPatchResponse,
"MoodCustomTagPatchEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
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.",
requestParams: {
path: z.object({ key: z.string() }),
query: z.object({ purge: z.enum(["true", "false"]).optional() }),
},
responses: {
"200": {
description: "Archived (or purged).",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ key: z.string(), purged: z.boolean() }),
"MoodCustomTagDeleteEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
},
"/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`.",
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: hideCatalogueTagSchema } },
},
responses: {
"200": {
description: "Visibility updated.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ key: z.string(), hidden: z.boolean() }),
"MoodTagHiddenEnvelope",
),
},
},
},
"400": {
description: "Custom-tag key — use the custom PATCH instead.",
content: { "application/json": { schema: errorEnvelope } },
},
...notFoundResponse,
...stdResponses,
},
},
},
"/api/mood/tags/groups": {
post: {
tags: ["Mood"],
summary: "Create a custom mood-tag group (v1.17.0)",
description:
"Mints a `customcat:<uuid>` 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: createCustomGroupSchema } },
},
responses: {
"201": {
description: "Custom group created.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagGroupResponse,
"MoodTagGroupCreatedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/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`.",
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomGroupSchema } },
},
responses: {
"200": {
description: "Custom group updated.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagGroupPatchResponse,
"MoodTagGroupPatchEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
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.",
requestParams: {
path: z.object({ key: z.string() }),
query: z.object({ purge: z.enum(["true", "false"]).optional() }),
},
responses: {
"200": {
description: "Group deleted; `rehomedCount` custom tags moved.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
key: z.string(),
purged: z.boolean(),
rehomedCount: z.number().int(),
}),
"MoodTagGroupDeleteEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
},
"/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: dataEnvelope(
moodTagLayoutResolved,
"MoodTagLayoutGetEnvelope",
),
},
},
},
...stdResponses,
},
},
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: moodTagLayoutSchema } },
},
responses: {
"200": {
description: "Merged + resolved layout.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagLayoutResolved,
"MoodTagLayoutPutEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
+17
View File
@@ -48,4 +48,21 @@ export const moodKeys = {
* `staleTime` is fine.
*/
moodTagCatalog: () => ["mood-tag-catalog"] as const,
/**
* v1.17 — the tag-MANAGEMENT read (`/api/mood/tags?include=
* hidden,archived,usage`). Rides under the `["mood-tag-catalog"]`
* prefix so one `invalidateQueries({ queryKey: ["mood-tag-catalog"] })`
* after any management mutation refreshes both this read and the
* picker read above (same trick as `moodEntriesList` under
* `["mood-entries"]`). The scope segment keeps the two queryFn shapes
* on different keys — same-key/different-shape silently poisons the
* cache.
*/
moodTagManage: () => ["mood-tag-catalog", "manage"] as const,
/**
* v1.17 — the per-user picker layout blob (`/api/mood/tags/layout`):
* group order + per-group tag placement. Its own root key — layout
* writes invalidate it alongside the catalog prefix.
*/
moodTagLayout: () => ["mood-tag-layout"] as const,
};
+322
View File
@@ -0,0 +1,322 @@
/**
* v1.17.0 — custom mood-tag groups + per-user layout against real Postgres.
*
* Pins the group lifecycle the unit mocks can't:
* - group create (encrypted label round-trip via the effective read),
* cap 422, cross-user 404 on PATCH/DELETE
* - custom-tag create into an own group via `categoryKey` (and the 422 on
* a foreign group key)
* - group delete: own tags re-home to the seeded `custom` category, the
* layout blob drops the group, default = retire vs `?purge=true` =
* hard delete of the emptied row
* - layout PUT preserve-when-absent + the resolved GET tree honouring
* group order and a catalogue-tag placement
* - `include=archived` surfacing an archived custom tag
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_A = "user-mtg-a";
const USER_B = "user-mtg-b";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function createUserSession(userId: string, username: string) {
await getPrismaClient().user.create({
data: {
id: userId,
username,
email: `${username}@example.test`,
timezone: "Europe/Berlin",
},
});
return getPrismaClient().session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
}
async function actAs(userId: string, username: string) {
const session = await createUserSession(userId, username);
cookieJar.set("healthlog_session", session.id);
}
function jsonReq(url: string, method: string, body: unknown): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const params = (key: string) => ({ params: Promise.resolve({ key }) });
async function createGroup(label: string, icon?: string): Promise<string> {
const { POST } = await import("@/app/api/mood/tags/groups/route");
const res = await POST(
jsonReq("http://localhost/api/mood/tags/groups", "POST", { label, icon }),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { key: string } };
return body.data.key;
}
async function createTag(
label: string,
categoryKey?: string,
): Promise<string> {
const { POST } = await import("@/app/api/mood/tags/custom/route");
const res = await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
label,
...(categoryKey ? { categoryKey } : {}),
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { key: string } };
return body.data.key;
}
interface TreeCategory {
key: string;
label: string | null;
custom: boolean;
tags: Array<{ key: string; label: string | null; archived?: boolean }>;
}
async function readTree(include?: string): Promise<TreeCategory[]> {
const { GET } = await import("@/app/api/mood/tags/route");
const res = await GET(
new NextRequest(
`http://localhost/api/mood/tags${include ? `?include=${include}` : ""}`,
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { categories: TreeCategory[] } };
return body.data.categories;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
});
describe("custom mood-tag groups (real Postgres)", () => {
it("creates a group, stores the label encrypted, and resolves it on the management read", async () => {
await actAs(USER_A, "mtg-a");
const groupKey = await createGroup("Therapie", "Stethoscope");
expect(groupKey.startsWith("customcat:")).toBe(true);
const row = await getPrismaClient().moodTagCategory.findUnique({
where: { key: groupKey },
});
expect(row?.userId).toBe(USER_A);
expect(row?.labelEncrypted).not.toBeNull();
expect(row?.labelEncrypted).not.toContain("Therapie");
// Empty group surfaces only on a management read.
const plain = await readTree();
expect(plain.find((c) => c.key === groupKey)).toBeUndefined();
const manage = await readTree("hidden,archived");
expect(manage.find((c) => c.key === groupKey)).toMatchObject({
custom: true,
label: "Therapie",
tags: [],
});
});
it("422s over the 12-group cap", async () => {
await actAs(USER_A, "mtg-a");
for (let i = 0; i < 12; i++) await createGroup(`G${i}`);
const { POST } = await import("@/app/api/mood/tags/groups/route");
const res = await POST(
jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "Nope" }),
);
expect(res.status).toBe(422);
});
it("404s a cross-user PATCH / DELETE and a 422 on a foreign categoryKey", async () => {
await actAs(USER_A, "mtg-a");
const groupKey = await createGroup("Privat");
await actAs(USER_B, "mtg-b");
const { PATCH, DELETE } = await import(
"@/app/api/mood/tags/groups/[key]/route"
);
const patch = await PATCH(
jsonReq(`http://localhost/api/mood/tags/groups/${groupKey}`, "PATCH", {
label: "Hijack",
}),
params(groupKey),
);
expect(patch.status).toBe(404);
const del = await DELETE(
new NextRequest(`http://localhost/api/mood/tags/groups/${groupKey}`),
params(groupKey),
);
expect(del.status).toBe(404);
// B cannot create a tag inside A's group either.
const { POST } = await import("@/app/api/mood/tags/custom/route");
const tag = await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
label: "X",
categoryKey: groupKey,
}),
);
expect(tag.status).toBe(422);
});
it("group delete re-homes own tags, strips the layout, retires by default and purges on demand", async () => {
await actAs(USER_A, "mtg-a");
const groupKey = await createGroup("Hobbys 2");
const tagKey = await createTag("Bouldern", groupKey);
// Seed a layout that references the group.
const { PUT: putLayout } = await import(
"@/app/api/mood/tags/layout/route"
);
const layoutRes = await putLayout(
jsonReq("http://localhost/api/mood/tags/layout", "PUT", {
groupOrder: [groupKey, "feelings"],
placements: { [groupKey]: ["happy"] },
}),
);
expect(layoutRes.status).toBe(200);
const { DELETE } = await import("@/app/api/mood/tags/groups/[key]/route");
const res = await DELETE(
new NextRequest(`http://localhost/api/mood/tags/groups/${groupKey}`),
params(groupKey),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { purged: boolean; rehomedCount: number };
};
expect(body.data).toMatchObject({ purged: false, rehomedCount: 1 });
// The tag survived, re-homed to the seeded custom category.
const tagRow = await getPrismaClient().moodTag.findUnique({
where: { key: tagKey },
});
expect(tagRow?.categoryId).toBe("mtc_custom");
// The group row is retired, not gone.
const groupRow = await getPrismaClient().moodTagCategory.findUnique({
where: { key: groupKey },
});
expect(groupRow?.isActive).toBe(false);
// The layout no longer references the group.
const userRow = await getPrismaClient().user.findUnique({
where: { id: USER_A },
select: { moodTagLayoutJson: true },
});
expect(JSON.stringify(userRow?.moodTagLayoutJson)).not.toContain(groupKey);
// Purge a second (empty) group → the row is gone.
const group2 = await createGroup("Weg damit");
const purge = await DELETE(
new NextRequest(
`http://localhost/api/mood/tags/groups/${group2}?purge=true`,
),
params(group2),
);
expect(purge.status).toBe(200);
expect(
await getPrismaClient().moodTagCategory.findUnique({
where: { key: group2 },
}),
).toBeNull();
});
it("layout PUT merges preserve-when-absent and the GET tree honours order + placement", async () => {
await actAs(USER_A, "mtg-a");
const groupKey = await createGroup("Sport");
await createTag("Klettern", groupKey);
const { PUT: putLayout, GET: getLayout } = await import(
"@/app/api/mood/tags/layout/route"
);
// 1st PUT: placements only.
await putLayout(
jsonReq("http://localhost/api/mood/tags/layout", "PUT", {
placements: { [groupKey]: ["happy"] },
}),
);
// 2nd PUT: groupOrder only — must keep the stored placements.
await putLayout(
jsonReq("http://localhost/api/mood/tags/layout", "PUT", {
groupOrder: [groupKey],
}),
);
const layoutRes = await getLayout();
const layout = (await layoutRes.json()) as {
data: { groupOrder: string[]; placements: Record<string, string[]> };
};
expect(layout.data.groupOrder[0]).toBe(groupKey);
expect(layout.data.placements).toEqual({ [groupKey]: ["happy"] });
// The effective tree leads with the group and carries the placed
// catalogue tag inside it (placement, not a categoryId change).
const tree = await readTree();
expect(tree[0].key).toBe(groupKey);
expect(tree[0].tags.map((t) => t.key)).toContain("happy");
const happyRow = await getPrismaClient().moodTag.findUnique({
where: { key: "happy" },
select: { category: { select: { key: true } } },
});
expect(happyRow?.category.key).not.toBe(groupKey);
});
it("include=archived surfaces an archived custom tag for restore", async () => {
await actAs(USER_A, "mtg-a");
const tagKey = await createTag("Alt");
const { DELETE } = await import("@/app/api/mood/tags/custom/[key]/route");
const res = await DELETE(
new NextRequest(`http://localhost/api/mood/tags/custom/${tagKey}`),
params(tagKey),
);
expect(res.status).toBe(200);
const plain = await readTree();
expect(
plain.flatMap((c) => c.tags).find((t) => t.key === tagKey),
).toBeUndefined();
const manage = await readTree("archived");
const archived = manage
.flatMap((c) => c.tags)
.find((t) => t.key === tagKey);
expect(archived).toMatchObject({ archived: true, label: "Alt" });
});
});
+22
View File
@@ -31,6 +31,16 @@ let catalogueSnapshot:
| Awaited<ReturnType<PrismaClient["moodTag"]["findMany"]>>
| null = null;
/**
* v1.17.0 — same treatment for the seeded mood-tag categories:
* `mood_tag_categories.user_id` now FKs to `users` (custom groups), so the
* users TRUNCATE cascades into the category table too. The categories must
* restore BEFORE the tags (tags FK into categories).
*/
let categorySnapshot:
| Awaited<ReturnType<PrismaClient["moodTagCategory"]["findMany"]>>
| null = null;
/**
* Truncate every user-facing table in dependency-safe order using
* `TRUNCATE … RESTART IDENTITY CASCADE`. Call from beforeEach() to make
@@ -92,12 +102,24 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
where: { userId: null },
});
}
if (categorySnapshot === null) {
categorySnapshot = await client.moodTagCategory.findMany({
where: { userId: null },
});
}
const quoted = tables.map((t) => `"${t}"`).join(", ");
await client.$executeRawUnsafe(
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
);
// Categories first — the tag rows FK into them.
if (categorySnapshot.length > 0) {
await client.moodTagCategory.createMany({
data: categorySnapshot,
skipDuplicates: true,
});
}
if (catalogueSnapshot.length > 0) {
await client.moodTag.createMany({
data: catalogueSnapshot,