feat(body-sites): record a body site on conditions and add a body-site view

A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.

- migration 0354: two nullable columns on illness_episodes, reusing the
  laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
  keeps the stored value, so an edit from the shipped iPhone app, which
  never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
  conditions, folded after the decrypt and bounded per kind; with site
  (and laterality) the visits and conditions there, one hop to their
  documents, lab results and visits. Read in the profile section;
  conditions only under an illness grant with the module on, and links
  into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
  end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
  one body-site field pair that suggests sites already used; a
  condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
  name the site on conditions; the Coach illness block carries it
  sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
  registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 21:55:14 +02:00
1 parent af65f01fec
commit 14f0fc4d4f
57 files changed
+3501 -133

No files matched your search

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The body-site field pair suggests the sites the record already holds, across
* procedures and conditions, as the server resolved them.
*
* - the suggestions are the server's list, in its order;
* - the site already typed is not suggested back to itself;
* - no list (a refused or failed read) is a plain field, not an empty list.
*
* Mutation check (run, seen red): drop the `list` attribute from the input →
* "offers the sites the record holds" goes red.
*/
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return { ...actual, apiGet: () => new Promise(() => {}) };
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BodySiteListDTO } from "@/lib/body-sites/dto";
import { BodySiteFields } from "../body-site-fields";
function render(data: BodySiteListDTO | null, typed = ""): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
if (data) queryClient.setQueryData(queryKeys.bodySites(null, null), data);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<BodySiteFields
idPrefix="illness"
bodySite={typed}
laterality={null}
onBodySiteChange={() => {}}
onLateralityChange={() => {}}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
const site = (bodySite: string) => ({
bodySite,
procedures: 1,
conditions: 0,
sides: [{ laterality: null, count: 1 }],
});
describe("<BodySiteFields>", () => {
it("offers the sites the record holds, in the server's order", () => {
const html = render({ sites: [site("Knee"), site("Lower back")] });
const listId = html.match(/<datalist id="([^"]+)"/)?.[1];
expect(listId).toBeTruthy();
expect(html).toContain(`list="${listId}"`);
expect(html.indexOf('value="Knee"')).toBeLessThan(
html.indexOf('value="Lower back"'),
);
expect(html).toContain('id="illness-body-site"');
expect(html).toContain('id="illness-laterality"');
});
it("does not suggest the site already typed", () => {
const html = render({ sites: [site("Knee"), site("Hip")] }, "knee");
expect(html).not.toContain('<option value="Knee"');
expect(html).toContain('<option value="Hip"');
});
it("without a list it is a plain field", () => {
const html = render(null);
expect(html).not.toContain("<datalist");
expect(html).not.toContain(" list=");
});
});
@@ -0,0 +1,204 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The body-site view's designed states, pinned by single-pass static renders
* (the repository's component-test convention; tapping a site is driven in the
* browser check, not here).
*
* - no site anywhere: an empty state that says how a site gets here;
* - a site picked: its procedures and conditions, each with what is linked;
* - a delegate's answer: a link the grant does not cover renders as a
* placeholder naming only its kind, never a label, never a link; and a
* condition list the server did not read (`null`) renders no conditions
* heading at all rather than an empty one;
* - a failed read is an error with a retry, never the empty state.
*
* Mutation checks (each run, each seen red):
* - render `item.label` for a redacted entry instead of the placeholder →
* "a link the grant does not cover" goes red;
* - render the conditions block whenever the selection carries the key
* (`conditions !== undefined`) → "no conditions heading" goes red.
*/
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return { ...actual, apiGet: () => new Promise(() => {}) };
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BodySiteListDTO } from "@/lib/body-sites/dto";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { BodySitesSection } from "../body-sites-section";
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: "2026-08-26T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: "Arthroscopy",
outcome: null,
bodySite: "Knee",
laterality: "LEFT",
reminderNextDueAt: null,
createdAt: "2026-08-26T09:00:00.000Z",
updatedAt: "2026-08-26T09:00:00.000Z",
...over,
};
}
const KNEE = {
bodySite: "Knee",
procedures: 1,
conditions: 1,
sides: [{ laterality: "LEFT" as const, count: 2 }],
};
function render(
data: BodySiteListDTO | "error",
site: string | null = "Knee",
): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
const key = queryKeys.bodySites(site, null);
if (data === "error") {
queryClient
.getQueryCache()
.build(queryClient, { queryKey: key })
.setState({
status: "error",
error: new Error("boom"),
fetchStatus: "idle",
});
} else {
queryClient.setQueryData(key, data);
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<BodySitesSection initialSite={site} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<BodySitesSection>", () => {
it("explains how a site gets here when there is none", () => {
const html = render({ sites: [] }, null);
expect(html).toContain("No body site recorded yet");
expect(html).toContain("to a procedure or a condition");
expect(html).not.toContain('data-slot="body-sites-filter"');
expect(html).not.toContain("Body sites could not be loaded.");
});
it("offers the sites and asks for a pick before showing anything", () => {
const html = render({ sites: [KNEE] }, null);
expect(html).toContain('data-slot="body-site-chip"');
expect(html).toContain("Pick a site to see what is filed there.");
expect(html).not.toContain('data-slot="body-site-selection"');
});
it("lists the procedures and conditions at the picked site with their links", () => {
const html = render({
sites: [KNEE],
selection: {
bodySite: "Knee",
laterality: null,
visits: [
visit({
id: "v1",
links: {
documents: [
{
id: "d1",
label: "MRI knee report",
date: "2026-08-20T00:00:00.000Z",
redacted: false,
},
],
labResults: [],
conditions: [],
},
}),
],
conditions: [
{
id: "c1",
label: "Meniscus tear",
type: "INJURY",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
resolvedAt: null,
bodySite: "knee",
laterality: "LEFT",
links: {
documents: [],
visits: [
{
id: "v1",
label: "PROCEDURE",
date: "2026-08-26T09:00:00.000Z",
redacted: false,
},
],
},
},
],
},
});
expect(html).toContain("Procedures and visits");
expect(html).toContain("Arthroscopy");
expect(html).toContain("Knee · Left");
expect(html).toContain('href="/documents?doc=d1"');
expect(html).toContain("MRI knee report");
expect(html).toContain("Conditions");
expect(html).toContain('href="/illness/c1"');
expect(html).toContain("Meniscus tear");
// A visit's link label is its kind constant, named by the reader.
expect(html).toContain("Procedure or surgery");
expect(html).not.toContain(">PROCEDURE<");
});
it("a link the grant does not cover is a placeholder, and no conditions heading without conditions", () => {
const hidden = { label: null, date: null, redacted: true };
const html = render({
sites: [{ ...KNEE, conditions: 0 }],
selection: {
bodySite: "Knee",
laterality: null,
visits: [
visit({
id: "v1",
links: {
documents: [{ id: "d1", ...hidden }],
labResults: [{ id: "l1", ...hidden }],
conditions: [{ id: "c1", ...hidden }],
},
}),
],
conditions: null,
},
});
expect(html).toContain("A document not shared with you");
expect(html).toContain("A lab result not shared with you");
expect(html).toContain("A condition not shared with you");
expect(html.match(/data-slot="body-site-link-hidden"/g)).toHaveLength(3);
// A withheld entry is never a way in.
expect(html).not.toContain('href="/documents?doc=d1"');
expect(html).not.toContain('href="/illness/c1"');
expect(html).not.toContain('data-slot="body-site-conditions"');
expect(html).not.toContain(">Conditions<");
});
it("a failed read is an error with a retry, never the empty state", () => {
const html = render("error");
expect(html).toContain("Body sites could not be loaded.");
expect(html).not.toContain("No body site recorded yet");
});
});
@@ -0,0 +1,99 @@
"use client";
/**
* The "Body site / Side" field pair, shared by the visit form and the
* condition form (v1.39.2).
*
* One component rather than two copies, because the point of the pair is that
* a knee is written the same way on a procedure and on a condition: the field
* offers the sites the record already holds, across both kinds, as the person
* types. The list comes from the server (`useBodySiteSuggestions`), which
* decrypts and folds the sites; the browser's own `datalist` does the
* narrowing, so a suggestion is a nudge and never a restriction. A caller whose
* grant cannot read the list gets a plain text field.
*/
import { useId } from "react";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useBodySiteSuggestions } from "@/hooks/use-body-sites";
import { useTranslations } from "@/lib/i18n/context";
import {
LATERALITIES,
lateralityText,
} from "@/components/encounters/encounter-labels";
export type BodySiteSideValue = "LEFT" | "RIGHT" | "BOTH" | null;
export function BodySiteFields({
idPrefix,
bodySite,
laterality,
onBodySiteChange,
onLateralityChange,
hint,
}: {
/** Prefix for the two input ids, so a label and a test can address them. */
idPrefix: string;
bodySite: string;
laterality: BodySiteSideValue;
onBodySiteChange: (value: string) => void;
onLateralityChange: (value: BodySiteSideValue) => void;
hint?: string;
}) {
const { t } = useTranslations();
const listId = `${useId()}-body-sites`;
const typed = bodySite.trim().toLowerCase();
// The exact site already typed is not suggested back to itself.
const suggestions = useBodySiteSuggestions().filter(
(site) => site.toLowerCase() !== typed,
);
return (
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
<FieldGroup
htmlFor={`${idPrefix}-body-site`}
label={t("encounters.form.bodySite")}
hint={hint ?? t("encounters.form.bodySiteHint")}
>
<Input
id={`${idPrefix}-body-site`}
value={bodySite}
maxLength={200}
autoComplete="off"
list={suggestions.length > 0 ? listId : undefined}
onChange={(event) => onBodySiteChange(event.target.value)}
/>
{suggestions.length > 0 ? (
<datalist id={listId} data-slot="body-site-suggestions">
{suggestions.map((site) => (
<option key={site} value={site} />
))}
</datalist>
) : null}
</FieldGroup>
<FieldGroup
htmlFor={`${idPrefix}-laterality`}
label={t("encounters.form.laterality")}
>
<NativeSelect
id={`${idPrefix}-laterality`}
value={laterality ?? ""}
onChange={(event) =>
onLateralityChange(
(event.target.value as BodySiteSideValue) || null,
)
}
>
<option value="">{t("encounters.laterality.none")}</option>
{LATERALITIES.map((side) => (
<option key={side} value={side}>
{lateralityText(t, side)}
</option>
))}
</NativeSelect>
</FieldGroup>
</div>
);
}
@@ -0,0 +1,458 @@
"use client";
/**
* The body-site view — the fourth tab of the checkups page (v1.39.2,
* Discussion #1025).
*
* "What was done on my left knee, and what is wrong with it?" Pick a site and
* see the procedures and conditions filed there, each with the documents, lab
* results and visits linked to it. A site is the person's own words on a
* procedure or a condition; nothing here is a new record type or a tag, so
* there is nothing to keep in step: the rows are the visits and conditions the
* other pages already show.
*
* The grouping and the matching run on the server. Body sites are encrypted at
* rest, so the server decrypts the record's own sites and folds them; this view
* sends the choice and renders the answer.
*
* For a delegate, what the grant does not cover is not shown and not guessed
* at. Without the illness section there is no condition list at all (the
* server does not read it, and says so with `conditions: null`). A link into a
* section the grant does not cover arrives with no label, and renders as a
* placeholder naming only its kind: the delegate learns that something is
* filed, which the visit already tells them, and never what.
*/
import {
CalendarClock,
FileText,
FlaskConical,
PersonStanding,
Stethoscope,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { useBodySites, type BodySiteSide } from "@/hooks/use-body-sites";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BodySiteConditionDTO, BodySiteDTO } from "@/lib/body-sites/dto";
import type { EncounterDTO, EncounterLinkDTO } from "@/lib/encounters/dto";
import type { EncounterKind } from "@/generated/prisma/client";
import {
bodySiteText,
encounterKindText,
lateralityText,
} from "@/components/encounters/encounter-labels";
type Translate = (key: string, values?: Record<string, string>) => string;
export function BodySitesSection({
enabled = true,
initialSite = null,
initialSide = null,
}: {
enabled?: boolean;
/** A site to open on, from a `?site=` link (a condition's page links here). */
initialSite?: string | null;
initialSide?: BodySiteSide;
}) {
const { t } = useTranslations();
const [site, setSite] = useState<string | null>(initialSite);
const [side, setSide] = useState<BodySiteSide>(
initialSite ? initialSide : null,
);
const list = useBodySites(enabled, { site, laterality: side });
const sites = list.data?.sites ?? [];
const selection = list.data?.selection;
// The site as the server spells it, once the answer is in; the chip the
// person tapped until then.
const picked = site
? (sites.find((entry) => fold(entry.bodySite) === fold(site)) ?? null)
: null;
const pickSite = (entry: BodySiteDTO | null) => {
setSite(entry?.bodySite ?? null);
setSide(null);
};
return (
<section aria-labelledby="body-sites-title" className="space-y-4">
<PageHeader
titleId="body-sites-title"
title={t("bodySites.title")}
description={t("bodySites.description")}
/>
{list.isError ? (
<QueryErrorCard
title={t("bodySites.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full rounded-xl" />
))}
</div>
) : sites.length === 0 ? (
<EmptyState
icon={<PersonStanding className="size-6" aria-hidden />}
title={t("bodySites.emptyTitle")}
description={t("bodySites.emptyDescription")}
/>
) : (
<div className="space-y-6">
<div className="space-y-3" data-slot="body-sites-filter">
<div
role="group"
aria-label={t("bodySites.sitesLabel")}
className="flex flex-wrap gap-2"
>
{sites.map((entry) => {
const on = picked?.bodySite === entry.bodySite;
return (
<Button
key={entry.bodySite}
type="button"
size="sm"
variant={on ? "secondary" : "outline"}
aria-pressed={on}
data-slot="body-site-chip"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => pickSite(on ? null : entry)}
>
{entry.bodySite}
<span className="text-muted-foreground tabular-nums">
{entry.procedures + entry.conditions}
</span>
</Button>
);
})}
</div>
{picked && picked.sides.some((s) => s.laterality !== null) ? (
<div
role="group"
aria-label={t("bodySites.sidesLabel")}
className="flex flex-wrap gap-2"
>
<Button
type="button"
size="sm"
variant={side === null ? "secondary" : "outline"}
aria-pressed={side === null}
data-slot="body-site-side"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => setSide(null)}
>
{t("bodySites.allSides")}
</Button>
{picked.sides
.filter((entry) => entry.laterality !== null)
.map((entry) => {
const value = entry.laterality as Exclude<
BodySiteSide,
null
>;
const on = side === value;
return (
<Button
key={value}
type="button"
size="sm"
variant={on ? "secondary" : "outline"}
aria-pressed={on}
data-slot="body-site-side"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => setSide(on ? null : value)}
>
{lateralityText(t, value)}
</Button>
);
})}
</div>
) : null}
</div>
{!site ? (
<p className="text-muted-foreground text-sm">
{t("bodySites.pickPrompt")}
</p>
) : !selection || list.isPlaceholderData ? (
<Skeleton className="h-32 w-full rounded-xl" />
) : (
<Selection
t={t}
visits={selection.visits}
conditions={selection.conditions}
/>
)}
</div>
)}
</section>
);
}
function fold(value: string): string {
return value
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function Selection({
t,
visits,
conditions,
}: {
t: Translate;
visits: EncounterDTO[];
conditions: BodySiteConditionDTO[] | null;
}) {
if (visits.length === 0 && (conditions === null || conditions.length === 0)) {
return (
<EmptyState
icon={<PersonStanding className="size-6" aria-hidden />}
title={t("bodySites.noMatchTitle")}
/>
);
}
return (
<div className="space-y-6" data-slot="body-site-selection">
{visits.length > 0 ? (
<div className="space-y-3">
<SectionHeading
icon={CalendarClock}
title={t("bodySites.visitsTitle")}
/>
<div className="space-y-2" data-slot="body-site-visits">
{visits.map((visit) => (
<VisitRow key={visit.id} t={t} visit={visit} />
))}
</div>
</div>
) : null}
{conditions && conditions.length > 0 ? (
<div className="space-y-3">
<SectionHeading
icon={Stethoscope}
title={t("bodySites.conditionsTitle")}
/>
<div className="space-y-2" data-slot="body-site-conditions">
{conditions.map((condition) => (
<ConditionRow key={condition.id} t={t} condition={condition} />
))}
</div>
</div>
) : null}
</div>
);
}
function VisitRow({ t, visit }: { t: Translate; visit: EncounterDTO }) {
const format = useFormatters();
const kind = encounterKindText(t, visit.kind as EncounterKind);
// A procedure leads with what was done, like the visit card does.
const heading = visit.reason ?? visit.practitioner?.name ?? kind;
const site = bodySiteText(t, visit.bodySite, visit.laterality);
return (
<Card
className="gap-2 py-3 md:py-4"
data-slot="body-site-visit"
data-encounter-id={visit.id}
>
<CardContent className="space-y-2">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium">{heading}</p>
<p className="text-muted-foreground text-xs">
{format.dateTime(visit.occurredAt)}
</p>
</div>
<Badge variant="outline" className="shrink-0">
{kind}
</Badge>
</div>
{site ? <p className="text-foreground text-sm">{site}</p> : null}
<LinkedList
t={t}
families={[
{
key: "documents",
icon: FileText,
items: visit.links?.documents ?? [],
hidden: t("bodySites.hiddenDocument"),
href: (id) => `/documents?doc=${encodeURIComponent(id)}`,
},
{
key: "labResults",
icon: FlaskConical,
items: visit.links?.labResults ?? [],
hidden: t("bodySites.hiddenLabResult"),
},
{
key: "conditions",
icon: Stethoscope,
items: visit.links?.conditions ?? [],
hidden: t("bodySites.hiddenCondition"),
href: (id) => `/illness/${encodeURIComponent(id)}`,
},
]}
/>
</CardContent>
</Card>
);
}
function ConditionRow({
t,
condition,
}: {
t: Translate;
condition: BodySiteConditionDTO;
}) {
const format = useFormatters();
const site = bodySiteText(t, condition.bodySite, condition.laterality);
return (
<Card
className="gap-2 py-3 md:py-4"
data-slot="body-site-condition"
data-episode-id={condition.id}
>
<CardContent className="space-y-2">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<Link
href={`/illness/${encodeURIComponent(condition.id)}`}
className="text-foreground text-sm font-medium underline-offset-4 hover:underline"
>
{condition.label}
</Link>
<p className="text-muted-foreground text-xs">
{condition.resolvedAt
? t("bodySites.conditionDates", {
onset: format.date(condition.onsetAt),
resolved: format.date(condition.resolvedAt),
})
: t("bodySites.conditionSince", {
onset: format.date(condition.onsetAt),
})}
</p>
</div>
<Badge variant="outline" className="shrink-0">
{t(`illness.type.${condition.type}`)}
</Badge>
</div>
{site ? <p className="text-foreground text-sm">{site}</p> : null}
<LinkedList
t={t}
families={[
{
key: "documents",
icon: FileText,
items: condition.links.documents,
hidden: t("bodySites.hiddenDocument"),
href: (id) => `/documents?doc=${encodeURIComponent(id)}`,
},
{
key: "visits",
icon: CalendarClock,
items: condition.links.visits,
hidden: t("bodySites.hiddenVisit"),
// A visit's label is its kind constant; the reader names it.
label: (item) =>
encounterKindText(t, item.label as EncounterKind),
},
]}
/>
</CardContent>
</Card>
);
}
interface LinkFamily {
key: string;
icon: typeof FileText;
items: EncounterLinkDTO[];
/** What a withheld entry says: its kind, nothing more. */
hidden: string;
href?: (id: string) => string;
label?: (item: EncounterLinkDTO) => string;
}
/**
* What is linked to one row, one line per entry.
*
* A withheld entry keeps its line and says only what kind of thing it is, in
* muted meta type: the fact that something is filed is visible to the caller
* already, the name is not theirs to read. It is never a link, since the page
* behind it would refuse them anyway.
*/
function LinkedList({ t, families }: { t: Translate; families: LinkFamily[] }) {
const format = useFormatters();
const rows = families.flatMap((family) =>
family.items.map((item) => ({ family, item })),
);
if (rows.length === 0) return null;
return (
<ul
className="space-y-1 border-t pt-2"
aria-label={t("bodySites.linkedLabel")}
data-slot="body-site-links"
>
{rows.map(({ family, item }) => {
const Icon = family.icon;
if (item.redacted) {
return (
<li
key={`${family.key}-${item.id}`}
className="text-muted-foreground flex items-center gap-2 text-xs"
data-slot="body-site-link-hidden"
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{family.hidden}
</li>
);
}
const text = family.label ? family.label(item) : (item.label ?? "");
return (
<li
key={`${family.key}-${item.id}`}
className="flex items-center gap-2 text-sm"
data-slot="body-site-link"
>
<Icon
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
{family.href ? (
<Link
href={family.href(item.id)}
className="text-foreground min-w-0 truncate underline-offset-4 hover:underline"
>
{text}
</Link>
) : (
<span className="text-foreground min-w-0 truncate">{text}</span>
)}
{item.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(item.date)}
</span>
) : null}
</li>
);
})}
</ul>
);
}
@@ -140,6 +140,16 @@ export function IllnessSection({
key={`${episode.label}-${episode.onsetAt}-${index}`}
label={episode.label}
value={compose([
episode.bodySite
? episode.laterality
? t("encounters.bodySiteWithSide", {
site: episode.bodySite,
side: t(
lateralityLabelKey(episode.laterality as Laterality),
),
})
: episode.bodySite
: null,
t(`illness.type.${episode.type}`),
t(`illness.lifecycle.${episode.lifecycle}`),
`${fmtDate(episode.onsetAt)} – ${
+8 -41
View File
@@ -27,7 +27,6 @@ import { useMemo, 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 { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
@@ -37,11 +36,10 @@ import type { Practitioner } from "@/hooks/use-practitioners";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
LATERALITIES,
encounterKindText,
encounterStatusText,
lateralityText,
} from "./encounter-labels";
import { BodySiteFields } from "@/components/body-sites/body-site-fields";
import { PractitionerCombobox } from "./practitioner-combobox";
import { EncounterLinkPickers } from "./encounter-link-pickers";
@@ -256,44 +254,13 @@ export function EncounterForm({
</FieldGroup>
{showBodySite ? (
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
<FieldGroup
htmlFor="encounter-body-site"
label={t("encounters.form.bodySite")}
hint={t("encounters.form.bodySiteHint")}
>
<Input
id="encounter-body-site"
value={draft.bodySite}
maxLength={200}
autoComplete="off"
onChange={(event) => patch({ bodySite: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="encounter-laterality"
label={t("encounters.form.laterality")}
>
<NativeSelect
id="encounter-laterality"
value={draft.laterality ?? ""}
onChange={(event) =>
patch({
laterality:
(event.target.value as EncounterDraft["laterality"]) ||
null,
})
}
>
<option value="">{t("encounters.laterality.none")}</option>
{LATERALITIES.map((side) => (
<option key={side} value={side}>
{lateralityText(t, side)}
</option>
))}
</NativeSelect>
</FieldGroup>
</div>
<BodySiteFields
idPrefix="encounter"
bodySite={draft.bodySite}
laterality={draft.laterality}
onBodySiteChange={(bodySite) => patch({ bodySite })}
onLateralityChange={(laterality) => patch({ laterality })}
/>
) : null}
<FieldGroup
@@ -10,6 +10,7 @@
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
@@ -20,6 +21,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { bodySiteText } from "@/components/encounters/encounter-labels";
import { IllnessCorrelationCard } from "./illness-correlation-card";
import { IllnessDayTimeline } from "./illness-day-timeline";
@@ -41,7 +43,10 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
// v1.36.x — logging a day, editing and resolving an episode are the
// owner's; a delegate reads the episode.
const { canManageDomain } = useRecordCapabilities();
const { canManageDomain, sections } = useRecordCapabilities();
// The body-site view is read in the visits' section; a grant without it
// gets the site as text rather than a link that would be refused.
const canOpenBodySites = sections === null || sections.includes("profile");
const canManageIllness = canManageDomain("illness");
const fmt = useFormatters();
const {
@@ -66,6 +71,15 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
}
const active = episode ? episode.resolvedAt === null : false;
const bodySite = episode
? bodySiteText(t, episode.bodySite, episode.laterality)
: null;
const bodySiteHref = episode?.bodySite
? `/checkups?${new URLSearchParams({
site: episode.bodySite,
...(episode.laterality ? { side: episode.laterality } : {}),
}).toString()}`
: "/checkups";
const isChronic = episode?.lifecycle === "CHRONIC_ONGOING";
return (
@@ -95,6 +109,20 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
})}`
: ""}
</span>
{bodySite && !canOpenBodySites ? (
<span className="text-foreground text-xs">{bodySite}</span>
) : null}
{bodySite && canOpenBodySites ? (
// Where on the body, in the person's own words, so foreground;
// it opens the body-site view on that site and side.
<Link
href={bodySiteHref}
className="text-foreground text-xs underline-offset-4 hover:underline"
data-slot="episode-body-site"
>
{bodySite}
</Link>
) : null}
</span>
) : undefined
}
@@ -22,6 +22,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import {
BodySiteFields,
type BodySiteSideValue,
} from "@/components/body-sites/body-site-fields";
import {
useCreateEpisode,
@@ -89,6 +93,10 @@ export function NewEpisodeSheet({
const [resolved, setResolved] = useState("");
const [parentId, setParentId] = useState<string>(NONE);
const [note, setNote] = useState("");
// v1.39.2 — where on the body. Offered on every type: an injury or a joint
// condition is the usual case, but nothing about a type rules a site out.
const [bodySite, setBodySite] = useState("");
const [laterality, setLaterality] = useState<BodySiteSideValue>(null);
// Reset / hydrate the form each time the sheet opens — adjusted during
// render keyed on the open transition (React's recommended alternative to a
@@ -105,6 +113,8 @@ export function NewEpisodeSheet({
);
setParentId(editEpisode?.parentConditionId ?? NONE);
setNote(editEpisode?.note ?? "");
setBodySite(editEpisode?.bodySite ?? "");
setLaterality(editEpisode?.laterality ?? null);
} else if (!open && wasOpen) {
setWasOpen(false);
}
@@ -135,6 +145,8 @@ export function NewEpisodeSheet({
resolvedAt,
parentConditionId,
note: note.trim() === "" ? null : note,
bodySite: bodySite.trim() || null,
laterality,
},
});
} else {
@@ -146,6 +158,8 @@ export function NewEpisodeSheet({
resolvedAt,
parentConditionId,
note: note.trim() === "" ? null : note,
bodySite: bodySite.trim() || null,
laterality,
});
}
onOpenChange(false);
@@ -204,6 +218,15 @@ export function NewEpisodeSheet({
</Select>
</div>
<BodySiteFields
idPrefix="illness"
bodySite={bodySite}
laterality={laterality}
onBodySiteChange={setBodySite}
onLateralityChange={setLaterality}
hint={t("illness.new.bodySiteHint")}
/>
<div className="space-y-1.5">
<Label>{t("illness.new.lifecycle")}</Label>
<Select
+8
View File
@@ -29,6 +29,9 @@ export interface IllnessEpisodeDTO {
resolvedAt: string | null;
parentConditionId: string | null;
note: string | null;
/** Where on the body, decrypted; null when not stated (v1.39.2). */
bodySite: string | null;
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
createdAt: string;
updatedAt: string;
}
@@ -64,6 +67,8 @@ export interface IllnessEpisodeCreateInput {
resolvedAt?: string | null;
parentConditionId?: string | null;
note?: string | null;
bodySite?: string | null;
laterality?: "LEFT" | "RIGHT" | "BOTH" | null;
}
export interface IllnessDayLogInput {
@@ -82,6 +87,9 @@ export interface IllnessEpisodeUpdateInput {
resolvedAt?: string | null;
parentConditionId?: string | null;
note?: string | null;
/** Absent keeps the stored value; null or blank clears it. */
bodySite?: string | null;
laterality?: "LEFT" | "RIGHT" | "BOTH" | null;
}
/* ── P3 correlation + retrospective DTOs (server-authoritative) ───────── */