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

+12 -2
View File
@@ -4903,7 +4903,8 @@
"symptomCategory": {
"physical": "Körperlich",
"emotional": "Emotional",
"digestive": "Verdauung"
"digestive": "Verdauung",
"custom": "Eigene"
},
"symptom": {
"cramps": "Krämpfe",
@@ -4920,7 +4921,16 @@
"foodCravings": "Heißhunger",
"nausea": "Übelkeit",
"diarrhea": "Durchfall",
"constipation": "Verstopfung"
"constipation": "Verstopfung",
"custom": {
"add": "Symptom hinzufügen",
"label": "Name",
"labelPlaceholder": "z. B. Schwindel",
"icon": "Symbol",
"limitReached": "Höchstzahl an eigenen Symptomen erreicht.",
"remove": "{label} entfernen",
"fallbackLabel": "Eigenes Symptom"
}
},
"prediction": {
"disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst."
+12 -2
View File
@@ -4903,7 +4903,8 @@
"symptomCategory": {
"physical": "Physical",
"emotional": "Emotional",
"digestive": "Digestive"
"digestive": "Digestive",
"custom": "Custom"
},
"symptom": {
"cramps": "Cramps",
@@ -4920,7 +4921,16 @@
"foodCravings": "Food cravings",
"nausea": "Nausea",
"diarrhea": "Diarrhea",
"constipation": "Constipation"
"constipation": "Constipation",
"custom": {
"add": "Add symptom",
"label": "Name",
"labelPlaceholder": "e.g. Dizziness",
"icon": "Icon",
"limitReached": "Custom symptom limit reached.",
"remove": "Remove {label}",
"fallbackLabel": "Custom symptom"
}
},
"prediction": {
"disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles."
+12 -2
View File
@@ -4903,7 +4903,8 @@
"symptomCategory": {
"physical": "Físico",
"emotional": "Emocional",
"digestive": "Digestivo"
"digestive": "Digestivo",
"custom": "Personalizado"
},
"symptom": {
"cramps": "Calambres",
@@ -4920,7 +4921,16 @@
"foodCravings": "Antojos",
"nausea": "Náuseas",
"diarrhea": "Diarrea",
"constipation": "Estreñimiento"
"constipation": "Estreñimiento",
"custom": {
"add": "Añadir síntoma",
"label": "Nombre",
"labelPlaceholder": "p. ej. Mareo",
"icon": "Icono",
"limitReached": "Se alcanzó el límite de síntomas personalizados.",
"remove": "Eliminar {label}",
"fallbackLabel": "Síntoma personalizado"
}
},
"prediction": {
"disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos."
+12 -2
View File
@@ -4903,7 +4903,8 @@
"symptomCategory": {
"physical": "Physique",
"emotional": "Émotionnel",
"digestive": "Digestif"
"digestive": "Digestif",
"custom": "Personnalisé"
},
"symptom": {
"cramps": "Crampes",
@@ -4920,7 +4921,16 @@
"foodCravings": "Fringales",
"nausea": "Nausées",
"diarrhea": "Diarrhée",
"constipation": "Constipation"
"constipation": "Constipation",
"custom": {
"add": "Ajouter un symptôme",
"label": "Nom",
"labelPlaceholder": "ex. Vertige",
"icon": "Icône",
"limitReached": "Limite de symptômes personnalisés atteinte.",
"remove": "Supprimer {label}",
"fallbackLabel": "Symptôme personnalisé"
}
},
"prediction": {
"disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles."
+12 -2
View File
@@ -4903,7 +4903,8 @@
"symptomCategory": {
"physical": "Fisico",
"emotional": "Emotivo",
"digestive": "Digestivo"
"digestive": "Digestivo",
"custom": "Personalizzato"
},
"symptom": {
"cramps": "Crampi",
@@ -4920,7 +4921,16 @@
"foodCravings": "Voglie alimentari",
"nausea": "Nausea",
"diarrhea": "Diarrea",
"constipation": "Stitichezza"
"constipation": "Stitichezza",
"custom": {
"add": "Aggiungi sintomo",
"label": "Nome",
"labelPlaceholder": "es. Vertigini",
"icon": "Icona",
"limitReached": "Limite di sintomi personalizzati raggiunto.",
"remove": "Rimuovi {label}",
"fallbackLabel": "Sintomo personalizzato"
}
},
"prediction": {
"disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli."
+12 -2
View File
@@ -4903,7 +4903,8 @@
"symptomCategory": {
"physical": "Fizyczne",
"emotional": "Emocjonalne",
"digestive": "Trawienne"
"digestive": "Trawienne",
"custom": "Własne"
},
"symptom": {
"cramps": "Skurcze",
@@ -4920,7 +4921,16 @@
"foodCravings": "Zachcianki",
"nausea": "Nudności",
"diarrhea": "Biegunka",
"constipation": "Zaparcia"
"constipation": "Zaparcia",
"custom": {
"add": "Dodaj objaw",
"label": "Nazwa",
"labelPlaceholder": "np. Zawroty głowy",
"icon": "Ikona",
"limitReached": "Osiągnięto limit własnych objawów.",
"remove": "Usuń {label}",
"fallbackLabel": "Objaw własny"
}
},
"prediction": {
"disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli."
@@ -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";
+7
View File
@@ -37,6 +37,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const dayLogs = await tx.cycleDayLog.deleteMany({
where: { userId: user.id },
});
// Per-user custom symptoms carry an intent-revealing free-text label
// (encrypted at rest). A purge that promises "nothing reproductive
// persists" must drop them too; their links cascade off the row delete.
const customSymptoms = await tx.cycleSymptom.deleteMany({
where: { userId: user.id },
});
const predictions = await tx.cyclePrediction.deleteMany({
where: { userId: user.id },
});
@@ -79,6 +85,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
});
return {
dayLogs: dayLogs.count,
customSymptoms: customSymptoms.count,
predictions: predictions.count,
cycles: cycles.count,
auditRows: auditRows.count,
@@ -0,0 +1,127 @@
/**
* v1.15.1 — update / delete a per-user custom cycle-symptom.
*
* 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 symptom
* can never be edited or removed across the ownership boundary. Gated
* (`cycle.disabled` 403). The decrypted label is NEVER logged.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
updateCustomSymptomSchema,
decryptCustomLabel,
encryptCustomLabel,
isCustomSymptomKey,
} from "@/lib/cycle/custom-symptoms";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ key: string }> };
/** `PATCH /api/cycle/symptoms/custom/:key` — rename / recolour / (de)activate. */
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { key } = await params;
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
const parsed = updateCustomSymptomSchema.safeParse(await request.json());
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.symptom.custom.invalid",
});
}
const owned = await prisma.cycleSymptom.findFirst({
where: { key, userId: user.id },
select: { id: true },
});
if (!owned) return apiError("Custom symptom not found", 404);
const updated = await prisma.cycleSymptom.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: "cycle.symptom.custom.update" } });
return apiSuccess({
key: updated.key,
label: decryptCustomLabel(updated.labelEncrypted),
icon: updated.icon,
isActive: updated.isActive,
custom: true,
});
},
);
/**
* `DELETE /api/cycle/symptoms/custom/:key` — soft-deactivate by default
* (history intact); `?purge=true` hard-deletes the row and cascades its links.
*/
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { key } = await params;
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
const owned = await prisma.cycleSymptom.findFirst({
where: { key, userId: user.id },
select: { id: true },
});
if (!owned) return apiError("Custom symptom not found", 404);
const purge = request.nextUrl.searchParams.get("purge") === "true";
if (purge) {
// FK cascade removes the `cycle_symptom_links` rows too.
await prisma.cycleSymptom.delete({ where: { id: owned.id } });
} else {
await prisma.cycleSymptom.update({
where: { id: owned.id },
data: { isActive: false },
});
}
await auditLog("cycle.symptom.custom.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { purge },
});
annotate({
action: { name: "cycle.symptom.custom.delete" },
meta: { purge },
});
return apiSuccess({ key, purged: purge });
},
);
@@ -0,0 +1,193 @@
/**
* v1.15.1 — custom cycle-symptom CRUD route guards: create cap + encrypted
* label, ownership 404 on edit/delete, non-custom-key rejection, soft-delete
* vs purge, and the gate refusal.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => ({
cycleSymptom: {
count: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
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("@/lib/cycle/gate", () => ({
requireCycleEnabled: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { PATCH, DELETE } from "../[key]/route";
import { getSession } from "@/lib/auth/session";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { apiError } from "@/lib/api-response";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
};
function jsonReq(url: string, method: string, body: unknown) {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireCycleEnabled).mockResolvedValue({
enabled: true,
profile: {} as never,
});
});
describe("GET /api/cycle/symptoms/custom", () => {
it("returns the caller's active customs with decrypted labels", async () => {
db.cycleSymptom.findMany.mockResolvedValue([
{ key: "custom:abc", icon: "Brain", labelEncrypted: "enc:Dizziness" },
]);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { symptoms: { key: string; label: string; custom: boolean }[] };
};
expect(body.data.symptoms).toEqual([
{ key: "custom:abc", label: "Dizziness", icon: "Brain", custom: true },
]);
});
});
describe("POST /api/cycle/symptoms/custom", () => {
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
db.cycleSymptom.count.mockResolvedValue(2);
db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
label: "Dizziness",
icon: "Brain",
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { custom: boolean; label: string; key: string };
};
expect(body.data).toMatchObject({
custom: true,
label: "Dizziness",
key: "custom:abc",
});
// Label is stored encrypted, never plaintext.
expect(db.cycleSymptom.create.mock.calls[0][0].data.labelEncrypted).toBe(
"enc:Dizziness",
);
// Owner-scoped + hung under the custom category.
expect(db.cycleSymptom.create.mock.calls[0][0].data.userId).toBe("user-1");
expect(db.cycleSymptom.create.mock.calls[0][0].data.categoryId).toBe(
"csc_custom",
);
});
it("rejects over the per-user cap with 422", async () => {
db.cycleSymptom.count.mockResolvedValue(50);
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
);
expect(res.status).toBe(422);
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
});
it("refuses when cycle tracking is disabled (403)", async () => {
vi.mocked(requireCycleEnabled).mockResolvedValue({
enabled: false,
response: apiError("Cycle tracking is not enabled", 403),
});
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
);
expect(res.status).toBe(403);
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
});
});
describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
const params = (key: string) => ({ params: Promise.resolve({ key }) });
it("404s when the key is not the caller's own custom symptom", async () => {
db.cycleSymptom.findFirst.mockResolvedValue(null);
const res = await PATCH(
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:x", "PATCH", {
isActive: false,
}),
params("custom:x"),
);
expect(res.status).toBe(404);
});
it("404s a non-custom key without touching the DB", async () => {
const res = await DELETE(
new NextRequest("http://localhost/api/cycle/symptoms/custom/cramps"),
params("cramps"),
);
expect(res.status).toBe(404);
expect(db.cycleSymptom.findFirst).not.toHaveBeenCalled();
});
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
const soft = await DELETE(
new NextRequest("http://localhost/api/cycle/symptoms/custom/custom:abc"),
params("custom:abc"),
);
expect(soft.status).toBe(200);
expect(db.cycleSymptom.update).toHaveBeenCalledWith({
where: { id: "id1" },
data: { isActive: false },
});
expect(db.cycleSymptom.delete).not.toHaveBeenCalled();
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireCycleEnabled).mockResolvedValue({
enabled: true,
profile: {} as never,
});
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
const purge = await DELETE(
new NextRequest(
"http://localhost/api/cycle/symptoms/custom/custom:abc?purge=true",
),
params("custom:abc"),
);
expect(purge.status).toBe(200);
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* v1.15.1 — per-user custom cycle-symptom catalogue.
*
* GET /api/cycle/symptoms/custom — the caller's active custom symptoms,
* labels decrypted, so the log-day sheet can merge them into the
* seeded chip grid.
* POST /api/cycle/symptoms/custom — create one (`{ label, icon?,
* categoryKey? }`). Mints a `custom:<uuid>` key, encrypts the label at
* rest, stores the row under the global `custom` category owned by the
* caller. Capped per user.
*
* Gated (`cycle.disabled` 403) + owner-scoped — a Bearer token for a disabled
* account never reaches the custom catalogue. The label is intent-revealing
* free text, so it is NEVER surfaced in a wide-event / audit excerpt (only the
* icon + key are annotated, mirroring the mood custom-tag precedent).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
createCustomSymptomSchema,
decryptCustomLabel,
encryptCustomLabel,
mintCustomSymptomKey,
CUSTOM_SYMPTOM_CATEGORY_ID,
MAX_CUSTOM_SYMPTOMS_PER_USER,
} from "@/lib/cycle/custom-symptoms";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rows = await prisma.cycleSymptom.findMany({
where: { userId: user.id, isActive: true },
orderBy: { sortOrder: "asc" },
select: { key: true, icon: true, labelEncrypted: true },
});
const symptoms = rows.map((r) => ({
key: r.key,
label: decryptCustomLabel(r.labelEncrypted),
icon: r.icon,
custom: true,
}));
annotate({
action: { name: "cycle.symptom.custom.read" },
meta: { count: symptoms.length },
});
return apiSuccess({ symptoms });
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const parsed = createCustomSymptomSchema.safeParse(await request.json());
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.symptom.custom.invalid",
});
}
const activeCount = await prisma.cycleSymptom.count({
where: { userId: user.id, isActive: true },
});
if (activeCount >= MAX_CUSTOM_SYMPTOMS_PER_USER) {
return apiError(
`Custom symptom limit reached (${MAX_CUSTOM_SYMPTOMS_PER_USER})`,
422,
{ errorCode: "cycle.symptom.custom.limit" },
);
}
const key = mintCustomSymptomKey();
const created = await prisma.cycleSymptom.create({
data: {
categoryId: CUSTOM_SYMPTOM_CATEGORY_ID,
key,
// Catalogue rows resolve `labelKey` against the locale; a custom symptom
// 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,
sortOrder: activeCount,
userId: user.id,
},
select: { key: true, icon: true },
});
// Audit the create (same tier as a mood-tag custom) — counts + the icon
// only, NEVER the decrypted label.
await auditLog("cycle.symptom.custom.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { icon: created.icon },
});
annotate({
action: { name: "cycle.symptom.custom.create" },
meta: { icon: created.icon },
});
return apiSuccess(
{
key: created.key,
label: parsed.data.label,
icon: created.icon,
custom: true,
},
201,
);
});
+332 -45
View File
@@ -1,24 +1,62 @@
"use client";
import { useState } from "react";
import { Droplets, Loader2, Trash2 } from "lucide-react";
import {
Activity,
BatteryLow,
Brain,
CircleDot,
Cookie,
Drama,
Droplet,
Droplets,
Flame,
Frown,
Heart,
HeartPulse,
Loader2,
MoonStar,
PersonStanding,
Pill,
Plus,
Snowflake,
Soup,
Stethoscope,
Tag,
Thermometer,
Trash2,
X,
Zap,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE } from "./phase-tokens";
import {
useCreateCustomSymptom,
useCustomSymptoms,
useCycleDayLog,
useDeleteCustomSymptom,
useDeleteDayLog,
useEndPeriod,
useLogDay,
usePatchDayLog,
useStartPeriod,
type CustomSymptomDTO,
type CycleDayLogPatch,
} from "./use-cycle";
import type {
@@ -75,6 +113,38 @@ const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [
];
const SEVERITY_LEVELS = [1, 2, 3, 4] as const;
/**
* Resolve a custom symptom's stored Lucide icon NAME to its component. Scoped
* to the `CUSTOM_SYMPTOM_ICON_ALLOWLIST` (server-validated on create) so the
* import set stays tight; an unknown / null name falls back to `Tag`.
*/
const CUSTOM_ICON_BY_NAME: Record<string, LucideIcon> = {
Tag,
Activity,
Heart,
HeartPulse,
Brain,
Zap,
Flame,
Snowflake,
Droplet,
CircleDot,
BatteryLow,
MoonStar,
PersonStanding,
Drama,
Frown,
Cookie,
Soup,
Pill,
Thermometer,
Stethoscope,
};
function customIcon(name: string | null): LucideIcon {
return (name && CUSTOM_ICON_BY_NAME[name]) || Tag;
}
/** The sheet's editable form state, lifted out so the two save-payload builders
* are pure + unit-testable (the clear-on-edit semantics are the QA W-2 fix). */
export interface DayLogFormState {
@@ -211,6 +281,8 @@ export function LogDaySheet({
// Pre-fill from the persisted day-log so a re-log never nulls untouched
// (or HealthKit-sourced) fields — the v1.15 web data-loss fix.
const dayLog = useCycleDayLog(open ? date : null);
// The caller's custom symptoms, merged into the seeded chip grid.
const customSymptoms = useCustomSymptoms();
const [flow, setFlow] = useState<FlowLevel | null>(null);
const [intermenstrual, setIntermenstrual] = useState(false);
@@ -440,53 +512,53 @@ export function LogDaySheet({
{t(cat.labelKey)}
</div>
<div className="flex flex-wrap gap-2">
{cat.symptoms.map((s) => {
const active = symptoms.has(s.key);
const sev = symptoms.get(s.key) ?? null;
return (
<div key={s.key} className="flex items-center gap-1">
<Chip
active={active}
onClick={() => toggleSymptom(s.key)}
>
<span className="flex items-center gap-1.5">
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(s.labelKey)}
</span>
</Chip>
{active ? (
<div
className="flex gap-0.5"
role="group"
aria-label={t("cycle.sheet.severity")}
>
{SEVERITY_LEVELS.map((lvl) => (
<button
key={lvl}
type="button"
aria-pressed={sev === lvl}
aria-label={t("cycle.sheet.severityLevel", {
level: lvl,
})}
onClick={() => setSymptomSeverity(s.key, lvl)}
className={cn(
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
sev === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{lvl}
</button>
))}
</div>
) : null}
</div>
);
})}
{cat.symptoms.map((s) => (
<SymptomChip
key={s.key}
symptomKey={s.key}
icon={s.icon}
label={t(s.labelKey)}
active={symptoms.has(s.key)}
severity={symptoms.get(s.key) ?? null}
onToggle={toggleSymptom}
onSeverity={setSymptomSeverity}
/>
))}
</div>
</div>
))}
{/* Custom symptoms — the user's own, under a `custom` category,
plus the dashed ghost-chip that mints a new one. */}
<div className="space-y-1.5">
<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" />
{t("cycle.symptomCategory.custom")}
</div>
<div className="flex flex-wrap gap-2">
{(customSymptoms.data?.symptoms ?? []).map((s) => (
<SymptomChip
key={s.key}
symptomKey={s.key}
icon={customIcon(s.icon)}
label={s.label ?? t("cycle.symptom.custom.fallbackLabel")}
active={symptoms.has(s.key)}
severity={symptoms.get(s.key) ?? null}
custom={s}
onToggle={toggleSymptom}
onSeverity={setSymptomSeverity}
onClear={(key) => {
setSymptoms((prev) => {
const next = new Map(prev);
next.delete(key);
return next;
});
}}
/>
))}
<AddSymptomChip />
</div>
</div>
</div>
</Field>
@@ -649,3 +721,218 @@ function Field({
</div>
);
}
/**
* One symptom chip + its inline 1-4 severity selector (shared by the seeded
* catalogue chips and the custom chips so both render identically). A custom
* chip additionally carries a tiny remove (×) affordance that soft-hides the
* symptom from the catalogue.
*/
function SymptomChip({
symptomKey,
icon: Icon,
label,
active,
severity,
custom,
onToggle,
onSeverity,
onClear,
}: {
symptomKey: string;
icon: LucideIcon;
label: string;
active: boolean;
severity: number | null;
custom?: CustomSymptomDTO;
onToggle: (key: string) => void;
onSeverity: (key: string, severity: number) => void;
onClear?: (key: string) => void;
}) {
const { t } = useTranslations();
const deleteCustom = useDeleteCustomSymptom();
async function handleRemove() {
if (!custom) return;
onClear?.(custom.key);
await deleteCustom.mutateAsync(custom.key);
}
return (
<div className="flex items-center gap-1">
<span className="relative inline-flex items-center">
<Chip active={active} onClick={() => onToggle(symptomKey)}>
<span className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</span>
</Chip>
{custom ? (
<button
type="button"
onClick={handleRemove}
disabled={deleteCustom.isPending}
aria-label={t("cycle.symptom.custom.remove", { label })}
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<X className="size-2.5" aria-hidden="true" />
</button>
) : null}
</span>
{active ? (
<div
className="flex gap-0.5"
role="group"
aria-label={t("cycle.sheet.severity")}
>
{SEVERITY_LEVELS.map((lvl) => (
<button
key={lvl}
type="button"
aria-pressed={severity === lvl}
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
onClick={() => onSeverity(symptomKey, lvl)}
className={cn(
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
severity === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{lvl}
</button>
))}
</div>
) : null}
</div>
);
}
/**
* The dashed ghost-chip in the symptom grid that opens a compact label + icon
* popover to mint a new custom symptom. Matches the chip grid (same rounded-
* full pill, same size) so it reads as part of the grid, not a foreign button.
*/
function AddSymptomChip() {
const { t } = useTranslations();
const create = useCreateCustomSymptom();
const [openForm, setOpenForm] = useState(false);
const [label, setLabel] = useState("");
const [icon, setIcon] = useState<string>("Tag");
function reset() {
setLabel("");
setIcon("Tag");
}
async function handleCreate() {
const trimmed = label.trim();
if (!trimmed) return;
await create.mutateAsync({ label: trimmed, icon });
reset();
setOpenForm(false);
}
const limitReached = create.isError;
return (
<Popover
open={openForm}
onOpenChange={(o) => {
setOpenForm(o);
if (!o) reset();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{t("cycle.symptom.custom.add")}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-symptom-label" className="text-xs font-medium">
{t("cycle.symptom.custom.label")}
</Label>
<Input
id="custom-symptom-label"
value={label}
maxLength={40}
autoFocus
placeholder={t("cycle.symptom.custom.labelPlaceholder")}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreate();
}
}}
/>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">
{t("cycle.symptom.custom.icon")}
</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("cycle.symptom.custom.icon")}
>
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = customIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
</div>
{limitReached ? (
<p className="text-destructive text-xs" role="alert">
{t("cycle.symptom.custom.limitReached")}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
reset();
setOpenForm(false);
}}
>
{t("cycle.sheet.cancel")}
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={!label.trim() || create.isPending}
>
{create.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.symptom.custom.add")}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
+7 -2
View File
@@ -1,13 +1,18 @@
/**
* v1.15.0 — the seeded cycle-symptom catalog, mirrored client-side.
*
* The catalog ships seeded-only in v1.15.0 (no per-user custom CRUD yet),
* so the keys are fixed at the migration seed (0129). The `key` is the
* The keys are fixed at the migration seed (0129). The `key` is the
* snake_case DB key the day-log write resolves against; the `labelKey` is
* the camelCase i18n leaf under `cycle.symptom.*`; the `icon` is the
* Lucide name from the seed. Keep this in lockstep with the seed in
* `prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql` and
* the `cycle.symptom.*` / `cycle.symptomCategory.*` i18n leaves.
*
* v1.15.1 layers per-user custom symptoms on top (the `custom` category,
* seeded by migration 0134): the log-day sheet fetches them from
* `/api/cycle/symptoms/custom` and merges them into this seeded grid. They
* are NOT in this static catalog — their labels are user free text held
* encrypted at rest, resolved server-side, never an i18n key.
*/
import {
Activity,
+55
View File
@@ -110,6 +110,61 @@ export function useCycleHistory(limit = 24) {
});
}
/** One per-user custom symptom: a `custom:<uuid>` key + decrypted label. */
export interface CustomSymptomDTO {
key: string;
label: string | null;
icon: string | null;
custom: true;
}
/**
* The caller's own custom symptoms, labels decrypted server-side. The log-day
* sheet merges these into the seeded chip grid under the `custom` category.
*/
export function useCustomSymptoms() {
return useQuery({
queryKey: queryKeys.cycleCustomSymptoms(),
queryFn: () =>
fetch("/api/cycle/symptoms/custom").then((r) =>
unwrap<{ symptoms: CustomSymptomDTO[] }>(r),
),
staleTime: 5 * 60_000,
});
}
/** Create a custom symptom (mint `custom:<uuid>`, encrypt the label). */
export function useCreateCustomSymptom() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: { label: string; icon?: string | null }) => {
const res = await fetch("/api/cycle/symptoms/custom", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return unwrap<CustomSymptomDTO>(res);
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: queryKeys.cycleCustomSymptoms() }),
});
}
/** Soft-hide a custom symptom by its key (history-preserving). */
export function useDeleteCustomSymptom() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => {
const res = await fetch(
`/api/cycle/symptoms/custom/${encodeURIComponent(key)}`,
{ method: "DELETE" },
);
return unwrap<{ key: string; purged: boolean }>(res);
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
});
}
export function useCycleProfile() {
return useQuery({
queryKey: queryKeys.cycleProfile(),
@@ -0,0 +1,108 @@
/**
* v1.15.1 — custom cycle-symptom helpers: key mint/detect, label encrypt /
* decrypt fail-soft, and the Zod create/update schemas.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => {
if (!s.startsWith("enc:")) throw new Error("bad ciphertext");
return s.replace(/^enc:/, "");
},
}));
import {
CUSTOM_SYMPTOM_KEY_PREFIX,
MAX_CUSTOM_SYMPTOMS_PER_USER,
createCustomSymptomSchema,
decryptCustomLabel,
encryptCustomLabel,
isCustomSymptomKey,
mintCustomSymptomKey,
updateCustomSymptomSchema,
} from "@/lib/cycle/custom-symptoms";
describe("custom-symptom keys", () => {
it("mints a custom:<uuid> key the detector recognises", () => {
const key = mintCustomSymptomKey();
expect(key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX)).toBe(true);
expect(isCustomSymptomKey(key)).toBe(true);
});
it("does not treat a bare catalogue slug as custom", () => {
expect(isCustomSymptomKey("cramps")).toBe(false);
expect(isCustomSymptomKey("headache")).toBe(false);
});
it("mints unique keys", () => {
expect(mintCustomSymptomKey()).not.toBe(mintCustomSymptomKey());
});
});
describe("label encryption", () => {
it("round-trips a label through encrypt/decrypt", () => {
const enc = encryptCustomLabel("Schwindel");
expect(enc).toBe("enc:Schwindel");
expect(decryptCustomLabel(enc)).toBe("Schwindel");
});
it("decrypts a corrupt/missing ciphertext fail-soft to null", () => {
expect(decryptCustomLabel(null)).toBeNull();
expect(decryptCustomLabel("not-encrypted")).toBeNull();
});
});
describe("create schema", () => {
it("accepts a label + allow-listed icon", () => {
const p = createCustomSymptomSchema.safeParse({
label: "Dizziness",
icon: "Brain",
});
expect(p.success).toBe(true);
});
it("rejects an empty label and an over-long one", () => {
expect(createCustomSymptomSchema.safeParse({ label: "" }).success).toBe(
false,
);
expect(
createCustomSymptomSchema.safeParse({ label: "x".repeat(41) }).success,
).toBe(false);
});
it("rejects an icon outside the allow-list", () => {
expect(
createCustomSymptomSchema.safeParse({ label: "X", icon: "Skull" })
.success,
).toBe(false);
});
it("rejects a categoryKey other than custom", () => {
expect(
createCustomSymptomSchema.safeParse({ label: "X", categoryKey: "physical" })
.success,
).toBe(false);
});
});
describe("update schema", () => {
it("requires at least one field", () => {
expect(updateCustomSymptomSchema.safeParse({}).success).toBe(false);
});
it("accepts a partial update", () => {
expect(
updateCustomSymptomSchema.safeParse({ isActive: false }).success,
).toBe(true);
expect(updateCustomSymptomSchema.safeParse({ label: "New" }).success).toBe(
true,
);
});
});
describe("per-user cap", () => {
it("pins a sane ceiling", () => {
expect(MAX_CUSTOM_SYMPTOMS_PER_USER).toBe(50);
});
});
@@ -0,0 +1,61 @@
/**
* v1.15.1 — `resolveSymptomIds` resolves a mix of seeded catalogue keys AND
* the caller's own `custom:` keys (the day-log write path for custom symptoms).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
cycleSymptom: { findMany: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
vi.mock("@/lib/cycle/profile", () => ({
getOrCreateCycleProfile: vi.fn(),
}));
import { resolveSymptomIds } from "@/lib/cycle/day-log-write";
beforeEach(() => {
vi.clearAllMocks();
});
describe("resolveSymptomIds", () => {
it("queries the catalogue (userId null) AND the caller's own rows", async () => {
db.cycleSymptom.findMany.mockResolvedValue([
{ id: "cs_cramps", key: "cramps" },
{ id: "id_custom", key: "custom:abc" },
]);
const out = await resolveSymptomIds("user-1", ["cramps", "custom:abc"]);
expect(out).toEqual([
{ key: "cramps", id: "cs_cramps" },
{ key: "custom:abc", id: "id_custom" },
]);
const where = db.cycleSymptom.findMany.mock.calls[0][0].where;
expect(where.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
expect(where.key.in).toEqual(["cramps", "custom:abc"]);
expect(where.isActive).toBe(true);
});
it("drops unknown keys (returns only resolved rows)", async () => {
db.cycleSymptom.findMany.mockResolvedValue([
{ id: "id_custom", key: "custom:abc" },
]);
const out = await resolveSymptomIds("user-1", [
"custom:abc",
"custom:not-mine",
]);
expect(out).toEqual([{ key: "custom:abc", id: "id_custom" }]);
});
it("short-circuits on an empty key set", async () => {
const out = await resolveSymptomIds("user-1", []);
expect(out).toEqual([]);
expect(db.cycleSymptom.findMany).not.toHaveBeenCalled();
});
});
+121
View File
@@ -0,0 +1,121 @@
import { randomUUID } from "node:crypto";
import { z } from "zod/v4";
import { encrypt, decrypt } from "@/lib/crypto";
/**
* v1.15.1 — per-user custom cycle-symptom helpers (the v1.13 custom-mood-tag
* precedent, mirrored 1:1).
*
* Custom symptoms live in the same `cycle_symptoms` table as the seeded
* catalogue, distinguished by a non-null `userId`. Their key carries a
* reserved `custom:` prefix so it never collides with a bare catalogue slug,
* and the write path (`day-log-write.ts:resolveSymptomIds`) already resolves a
* key against the catalogue rows (userId NULL) PLUS the caller's own rows —
* so a `custom:` key routes to the user's symptom by construction. The label
* is held AES-256-GCM-encrypted at rest (`labelEncrypted`): a symptom name is
* intent-revealing free text, the highest-sensitivity category in the
* post-Dobbs threat model.
*/
/** Reserved key prefix for a user-minted custom symptom. */
export const CUSTOM_SYMPTOM_KEY_PREFIX = "custom:";
/** Stable category key + seeded id every custom symptom hangs under. */
export const CUSTOM_SYMPTOM_CATEGORY_KEY = "custom";
export const CUSTOM_SYMPTOM_CATEGORY_ID = "csc_custom";
/** Per-user ceiling on custom symptoms (422 over the cap). */
export const MAX_CUSTOM_SYMPTOMS_PER_USER = 50;
/**
* Allow-list of Lucide icon names a custom symptom may use. Kept to names the
* iOS client maps to an SF Symbol so a custom symptom never falls back to the
* generic glyph on one platform. An unknown name → 422. `Tag` is the default.
*/
export const CUSTOM_SYMPTOM_ICON_ALLOWLIST = [
"Tag",
"Activity",
"Heart",
"HeartPulse",
"Brain",
"Zap",
"Flame",
"Snowflake",
"Droplet",
"CircleDot",
"BatteryLow",
"MoonStar",
"PersonStanding",
"Drama",
"Frown",
"Cookie",
"Soup",
"Pill",
"Thermometer",
"Stethoscope",
] as const;
const ICON_SET = new Set<string>(CUSTOM_SYMPTOM_ICON_ALLOWLIST);
/** Is `key` a custom-symptom key (vs a bare catalogue slug)? */
export function isCustomSymptomKey(key: string): boolean {
return key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX);
}
/** Mint a fresh, collision-proof custom-symptom key. */
export function mintCustomSymptomKey(): string {
return `${CUSTOM_SYMPTOM_KEY_PREFIX}${randomUUID()}`;
}
/** Encrypt a custom label for storage in `label_encrypted`. */
export function encryptCustomLabel(label: string): string {
return encrypt(label);
}
/**
* Decrypt a stored custom label. Returns null on a missing or corrupt
* ciphertext so one bad row never throws the whole effective-set read.
*/
export function decryptCustomLabel(encoded: string | null): string | null {
if (!encoded) return null;
try {
return decrypt(encoded);
} catch {
return null;
}
}
const labelSchema = z
.string()
.trim()
.min(1, "Label must not be empty")
.max(40, "Label must be at most 40 characters");
const iconSchema = z
.string()
.refine((v) => ICON_SET.has(v), "Unknown icon")
.nullish();
/** POST body — create a custom symptom. */
export const createCustomSymptomSchema = z.object({
label: labelSchema,
icon: iconSchema,
// Reserved for a future per-symptom category choice; v1 pins everything to
// the `custom` category, so a supplied value other than `custom` is rejected.
categoryKey: z.literal(CUSTOM_SYMPTOM_CATEGORY_KEY).optional(),
});
/** PATCH body — update a custom symptom (every field optional). */
export const updateCustomSymptomSchema = 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",
);
+3
View File
@@ -518,6 +518,9 @@ export const queryKeys = {
cyclePrefs: () => ["cycle", "prefs"] as const,
cycleInsights: () => ["cycle", "insights"] as const,
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
* merges into the seeded chip grid. */
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
};
/**