mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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).
This commit is contained in:
1 parent
bee7295b19
commit
b079431a8d
34 files changed
+2799
-6
No files matched your search
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the illness / condition-journal surface.
|
||||
*
|
||||
* A calm, retrospective journal: the episode history (newest first) with a
|
||||
* neutral status Badge (active / recovered — never an alarming colour, the
|
||||
* med-card rule generalised), a "log a day" entry into the day-log sheet,
|
||||
* and a "mark recovered" one-tap. Retrospective-only copy: this is a
|
||||
* journal, not a medical device, and it does not diagnose. No prediction
|
||||
* UI by design.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Plus, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
import {
|
||||
useDeleteEpisode,
|
||||
useIllnessEpisodes,
|
||||
useResolveEpisode,
|
||||
} from "./use-illness";
|
||||
import type { IllnessEpisodeDTO } from "./types";
|
||||
|
||||
/** Today as a YYYY-MM-DD string in the viewer's local calendar. */
|
||||
function todayLocal(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function EpisodeRow({
|
||||
episode,
|
||||
onLogDay,
|
||||
onResolve,
|
||||
onDelete,
|
||||
resolving,
|
||||
}: {
|
||||
episode: IllnessEpisodeDTO;
|
||||
onLogDay: (id: string) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
resolving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const active = episode.resolvedAt === null;
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{episode.label}</span>
|
||||
<Badge variant="secondary">{t(`illness.type.${episode.type}`)}</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
? isChronic
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })}
|
||||
{episode.resolvedAt
|
||||
? ` · ${t("illness.recoveredOn", {
|
||||
date: fmt.dateShort(new Date(episode.resolvedAt)),
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onLogDay(episode.id)}>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{active && !isChronic ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={resolving}
|
||||
onClick={() => onResolve(episode.id)}
|
||||
>
|
||||
{t("illness.markRecovered")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => onDelete(episode.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
const del = useDeleteEpisode();
|
||||
|
||||
const today = todayLocal();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("illness.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("illness.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("common.loading")}</p>
|
||||
) : episodes && episodes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{episodes.map((episode) => (
|
||||
<EpisodeRow
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
onDelete={(id) => del.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex flex-col items-center gap-3 p-10 text-center">
|
||||
<Stethoscope className="text-muted-foreground h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-medium">{t("illness.empty.title")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.empty.body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<NewEpisodeSheet open={newOpen} onOpenChange={setNewOpen} today={today} />
|
||||
{logEpisodeId ? (
|
||||
<LogDaySheet
|
||||
open={logEpisodeId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLogEpisodeId(null);
|
||||
}}
|
||||
episodeId={logEpisodeId}
|
||||
date={today}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the "new episode" capture sheet. A calm, minimal form: a
|
||||
* label, the broad condition type, the lifecycle, an onset date, and an
|
||||
* optional encrypted note. Retrospective-only — no prediction UI, no
|
||||
* diagnosis prompt. Neutral palette throughout.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { useCreateEpisode } from "./use-illness";
|
||||
import type { IllnessLifecycle, IllnessType } from "./types";
|
||||
|
||||
const TYPES: IllnessType[] = [
|
||||
"INFECTION",
|
||||
"ALLERGY",
|
||||
"INJURY",
|
||||
"MENTAL_HEALTH",
|
||||
"AUTOIMMUNE",
|
||||
"CHRONIC",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
const LIFECYCLES: IllnessLifecycle[] = [
|
||||
"ACUTE",
|
||||
"CHRONIC_ONGOING",
|
||||
"RECURRING",
|
||||
];
|
||||
|
||||
interface NewEpisodeSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
today: string;
|
||||
}
|
||||
|
||||
export function NewEpisodeSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
today,
|
||||
}: NewEpisodeSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateEpisode();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [type, setType] = useState<IllnessType>("INFECTION");
|
||||
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
|
||||
const [onset, setOnset] = useState(today);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
// Reset the form each time the sheet opens — adjusted during render keyed
|
||||
// on the open transition (React's recommended alternative to a
|
||||
// setState-in-effect).
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open && !wasOpen) {
|
||||
setWasOpen(true);
|
||||
setLabel("");
|
||||
setType("INFECTION");
|
||||
setLifecycle("ACUTE");
|
||||
setOnset(today);
|
||||
setNote("");
|
||||
} else if (!open && wasOpen) {
|
||||
setWasOpen(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (label.trim() === "") return;
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
label: label.trim(),
|
||||
type,
|
||||
lifecycle,
|
||||
// Anchor the date string to local noon so the UTC instant lands on
|
||||
// the intended calendar day regardless of the viewer's offset.
|
||||
onsetAt: new Date(`${onset}T12:00:00`).toISOString(),
|
||||
note: note.trim() === "" ? null : note,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
// Keep the sheet open; the error strip reports it.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("illness.new.title")}
|
||||
description={t("illness.new.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={create.isPending || label.trim() === ""}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-label">{t("illness.new.label")}</Label>
|
||||
<Input
|
||||
id="illness-label"
|
||||
value={label}
|
||||
maxLength={120}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("illness.new.labelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.type")}</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as IllnessType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`illness.type.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.lifecycle")}</Label>
|
||||
<Select
|
||||
value={lifecycle}
|
||||
onValueChange={(v) => setLifecycle(v as IllnessLifecycle)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LIFECYCLES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`illness.lifecycle.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-onset">{t("illness.new.onset")}</Label>
|
||||
<Input
|
||||
id="illness-onset"
|
||||
type="date"
|
||||
value={onset}
|
||||
max={today}
|
||||
onChange={(e) => setOnset(e.target.value)}
|
||||
className="max-w-44"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-note">{t("illness.new.note")}</Label>
|
||||
<Textarea
|
||||
id="illness-note"
|
||||
value={note}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("illness.new.notePlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{create.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("illness.new.saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* v1.18.1 — the seeded illness-symptom catalog, mirrored client-side.
|
||||
*
|
||||
* The keys are fixed at the migration seed (0171). The `key` is the
|
||||
* snake_case DB key the day-log write resolves against; the `labelKey` is
|
||||
* the i18n leaf under `illness.symptom.*`; the `icon` is the Lucide name
|
||||
* from the seed. Keep this in lockstep with the seed in
|
||||
* `prisma/migrations/0171_v1181_illness_episode/migration.sql` and the
|
||||
* `illness.symptom.*` i18n leaves.
|
||||
*
|
||||
* The set is Jackson / WURSS-derived (~8 cardinal upper-respiratory + viral
|
||||
* symptoms, 0–3 severity). Fever lives on the day row (`feverC`), not as a
|
||||
* symptom chip; functional impact is a separate slider.
|
||||
*/
|
||||
import {
|
||||
BatteryLow,
|
||||
Brain,
|
||||
Droplets,
|
||||
Flame,
|
||||
Megaphone,
|
||||
PersonStanding,
|
||||
Waves,
|
||||
Wind,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface IllnessSymptom {
|
||||
/** snake_case DB key — the value sent in `symptoms[].key`. */
|
||||
key: string;
|
||||
/** i18n leaf under `illness.symptom.*`. */
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const ILLNESS_SYMPTOM_CATALOG: IllnessSymptom[] = [
|
||||
{ key: "runny_nose", labelKey: "illness.symptom.runnyNose", icon: Droplets },
|
||||
{ key: "stuffy_nose", labelKey: "illness.symptom.stuffyNose", icon: Wind },
|
||||
{ key: "sneezing", labelKey: "illness.symptom.sneezing", icon: Waves },
|
||||
{ key: "sore_throat", labelKey: "illness.symptom.soreThroat", icon: Flame },
|
||||
{ key: "cough", labelKey: "illness.symptom.cough", icon: Megaphone },
|
||||
{ key: "headache", labelKey: "illness.symptom.headache", icon: Brain },
|
||||
{
|
||||
key: "body_aches",
|
||||
labelKey: "illness.symptom.bodyAches",
|
||||
icon: PersonStanding,
|
||||
},
|
||||
{ key: "fatigue", labelKey: "illness.symptom.fatigue", icon: BatteryLow },
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Client-side illness DTO mirrors (v1.18.1) — the wire shapes the
|
||||
* `/api/illness/*` routes return. Kept in lockstep with
|
||||
* `src/lib/illness/dto.ts` (the server-authoritative source).
|
||||
*/
|
||||
export type IllnessType =
|
||||
| "INFECTION"
|
||||
| "ALLERGY"
|
||||
| "INJURY"
|
||||
| "MENTAL_HEALTH"
|
||||
| "AUTOIMMUNE"
|
||||
| "CHRONIC"
|
||||
| "OTHER";
|
||||
|
||||
export type IllnessLifecycle =
|
||||
| "ACUTE"
|
||||
| "CHRONIC_ONGOING"
|
||||
| "RECURRING"
|
||||
| "FLARE";
|
||||
|
||||
export interface IllnessSymptomSelection {
|
||||
key: string;
|
||||
severity?: number | null;
|
||||
}
|
||||
|
||||
export interface IllnessEpisodeDTO {
|
||||
id: string;
|
||||
label: string;
|
||||
type: IllnessType;
|
||||
lifecycle: IllnessLifecycle;
|
||||
onsetAt: string;
|
||||
resolvedAt: string | null;
|
||||
parentConditionId: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IllnessDayLogDTO {
|
||||
id: string;
|
||||
episodeId: string;
|
||||
date: string;
|
||||
functionalImpact: number | null;
|
||||
feverC: number | null;
|
||||
symptoms: IllnessSymptomSelection[];
|
||||
note: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IllnessEpisodeCreateInput {
|
||||
label: string;
|
||||
type: IllnessType;
|
||||
lifecycle?: IllnessLifecycle;
|
||||
onsetAt?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface IllnessDayLogInput {
|
||||
date: string;
|
||||
functionalImpact?: number | null;
|
||||
feverC?: number | null;
|
||||
symptoms?: IllnessSymptomSelection[];
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — illness / condition-journal read + write hooks.
|
||||
*
|
||||
* Reads unwrap the envelope `data` (via `apiGet`/`apiPost`) per the project
|
||||
* rule; every key is factory-routed through `queryKeys.illness*`. Writes
|
||||
* invalidate the whole `["illness"]` prefix so the episode history list and
|
||||
* an open day-log sheet repaint in lockstep after a log.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type {
|
||||
IllnessDayLogDTO,
|
||||
IllnessDayLogInput,
|
||||
IllnessEpisodeCreateInput,
|
||||
IllnessEpisodeDTO,
|
||||
} from "./types";
|
||||
|
||||
/** Newest-first episode list. */
|
||||
export function useIllnessEpisodes(includeResolved = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.illnessEpisodes(includeResolved),
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=${includeResolved}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** One episode's day-log for a given date (null when nothing is logged). */
|
||||
export function useIllnessDayLog(episodeId: string | null, date: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.illnessDayLog(episodeId ?? "none", date),
|
||||
enabled: episodeId !== null,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessDayLogDTO | null>(
|
||||
`/api/illness/episodes/${episodeId}/day-logs?date=${date}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateIllness() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });
|
||||
}
|
||||
|
||||
/** Create an episode. */
|
||||
export function useCreateEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (input: IllnessEpisodeCreateInput) =>
|
||||
apiPost<IllnessEpisodeDTO>("/api/illness/episodes", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark an episode recovered. */
|
||||
export function useResolveEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/resolve`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete an episode. */
|
||||
export function useDeleteEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<void>(`/api/illness/episodes/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Upsert one day-log on an episode. */
|
||||
export function useUpsertDayLog(episodeId: string) {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (input: IllnessDayLogInput) =>
|
||||
apiPost<IllnessDayLogDTO>(
|
||||
`/api/illness/episodes/${episodeId}/day-logs`,
|
||||
input,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Pill,
|
||||
Settings,
|
||||
Stethoscope,
|
||||
Thermometer,
|
||||
Trophy,
|
||||
Waves,
|
||||
type LucideIcon,
|
||||
@@ -97,6 +98,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
tourId: "nav-labs",
|
||||
requiresModule: "labs",
|
||||
},
|
||||
// v1.18.1 — the illness/condition journal sits in the clinical spine
|
||||
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
|
||||
// opt-in `illness` key from the resolved module map, so the entry is
|
||||
// absent until the account turns the module on from the Modules hub.
|
||||
{
|
||||
href: "/illness",
|
||||
tKey: "nav.illness",
|
||||
icon: Thermometer,
|
||||
tourId: "nav-illness",
|
||||
requiresModule: "illness",
|
||||
},
|
||||
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
|
||||
// clinical spine, peer to Labs and Recovery. It is a first-class tracking
|
||||
// surface ("wann muss ich was wo machen"), not pure configuration, so it
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Smile,
|
||||
Sparkles,
|
||||
TestTube,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -66,6 +67,7 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
workouts: Dumbbell,
|
||||
recovery: Activity,
|
||||
labs: TestTube,
|
||||
illness: Thermometer,
|
||||
achievements: Award,
|
||||
coach: MessageCircleHeart,
|
||||
insights: Sparkles,
|
||||
|
||||
Reference in new issue
Block a user