feat(cycle): user-defined custom symptoms

Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint
their own symptoms beyond the 15 seeded ones, picked from a label + icon
popover via a dashed ghost-chip in the log-day sheet's symptom grid.

- POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a
  custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM),
  per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by
  default, hard-purge on ?purge=true.
- The day-log write already resolves a symptom key against the catalogue plus
  the caller's own rows, so a custom: key links by construction.
- The log-day sheet fetches the user's customs and merges them under a new
  custom category in the chip grid; the ghost-chip + popover reuse the chip
  styling and the Lucide icon set.
- Seed the global custom symptom category (migration 0134).
- The purge route (DELETE /api/cycle/all) now drops the user's custom symptom
  rows too, so no intent-revealing label survives a purge.
- The label is never logged: only the icon + counts reach annotate/auditLog.
- i18n: cycle.symptom.custom.* across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:54:43 +02:00
1 parent 81ec98fc97
commit 985e197db4
18 files changed
+1233 -59

No files matched your search

@@ -0,0 +1,16 @@
-- v1.15.1 — seed the global `custom` cycle-symptom category.
--
-- Per-user custom symptoms (the v1.13 custom-mood-tag precedent) hang under a
-- single global category row, distinguished from the seeded catalogue by a
-- non-null `cycle_symptoms.user_id`. The owner column + `label_encrypted`
-- already exist (migration 0129); this only adds the parent category so a
-- custom symptom has a stable `category_id` to reference.
--
-- Idempotent: upserts by `key` (deterministic id `csc_custom`), so re-running
-- the migration is a no-op. No table or column changes.
INSERT INTO "cycle_symptom_categories"
("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('csc_custom', 'custom', 'cycle.symptomCategory.custom', 'Tag', 100, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";