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:
Marc-André Bombeck committed 2026-06-16 21:09:35 +02:00
1 parent bee7295b19
commit b079431a8d
34 files changed
+2799 -6

No files matched your search

+184
View File
@@ -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>
);
}
+342
View File
@@ -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>
);
}
+48
View File
@@ -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 },
];
+64
View File
@@ -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;
}
+92
View File
@@ -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,
});
}
+12
View File
@@ -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,