fix(insights): scope generation guard to prompt fields, clear cache on restore

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:11:43 +02:00
1 parent 666a85d311
commit 3aff2c16db
104 files changed
+8082 -651

No files matched your search

@@ -3,6 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
import de from "../../../../messages/de.json";
import en from "../../../../messages/en.json";
const mocks = vi.hoisted(() => ({
mutationOptions: [] as Array<{
onSuccess?: (data: { summary: Record<string, number> }) => void;
}>,
toastSuccess: vi.fn(),
}));
/**
* v1.4.15 phase-C5 — `/admin/backups` empty state.
*
@@ -18,6 +25,13 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/admin/backups",
}));
vi.mock("sonner", () => ({
toast: {
success: mocks.toastSuccess,
error: vi.fn(),
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { rows: [], retentionDays: 30 },
@@ -25,14 +39,21 @@ vi.mock("@tanstack/react-query", () => ({
isError: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
variables: undefined,
}),
useMutation: (options: unknown) => {
mocks.mutationOptions.push(
options as {
onSuccess?: (data: { summary: Record<string, number> }) => void;
},
);
return {
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
variables: undefined,
};
},
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -107,3 +128,27 @@ describe("BackupsSection — empty state", () => {
);
});
});
describe("BackupsSection — upload outcome", () => {
it("includes health profile fact revisions in the restored record total", () => {
mocks.toastSuccess.mockClear();
const firstMutation = mocks.mutationOptions.length;
render();
const uploadOutcome = mocks.mutationOptions[firstMutation + 1]?.onSuccess;
expect(uploadOutcome).toBeTypeOf("function");
uploadOutcome!({
summary: {
measurements: 1,
medications: 2,
intakeEvents: 3,
moodEntries: 4,
healthProfileFactRevisions: 5,
},
});
expect(mocks.toastSuccess).toHaveBeenCalledWith(
expect.stringContaining("15"),
);
});
});
+2
View File
@@ -244,6 +244,7 @@ export function BackupsSection() {
// nothing rather than break the count.
nutrientDays?: number;
healthProfile?: number;
healthProfileFactRevisions?: number;
customMetrics?: number;
customMetricEntries?: number;
intradayProfiles?: number;
@@ -272,6 +273,7 @@ export function BackupsSection() {
// held — a smaller version of the same defect, on the reporting side.
(data.summary.nutrientDays ?? 0) +
(data.summary.healthProfile ?? 0) +
(data.summary.healthProfileFactRevisions ?? 0) +
(data.summary.customMetrics ?? 0) +
(data.summary.customMetricEntries ?? 0) +
(data.summary.intradayProfiles ?? 0);
@@ -66,7 +66,7 @@ function render(
extra?: Partial<
Pick<
React.ComponentProps<typeof ClinicianView>,
"documents" | "token" | "locale"
"documents" | "token" | "locale" | "selection"
>
>,
) {
@@ -84,6 +84,78 @@ function render(
}
describe("<ClinicianView>", () => {
it("renders selected anamnesis and allergy facts with the PDF vocabulary", () => {
const html = render(
makeReport({
allergies: [
{
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
reaction: "Hives",
reactionUnreadable: false,
},
],
anamnesis: {
conditions: "Hypertension",
conditionsUnreadable: false,
smokingStatus: "FORMER",
alcoholPattern: "OCCASIONAL",
shiftSchedule: "ROTATING",
unreadableFacts: [],
},
}),
);
expect(html).toContain("Health profile");
expect(html).toContain("Hypertension");
expect(html).toContain("Former smoker");
expect(html).toContain("Occasional");
expect(html).toContain("Rotating shifts");
expect(html).toContain("Penicillin");
expect(html).toContain("Hives");
});
it("labels absent anamnesis facts without inventing negative values", () => {
const html = render(
makeReport({
anamnesis: {
conditions: null,
conditionsUnreadable: false,
smokingStatus: null,
alcoholPattern: null,
shiftSchedule: null,
unreadableFacts: [],
},
}),
);
expect(html).toContain("Health profile");
expect(html.match(/Not recorded/g)).toHaveLength(4);
expect(html).not.toContain("Never smoker");
expect(html).not.toContain("No alcohol");
expect(html).not.toContain("No shift work");
});
it("does not render anamnesis carried outside the frozen selection", () => {
const html = render(
makeReport({
anamnesis: {
conditions: "Must stay private",
conditionsUnreadable: false,
smokingStatus: "CURRENT",
alcoholPattern: "MOST_DAYS",
shiftSchedule: "FIXED_SHIFT",
unreadableFacts: [],
},
}),
{ selection: selectionFromLeaves(["PULSE"]) },
);
expect(html).not.toContain("Health profile");
expect(html).not.toContain("Must stay private");
expect(html).not.toContain("Current smoker");
});
it("renders the fenced wellness card with the descriptive disclaimer", () => {
const html = render(makeReport());
expect(html).toContain("Wellness scores");
@@ -22,6 +22,8 @@ import type { ReportSelection } from "@/lib/report-selection/selection";
import { DocumentEntry } from "./documents-list";
import { ShareDownloadActions } from "./download-actions";
import {
AllergiesSection,
AnamnesisSection,
GlucoseSection,
MeasurementGroups,
MedicationsSection,
@@ -136,6 +138,8 @@ export function ClinicianView({
) : null}
<GlucoseSection t={t} report={report} fmtNum={fmtNum} />
<MedicationsSection t={t} report={report} selection={selection} />
<AllergiesSection t={t} report={report} selection={selection} />
<AnamnesisSection t={t} report={report} selection={selection} />
<WellnessSection t={t} report={report} fmtNum={fmtNum} />
</>
) : null}
@@ -188,6 +188,119 @@ export function MedicationsSection({
);
}
/** Structured allergy records, rendered only when the frozen scope allows it. */
export function AllergiesSection({
t,
report,
selection,
}: {
t: Translate;
report: DoctorReportData;
selection: ReportSelection;
}) {
const allergies = selection.has("ALLERGIES") ? report.allergies : null;
if (!allergies || allergies.length === 0) return null;
return (
<Section title={t("doctorReport.allergiesTitle")}>
<div className="space-y-4">
{allergies.map((allergy, index) => (
<div
key={`${allergy.substance}-${allergy.category}-${allergy.type}-${index}`}
className="border-border/60 rounded-md border px-3"
>
<StatRow
label={t("doctorReport.allergiesColSubstance")}
value={allergy.substance}
/>
<StatRow
label={t("doctorReport.allergiesColCategory")}
value={t(`records.allergies.category.${allergy.category}`)}
/>
<StatRow
label={t("doctorReport.allergiesColKind")}
value={t(`records.allergies.type.${allergy.type}`)}
/>
<StatRow
label={t("doctorReport.allergiesColSeverity")}
value={
allergy.severity
? t(`records.allergies.severity.${allergy.severity}`)
: "—"
}
/>
<StatRow
label={t("doctorReport.allergiesColReaction")}
value={
allergy.reactionUnreadable
? t("doctorReport.reactionUnreadable")
: (allergy.reaction ?? "—")
}
/>
<StatRow
label={t("doctorReport.allergiesColStatus")}
value={t(`records.allergies.status.${allergy.status}`)}
/>
</div>
))}
</div>
</Section>
);
}
/**
* The selected health-profile leaf. It deliberately renders all four rows:
* `not recorded` is different from a deselected leaf, while an unreadable
* ciphertext is different from both.
*/
export function AnamnesisSection({
t,
report,
selection,
}: {
t: Translate;
report: DoctorReportData;
selection: ReportSelection;
}) {
const anamnesis = selection.has("ANAMNESIS") ? report.anamnesis : null;
if (!anamnesis) return null;
const absent = t("doctorReport.anamnesisNotRecorded");
const unreadable = t("doctorReport.anamnesisUnreadable");
const factValue = (
kind: "SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE",
value: string | null,
): string => {
if (anamnesis.unreadableFacts.includes(kind)) return unreadable;
return value ? t(`records.profileFacts.values.${kind}.${value}`) : absent;
};
return (
<Section title={t("doctorReport.anamnesisTitle")}>
<StatRow
label={t("doctorReport.anamnesisConditions")}
value={
anamnesis.conditionsUnreadable
? unreadable
: (anamnesis.conditions ?? absent)
}
/>
<StatRow
label={t("doctorReport.anamnesisSmoking")}
value={factValue("SMOKING_STATUS", anamnesis.smokingStatus)}
/>
<StatRow
label={t("doctorReport.anamnesisAlcohol")}
value={factValue("ALCOHOL_PATTERN", anamnesis.alcoholPattern)}
/>
<StatRow
label={t("doctorReport.anamnesisShiftSchedule")}
value={factValue("SHIFT_SCHEDULE", anamnesis.shiftSchedule)}
/>
</Section>
);
}
/**
* The fenced wellness card. Descriptive composites, not clinical assessments —
* the disclaimer is load-bearing and sits above the numbers, not under them.
@@ -0,0 +1,190 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/query-keys";
import {
applyPatternDecisionSuccess,
patternDismissalOverrideReducer,
resolvePatternDismissed,
type PatternDismissalOverrideState,
} from "../pattern-dismiss-action";
const PATTERN_ID = "pattern-1";
describe("pattern dismissal override state", () => {
it("applies dismiss and restore transitions optimistically", () => {
let state: PatternDismissalOverrideState = {};
state = patternDismissalOverrideReducer(state, {
type: "optimistic",
patternId: PATTERN_ID,
dismissed: true,
});
expect(resolvePatternDismissed(state, PATTERN_ID, false)).toBe(true);
state = patternDismissalOverrideReducer(
{},
{
type: "optimistic",
patternId: PATTERN_ID,
dismissed: false,
},
);
expect(resolvePatternDismissed(state, PATTERN_ID, true)).toBe(false);
});
it("keeps local state until every owning query invalidation settles", async () => {
const queryClient = new QueryClient();
const applyOptimisticState = vi.fn();
const clearOptimisticState = vi.fn();
const settleInvalidation: Array<() => void> = [];
const invalidateQueries = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(
() =>
new Promise<void>((resolve) => {
settleInvalidation.push(resolve);
}),
);
const success = applyPatternDecisionSuccess(
queryClient,
applyOptimisticState,
clearOptimisticState,
);
expect(applyOptimisticState).toHaveBeenCalledOnce();
expect(clearOptimisticState).not.toHaveBeenCalled();
expect(applyOptimisticState.mock.invocationCallOrder[0]).toBeLessThan(
invalidateQueries.mock.invocationCallOrder[0],
);
expect(invalidateQueries).toHaveBeenCalledTimes(3);
expect(invalidateQueries).toHaveBeenNthCalledWith(
1,
{
queryKey: queryKeys.analytics(),
},
{ throwOnError: true },
);
expect(invalidateQueries).toHaveBeenNthCalledWith(
2,
{
queryKey: queryKeys.insightsCorrelations(),
},
{ throwOnError: true },
);
expect(invalidateQueries).toHaveBeenNthCalledWith(
3,
{
queryKey: queryKeys.moodInsights(),
},
{ throwOnError: true },
);
settleInvalidation[0]();
settleInvalidation[1]();
await Promise.resolve();
expect(clearOptimisticState).not.toHaveBeenCalled();
settleInvalidation[2]();
await success;
expect(clearOptimisticState).toHaveBeenCalledOnce();
expect(invalidateQueries.mock.invocationCallOrder[2]).toBeLessThan(
clearOptimisticState.mock.invocationCallOrder[0],
);
});
it("retains local state when an active query refetch rejects", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const refetchError = new Error("refetch failed");
const refetch = vi.fn().mockRejectedValue(refetchError);
queryClient.setQueryData(queryKeys.analytics(), { version: "cached" });
const observer = new QueryObserver(queryClient, {
queryKey: queryKeys.analytics(),
queryFn: refetch,
staleTime: Infinity,
});
const unsubscribe = observer.subscribe(() => {});
let state: PatternDismissalOverrideState = {};
const applyOptimisticState = vi.fn(() => {
state = patternDismissalOverrideReducer(state, {
type: "optimistic",
patternId: PATTERN_ID,
dismissed: true,
});
});
const clearOptimisticState = vi.fn(() => {
state = patternDismissalOverrideReducer(state, {
type: "settled",
patternId: PATTERN_ID,
dismissed: true,
});
});
try {
await applyPatternDecisionSuccess(
queryClient,
applyOptimisticState,
clearOptimisticState,
);
expect(applyOptimisticState).toHaveBeenCalledOnce();
expect(refetch).toHaveBeenCalledOnce();
expect(clearOptimisticState).not.toHaveBeenCalled();
expect(state[PATTERN_ID]).toBe(true);
} finally {
unsubscribe();
queryClient.clear();
}
});
it("reveals mismatching authoritative state after invalidation settles", () => {
let state = patternDismissalOverrideReducer(
{},
{
type: "optimistic",
patternId: PATTERN_ID,
dismissed: true,
},
);
expect(resolvePatternDismissed(state, PATTERN_ID, false)).toBe(true);
state = patternDismissalOverrideReducer(state, {
type: "settled",
patternId: PATTERN_ID,
dismissed: true,
});
expect(resolvePatternDismissed(state, PATTERN_ID, false)).toBe(false);
expect(state[PATTERN_ID]).toBeUndefined();
});
it("does not let an older lifecycle clear a newer opposite override", () => {
let state = patternDismissalOverrideReducer(
{},
{
type: "optimistic",
patternId: PATTERN_ID,
dismissed: true,
},
);
state = patternDismissalOverrideReducer(state, {
type: "optimistic",
patternId: PATTERN_ID,
dismissed: false,
});
state = patternDismissalOverrideReducer(state, {
type: "settled",
patternId: PATTERN_ID,
dismissed: true,
});
expect(state[PATTERN_ID]).toBe(false);
expect(resolvePatternDismissed(state, PATTERN_ID, true)).toBe(false);
});
});
+15 -11
View File
@@ -1,7 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { Activity, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -18,6 +17,7 @@ import { AskCoachAction } from "./ask-coach-action";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "./pattern-dismiss-action";
/**
@@ -100,10 +100,12 @@ const COACH_QUESTION_BY_KIND: Record<CorrelationResult["kind"], string> = {
export function CorrelationCard({ result }: CorrelationCardProps) {
const { t } = useTranslations();
const initialDismissed =
result.status === "ok" && result.dismissed === true && !!result.patternId;
const [dismissed, setDismissed] = useState(initialDismissed);
useEffect(() => setDismissed(initialDismissed), [initialDismissed]);
const dismissal = usePatternDismissalOverrides();
const patternId = result.status === "ok" ? result.patternId : undefined;
const dismissed = dismissal.isDismissed(
patternId,
result.status === "ok" && result.dismissed === true,
);
const title = t(TITLE_KEY[result.kind]);
const subtitle = t(SUBTITLE_KEY[result.kind]);
const coachSources = COACH_SOURCES_BY_KIND[result.kind];
@@ -149,10 +151,11 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
</div>
</CardHeader>
<CardContent className="space-y-3">
{dismissed && result.status === "ok" && result.patternId ? (
{dismissed && result.status === "ok" && patternId ? (
<PatternDismissedNotice
patternId={result.patternId}
onRestored={() => setDismissed(false)}
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
) : result.status === "ok" ? (
<>
@@ -202,10 +205,11 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
question={t(COACH_QUESTION_BY_KIND[result.kind])}
scope={coachScope}
/>
{result.patternId ? (
{patternId ? (
<PatternDismissButton
patternId={result.patternId}
onDismissed={() => setDismissed(true)}
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
/>
) : null}
</div>
@@ -1,11 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { MoodExplainerIcon } from "./mood-explainer-icon";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "@/components/insights/pattern-dismiss-action";
/**
@@ -90,23 +90,7 @@ export function MoodDiscoveredRelations({
pairsTested: number;
}) {
const { t } = useTranslations();
const [dismissedIds, setDismissedIds] = useState(
() =>
new Set(
pairs
.filter((pair) => pair.dismissed && pair.patternId)
.map((pair) => pair.patternId as string),
),
);
useEffect(() => {
setDismissedIds(
new Set(
pairs
.filter((pair) => pair.dismissed && pair.patternId)
.map((pair) => pair.patternId as string),
),
);
}, [pairs]);
const dismissal = usePatternDismissalOverrides();
if (pairs.length === 0) return null;
@@ -131,6 +115,7 @@ export function MoodDiscoveredRelations({
</div>
<ul className="divide-border divide-y">
{pairs.map((pair) => {
const patternId = pair.patternId;
const moodIsOutcome = pair.outcome === "MOOD";
const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome;
const dynamicLabel = moodIsOutcome
@@ -138,19 +123,17 @@ export function MoodDiscoveredRelations({
: pair.outcomeLabel;
const factorLabel =
dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey);
if (pair.patternId && dismissedIds.has(pair.patternId)) {
if (
patternId &&
dismissal.isDismissed(patternId, pair.dismissed === true)
) {
return (
<li key={pair.canonicalKey ?? pair.patternId} className="py-2">
<li key={pair.canonicalKey ?? patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={pair.patternId}
onRestored={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.delete(pair.patternId as string);
return next;
})
}
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
</li>
);
@@ -187,16 +170,11 @@ export function MoodDiscoveredRelations({
})}
/>
</span>
{pair.patternId ? (
{patternId ? (
<PatternDismissButton
patternId={pair.patternId}
onDismissed={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.add(pair.patternId as string);
return next;
})
}
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
className="self-start"
/>
) : null}
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
@@ -9,6 +8,7 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "@/components/insights/pattern-dismiss-action";
/**
@@ -95,23 +95,7 @@ export function MoodFactorMetricCrosstab({
}) {
const { t } = useTranslations();
const unitDisplay = useUnitDisplay();
const [dismissedIds, setDismissedIds] = useState(
() =>
new Set(
rows
.filter((row) => row.dismissed && row.patternId)
.map((row) => row.patternId as string),
),
);
useEffect(() => {
setDismissedIds(
new Set(
rows
.filter((row) => row.dismissed && row.patternId)
.map((row) => row.patternId as string),
),
);
}, [rows]);
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
return (
@@ -121,19 +105,18 @@ export function MoodFactorMetricCrosstab({
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
if (row.patternId && dismissedIds.has(row.patternId)) {
const patternId = row.patternId;
if (
patternId &&
dismissal.isDismissed(patternId, row.dismissed === true)
) {
return (
<li key={row.canonicalKey ?? row.patternId} className="py-2">
<li key={row.canonicalKey ?? patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={row.patternId}
onRestored={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.delete(row.patternId as string);
return next;
})
}
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
</li>
);
@@ -197,16 +180,11 @@ export function MoodFactorMetricCrosstab({
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
{row.patternId ? (
{patternId ? (
<PatternDismissButton
patternId={row.patternId}
onDismissed={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.add(row.patternId as string);
return next;
})
}
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
className="shrink-0"
/>
) : null}
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
@@ -8,6 +7,7 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "@/components/insights/pattern-dismiss-action";
/**
@@ -102,23 +102,7 @@ export function MoodTagMetricCrosstab({
rows: MoodTagMetricCrosstabRow[];
}) {
const { t } = useTranslations();
const [dismissedIds, setDismissedIds] = useState(
() =>
new Set(
rows
.filter((row) => row.dismissed && row.patternId)
.map((row) => row.patternId as string),
),
);
useEffect(() => {
setDismissedIds(
new Set(
rows
.filter((row) => row.dismissed && row.patternId)
.map((row) => row.patternId as string),
),
);
}, [rows]);
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
return (
@@ -128,19 +112,18 @@ export function MoodTagMetricCrosstab({
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
if (row.patternId && dismissedIds.has(row.patternId)) {
const patternId = row.patternId;
if (
patternId &&
dismissal.isDismissed(patternId, row.dismissed === true)
) {
return (
<li key={row.canonicalKey ?? row.patternId} className="py-2">
<li key={row.canonicalKey ?? patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={row.patternId}
onRestored={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.delete(row.patternId as string);
return next;
})
}
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
</li>
);
@@ -189,16 +172,11 @@ export function MoodTagMetricCrosstab({
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
{row.patternId ? (
{patternId ? (
<PatternDismissButton
patternId={row.patternId}
onDismissed={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.add(row.patternId as string);
return next;
})
}
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
className="shrink-0"
/>
) : null}
@@ -1,12 +1,14 @@
"use client";
import { QueryClientContext, type QueryClient } from "@tanstack/react-query";
import { EyeOff, RotateCcw, X } from "lucide-react";
import { useState } from "react";
import { useContext, useReducer, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
interface PatternDecisionResponse {
@@ -14,16 +16,118 @@ interface PatternDecisionResponse {
dismissed: boolean;
}
async function invalidatePatternDecisionQueries(
queryClient: Pick<QueryClient, "invalidateQueries">,
): Promise<boolean> {
const results = await Promise.allSettled([
queryClient.invalidateQueries(
{ queryKey: queryKeys.analytics() },
{ throwOnError: true },
),
queryClient.invalidateQueries(
{
queryKey: queryKeys.insightsCorrelations(),
},
{ throwOnError: true },
),
queryClient.invalidateQueries(
{ queryKey: queryKeys.moodInsights() },
{ throwOnError: true },
),
]);
return results.every((result) => result.status === "fulfilled");
}
export async function applyPatternDecisionSuccess(
queryClient: Pick<QueryClient, "invalidateQueries"> | undefined,
applyOptimisticState: () => void,
clearOptimisticState: () => void,
): Promise<void> {
applyOptimisticState();
if (!queryClient || (await invalidatePatternDecisionQueries(queryClient))) {
clearOptimisticState();
}
}
/**
* An entry is a local desired state that wins until the mutation's active
* query invalidations have settled.
*/
export type PatternDismissalOverrideState = Record<string, boolean>;
export type PatternDismissalOverrideAction =
| {
type: "optimistic";
patternId: string;
dismissed: boolean;
}
| {
type: "settled";
patternId: string;
dismissed: boolean;
};
export function patternDismissalOverrideReducer(
state: PatternDismissalOverrideState,
action: PatternDismissalOverrideAction,
): PatternDismissalOverrideState {
if (action.type === "optimistic") {
return { ...state, [action.patternId]: action.dismissed };
}
if (state[action.patternId] !== action.dismissed) return state;
const next = { ...state };
delete next[action.patternId];
return next;
}
export function resolvePatternDismissed(
state: PatternDismissalOverrideState,
patternId: string | null | undefined,
persisted: boolean,
): boolean {
if (!patternId) return false;
return state[patternId] ?? persisted;
}
export function usePatternDismissalOverrides() {
const [overrides, dispatch] = useReducer(patternDismissalOverrideReducer, {});
return {
isDismissed(patternId: string | null | undefined, persisted: boolean) {
return resolvePatternDismissed(overrides, patternId, persisted);
},
setDismissed(patternId: string, dismissed: boolean) {
dispatch({
type: "optimistic",
patternId,
dismissed,
});
},
clearDismissed(patternId: string, dismissed: boolean) {
dispatch({
type: "settled",
patternId,
dismissed,
});
},
};
}
export function PatternDismissButton({
patternId,
onDismissed,
onSettled,
className,
}: {
patternId: string;
onDismissed: () => void;
onSettled: () => void;
className?: string;
}) {
const { t } = useTranslations();
const queryClient = useContext(QueryClientContext);
const [pending, setPending] = useState(false);
async function dismiss() {
@@ -33,7 +137,7 @@ export function PatternDismissButton({
`/api/insights/patterns/${patternId}`,
{ dismissed: true },
);
onDismissed();
await applyPatternDecisionSuccess(queryClient, onDismissed, onSettled);
} catch {
toast.error(t("insights.pattern.dismissError"));
} finally {
@@ -60,13 +164,16 @@ export function PatternDismissButton({
export function PatternDismissedNotice({
patternId,
onRestored,
onSettled,
compact = false,
}: {
patternId: string;
onRestored: () => void;
onSettled: () => void;
compact?: boolean;
}) {
const { t } = useTranslations();
const queryClient = useContext(QueryClientContext);
const [pending, setPending] = useState(false);
async function restore() {
@@ -76,7 +183,7 @@ export function PatternDismissedNotice({
`/api/insights/patterns/${patternId}`,
{ dismissed: false },
);
onRestored();
await applyPatternDecisionSuccess(queryClient, onRestored, onSettled);
} catch {
toast.error(t("insights.pattern.restoreError"));
} finally {
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const mocks = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
mutationOptions: [] as Array<{ onSuccess?: () => void }>,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
aiIncludedSections: ["ABOUT_ME"],
updatedAt: "2026-07-28T12:00:00.000Z",
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useQueryClient: () => ({
invalidateQueries: mocks.invalidateQueries,
getQueryData: vi.fn(),
}),
useMutation: (options: unknown) => {
mocks.mutationOptions.push(options as { onSuccess?: () => void });
return {
mutate: vi.fn(),
isPending: false,
};
},
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: vi.fn(),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { AiProfileInclusionManager } from "../ai-profile-inclusion-manager";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AiProfileInclusionManager />
</I18nProvider>,
);
}
beforeEach(() => {
mocks.invalidateQueries.mockClear();
mocks.mutationOptions.length = 0;
});
describe("AI profile inclusion persistence", () => {
it("invalidates the centralized Insights advisor query after a successful save", () => {
render();
const onSuccess = mocks.mutationOptions[0]?.onSuccess;
expect(onSuccess).toBeTypeOf("function");
onSuccess!();
expect(mocks.invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.insightsAdvisor(),
});
});
});
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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,
confirmButtons,
renderedButtons,
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(),
confirmButtons: [] as Record<string, unknown>[],
renderedButtons: [] as Record<string, unknown>[],
removedAt,
};
});
vi.mock("@/lib/api/api-fetch", () => ({
ApiError: MockApiError,
apiDelete,
apiGet: vi.fn(),
apiPatch,
apiPost: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: vi.fn(),
}));
vi.mock("@/components/ui/button", () => ({
Button: (props: Record<string, unknown>) => {
renderedButtons.push(props);
return <button />;
},
}));
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 };
}
beforeEach(() => {
confirmButtons.length = 0;
renderedButtons.length = 0;
apiDelete.mockClear();
apiPatch.mockClear();
});
describe("health-profile fact removal control", () => {
it("renders a confirmed removal action only for each recorded current fact", () => {
const { html } = render();
expect(confirmButtons).toHaveLength(1);
expect(confirmButtons[0]).toMatchObject({
slot: "health-profile-fact-remove-SMOKING_STATUS",
label: "Remove Smoking status",
body: "The current value will become not recorded. Its dated history will be retained.",
});
expect(html).toContain("Remove Smoking status");
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 or correction", async () => {
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
typeof props.onClick === "function",
);
expect(saveButton).toBeDefined();
(saveButton!.onClick as () => void)();
await vi.waitFor(() =>
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.insightsAdvisor(),
}),
);
});
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 save or correction finds a stale 404 target", async () => {
apiPatch.mockRejectedValueOnce(new MockApiError("Not found", 404));
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
typeof props.onClick === "function",
);
expect(saveButton).toBeDefined();
(saveButton!.onClick as () => void)();
await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.healthProfileFacts(),
});
});
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(),
});
});
});
@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import {
isConflict,
readUpdatedAtToken,
withBaseToken,
} from "@/lib/api/optimistic-token";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
HEALTH_PROFILE_AI_SECTIONS,
type HealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
interface AboutMeData {
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
aiIncludedSections: HealthProfileAiSection[];
updatedAt: string | null;
}
const SECTION_LABEL_KEY: Record<HealthProfileAiSection, string> = {
ABOUT_ME: "records.aiInclusion.sectionAboutMe",
CONDITIONS: "records.aiInclusion.sectionConditions",
ALLERGIES: "records.aiInclusion.sectionAllergies",
COACH_FOCUS: "records.aiInclusion.sectionCoachFocus",
FAMILY_HISTORY: "records.aiInclusion.sectionFamilyHistory",
SMOKING_STATUS: "records.aiInclusion.sectionSmoking",
ALCOHOL_PATTERN: "records.aiInclusion.sectionAlcohol",
SHIFT_SCHEDULE: "records.aiInclusion.sectionShiftSchedule",
};
export function AiProfileInclusionManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<HealthProfileAiSection[] | null>(null);
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
});
const saved = query.data?.aiIncludedSections ?? [];
const value = draft ?? saved;
const dirty =
draft !== null &&
HEALTH_PROFILE_AI_SECTIONS.some(
(section) => draft.includes(section) !== saved.includes(section),
);
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: (sections: HealthProfileAiSection[]) =>
apiPut<AboutMeData>(
"/api/coach/about-me",
withBaseToken(
{
aiIncludedSections: sections,
},
readUpdatedAtToken(queryClient, queryKeys.coachAboutMe()) ??
undefined,
),
),
onSuccess: () => {
setDraft(null);
toastWrittenOutcome("success", t("records.aiInclusion.savedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
},
onError: (error) => {
if (isConflict(error)) {
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
}
toast.error(t("records.aiInclusion.saveError"));
},
});
if (query.isError) {
return (
<QueryErrorCard
title={t("records.aiInclusion.loadError")}
onRetry={() => void query.refetch()}
/>
);
}
const disabled = query.isLoading || save.isPending;
return (
<div className="space-y-4" data-slot="ai-profile-inclusion-manager">
<div className="divide-border divide-y">
{HEALTH_PROFILE_AI_SECTIONS.map((section) => {
const checked = value.includes(section);
return (
<div
key={section}
className="flex min-h-11 items-center justify-between gap-4 py-2"
>
<label
htmlFor={`ai-profile-section-${section}`}
className="text-sm"
>
{t(SECTION_LABEL_KEY[section])}
</label>
<Switch
id={`ai-profile-section-${section}`}
checked={checked}
disabled={disabled}
onCheckedChange={(next) => {
const current = draft ?? saved;
setDraft(
next
? [...current, section]
: current.filter((item) => item !== section),
);
}}
aria-label={t(SECTION_LABEL_KEY[section])}
/>
</div>
);
})}
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || !dirty}
onClick={() => save.mutate(value)}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.aiInclusion.save")}
</Button>
</div>
</div>
);
}
+5 -11
View File
@@ -14,9 +14,8 @@
* (`GET`/`PUT /api/coach/about-me`) and are read/written through the same
* `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app uses,
* so every consumer (the Coach above all) keeps reading the same store. The PUT
* echoes the current `aboutMe` free text (a required field that clears on empty)
* and omits `allergies` (optional, preserved server-side) so saving conditions
* never disturbs the other self-context fields.
* sends only the two fields this editor owns, so unreadable or concurrently
* updated profile text and allergies stay untouched.
*
* Plain text only — values render exclusively through inputs and React text
* children; no markdown renderer (XSS posture, see contributor notes).
@@ -76,16 +75,11 @@ export function ConditionsManager() {
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: async (input: { conditions: string; coachFocus: string }) => {
// Echo the current free-text `aboutMe` (required; empty clears it) and
// omit `allergies` (optional, preserved server-side) so this write only
// touches the two conditions fields.
return apiPut<AboutMeData>("/api/coach/about-me", {
aboutMe: query.data?.aboutMe ?? "",
mutationFn: async (input: { conditions: string; coachFocus: string }) =>
apiPut<AboutMeData>("/api/coach/about-me", {
conditions: input.conditions,
coachFocus: input.coachFocus,
});
},
}),
onSuccess: () => {
toast.success(t("records.conditions.savedToast"));
setDrafts({ conditions: null, coachFocus: null });
@@ -0,0 +1,317 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
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>>
>({});
const query = useQuery({
queryKey: queryKeys.healthProfileFacts(),
queryFn: () => apiGet<FactsData>("/api/anamnesis/facts"),
});
const save = useMutation({
mutationKey: queryKeys.healthProfileFacts(),
mutationFn: async (input: {
kind: HealthProfileFactKind;
value: string;
current: FactDto | null;
}) => {
if (input.current) {
return apiPatch<FactDto>(`/api/anamnesis/facts/${input.current.id}`, {
value: input.value,
});
}
return apiPost<FactDto>("/api/anamnesis/facts", {
kind: input.kind,
value: input.value,
});
},
onSuccess: (_data, input) => {
setDrafts((current) => ({ ...current, [input.kind]: undefined }));
toastWrittenOutcome("success", t("records.profileFacts.savedToast"));
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
},
onError: (error) => {
if (isStaleFactTarget(error)) {
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
}
toast.error(t("records.profileFacts.saveError"));
},
});
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(),
});
}
toast.error(
t("records.profileFacts.removeError", {
kind: t(KIND_LABEL[current.kind]),
}),
);
},
});
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,
);
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 dirty =
drafts[kind] !== undefined && drafts[kind] !== current?.value;
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) =>
setDrafts((old) => ({ ...old, [kind]: next }))
}
>
<SelectTrigger id={`health-profile-fact-${kind}`}>
<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>
<div className="space-y-2">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 w-full sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{current
? t("records.profileFacts.correct")
: t("records.profileFacts.save")}
</Button>
{current ? (
<ConfirmButton
label={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}
size="sm"
className="text-destructive min-h-11 w-full sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
</div>
</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>
)}
</div>
);
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { AnamnesisSection } from "../anamnesis-section";
const moduleState = vi.hoisted(() => ({
coach: true,
insights: true,
}));
vi.mock("@/hooks/use-module-enabled", () => ({
useModuleEnabled: (key: "coach" | "insights") => moduleState[key],
}));
vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
AiProfileInclusionManager: () => <div data-slot="ai-inclusion" />,
}));
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/allergy-manager", () => ({
AllergyManager: () => null,
}));
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => null,
}));
function renderSection(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AnamnesisSection />
</I18nProvider>,
);
}
beforeEach(() => {
moduleState.coach = true;
moduleState.insights = true;
});
describe("Anamnesis AI inclusion availability", () => {
it("keeps inclusion controls available to Insights-only users", () => {
moduleState.coach = false;
const html = renderSection();
expect(html).toContain('data-slot="ai-inclusion"');
expect(html).not.toContain('data-slot="conditions"');
});
it("hides inclusion controls when no self-context AI consumer is enabled", () => {
moduleState.coach = false;
moduleState.insights = false;
expect(renderSection()).not.toContain('data-slot="ai-inclusion"');
});
it("retains the Coach-only conditions manager gate", () => {
moduleState.insights = false;
const html = renderSection();
expect(html).toContain('data-slot="ai-inclusion"');
expect(html).toContain('data-slot="conditions"');
});
});
+29 -1
View File
@@ -18,7 +18,13 @@
* record where it belongs.
*/
import { HeartPulse, ShieldAlert, Users } from "lucide-react";
import {
ClipboardList,
EyeOff,
HeartPulse,
ShieldAlert,
Users,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -28,12 +34,26 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { AllergyManager } from "@/components/records/allergy-manager";
import { ConditionsManager } from "@/components/records/conditions-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";
export function AnamnesisSection() {
const { t } = useTranslations();
const coachEnabled = useModuleEnabled("coach");
const insightsEnabled = useModuleEnabled("insights");
return (
<div className="space-y-6">
{(coachEnabled || insightsEnabled) && (
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={EyeOff}
title={t("records.aiInclusion.cardTitle")}
description={t("records.aiInclusion.cardDescription")}
/>
<AiProfileInclusionManager />
</SettingsCard>
)}
{coachEnabled && (
<SettingsCard className="space-y-4">
<SettingsCardHeader
@@ -44,6 +64,14 @@ export function AnamnesisSection() {
<ConditionsManager />
</SettingsCard>
)}
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={ClipboardList}
title={t("records.profileFacts.cardTitle")}
description={t("records.profileFacts.cardDescription")}
/>
<HealthProfileFactsManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCardHeader