mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(insights): scope generation guard to prompt fields, clear cache on restore
This commit is contained in:
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user