Files
HealthLog/src/components/illness/log-day-sheet.tsx
T
Marc-André Bombeck b079431a8d feat(illness): add the condition-journal MVP behind a born-gated module
Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.

- Register `illness` as a born-gated (opt-in / default-off) module. The
  gate resolver flips the disabled-allowlist default to off for born-gated
  keys, so the surface stays dark until the account opts in from the
  Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
  module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
  delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
  route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
  userId from auth, builds data field-by-field, and gates the module.
  Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
  functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
  via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
  the day-log upsert, the gate enforcement, and the born-gated resolver.

The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).
2026-06-16 21:09:35 +02:00

343 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* v1.18.1 — illness day-log sheet. The daily symptom timeline for one
* episode-day: ~8 Jackson/WURSS symptom chips with a 0–3 severity, a
* coarse functional-impact chip group (0 = fully functional … 3 =
* bedbound), an optional fever reading, and an encrypted free-text note.
*
* Structurally a fork of the cycle `log-day-sheet`: the shared
* `ResponsiveSheet` + `SheetSection` primitives, a `Chip`/`SymptomChip`
* vocabulary, the envelope-aware pre-fill, and the `["illness"]`-prefix
* invalidation on write. Neutral palette throughout — no alarming colour;
* status reads via discreet text, never a red tint (the med-card rule
* generalised).
*/
import { useMemo, useState } from "react";
import { Activity, NotebookPen, Thermometer } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
import type { IllnessDayLogInput } from "./types";
interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
episodeId: string;
date: string;
}
/** Mini toggle chip — the shared selection vocabulary. */
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
{children}
</button>
);
}
/** A symptom chip with an inline 0–3 severity selector once active. */
function SymptomChip({
label,
icon: Icon,
active,
severity,
onToggle,
onSeverity,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
active: boolean;
severity: number | null;
onToggle: () => void;
onSeverity: (value: number) => void;
}) {
const { t } = useTranslations();
return (
<div className="flex flex-col gap-1">
<button
type="button"
aria-pressed={active}
onClick={onToggle}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
{active ? (
<div
className="flex gap-1 pl-2"
role="group"
aria-label={t("illness.sheet.severityFor", { symptom: label })}
>
{[1, 2, 3].map((value) => (
<button
key={value}
type="button"
aria-pressed={severity === value}
onClick={() => onSeverity(value)}
className={cn(
"h-6 w-6 rounded-md border text-xs",
severity === value
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
{value}
</button>
))}
</div>
) : null}
</div>
);
}
export function LogDaySheet({
open,
onOpenChange,
episodeId,
date,
}: LogDaySheetProps) {
const { t } = useTranslations();
const dayLog = useIllnessDayLog(open ? episodeId : null, date);
const upsert = useUpsertDayLog(episodeId);
const [symptoms, setSymptoms] = useState<Map<string, number | null>>(
new Map(),
);
const [functionalImpact, setFunctionalImpact] = useState<number | null>(null);
const [fever, setFever] = useState("");
const [note, setNote] = useState("");
// Hydrate the form from the fetched DTO once per (open, date, fetched-row)
// tuple — React's recommended "adjust state during render keyed on a prop"
// alternative to a setState-in-effect (the cycle log-day-sheet precedent).
// `dto === null` (nothing logged) resets to blank.
const dto = open ? (dayLog.data ?? null) : undefined;
const formKey = open
? `${episodeId}:${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
: null;
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
setLastFormKey(formKey);
setSymptoms(
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
);
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(dto?.feverC != null ? String(dto.feverC) : "");
setNote(dto?.note ?? "");
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
}
const symptomCount = symptoms.size;
function toggleSymptom(key: string) {
setSymptoms((prev) => {
const next = new Map(prev);
if (next.has(key)) next.delete(key);
else next.set(key, null);
return next;
});
}
function setSeverity(key: string, value: number) {
setSymptoms((prev) => {
const next = new Map(prev);
next.set(key, prev.get(key) === value ? null : value);
return next;
});
}
const feverValue = useMemo(() => {
if (fever.trim() === "") return null;
const parsed = Number.parseFloat(fever.replace(",", "."));
return Number.isFinite(parsed) ? parsed : null;
}, [fever]);
async function handleSave() {
const input: IllnessDayLogInput = {
date,
functionalImpact,
feverC: feverValue,
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
key,
severity: severity ?? undefined,
})),
note: note.trim() === "" ? null : note,
};
try {
await upsert.mutateAsync(input);
onOpenChange(false);
} catch {
// Keep the sheet open; the error strip below reports it.
}
}
const impactLabels = [
t("illness.impact.0"),
t("illness.impact.1"),
t("illness.impact.2"),
t("illness.impact.3"),
];
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("illness.sheet.title", { date })}
description={t("illness.sheet.description")}
footer={
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={upsert.isPending}>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<SheetSection
title={t("illness.sheet.symptoms")}
icon={<Activity className="h-4 w-4" />}
summary={<SheetSectionCount count={symptomCount} />}
defaultOpen
>
<div className="flex flex-wrap gap-2 pt-2">
{ILLNESS_SYMPTOM_CATALOG.map((s) => (
<SymptomChip
key={s.key}
label={t(s.labelKey)}
icon={s.icon}
active={symptoms.has(s.key)}
severity={symptoms.get(s.key) ?? null}
onToggle={() => toggleSymptom(s.key)}
onSeverity={(value) => setSeverity(s.key, value)}
/>
))}
</div>
</SheetSection>
<SheetSection
title={t("illness.sheet.functionalImpact")}
icon={<Activity className="h-4 w-4" />}
summary={
functionalImpact != null ? (
<span className="text-muted-foreground text-xs">
{impactLabels[functionalImpact]}
</span>
) : null
}
>
<div className="flex flex-wrap gap-2 pt-2">
{[0, 1, 2, 3].map((value) => (
<Chip
key={value}
active={functionalImpact === value}
onClick={() =>
setFunctionalImpact(
functionalImpact === value ? null : value,
)
}
>
{impactLabels[value]}
</Chip>
))}
</div>
</SheetSection>
<SheetSection
title={t("illness.sheet.fever")}
icon={<Thermometer className="h-4 w-4" />}
summary={
feverValue != null ? (
<span className="text-muted-foreground text-xs">
{feverValue.toFixed(1)} °C
</span>
) : null
}
>
<div className="pt-2">
<Label htmlFor="illness-fever" className="sr-only">
{t("illness.sheet.fever")}
</Label>
<Input
id="illness-fever"
inputMode="decimal"
placeholder="37.0"
value={fever}
onChange={(e) => setFever(e.target.value)}
className="max-w-32"
/>
</div>
</SheetSection>
<SheetSection
title={t("illness.sheet.note")}
icon={<NotebookPen className="h-4 w-4" />}
summary={
note.trim() !== "" ? (
<span className="text-muted-foreground text-xs">
{t("illness.sheet.noteSet")}
</span>
) : null
}
>
<div className="pt-2">
<Textarea
value={note}
maxLength={2000}
onChange={(e) => setNote(e.target.value)}
placeholder={t("illness.sheet.notePlaceholder")}
rows={4}
/>
</div>
</SheetSection>
{upsert.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("illness.sheet.saveError")}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}