mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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)} – ${
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ───────── */
|
||||
|
||||
Reference in new issue
Block a user