mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
778c3a53a4
commit
058d1d7a69
50 files changed
+6781
-129
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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(">customcat:g1<");
|
||||
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 & 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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]> = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user