mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
81ec98fc97
commit
985e197db4
18 files changed
+1233
-59
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user