mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
commit
4daad8f2fb
88 files changed
+8313
-1386
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user