Merge branch 'v143/life' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 03:39:40 +02:00
commit 4daad8f2fb
88 files changed
+8313 -1386

No files matched your search

+7 -1
View File
@@ -280,7 +280,13 @@ export function ClinicianView({
{/* ── the fenced tier, as the owner chose it ───────────── */}
<FamilyHistorySection t={t} report={report} scope={scope} />
<AnamnesisSection t={t} report={report} scope={scope} />
<AnamnesisSection
t={t}
report={report}
scope={scope}
fmtNum={fmtNum}
fmtDate={fmtDate}
/>
<MoodSection t={t} report={report} scope={scope} fmtNum={fmtNum} />
<CycleSection
t={t}
@@ -13,6 +13,7 @@
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import { lifeReportRows } from "@/lib/profile/life-report-rows";
import { Fragment } from "react";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
@@ -473,10 +474,14 @@ export function AnamnesisSection({
t,
report,
scope,
fmtNum,
fmtDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtNum: (n: number) => string;
fmtDate: (iso: string) => string;
}) {
const anamnesis = report.anamnesis ?? null;
@@ -518,6 +523,11 @@ export function AnamnesisSection({
label={t("doctorReport.anamnesisShiftSchedule")}
value={factValue("SHIFT_SCHEDULE", anamnesis?.shiftSchedule ?? null)}
/>
{lifeReportRows(anamnesis?.life, t, fmtNum, fmtDate).map(
([label, value]) => (
<StatRow key={label} label={label} value={value} />
),
)}
</LeafSection>
);
}
@@ -1,333 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { HealthProfileFactsManager } from "../health-profile-facts-manager";
const {
MockApiError,
apiDelete,
apiPatch,
apiPost,
toastError,
toastWritten,
confirmButtons,
renderedButtons,
selects,
removedAt,
} = vi.hoisted(() => {
const removedAt = "2026-07-28T12:00:00.000Z";
class MockApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
return {
MockApiError,
apiDelete: vi.fn(async () => ({
id: "fact-1",
kind: "SMOKING_STATUS" as const,
removedAt,
})),
apiPatch: vi.fn(),
apiPost: vi.fn(),
toastError: vi.fn(),
toastWritten: vi.fn(),
confirmButtons: [] as Record<string, unknown>[],
renderedButtons: [] as Record<string, unknown>[],
selects: [] as Record<string, unknown>[],
removedAt,
};
});
vi.mock("@/lib/api/api-fetch", () => ({
ApiError: MockApiError,
apiDelete,
apiGet: vi.fn(),
apiPatch,
apiPost,
}));
vi.mock("sonner", () => ({ toast: { error: toastError } }));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: toastWritten,
}));
vi.mock("@/components/ui/button", () => ({
Button: (props: Record<string, unknown>) => {
renderedButtons.push(props);
return <button />;
},
}));
// The real Radix Select hides its `onValueChange` behind the trigger, so the
// draft edits the interactions drive can't reach it. A thin mock exposes the
// callback (kinds render in `HEALTH_PROFILE_FACT_KINDS` order) so a test can set
// a draft the one footer Save then writes.
vi.mock("@/components/ui/select", () => ({
Select: (props: Record<string, unknown>) => {
selects.push(props);
return <div>{props.children as ReactNode}</div>;
},
SelectContent: (props: Record<string, unknown>) => (
<>{props.children as ReactNode}</>
),
SelectItem: (props: Record<string, unknown>) => (
<>{props.children as ReactNode}</>
),
SelectTrigger: (props: Record<string, unknown>) => (
<>{props.children as ReactNode}</>
),
SelectValue: () => null,
}));
vi.mock("@/components/ui/confirm-button", () => ({
ConfirmButton: (props: Record<string, unknown>) => {
confirmButtons.push(props);
return (
<button data-slot={String(props.slot)}>{String(props.label)}</button>
);
},
ConfirmDialog: () => null,
}));
const fact = {
id: "fact-1",
kind: "SMOKING_STATUS" as const,
value: "FORMER" as const,
unreadable: false,
validFrom: "2026-07-28T10:00:00.000Z",
validUntil: null,
provenance: "USER_REPORTED" as const,
supersededByRevisionId: null,
createdAt: "2026-07-28T10:00:00.000Z",
};
function render() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.healthProfileFacts(), {
current: {
SMOKING_STATUS: fact,
ALCOHOL_PATTERN: null,
SHIFT_SCHEDULE: null,
},
history: [fact],
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<HealthProfileFactsManager />
</I18nProvider>
</QueryClientProvider>,
);
return { client, html };
}
// The one primary action: default variant, a button-type, its own onClick.
function saveButton() {
return renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
}
function editDraft(index: number, value: string) {
(selects[index].onValueChange as (next: string) => void)(value);
}
beforeEach(() => {
confirmButtons.length = 0;
renderedButtons.length = 0;
selects.length = 0;
apiDelete.mockClear();
apiPatch.mockReset();
apiPost.mockReset();
toastError.mockClear();
toastWritten.mockClear();
});
describe("health-profile fact save + removal controls", () => {
it("renders exactly one Save for the whole lifestyle-context tile", () => {
render();
const actionButtons = renderedButtons.filter(
(props) =>
props.type === "button" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
const labels = actionButtons.map((props) => {
const children = Array.isArray(props.children)
? props.children
: [props.children];
return children.filter((child) => typeof child === "string").join("");
});
expect(labels).toEqual(["Save"]);
});
it("renders a confirmed removal action only for each recorded current fact", () => {
const { html } = render();
expect(confirmButtons).toHaveLength(1);
// The visible label is short — three entries side by side each carrying
// their own field name read as one run of words — but the ACCESSIBLE name
// still says which entry it removes, and so does the dialog it opens.
expect(confirmButtons[0]).toMatchObject({
slot: "health-profile-fact-remove-SMOKING_STATUS",
label: "Remove",
ariaLabel: "Remove Smoking status",
confirmLabel: "Remove Smoking status",
title: "Remove Smoking status?",
body: "The current value will become not recorded. Its dated history will be retained.",
});
expect(html).toContain("Remove");
expect(apiDelete).not.toHaveBeenCalled();
});
it("uses the current revision id as the concurrency token after confirmation", async () => {
render();
(confirmButtons[0].onConfirm as () => void)();
await vi.waitFor(() =>
expect(apiDelete).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1"),
);
});
it("optimistically resolves the current fact as absent while retaining closed history", async () => {
const { client } = render();
(confirmButtons[0].onConfirm as () => void)();
await vi.waitFor(() => {
const data = client.getQueryData<{
current: Record<string, unknown>;
history: Array<{ id: string; validUntil: string | null }>;
}>(queryKeys.healthProfileFacts());
expect(data?.current.SMOKING_STATUS).toBeNull();
expect(data?.history).toEqual([
expect.objectContaining({ id: "fact-1", validUntil: removedAt }),
]);
});
});
it("invalidates advisor data after a successful save", async () => {
apiPatch.mockResolvedValueOnce({ ...fact, value: "NEVER" });
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
// Revise the recorded smoking status, then hit the one Save.
editDraft(0, "NEVER");
(saveButton()!.onClick as () => void)();
await vi.waitFor(() =>
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
value: "NEVER",
}),
);
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.insightsAdvisor(),
});
expect(toastWritten).toHaveBeenCalledWith(
"success",
"Lifestyle context saved",
);
});
it("invalidates the facts read when a save finds a stale 404 target", async () => {
apiPatch.mockRejectedValueOnce(new MockApiError("Not found", 404));
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
editDraft(0, "NEVER");
(saveButton()!.onClick as () => void)();
await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.healthProfileFacts(),
});
// A stale target is a failure, never a green toast.
expect(toastWritten).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalled();
});
it("invalidates advisor data after a successful removal", async () => {
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
(confirmButtons[0].onConfirm as () => void)();
await vi.waitFor(() =>
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.insightsAdvisor(),
}),
);
});
it("invalidates the facts read when removal finds a stale 404 target", async () => {
apiDelete.mockRejectedValueOnce(new MockApiError("Not found", 404));
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
(confirmButtons[0].onConfirm as () => void)();
await vi.waitFor(() => expect(apiDelete).toHaveBeenCalledOnce());
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.healthProfileFacts(),
});
});
it("writes every dirty kind and keeps a conflicted kind while the other persists", async () => {
// Two dirty kinds: SMOKING revises an existing fact (PATCH), ALCOHOL is a
// first value (POST). Force the PATCH to 409 and prove the POST still lands,
// the failure is surfaced per-kind, and the conflicted draft survives.
apiPatch.mockRejectedValue(new MockApiError("Stale", 409));
apiPost.mockResolvedValue({
id: "fact-2",
kind: "ALCOHOL_PATTERN",
value: "WEEKLY",
});
render();
editDraft(0, "NEVER"); // SMOKING_STATUS, differs from FORMER
editDraft(1, "WEEKLY"); // ALCOHOL_PATTERN, first value
(saveButton()!.onClick as () => void)();
// Both writes reach the real endpoints in one Save.
await vi.waitFor(() => expect(toastError).toHaveBeenCalled());
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
value: "NEVER",
});
expect(apiPost).toHaveBeenCalledWith("/api/anamnesis/facts", {
kind: "ALCOHOL_PATTERN",
value: "WEEKLY",
});
// The partial failure is not hidden behind a green toast: the error names
// the kind that did not land, and no success toast fired.
expect(toastError).toHaveBeenCalledWith(
expect.stringContaining("Smoking status"),
);
expect(toastWritten).not.toHaveBeenCalled();
// The conflicted draft survives while the persisted one is consumed: a
// second Save re-attempts only SMOKING and never re-POSTs ALCOHOL.
apiPatch.mockClear();
apiPost.mockClear();
(saveButton()!.onClick as () => void)();
await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
value: "NEVER",
});
expect(apiPost).not.toHaveBeenCalled();
});
});
@@ -43,6 +43,18 @@ const SECTION_LABEL_KEY: Record<HealthProfileAiSection, string> = {
SHIFT_SCHEDULE: "records.aiInclusion.sectionShiftSchedule",
};
/**
* v1.43 (#614) — smoking, alcohol and shift work are switched beside their
* answers in the life-circumstances card; listing them here as well would put
* two switches for one setting on one page. They still travel in the PUT
* below with their saved value, so this card never changes them.
*/
const LIFE_CARD_SECTIONS: ReadonlySet<HealthProfileAiSection> = new Set([
"SMOKING_STATUS",
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
]);
export function AiProfileInclusionManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -83,6 +95,9 @@ export function AiProfileInclusionManager() {
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.lifeCircumstances(),
});
},
onError: (error) => {
if (isConflict(error)) {
@@ -106,7 +121,9 @@ export function AiProfileInclusionManager() {
return (
<div className="space-y-4" data-slot="ai-profile-inclusion-manager">
<div className="divide-border divide-y">
{HEALTH_PROFILE_AI_SECTIONS.map((section) => {
{HEALTH_PROFILE_AI_SECTIONS.filter(
(section) => !LIFE_CARD_SECTIONS.has(section),
).map((section) => {
const checked = value.includes(section);
return (
<div
@@ -31,6 +31,7 @@ import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
@@ -142,6 +143,8 @@ export function AllergyFreeTextNote({
) : (
<SettingsCardActions>
{leadingAction}
{/* v1.43 (#614) — move the free text into a structured entry by hand. */}
<FreeTextTakeOver field="allergies" />
<Button
type="button"
size="sm"
+248
View File
@@ -0,0 +1,248 @@
"use client";
/**
* v1.43 (#614) — the alcohol section of the life circumstances: AUDIT-C.
*
* The three items render exactly as the server delivers them
* (`auditC.instrument`): validated wording in the account language, or the
* WHO English original with a note when no validated translation exists.
* This file never carries item text of its own. The result line, the cut-off
* and its source are the server's too; nothing is scored here.
*/
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
import { queryKeys } from "@/lib/query-keys";
type AuditC = LifeCircumstancesDto["auditC"];
export function AuditCPanel({
data,
onChanged,
}: {
data: AuditC;
onChanged: () => void;
}) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const [open, setOpen] = useState(false);
const [answers, setAnswers] = useState<Array<number | null>>([
null,
null,
null,
]);
const { instrument, latest } = data;
const submit = useMutation({
mutationKey: queryKeys.lifeCircumstances(),
mutationFn: (items: number[]) =>
apiPost("/api/anamnesis/audit-c", {
items,
// The language of the wording actually answered; the cut-off follows it.
locale: instrument.locale,
source: "WEB",
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
}),
onSuccess: () => {
toastWrittenOutcome("success", t("records.life.auditC.savedToast"));
setOpen(false);
setAnswers([null, null, null]);
onChanged();
},
onError: (error) =>
showErrorToast(t("records.life.auditC.saveError"), { cause: error }),
});
const remove = useMutation({
mutationKey: queryKeys.lifeCircumstances(),
mutationFn: (id: string) => apiDelete(`/api/anamnesis/audit-c/${id}`),
onSuccess: () => {
toastWrittenOutcome("success", t("records.life.auditC.removedToast"));
onChanged();
},
onError: (error) =>
showErrorToast(t("records.life.auditC.removeError"), { cause: error }),
});
const complete = answers.every((answer) => answer !== null);
return (
<div className="space-y-4" data-slot="audit-c-panel">
<p className="text-sm">{t("records.life.auditC.intro")}</p>
{latest ? (
<div className="space-y-1" data-slot="audit-c-latest">
<p className="text-sm font-medium">
{t("records.life.auditC.result", { score: latest.totalScore })}
<span className="text-muted-foreground text-xs font-normal">
{" "}
{fmt.date(new Date(latest.takenAt))}
</span>
</p>
<p className="text-sm">
{latest.cutoff === null
? t("records.life.auditC.noCutoff")
: t(
latest.aboveCutoff
? "records.life.auditC.above"
: "records.life.auditC.below",
{ cutoff: latest.cutoff.value },
)}
</p>
{latest.cutoff ? (
<p className="text-muted-foreground text-xs">
<a
href={latest.cutoff.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
{t("records.life.auditC.source", {
source: latest.cutoff.sourceLabel,
})}
</a>
</p>
) : null}
</div>
) : null}
{data.history.length > 0 ? (
<div className="space-y-1">
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("records.life.auditC.history")}
</h4>
<ul className="divide-border divide-y text-sm">
{data.history.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between gap-2 py-1.5"
>
<span>
{t("records.life.auditC.historyEntry", {
date: fmt.date(new Date(entry.takenAt)),
score: entry.totalScore,
})}
</span>
<ConfirmButton
label={t("records.life.auditC.remove")}
ariaLabel={t("records.life.auditC.remove")}
title={t("records.life.auditC.removeConfirmTitle")}
body={t("records.life.auditC.removeConfirmBody")}
confirmLabel={t("records.life.auditC.remove")}
onConfirm={() => remove.mutate(entry.id)}
pending={remove.isPending && remove.variables === entry.id}
disabled={remove.isPending}
variant="ghost"
size="icon"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`audit-c-remove-${entry.id}`}
/>
</li>
))}
</ul>
</div>
) : null}
{data.offerDue ? (
<p className="text-muted-foreground text-xs">
{t("records.life.auditC.offerDue")}
</p>
) : null}
<SettingsCardActions>
<Button
type="button"
size="sm"
variant={data.offerDue ? "default" : "outline"}
className="min-h-11 sm:min-h-9"
onClick={() => setOpen(true)}
data-slot="audit-c-start"
>
{latest
? t("records.life.auditC.again")
: t("records.life.auditC.start")}
</Button>
</SettingsCardActions>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={t("records.life.sections.alcohol.title")}
description={t("records.life.auditC.intro")}
footer={
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={!complete || submit.isPending}
onClick={() => submit.mutate(answers as number[])}
data-slot="audit-c-submit"
>
{submit.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.life.auditC.submit")}
</Button>
}
>
<div className="space-y-6" lang={instrument.locale}>
{!instrument.validatedLocale ? (
<p className="text-sm" lang={locale}>
{t("records.life.auditC.notValidated")}
</p>
) : null}
<p className="text-sm" lang={locale}>
{instrument.standardDrink}
</p>
{instrument.items.map((item, index) => (
<fieldset key={index} className="space-y-2">
<legend className="text-sm font-medium">
{index + 1}. {item.stem}
</legend>
<div className="space-y-1">
{item.options.map((option) => (
<label
key={option.value}
className="flex min-h-11 items-center gap-2 text-sm sm:min-h-9"
>
<input
type="radio"
name={`audit-c-${index}`}
value={option.value}
checked={answers[index] === option.value}
onChange={() =>
setAnswers((old) =>
old.map((value, i) =>
i === index ? option.value : value,
),
)
}
className="accent-primary size-4"
/>
{option.label}
</label>
))}
</div>
</fieldset>
))}
<p className="text-muted-foreground text-xs" lang="en">
{instrument.attribution}
</p>
</div>
</ResponsiveSheet>
</div>
);
}
@@ -29,6 +29,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -140,6 +141,8 @@ export function ConditionsManager() {
t("settings.ai.aboutMe.focusPlaceholder"),
)}
<SettingsCardActions>
{/* v1.43 (#614) — move the free text into the condition journal by hand. */}
<FreeTextTakeOver field="conditions" />
<Button
type="button"
size="sm"
@@ -0,0 +1,240 @@
"use client";
/**
* v1.43 (#614) — turn the free-text conditions or allergies line into a
* structured entry, by hand.
*
* The free text and the structured records overlap: the chronic conditions
* live in the condition journal (`IllnessEpisode`, `CHRONIC_ONGOING`), the
* allergies in `Allergy`. Nothing here parses the free text or merges
* anything on its own. The button opens a form beside the text; the person
* types the entry, it is saved through the ordinary create route, and only
* then is clearing the free text offered, as a separate decision.
*
* The button shows only while there is free text and the target is
* available (`takeOver` from the life-circumstances read).
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { AllergyForm } from "@/components/records/allergy-form";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { withBaseToken } from "@/lib/api/optimistic-token";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
import { queryKeys } from "@/lib/query-keys";
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
interface AboutMeData {
conditions: string | null;
allergies: string | null;
updatedAt: string | null;
}
export function FreeTextTakeOver({
field,
}: {
field: "conditions" | "allergies";
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [saved, setSaved] = useState(false);
const [label, setLabel] = useState("");
const [onset, setOnset] = useState("");
const [footerEl, setFooterEl] = useState<HTMLElement | null>(null);
const life = useQuery({
queryKey: queryKeys.lifeCircumstances(),
queryFn: () =>
apiGet<LifeCircumstancesDto>("/api/anamnesis/life-circumstances"),
});
const aboutMe = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
});
const afterCreate = () => {
setSaved(true);
toastWrittenOutcome("success", t("records.life.takeOver.savedToast"));
void queryClient.invalidateQueries({
queryKey: queryKeys.lifeCircumstances(),
});
void queryClient.invalidateQueries({
queryKey:
field === "conditions" ? queryKeys.illness() : queryKeys.allergies(),
});
};
const createCondition = useMutation({
mutationKey: queryKeys.lifeCircumstances(),
mutationFn: () =>
apiPost("/api/illness/episodes", {
label: label.trim(),
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
...(isCalendarDateKey(onset)
? { onsetAt: dateOnlyAtNoonUtc(onset).toISOString() }
: {}),
}),
onSuccess: afterCreate,
onError: (error) =>
showErrorToast(t("records.life.takeOver.saveError"), { cause: error }),
});
const clear = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: () =>
apiPut(
"/api/coach/about-me",
withBaseToken({ [field]: "" }, aboutMe.data?.updatedAt ?? undefined),
),
onSuccess: () => {
toastWrittenOutcome("success", t("records.life.takeOver.clearedToast"));
close();
void queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMe(),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.lifeCircumstances(),
});
},
onError: (error) =>
showErrorToast(t("records.life.takeOver.clearError"), { cause: error }),
});
function close() {
setOpen(false);
setSaved(false);
setLabel("");
setOnset("");
}
const entry = life.data?.takeOver.find((item) => item.field === field);
const text = aboutMe.data?.[field] ?? null;
if (!entry?.freeTextPresent || !entry.available || !text) return null;
const title =
field === "conditions"
? t("records.life.takeOver.conditionsTitle")
: t("records.life.takeOver.allergiesTitle");
return (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setOpen(true)}
data-slot={`take-over-${field}`}
>
{field === "conditions"
? t("records.life.takeOver.conditionsButton")
: t("records.life.takeOver.allergiesButton")}
</Button>
<ResponsiveSheet
open={open}
onOpenChange={(next) => (next ? setOpen(true) : close())}
title={title}
description={t("records.life.takeOver.description")}
footer={
saved ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={close}
>
{t("records.life.takeOver.keep")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={clear.isPending}
onClick={() => clear.mutate()}
>
{t("records.life.takeOver.clear")}
</Button>
</>
) : field === "conditions" ? (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={label.trim().length === 0 || createCondition.isPending}
onClick={() => createCondition.mutate()}
>
{createCondition.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.life.takeOver.save")}
</Button>
) : (
<div ref={setFooterEl} className="flex w-full justify-end gap-2" />
)
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-sm font-medium">
{t("records.life.takeOver.freeTextLabel")}
</p>
<p className="bg-muted/40 rounded-md p-3 text-sm whitespace-pre-wrap">
{text}
</p>
</div>
{saved ? (
<p className="text-sm">
{t("records.life.takeOver.clearQuestion")}
</p>
) : field === "conditions" ? (
<>
<div className="space-y-1.5">
<Label htmlFor="take-over-condition-label">
{t("records.life.takeOver.conditionLabel")}
</Label>
<Input
id="take-over-condition-label"
maxLength={120}
value={label}
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="take-over-condition-onset">
{t("records.life.takeOver.onset")}
</Label>
<DateField
id="take-over-condition-onset"
value={onset}
onChange={setOnset}
/>
</div>
</>
) : (
<AllergyForm
footerSlot={footerEl}
onSuccess={afterCreate}
onCancel={close}
/>
)}
</div>
</ResponsiveSheet>
</>
);
}
@@ -1,426 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Label } from "@/components/ui/label";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { isConflict } from "@/lib/api/optimistic-token";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
ALCOHOL_PATTERN_VALUES,
HEALTH_PROFILE_FACT_KINDS,
SHIFT_SCHEDULE_VALUES,
SMOKING_STATUS_VALUES,
type HealthProfileFactKind,
type HealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
interface FactDto {
id: string;
kind: HealthProfileFactKind;
value: HealthProfileFactValue | null;
unreadable: boolean;
validFrom: string;
validUntil: string | null;
provenance: "USER_REPORTED" | "USER_CORRECTION";
supersededByRevisionId: string | null;
createdAt: string;
}
interface FactsData {
current: Record<HealthProfileFactKind, FactDto | null>;
history: FactDto[];
}
interface RemovedFactDto {
id: string;
kind: HealthProfileFactKind;
removedAt: string;
}
const VALUES: Record<HealthProfileFactKind, readonly string[]> = {
SMOKING_STATUS: SMOKING_STATUS_VALUES,
ALCOHOL_PATTERN: ALCOHOL_PATTERN_VALUES,
SHIFT_SCHEDULE: SHIFT_SCHEDULE_VALUES,
};
function isStaleFactTarget(error: unknown): boolean {
return (
isConflict(error) || (error instanceof ApiError && error.status === 404)
);
}
const KIND_LABEL: Record<HealthProfileFactKind, string> = {
SMOKING_STATUS: "records.profileFacts.smokingLabel",
ALCOHOL_PATTERN: "records.profileFacts.alcoholLabel",
SHIFT_SCHEDULE: "records.profileFacts.shiftLabel",
};
export function HealthProfileFactsManager() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<
Partial<Record<HealthProfileFactKind, string>>
>({});
// Kinds whose last save attempt failed. A per-kind flag rather than a single
// banner, so a partial failure names which entry the user still has to redo
// instead of hiding it behind one green toast.
const [failedKinds, setFailedKinds] = useState<
Partial<Record<HealthProfileFactKind, boolean>>
>({});
// Live copy of the draft set. State drives the controlled Selects; this ref
// is what the one footer Save reads at click time, so a multi-field save
// always writes the latest selections and never a value a render lagged on.
const draftsRef = useRef<Partial<Record<HealthProfileFactKind, string>>>({});
const editDraft = (kind: HealthProfileFactKind, value: string) => {
draftsRef.current = { ...draftsRef.current, [kind]: value };
setDrafts(draftsRef.current);
// Editing a kind clears its stale failure marker; the next Save decides its
// outcome afresh.
setFailedKinds((old) => (old[kind] ? { ...old, [kind]: undefined } : old));
};
const query = useQuery({
queryKey: queryKeys.healthProfileFacts(),
queryFn: () => apiGet<FactsData>("/api/anamnesis/facts"),
});
const save = useMutation({
mutationKey: queryKeys.healthProfileFacts(),
// One Save for the whole tile: iterate only the dirty kinds, fire the right
// bitemporal call per kind (PATCH a revision, POST a first value), and settle
// them together so a single 409 cannot take the healthy writes down with it.
mutationFn: async (
inputs: Array<{
kind: HealthProfileFactKind;
value: string;
current: FactDto | null;
}>,
) => {
const settled = await Promise.allSettled(
inputs.map((input) =>
input.current
? apiPatch<FactDto>(`/api/anamnesis/facts/${input.current.id}`, {
value: input.value,
})
: apiPost<FactDto>("/api/anamnesis/facts", {
kind: input.kind,
value: input.value,
}),
),
);
return inputs.map((input, index) => ({
kind: input.kind,
outcome: settled[index],
}));
},
onSuccess: (results) => {
const succeeded = results.filter((r) => r.outcome.status === "fulfilled");
const failed = results.filter((r) => r.outcome.status === "rejected");
// Clear the drafts and the failure flags only for the kinds that landed.
// A kind that 409'd keeps its draft on screen so the user can retry it.
const remaining = { ...draftsRef.current };
for (const { kind } of succeeded) delete remaining[kind];
draftsRef.current = remaining;
setDrafts(remaining);
setFailedKinds((current) => {
const next = { ...current };
for (const { kind } of succeeded) next[kind] = undefined;
for (const { kind } of failed) next[kind] = true;
return next;
});
if (succeeded.length > 0) {
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
} else if (
failed.some((r) =>
isStaleFactTarget((r.outcome as PromiseRejectedResult).reason),
)
) {
// Nothing persisted, but a stale target means our view is behind the
// server; refetch so the next attempt revises the current revision.
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
}
if (failed.length === 0) {
toastWrittenOutcome("success", t("records.profileFacts.savedToast"));
} else {
showErrorToast(
t("records.profileFacts.savePartialError", {
kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "),
}),
);
}
},
});
const remove = useMutation({
mutationKey: queryKeys.healthProfileFacts(),
mutationFn: (current: FactDto) =>
apiDelete<RemovedFactDto>(`/api/anamnesis/facts/${current.id}`),
onSuccess: (removed) => {
setDrafts((current) => ({ ...current, [removed.kind]: undefined }));
queryClient.setQueryData<FactsData>(
queryKeys.healthProfileFacts(),
(facts) =>
facts
? {
current: {
...facts.current,
[removed.kind]: null,
},
history: facts.history.map((revision) =>
revision.id === removed.id
? { ...revision, validUntil: removed.removedAt }
: revision,
),
}
: facts,
);
toastWrittenOutcome(
"success",
t("records.profileFacts.removedToast", {
kind: t(KIND_LABEL[removed.kind]),
}),
);
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
},
onError: (error, current) => {
if (isStaleFactTarget(error)) {
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
}
showErrorToast(
t("records.profileFacts.removeError", {
kind: t(KIND_LABEL[current.kind]),
}),
{ cause: error },
);
},
});
if (query.isError) {
return (
<QueryErrorCard
title={t("records.profileFacts.loadError")}
onRetry={() => void query.refetch()}
/>
);
}
const disabled = query.isLoading || save.isPending || remove.isPending;
const closedHistory = (query.data?.history ?? []).filter(
(revision) => revision.validUntil !== null,
);
// A kind is dirty when its draft differs from the stored value and is not the
// empty placeholder. `isDirty` drives the render (Save enabled state); the
// footer re-collects the same set from the ref at click time.
const isDirty = (kind: HealthProfileFactKind, draft: string | undefined) => {
const current = query.data?.current[kind] ?? null;
return draft !== undefined && draft !== "" && draft !== current?.value;
};
const dirtyCount = HEALTH_PROFILE_FACT_KINDS.filter((kind) =>
isDirty(kind, drafts[kind]),
).length;
const collectDirtyInputs = () =>
HEALTH_PROFILE_FACT_KINDS.flatMap((kind) => {
const draft = draftsRef.current[kind];
if (!isDirty(kind, draft)) return [];
return [
{
kind,
value: draft as string,
current: query.data?.current[kind] ?? null,
},
];
});
return (
<div className="space-y-6" data-slot="health-profile-facts-manager">
<div className="grid gap-4 md:grid-cols-3">
{HEALTH_PROFILE_FACT_KINDS.map((kind) => {
const current = query.data?.current[kind] ?? null;
const value = drafts[kind] ?? current?.value ?? "";
const failed = failedKinds[kind] === true;
return (
<div key={kind} className="space-y-2">
<Label htmlFor={`health-profile-fact-${kind}`}>
{t(KIND_LABEL[kind])}
</Label>
<Select
value={value}
disabled={disabled}
onValueChange={(next) => editDraft(kind, next)}
>
<SelectTrigger
id={`health-profile-fact-${kind}`}
className="w-full"
>
<SelectValue
placeholder={
current?.unreadable
? t("records.profileFacts.unreadable")
: t("records.profileFacts.notRecorded")
}
/>
</SelectTrigger>
<SelectContent>
{VALUES[kind].map((option) => (
<SelectItem key={option} value={option}>
{t(`records.profileFacts.values.${kind}.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Per-entry row now carries only the quiet destructive action:
saving is one shared footer button (UI-STANDARDS §11), so the
three fields no longer each stack their own grey Save slab.
When a shared Save partially fails, the kind that didn't land
says so here rather than behind the toast. */}
{failed || current ? (
<div className="flex items-center justify-between gap-2 pt-1">
{failed ? (
<span
className="text-destructive text-xs"
data-slot={`health-profile-fact-error-${kind}`}
>
{t("records.profileFacts.saveKindError")}
</span>
) : (
<span aria-hidden />
)}
{current ? (
<ConfirmButton
// Short label inside the row; the full phrase stays the
// accessible name, because "Remove" on its own is
// ambiguous once three of them are on screen.
label={t("records.profileFacts.removeShort")}
ariaLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
title={t("records.profileFacts.removeConfirmTitle", {
kind: t(KIND_LABEL[kind]),
})}
body={t("records.profileFacts.removeConfirmDescription")}
confirmLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
onConfirm={() => remove.mutate(current)}
pending={
remove.isPending && remove.variables?.id === current.id
}
disabled={disabled}
variant="ghost"
size="sm"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
</div>
) : null}
</div>
);
})}
</div>
{closedHistory.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("records.profileFacts.historyTitle")}
</h3>
<ul className="divide-border divide-y text-sm">
{closedHistory.map((revision) => (
<li
key={revision.id}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span>
{t(KIND_LABEL[revision.kind])}:{" "}
{revision.unreadable
? t("records.profileFacts.unreadable")
: revision.value
? t(
`records.profileFacts.values.${revision.kind}.${revision.value}`,
)
: t("records.profileFacts.notRecorded")}
</span>
<span className="text-muted-foreground text-xs">
{fmt.dateTime(revision.validFrom)}
{revision.validUntil ? (
<>
{" "}
{t("records.profileFacts.to")}{" "}
{fmt.dateTime(revision.validUntil)}
</>
) : null}
</span>
</li>
))}
</ul>
</div>
)}
{/* One Save for the whole tile. It writes only the dirty kinds and stays
disabled until at least one field differs from its stored value. It
is the last thing in the card, below the history (design standards
§12). */}
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || dirtyCount === 0}
onClick={() => save.mutate(collectDirtyInputs())}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</SettingsCardActions>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,80 @@
"use client";
/**
* v1.43 (#614) — screening hints from the life circumstances, in Settings →
* Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound,
* each with its status, the reasons in the account's language and the rule's
* source and country. A hint, never a reminder: nothing is scheduled here,
* and the card says so. Resolved on the server; rendered as delivered.
*
* The card stays away until the person has said something about smoking,
* because every hint would otherwise read "not enough information".
*/
import { useQuery } from "@tanstack/react-query";
import { Stethoscope } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
import { queryKeys } from "@/lib/query-keys";
export function ScreeningHintsCard() {
const { t } = useTranslations();
const query = useQuery({
queryKey: queryKeys.lifeCircumstances(),
queryFn: () =>
apiGet<LifeCircumstancesDto>("/api/anamnesis/life-circumstances"),
});
const data = query.data;
const tobacco = data?.sections.find((section) => section.key === "tobacco");
if (!data || !tobacco || tobacco.facts.length === 0) return null;
return (
<SettingsCard data-slot="screening-hints-card">
<SettingsCardHeader
anchor="screening-hints"
icon={Stethoscope}
title={t("records.life.screening.cardTitle")}
description={t("records.life.screening.cardDescription")}
/>
<ul className="divide-border divide-y">
{data.derived.screeningHints.map((hint) => (
<li
key={hint.id}
className="space-y-1 py-3"
data-slot={`screening-hint-${hint.id}`}
>
<p className="text-sm font-medium">
{t(`records.life.screening.titles.${hint.id}`)}
</p>
<p className="text-sm">
{t(`records.life.screening.status.${hint.status}`)}
{hint.reasonKeys.length > 0
? ` ${hint.reasonKeys
.map((key) => t(`records.life.screening.reasons.${key}`))
.join(" ")}`
: ""}
</p>
<p className="text-muted-foreground text-xs">
<a
href={hint.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
{t("records.life.screening.source", {
source: hint.sourceLabel,
})}
</a>
</p>
</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("records.life.screening.noReminder")}
</p>
</SettingsCard>
);
}
@@ -35,8 +35,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => null,
}));
vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: ({
@@ -18,8 +18,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => <div data-slot="conditions" />,
}));
vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => null,
@@ -40,7 +40,7 @@ import { ConditionsManager } from "@/components/records/conditions-manager";
import { EmergencyProfileManager } from "@/components/records/emergency-profile-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager";
import { HealthProfileFactsManager } from "@/components/records/health-profile-facts-manager";
import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager";
export function AnamnesisSection() {
const { t } = useTranslations();
@@ -99,14 +99,17 @@ export function AnamnesisSection() {
</SettingsCard>
)}
{/* v1.43 (#614) — the life circumstances took over the three profile
facts (smoking status and shift work live in their sections now;
alcohol is AUDIT-C). */}
<SettingsCard>
<SettingsCardHeader
anchor="profile-facts"
anchor="life-circumstances"
icon={ClipboardList}
title={t("records.profileFacts.cardTitle")}
description={t("records.profileFacts.cardDescription")}
title={t("records.life.cardTitle")}
description={t("records.life.cardDescription")}
/>
<HealthProfileFactsManager />
<LifeCircumstancesManager />
</SettingsCard>
<SettingsCard>
@@ -12,11 +12,14 @@
*/
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
export function VorsorgeSection() {
return (
<div className="space-y-6">
<VorsorgeSettings />
{/* v1.43 (#614) — hints from the life circumstances, with their source. */}
<ScreeningHintsCard />
</div>
);
}