feat(symptoms): log a symptom in one step and show it on the illness pages

- A new capture kind "symptom" (illness module, illness write grant) in
  the bottom bar's picker and the dashboard Add menu. The sheet follows
  the measurement and mood forms: the person's symptoms as chips with the
  cycle sheet's dashed "new symptom" chip, the 0-10 slider with its
  anchors, the time prefilled with now, an optional note, and a "during"
  selector only while an illness episode is open.
- "Your symptoms" on the illness page: one line per symptom (how often in
  30 days, how strong at worst, when last), the last 30 days grouped by
  day, edit and delete per entry, rename, icon, hide and delete per
  symptom. Renders nothing until a symptom exists.
- "Symptoms logged" on an episode's page: the entries filed against it or
  logged while it ran, the filed ones marked. This is the reader of the
  episode link.
- Query keys live under the illness root, so either side's writes
  refresh both.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 21:24:12 +02:00
1 parent 9d9e1f153c
commit 9272daa59c
17 files changed
+1940 -19

No files matched your search

@@ -153,7 +153,8 @@ describe("<DashboardHeader> — every entry follows its module, as the capture p
{ mood: false },
{ medications: false },
{ workouts: false },
{ mood: false, medications: false, workouts: false },
{ illness: false },
{ mood: false, medications: false, workouts: false, illness: false },
];
for (const modules of cases) {
modulesRef.value = modules;
@@ -170,6 +171,7 @@ describe("<DashboardHeader> — every entry follows its module, as the capture p
measurement: "Log measurement",
medication: "Log medication intake",
mood: "Log mood",
symptom: "Log symptom",
workout: "Log workout",
} as const;
for (const kind of CAPTURE_KIND_ORDER) {
+16 -1
View File
@@ -12,7 +12,15 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react";
import {
Activity,
Footprints,
Pill,
Plus,
Stethoscope,
Waves,
Wrench,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -62,6 +70,7 @@ export function DashboardHeader({
const canAddMeasurement = offered.includes("measurement");
const canAddIntake = offered.includes("medication");
const canAddMood = offered.includes("mood");
const canAddSymptom = offered.includes("symptom");
const canAddWorkout = offered.includes("workout");
const canAddAnything = offered.length > 0;
@@ -198,6 +207,12 @@ export function DashboardHeader({
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
{canAddSymptom && (
<DropdownMenuItem onClick={() => onQuickEntry("symptom")}>
<Stethoscope className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddSymptom")}
</DropdownMenuItem>
)}
{canAddWorkout && (
<DropdownMenuItem onClick={() => onQuickEntry("workout")}>
<Footprints className="mr-2 h-4 w-4" aria-hidden="true" />
@@ -25,6 +25,7 @@ import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { SymptomEntryForm } from "@/components/symptoms/symptom-entry-form";
import { useTranslations } from "@/lib/i18n/context";
import {
useRecordCapabilities,
@@ -33,7 +34,7 @@ import {
import type { ShareDomain } from "@/lib/sharing/scope";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | "workout" | null;
"measurement" | "mood" | "medicationIntake" | "symptom" | "workout" | null;
/**
* The section each sheet writes to; see the capture picker. `null` marks a
@@ -46,6 +47,7 @@ const QUICK_ENTRY_DOMAIN: Readonly<
measurement: "measurements",
medicationIntake: "medications",
mood: "mind",
symptom: "illness",
workout: null,
};
@@ -163,6 +165,9 @@ export function QuickEntrySheets({
const [workoutFooterEl, setWorkoutFooterEl] = useState<HTMLDivElement | null>(
null,
);
const [symptomFooterEl, setSymptomFooterEl] = useState<HTMLDivElement | null>(
null,
);
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
@@ -226,6 +231,24 @@ export function QuickEntrySheets({
/>
</ResponsiveSheet>
{/* v1.40 — one occurrence of the person's own symptom. Mounted only
while open, so every opening starts with "now" and nothing chosen. */}
<ResponsiveSheet
open={openSheet === "symptom"}
onOpenChange={handleQuickEntryOpenChange}
title={t("symptoms.entry.sheetTitle")}
description={t("symptoms.entry.sheetDescription")}
footer={<div ref={setSymptomFooterEl} className="flex w-full" />}
>
{openSheet === "symptom" && (
<SymptomEntryForm
onSuccess={onClose}
onCancel={onClose}
footerSlot={symptomFooterEl}
/>
)}
</ResponsiveSheet>
{/* Log a workout by hand. Mounted only while open, so every opening
is a fresh form with its own external id. */}
<ResponsiveSheet
@@ -14,6 +14,7 @@ import Link from "next/link";
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { EpisodeSymptomsCard } from "@/components/symptoms/episode-symptoms-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -176,6 +177,10 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
/>
) : null}
{/* v1.40 — the person's own symptoms filed against this episode or
logged while it ran. Renders nothing when there are none. */}
{episode ? <EpisodeSymptomsCard episode={episode} /> : null}
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
+22 -14
View File
@@ -41,6 +41,7 @@ import { LogDaySheet } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
import { EpisodeMenu } from "./episode-menu";
import { IllnessInsightsCard } from "./illness-insights-card";
import { SymptomsSection } from "@/components/symptoms/symptoms-section";
import { useIllnessEpisodes, useResolveEpisode } from "./use-illness";
import type { IllnessEpisodeDTO } from "./types";
@@ -448,6 +449,10 @@ export function IllnessView() {
never buries it. */}
<IllnessInsightsCard />
{/* v1.40 — the person's own symptoms; renders nothing until one is
defined. */}
<SymptomsSection />
<EpisodeGroup
title={t("illness.section.active")}
parents={grouped.activeParents}
@@ -471,20 +476,23 @@ export function IllnessView() {
/>
</>
) : (
<EmptyState
icon={<Stethoscope className="size-6" />}
title={t("illness.empty.title")}
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
) : undefined
}
/>
<>
<SymptomsSection />
<EmptyState
icon={<Stethoscope className="size-6" />}
title={t("illness.empty.title")}
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
) : undefined
}
/>
</>
)}
<NewEpisodeSheet open={newOpen} onOpenChange={setNewOpen} today={today} />
+23 -2
View File
@@ -1,12 +1,13 @@
"use client";
import { useState } from "react";
import { Activity, Footprints, Pill, Waves } from "lucide-react";
import { Activity, Footprints, Pill, Stethoscope, Waves } from "lucide-react";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { SymptomEntryForm } from "@/components/symptoms/symptom-entry-form";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
@@ -37,6 +38,8 @@ import {
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
* - Symptom → `<SymptomEntryForm>` (one of the person's own symptoms,
* v1.40; rides the illness module)
*
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
@@ -52,7 +55,8 @@ import {
* instead of closing unconditionally.
*/
type CaptureKind = "measurement" | "medication" | "mood" | "workout";
export type CaptureKind =
"measurement" | "medication" | "mood" | "symptom" | "workout";
/**
* The section each capture surface writes to.
@@ -72,6 +76,8 @@ const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
measurement: "measurements",
medication: "medications",
mood: "mind",
// A symptom occurrence is an illness-section write, admitted at WRITE.
symptom: "illness",
// A workout goes through the batch ingest, which resolves the caller:
// no grant reaches it, so it is offered in one's own record only.
workout: null,
@@ -82,6 +88,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
"symptom",
"workout",
];
@@ -210,6 +217,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
description: t("nav.capture.moodDescription"),
icon: Waves,
},
{
kind: "symptom",
label: t("nav.capture.symptom"),
description: t("nav.capture.symptomDescription"),
icon: Stethoscope,
},
{
kind: "workout",
label: t("nav.capture.workout"),
@@ -223,6 +236,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
symptom: t("symptoms.entry.sheetTitle"),
workout: t("insights.workouts.manual.sheetTitle"),
};
// `openKind === null` keeps the form sheet closed (the title is unread
@@ -293,6 +307,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
footerSlot={footerEl}
/>
)}
{openKind === "symptom" && (
<SymptomEntryForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{openKind === "mood" && (
<MoodForm
onSuccess={closeForm}
@@ -0,0 +1,114 @@
/**
* The symptom quick-entry form (v1.40): the person's symptoms as chips with
* the inline "add" chip beside them, the 0-10 slider, and the "during" episode
* selector only while an episode is open.
*
* Mutation checks (each run, each seen red):
* - drop the `resolvedAt === null` filter on the episodes → "offers no
* episode selector when every episode is resolved" goes red;
* - render the add chip unconditionally → "a delegate who may log but not
* manage gets no add chip" goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const state = vi.hoisted(() => ({
canManage: true,
episodes: [] as Array<{
id: string;
label: string;
resolvedAt: string | null;
}>,
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManageDomain: () => state.canManage,
canWriteDomain: () => true,
}),
useActiveRecordName: () => null,
}));
vi.mock("@/components/illness/use-illness", () => ({
useIllnessEpisodes: () => ({ data: state.episodes, isLoading: false }),
}));
vi.mock("../use-symptoms", () => ({
useSymptomDefinitions: () => ({
isLoading: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
},
],
},
}),
useLogSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateSymptomDefinition: () => ({
mutateAsync: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SymptomEntryForm } from "../symptom-entry-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SymptomEntryForm />
</I18nProvider>,
);
}
describe("<SymptomEntryForm>", () => {
it("lists the person's symptoms as chips, nothing chosen, with the add chip", () => {
state.canManage = true;
state.episodes = [];
const html = render();
expect(html.match(/data-testid="symptom-chip"/g)).toHaveLength(2);
expect(html).toContain("Aura");
expect(html).toContain("Headache");
expect(html).not.toContain('aria-checked="true"');
expect(html).toContain('data-testid="symptom-add-chip"');
// The intensity is a slider on the 0-10 scale.
expect(html).toContain('data-slot="slider-field"');
expect(html).toContain('aria-valuemax="10"');
});
it("a delegate who may log but not manage gets no add chip", () => {
state.canManage = false;
expect(render()).not.toContain('data-testid="symptom-add-chip"');
});
it("offers the episode selector only while an episode is open", () => {
state.canManage = true;
state.episodes = [{ id: "e1", label: "Migraine", resolvedAt: null }];
expect(render()).toContain("-episode");
});
it("offers no episode selector when every episode is resolved", () => {
state.episodes = [
{ id: "e1", label: "Cold", resolvedAt: "2026-08-08T00:00:00.000Z" },
];
expect(render()).not.toContain("-episode");
});
});
@@ -0,0 +1,165 @@
"use client";
/**
* The dashed ghost chip at the end of the symptom row that opens a compact
* name + icon popover to define a new symptom. The cycle log sheet's
* `AddSymptomChip`, the same shape and the same popover, so defining a symptom
* reads the same wherever it happens.
*/
import { useState } from "react";
import { Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
import { useCreateSymptomDefinition } from "./use-symptoms";
export function AddSymptomChip({
onCreated,
}: {
onCreated: (id: string) => void;
}) {
const { t } = useTranslations();
const create = useCreateSymptomDefinition();
const [open, setOpen] = useState(false);
const [label, setLabel] = useState("");
const [icon, setIcon] = useState<string>("Tag");
function reset() {
setLabel("");
setIcon("Tag");
create.reset();
}
async function handleCreate() {
const trimmed = label.trim();
if (!trimmed) return;
try {
const created = await create.mutateAsync({ label: trimmed, icon });
// Selected straight away: defining one is almost always followed by
// logging it.
onCreated(created.id);
reset();
setOpen(false);
} catch {
// `create.error` drives the inline message below.
}
}
const limitReached =
create.error instanceof ApiError &&
create.error.meta?.errorCode === "symptoms.definition.limitReached";
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<PopoverTrigger asChild>
<button
type="button"
data-testid="symptom-add-chip"
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8"
>
<Plus className="size-3.5" aria-hidden="true" />
{t("symptoms.add")}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new-symptom-label" className="text-xs font-medium">
{t("symptoms.nameLabel")}
</Label>
<Input
id="new-symptom-label"
value={label}
maxLength={40}
autoFocus
placeholder={t("symptoms.namePlaceholder")}
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("symptoms.iconLabel")}</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(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>
{create.isError ? (
<p className="text-destructive text-sm" role="alert">
{limitReached
? t("symptoms.limitReached")
: t("symptoms.saveError")}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
reset();
setOpen(false);
}}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={!label.trim() || create.isPending}
>
{create.isPending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : null}
{t("symptoms.add")}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,135 @@
"use client";
/**
* "Symptoms logged" on an episode's detail page (v1.40): the person's own
* symptom occurrences that belong to this episode, either because they were
* filed against it or because they happened while it ran (onset to recovery,
* or to now while it is open). The filed ones say so. Renders nothing when
* there are none, so an episode without them looks exactly as before.
*
* This is the reader of `SymptomEvent.episodeId`; the quick-entry sheet's
* "during" selector is its writer.
*/
import { useMemo, useState } from "react";
import { Activity } from "lucide-react";
import { TileHeader } from "@/components/insights/tile-header";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { SymptomEventDTO } from "@/lib/symptoms/shared";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
groupEventsByDay,
SymptomEventMenu,
SymptomEventRow,
} from "./symptoms-section";
import { useSymptomDefinitions, useSymptomEvents } from "./use-symptoms";
export function EpisodeSymptomsCard({
episode,
}: {
episode: { id: string; onsetAt: string; resolvedAt: string | null };
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const { canManageDomain } = useRecordCapabilities();
const canManage = canManageDomain("illness");
const timezone = user?.timezone ?? DEFAULT_TIMEZONE;
// The span is fixed per episode state, so the key stays stable while the
// page is open; an open episode reads up to a few minutes past mount.
const [openUntil] = useState(() =>
new Date(Date.now() + 10 * 60 * 1000).toISOString(),
);
const range = {
from: episode.onsetAt,
to: episode.resolvedAt ?? openUntil,
};
const definitions = useSymptomDefinitions(true);
const inSpan = useSymptomEvents(range.from, range.to);
const filed = useSymptomEvents("1900-01-01T00:00:00.000Z", range.to, {
episodeId: episode.id,
});
const merged = useMemo(() => {
const byId = new Map<string, SymptomEventDTO>();
for (const event of [
...(inSpan.data?.events ?? []),
...(filed.data?.events ?? []),
]) {
byId.set(event.id, event);
}
return [...byId.values()].sort((a, b) =>
a.occurredAt < b.occurredAt ? 1 : a.occurredAt > b.occurredAt ? -1 : 0,
);
}, [inSpan.data, filed.data]);
if (definitions.isError || inSpan.isError || filed.isError) {
return (
<QueryErrorCard
title={t("symptoms.section.loadError")}
onRetry={() => {
void definitions.refetch();
void inSpan.refetch();
void filed.refetch();
}}
/>
);
}
if (merged.length === 0) return null;
const byId = new Map(
(definitions.data?.definitions ?? []).map((d) => [d.id, d]),
);
const days = groupEventsByDay(merged, timezone);
return (
<Card data-testid="episode-symptoms-card">
<CardHeader>
<TileHeader
icon={Activity}
title={t("symptoms.episode.title")}
titleAs="h2"
/>
</CardHeader>
<CardContent className="space-y-3">
{days.map((group) => (
<section key={group.day} className="space-y-0.5">
<h3 className="text-sm font-medium">
{fmt.dateWithWeekdaySmart(group.at)}
</h3>
<ul className="divide-border divide-y">
{group.events.map((event) => {
const definition = byId.get(event.definitionId);
const label =
definition?.label ?? t("symptoms.unreadableLabel");
return (
<SymptomEventRow
key={event.id}
event={event}
definition={definition}
annotation={
event.episodeId === episode.id
? t("symptoms.episode.filed")
: undefined
}
actions={
canManage ? (
<SymptomEventMenu event={event} label={label} />
) : undefined
}
/>
);
})}
</ul>
</section>
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,267 @@
"use client";
/**
* The two edit sheets of the symptoms section (v1.40): one occurrence (how
* strong, when, the note) and one symptom (its name and icon). Same
* `ResponsiveSheet` + `FieldGroup` + `SliderField` vocabulary as the entry
* form, with Cancel + Save in the sheet footer like the illness day sheet.
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SliderField } from "@/components/ui/slider";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
SYMPTOM_ICON_ALLOWLIST,
SYMPTOM_INTENSITY_MAX,
SYMPTOM_INTENSITY_MIN,
type SymptomDefinitionDTO,
type SymptomEventDTO,
} from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
import {
useUpdateSymptomDefinition,
useUpdateSymptomEvent,
} from "./use-symptoms";
/** An instant as the `DateTimeField` value: local wall time, minute grain. */
function toLocalValue(iso: string): string {
const at = new Date(iso);
const local = new Date(at.getTime() - at.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function nowLocalValue(): string {
return toLocalValue(new Date().toISOString());
}
export function EditSymptomEventSheet({
event,
label,
onClose,
}: {
event: SymptomEventDTO;
label: string;
onClose: () => void;
}) {
const { t } = useTranslations();
const update = useUpdateSymptomEvent();
const [intensity, setIntensity] = useState<number>(event.intensity);
const [occurredAt, setOccurredAt] = useState(toLocalValue(event.occurredAt));
const [note, setNote] = useState(event.note ?? "");
const [error, setError] = useState<string | null>(null);
const low = t("symptoms.entry.anchorLow");
const high = t("symptoms.entry.anchorHigh");
async function handleSave() {
setError(null);
try {
await update.mutateAsync({
id: event.id,
input: {
intensity,
occurredAt: new Date(occurredAt).toISOString(),
note: note.trim() ? note.trim() : null,
},
});
onClose();
} catch (err) {
setError(localizedApiError(err, t, "symptoms.saveError"));
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => {
if (!open) onClose();
}}
title={t("symptoms.section.editEventTitle", { symptom: label })}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<SliderField
label={t("symptoms.entry.intensity")}
value={intensity}
onValueChange={setIntensity}
min={SYMPTOM_INTENSITY_MIN}
max={SYMPTOM_INTENSITY_MAX}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("symptoms.entry.intensityUnset")}
valueText={t("symptoms.entry.intensityValueText", {
value: intensity,
max: SYMPTOM_INTENSITY_MAX,
low,
high,
})}
/>
<FieldGroup
htmlFor="symptom-event-occurred-at"
label={t("symptoms.entry.when")}
>
<DateTimeField
id="symptom-event-occurred-at"
value={occurredAt}
onChange={setOccurredAt}
max={nowLocalValue()}
required
/>
</FieldGroup>
<FieldGroup
htmlFor="symptom-event-note"
label={
<>
{t("symptoms.entry.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</>
}
>
<Input
id="symptom-event-note"
value={note}
maxLength={500}
onChange={(e) => setNote(e.target.value)}
autoCapitalize="sentences"
/>
</FieldGroup>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}
export function EditSymptomDefinitionSheet({
definition,
onClose,
}: {
definition: SymptomDefinitionDTO;
onClose: () => void;
}) {
const { t } = useTranslations();
const update = useUpdateSymptomDefinition();
const [label, setLabel] = useState(definition.label ?? "");
const [icon, setIcon] = useState<string>(definition.icon ?? "Tag");
const [labelError, setLabelError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
setError(null);
const trimmed = label.trim();
if (!trimmed) {
setLabelError(t("symptoms.nameRequired"));
return;
}
try {
await update.mutateAsync({
id: definition.id,
input: { label: trimmed, icon },
});
onClose();
} catch (err) {
setError(localizedApiError(err, t, "symptoms.saveError"));
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => {
if (!open) onClose();
}}
title={t("symptoms.section.editSymptomTitle")}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<FieldGroup
htmlFor="symptom-definition-label"
label={t("symptoms.nameLabel")}
error={labelError}
>
<Input
id="symptom-definition-label"
value={label}
maxLength={40}
aria-invalid={labelError ? true : undefined}
aria-describedby={
labelError ? "symptom-definition-label-error" : undefined
}
onChange={(e) => {
setLabel(e.target.value);
setLabelError(null);
}}
/>
</FieldGroup>
<div className="space-y-2">
<Label>{t("symptoms.iconLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(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-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
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>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,395 @@
"use client";
/**
* Log one occurrence of a symptom the person defined (v1.40): which one, how
* strong (0-10, the scale the pain score uses), when, an optional note, and,
* only while an illness episode is open, which episode it happened during.
*
* Built like the measurement and mood quick-entry forms it sits beside: the
* `FieldGroup` rhythm, `SliderField` for the bounded scale, `DateTimeField`
* prefilled with now, the note with its counter, inline field errors, and the
* kebab + Cancel + Save action row portalled into the sheet's footer slot so
* it stays above the phone keyboard.
*/
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SliderField } from "@/components/ui/slider";
import {
useActiveRecordName,
useRecordCapabilities,
} from "@/hooks/use-record-capabilities";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
SYMPTOM_INTENSITY_MAX,
SYMPTOM_INTENSITY_MIN,
} from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { AddSymptomChip } from "./add-symptom-chip";
import { SymptomIcon } from "./symptom-icons";
import { useLogSymptomEvent, useSymptomDefinitions } from "./use-symptoms";
const NOTE_MAX_LENGTH = 500;
const NO_EPISODE = "none";
function nowLocalValue(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
interface SymptomEntryFormProps {
onSuccess?: () => void;
onCancel?: () => void;
/** The sheet's footer slot; the action row portals into it. */
footerSlot?: HTMLElement | null;
}
export function SymptomEntryForm({
onSuccess,
onCancel,
footerSlot,
}: SymptomEntryFormProps) {
const { t } = useTranslations();
const recordName = useActiveRecordName();
const { canManageDomain } = useRecordCapabilities();
const canDefine = canManageDomain("illness");
const definitions = useSymptomDefinitions(false);
const episodes = useIllnessEpisodes(false);
const log = useLogSymptomEvent();
const [definitionId, setDefinitionId] = useState<string | null>(null);
const [intensity, setIntensity] = useState<number | null>(null);
const [occurredAt, setOccurredAt] = useState(nowLocalValue);
const [note, setNote] = useState("");
const [episodeId, setEpisodeId] = useState<string>(NO_EPISODE);
const [symptomError, setSymptomError] = useState<string | null>(null);
const [intensityError, setIntensityError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const errorId = useId();
const list = definitions.data?.definitions ?? [];
const limit = definitions.data?.limit ?? 0;
const atLimit = limit > 0 && list.length >= limit;
// Only an open episode can take a new occurrence; the server refuses the
// rest, so the selector never offers them.
const openEpisodes = (episodes.data ?? []).filter(
(episode) => episode.resolvedAt === null,
);
function resetForm() {
setDefinitionId(null);
setIntensity(null);
setOccurredAt(nowLocalValue());
setNote("");
setEpisodeId(NO_EPISODE);
setSymptomError(null);
setIntensityError(null);
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const missingSymptom = definitionId === null;
const missingIntensity = intensity === null;
setSymptomError(
missingSymptom ? t("symptoms.entry.symptomRequired") : null,
);
setIntensityError(
missingIntensity ? t("symptoms.entry.intensityRequired") : null,
);
if (missingSymptom || missingIntensity) return;
try {
await log.mutateAsync({
definitionId,
intensity,
occurredAt: new Date(occurredAt).toISOString(),
note: note.trim() ? note.trim() : undefined,
episodeId: episodeId === NO_EPISODE ? undefined : episodeId,
});
toast.success(
t("common.saved"),
recordName
? {
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: undefined,
);
resetForm();
onSuccess?.();
} catch (err) {
setError(localizedApiError(err, t, "symptoms.saveError"));
}
}
const footerNode = (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={log.isPending}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetForm}>
<RotateCcw className="mr-2 size-4" />
{t("symptoms.entry.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={log.isPending}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={log.isPending}>
{log.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="size-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
const symptomGroupId = `${formId}-symptom`;
const low = t("symptoms.entry.anchorLow");
const high = t("symptoms.entry.anchorHigh");
return (
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
data-testid="symptom-entry-form"
>
<div className="space-y-2">
<Label id={symptomGroupId}>{t("symptoms.entry.symptom")}</Label>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
{symptomError ? (
<p
id={`${symptomGroupId}-error`}
role="alert"
className="text-destructive text-sm"
>
{symptomError}
</p>
) : null}
</div>
<div className="space-y-2">
<SliderField
label={t("symptoms.entry.intensity")}
value={intensity}
onValueChange={(next) => {
setIntensity(next);
setIntensityError(null);
}}
min={SYMPTOM_INTENSITY_MIN}
max={SYMPTOM_INTENSITY_MAX}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("symptoms.entry.intensityUnset")}
valueText={
intensity === null
? undefined
: t("symptoms.entry.intensityValueText", {
value: intensity,
max: SYMPTOM_INTENSITY_MAX,
low,
high,
})
}
/>
{intensityError ? (
<p role="alert" className="text-destructive text-sm">
{intensityError}
</p>
) : null}
</div>
<FieldGroup
htmlFor={`${formId}-occurred-at`}
label={t("symptoms.entry.when")}
hint={t("measurements.timestampBackdateHint")}
>
<DateTimeField
id={`${formId}-occurred-at`}
value={occurredAt}
onChange={setOccurredAt}
max={nowLocalValue()}
required
aria-required="true"
/>
</FieldGroup>
{openEpisodes.length > 0 ? (
<FieldGroup
htmlFor={`${formId}-episode`}
label={
<>
{t("symptoms.entry.during")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</>
}
>
<Select value={episodeId} onValueChange={setEpisodeId}>
<SelectTrigger id={`${formId}-episode`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_EPISODE}>
{t("symptoms.entry.duringNone")}
</SelectItem>
{openEpisodes.map((episode) => (
<SelectItem key={episode.id} value={episode.id}>
{episode.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldGroup>
) : null}
<FieldGroup
htmlFor={`${formId}-note`}
label={
<>
{t("symptoms.entry.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</>
}
labelAccessory={
<span className="text-muted-foreground text-xs">
{note.length}/{NOTE_MAX_LENGTH}
</span>
}
>
<Input
id={`${formId}-note`}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("symptoms.entry.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
enterKeyHint="done"
autoCapitalize="sentences"
/>
</FieldGroup>
{error && (
<div
id={errorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
</form>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { createElement } from "react";
import {
Activity,
BatteryLow,
Brain,
CircleDot,
Cookie,
Drama,
Droplet,
Flame,
Frown,
Heart,
HeartPulse,
MoonStar,
PersonStanding,
Pill,
Snowflake,
Soup,
Stethoscope,
Tag,
Thermometer,
Zap,
type LucideIcon,
} from "lucide-react";
import type { SymptomIconName } from "@/lib/symptoms/shared";
/**
* A symptom's stored icon NAME to its Lucide component. Keyed by the
* allowlist type, so an icon added to the allowlist without a component here
* does not compile. The same names the cycle custom symptoms use and the iOS
* client maps.
*/
const SYMPTOM_ICON_BY_NAME = {
Tag,
Activity,
Heart,
HeartPulse,
Brain,
Zap,
Flame,
Snowflake,
Droplet,
CircleDot,
BatteryLow,
MoonStar,
PersonStanding,
Drama,
Frown,
Cookie,
Soup,
Pill,
Thermometer,
Stethoscope,
} satisfies Record<SymptomIconName, LucideIcon>;
/** The component for a stored icon name; `Tag` for none or an unknown one. */
export function symptomIcon(name: string | null | undefined): LucideIcon {
return (
(name && (SYMPTOM_ICON_BY_NAME as Record<string, LucideIcon>)[name]) || Tag
);
}
/**
* A symptom's icon as an element. Looked up and created in one call so a
* render never declares a component of its own.
*/
export function SymptomIcon({
name,
className,
}: {
name: string | null | undefined;
className?: string;
}) {
return createElement(symptomIcon(name), {
className,
"aria-hidden": true,
});
}
@@ -0,0 +1,524 @@
"use client";
/**
* "Your symptoms" on the illness page (v1.40): the person's own symptoms with
* a one-line summary each, and every occurrence of the last 30 days grouped by
* day. Renders nothing until a symptom has been defined, so an illness page
* that never used it looks exactly as before.
*
* One `Card` + `TileHeader`, rows divided like the illness day timeline, the
* intensity as a neutral `TagChip` ("6/10"), and the same kebab + confirm
* vocabulary as the episode menu.
*/
import { useState } from "react";
import {
Activity,
Eye,
EyeOff,
MoreVertical,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { TileHeader } from "@/components/insights/tile-header";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TagChip } from "@/components/ui/tag-chip";
import { QuickEntrySheets } from "@/components/dashboard/quick-entry-sheets";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import type {
SymptomDefinitionDTO,
SymptomEventDTO,
} from "@/lib/symptoms/shared";
import {
EditSymptomDefinitionSheet,
EditSymptomEventSheet,
} from "./symptom-edit-sheets";
import { SymptomIcon } from "./symptom-icons";
import {
useDeleteSymptomDefinition,
useDeleteSymptomEvent,
useSymptomDefinitions,
useSymptomEvents,
useUpdateSymptomDefinition,
} from "./use-symptoms";
const DAY_MS = 24 * 60 * 60 * 1000;
/** The window the section reads, fixed at mount so the query key is stable. */
function useThirtyDayWindow(): { from: string; to: string } {
const [range] = useState(() => {
const now = Date.now();
return {
from: new Date(now - 30 * DAY_MS).toISOString(),
// A little slack so an occurrence logged a minute ago with a phone clock
// running fast is still inside the window.
to: new Date(now + 10 * 60 * 1000).toISOString(),
};
});
return range;
}
/** One occurrence row: time, symptom, intensity, note, and its actions. */
export function SymptomEventRow({
event,
definition,
annotation,
actions,
}: {
event: SymptomEventDTO;
definition: SymptomDefinitionDTO | undefined;
/** A short muted tag after the time, e.g. "filed with this episode". */
annotation?: string;
actions?: React.ReactNode;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
return (
<li
className="flex items-start justify-between gap-3 py-2.5"
data-testid="symptom-event-row"
>
<div className="flex min-w-0 flex-1 items-start gap-2">
<SymptomIcon
name={definition?.icon}
className="text-muted-foreground mt-0.5 size-4 shrink-0"
/>
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium">
{definition?.label ?? t("symptoms.unreadableLabel")}
</span>
<TagChip>
{t("symptoms.intensityPill", { value: event.intensity })}
</TagChip>
</div>
<p className="text-muted-foreground text-xs">
{fmt.time(new Date(event.occurredAt))}
{annotation ? ` · ${annotation}` : ""}
</p>
{event.note ? (
<p className="text-sm break-words">{event.note}</p>
) : null}
</div>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</li>
);
}
/** Occurrences grouped by the person's own calendar day, newest day first. */
export function groupEventsByDay(
events: SymptomEventDTO[],
timezone: string,
): Array<{ day: string; at: Date; events: SymptomEventDTO[] }> {
const groups = new Map<string, SymptomEventDTO[]>();
for (const event of events) {
const key = userDayKey(new Date(event.occurredAt), timezone);
const bucket = groups.get(key) ?? [];
bucket.push(event);
groups.set(key, bucket);
}
return [...groups.entries()]
.sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
.map(([day, list]) => ({
day,
at: new Date(list[0].occurredAt),
events: list,
}));
}
function summaryLine(
definition: SymptomDefinitionDTO,
i18n: ReturnType<typeof useTranslations>,
fmt: ReturnType<typeof useFormatters>,
): string {
const { t, tCount } = i18n;
const { count30d, maxIntensity30d, lastOccurredAt } = definition.recent;
if (count30d === 0) {
return lastOccurredAt
? t("symptoms.section.summaryNoneRecent", {
date: fmt.dateShortSmart(new Date(lastOccurredAt)),
})
: t("symptoms.section.summaryNever");
}
return tCount("symptoms.section.summary", count30d, {
max: maxIntensity30d ?? 0,
// Non-null whenever something happened in the window.
date: lastOccurredAt ? fmt.dateShortSmart(new Date(lastOccurredAt)) : "",
});
}
function DefinitionMenu({
definition,
onEdit,
}: {
definition: SymptomDefinitionDTO;
onEdit: () => void;
}) {
const { t } = useTranslations();
const update = useUpdateSymptomDefinition();
const del = useDeleteSymptomDefinition();
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={t("symptoms.section.menuLabel", {
symptom: definition.label ?? "",
})}
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onEdit}>
<Pencil className="size-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
update.mutate({
id: definition.id,
input: { isActive: !definition.isActive },
})
}
>
{definition.isActive ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
{definition.isActive
? t("symptoms.section.hide")
: t("symptoms.section.show")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={(e) => {
e.preventDefault();
setConfirmOpen(true);
}}
>
<Trash2 className="size-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("symptoms.section.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("symptoms.section.deleteBody", {
symptom: definition.label ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={del.isPending}
onClick={() =>
del.mutate(
{ id: definition.id, purge: true },
{ onSuccess: () => setConfirmOpen(false) },
)
}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
/** The per-occurrence kebab: edit, delete (confirmed). */
export function SymptomEventMenu({
event,
label,
}: {
event: SymptomEventDTO;
label: string;
}) {
const { t } = useTranslations();
const del = useDeleteSymptomEvent();
const [editing, setEditing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={t("symptoms.section.eventMenuLabel", {
symptom: label,
})}
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setEditing(true)}>
<Pencil className="size-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={(e) => {
e.preventDefault();
setConfirmOpen(true);
}}
>
<Trash2 className="size-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{editing ? (
<EditSymptomEventSheet
event={event}
label={label}
onClose={() => setEditing(false)}
/>
) : null}
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("symptoms.section.deleteEventTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("symptoms.section.deleteEventBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={del.isPending}
onClick={() =>
del.mutate(event.id, {
onSuccess: () => setConfirmOpen(false),
})
}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export function SymptomsSection() {
const i18n = useTranslations();
const { t, tCount } = i18n;
const fmt = useFormatters();
const { user } = useAuth();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canLog = canWriteDomain("illness");
const canManage = canManageDomain("illness");
const timezone = user?.timezone ?? DEFAULT_TIMEZONE;
const range = useThirtyDayWindow();
const definitions = useSymptomDefinitions(true);
const events = useSymptomEvents(range.from, range.to);
const [logOpen, setLogOpen] = useState(false);
const [editingDefinition, setEditingDefinition] =
useState<SymptomDefinitionDTO | null>(null);
const all = definitions.data?.definitions ?? [];
const byId = new Map(all.map((d) => [d.id, d]));
const active = all.filter((d) => d.isActive);
const hidden = all.filter((d) => !d.isActive);
const days = groupEventsByDay(events.data?.events ?? [], timezone);
if (definitions.isLoading) return null;
if (definitions.isError || events.isError) {
return (
<QueryErrorCard
title={t("symptoms.section.loadError")}
onRetry={() => {
void definitions.refetch();
void events.refetch();
}}
/>
);
}
// Nothing defined yet: the page stays as it was; the quick entry is where a
// first symptom is defined.
if (all.length === 0) return null;
return (
<Card data-testid="symptoms-section">
<CardHeader>
<TileHeader
icon={Activity}
title={t("symptoms.section.title")}
titleAs="h2"
right={
canLog ? (
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setLogOpen(true)}
>
<Plus className="size-4" />
{t("symptoms.section.log")}
</Button>
) : null
}
/>
</CardHeader>
<CardContent className="space-y-4">
<ul className="divide-border divide-y">
{active.map((definition) => {
return (
<li
key={definition.id}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex min-w-0 items-center gap-2">
<SymptomIcon
name={definition.icon}
className="text-muted-foreground size-4 shrink-0"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{definition.label ?? t("symptoms.unreadableLabel")}
</p>
<p className="text-muted-foreground text-xs">
{summaryLine(definition, i18n, fmt)}
</p>
</div>
</div>
{canManage ? (
<DefinitionMenu
definition={definition}
onEdit={() => setEditingDefinition(definition)}
/>
) : null}
</li>
);
})}
</ul>
{hidden.length > 0 && canManage ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{tCount("symptoms.section.hidden", hidden.length)}
</p>
<ul className="divide-border divide-y">
{hidden.map((definition) => (
<li
key={definition.id}
className="flex items-center justify-between gap-3 py-1.5"
>
<span className="text-muted-foreground truncate text-sm">
{definition.label ?? t("symptoms.unreadableLabel")}
</span>
<DefinitionMenu
definition={definition}
onEdit={() => setEditingDefinition(definition)}
/>
</li>
))}
</ul>
</div>
) : null}
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("symptoms.section.recent")}
</p>
{days.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("symptoms.section.recentEmpty")}
</p>
) : (
<div className="space-y-3">
{days.map((group) => (
<section key={group.day} className="space-y-0.5">
<h3 className="text-sm font-medium">
{fmt.dateWithWeekdaySmart(group.at)}
</h3>
<ul className="divide-border divide-y">
{group.events.map((event) => {
const definition = byId.get(event.definitionId);
const label =
definition?.label ?? t("symptoms.unreadableLabel");
return (
<SymptomEventRow
key={event.id}
event={event}
definition={definition}
actions={
canManage ? (
<SymptomEventMenu event={event} label={label} />
) : undefined
}
/>
);
})}
</ul>
</section>
))}
</div>
)}
</div>
</CardContent>
{/* The dashboard's own quick-entry sheet, so logging from here is the
same sheet, with the same discard guard, as from the Add menu. */}
<QuickEntrySheets
open={logOpen ? "symptom" : null}
onClose={() => setLogOpen(false)}
/>
{editingDefinition ? (
<EditSymptomDefinitionSheet
definition={editingDefinition}
onClose={() => setEditingDefinition(null)}
/>
) : null}
</Card>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
/**
* v1.40 — person-defined symptoms: read and write hooks.
*
* Reads unwrap the envelope through `apiGet`; every key comes from the
* factory (`queryKeys.symptom*`, under the `["illness"]` root). Every write
* evicts the whole illness tree, because the episode detail lists the
* occurrences filed against it and the definition summaries count them.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
SymptomDefinitionDTO,
SymptomEventDTO,
} from "@/lib/symptoms/shared";
export interface SymptomDefinitionList {
definitions: SymptomDefinitionDTO[];
limit: number;
}
export interface SymptomEventList {
events: SymptomEventDTO[];
limit: number;
hasMore: boolean;
}
export interface SymptomEventCreateInput {
definitionId: string;
intensity: number;
occurredAt?: string;
note?: string | null;
episodeId?: string | null;
}
export interface SymptomEventUpdateInput {
occurredAt?: string;
intensity?: number;
note?: string | null;
episodeId?: string | null;
}
/** The record's symptom list; `includeHidden` adds the hidden ones. */
export function useSymptomDefinitions(includeHidden = false, enabled = true) {
return useQuery({
queryKey: queryKeys.symptomDefinitions(includeHidden),
enabled,
queryFn: () =>
apiGet<SymptomDefinitionList>(
`/api/symptoms/definitions?includeHidden=${includeHidden}`,
),
});
}
/**
* Occurrences inside `[from, to]` (ISO instants), optionally only those filed
* against one episode. At most 500, newest first.
*/
export function useSymptomEvents(
from: string,
to: string,
options: { episodeId?: string | null; enabled?: boolean } = {},
) {
const episodeId = options.episodeId ?? null;
return useQuery({
queryKey: queryKeys.symptomEvents(from, to, episodeId),
enabled: options.enabled ?? true,
queryFn: () => {
const params = new URLSearchParams({ from, to, limit: "500" });
if (episodeId) params.set("episodeId", episodeId);
return apiGet<SymptomEventList>(`/api/symptoms/events?${params}`);
},
});
}
function useInvalidateSymptoms() {
const qc = useQueryClient();
return () => invalidateKeys(qc, [queryKeys.illness()]);
}
export function useCreateSymptomDefinition() {
const invalidate = useInvalidateSymptoms();
return useMutation({
mutationFn: (input: { label: string; icon?: string | null }) =>
apiPost<SymptomDefinitionDTO>("/api/symptoms/definitions", input),
onSuccess: invalidate,
});
}
export function useUpdateSymptomDefinition() {
const invalidate = useInvalidateSymptoms();
return useMutation({
mutationFn: ({
id,
input,
}: {
id: string;
input: {
label?: string;
icon?: string | null;
isActive?: boolean;
sortOrder?: number;
};
}) =>
apiPatch<SymptomDefinitionDTO>(`/api/symptoms/definitions/${id}`, input),
onSuccess: invalidate,
});
}
export function useDeleteSymptomDefinition() {
const invalidate = useInvalidateSymptoms();
return useMutation({
mutationFn: ({ id, purge }: { id: string; purge: boolean }) =>
apiDelete<{ deleted: true; purged: boolean; eventsDeleted: number }>(
`/api/symptoms/definitions/${id}${purge ? "?purge=true" : ""}`,
),
onSuccess: invalidate,
});
}
export function useLogSymptomEvent() {
const invalidate = useInvalidateSymptoms();
return useMutation({
mutationFn: (input: SymptomEventCreateInput) =>
apiPost<SymptomEventDTO>("/api/symptoms/events", input),
onSuccess: invalidate,
});
}
export function useUpdateSymptomEvent() {
const invalidate = useInvalidateSymptoms();
return useMutation({
mutationFn: ({
id,
input,
}: {
id: string;
input: SymptomEventUpdateInput;
}) => apiPatch<SymptomEventDTO>(`/api/symptoms/events/${id}`, input),
onSuccess: invalidate,
});
}
export function useDeleteSymptomEvent() {
const invalidate = useInvalidateSymptoms();
return useMutation({
mutationFn: (id: string) =>
apiDelete<{ deleted: true }>(`/api/symptoms/events/${id}`),
onSuccess: invalidate,
});
}
@@ -263,6 +263,7 @@ describe("every surface that shows a module's data is owned by it", () => {
["capture:mood", "mood"],
["capture:medication", "medications"],
["capture:workout", "workouts"],
["capture:symptom", "illness"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
+2
View File
@@ -124,6 +124,8 @@ const STATIC_SURFACE_MODULE = {
"capture:mood": "mood",
"capture:medication": "medications",
"capture:workout": "workouts",
// v1.40 — an occurrence of a person-defined symptom rides the illness module.
"capture:symptom": "illness",
// ── Dashboard widgets ──
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
+11
View File
@@ -48,4 +48,15 @@ export const illnessKeys = {
*/
illnessInsights: (windowDays: number, includeRecoveryGap: boolean) =>
["illness", "insights", windowDays, includeRecoveryGap] as const,
/**
* v1.40 — person-defined symptoms. Under the `["illness"]` root because they
* ride the illness module and the episode detail reads them: an illness
* write and a symptom write each evict the whole tree. The definition list
* with and without hidden ones are two payloads, so two keys.
*/
symptomDefinitions: (includeHidden: boolean) =>
["illness", "symptoms", "definitions", includeHidden] as const,
/** One window of occurrences; the window bounds and the filters are the key. */
symptomEvents: (from: string, to: string, episodeId: string | null) =>
["illness", "symptoms", "events", from, to, episodeId] as const,
};