diff --git a/CHANGELOG.md b/CHANGELOG.md
index a36262fa8..f9c4c412a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## [1.37.11] — 2026-08-10
+
+### Changed
+
+- The lifestyle-context card in Anamnese saves in one action. Smoking, alcohol and shift-work each had their own save button; now you set what you want across all three and one Save writes them together, with a single confirmation. If one of them cannot be saved, that entry stays unsaved and says so while the others go through, rather than a single message hiding which one failed.
+
## [1.37.10] — 2026-08-10
### Added
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 7179d03f3..925790262 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.37.10
+ version: 1.37.11
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index 855a160a0..5311a50d8 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -329,11 +329,32 @@ async function installA11yMocks(page: Page) {
}),
);
+ // Matches the current `MoodInsightsResponse` (mood-insights-shared.tsx): the
+ // sections short-circuit on `summary.totalEntries === 0` and then read
+ // `heatmap.cells`, so `totalEntries` and a populated `heatmap` are both
+ // required or the page throws into its error boundary. Breakdown arrays are
+ // present-but-empty; the correlations block carries every metric key.
await page.route("**/api/mood/insights", (route) =>
fulfilJson(route, {
- summary: { count: 1 },
- tagBreakdown: [],
- trends: [],
+ summary: { totalEntries: 1, inTargetPct: null },
+ heatmap: {
+ windowDays: 30,
+ cells: [{ date: "2026-07-20", score: 4, samples: 1 }],
+ },
+ distribution: [],
+ weekday: [],
+ timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
+ stability: null,
+ tags: [],
+ structuredTags: [],
+ narratives: [],
+ correlations: {
+ sleep: { result: null, points: [], n: 0 },
+ steps: { result: null, points: [], n: 0 },
+ pulse: { result: null, points: [], n: 0 },
+ weight: { result: null, points: [], n: 0 },
+ bloodPressureSystolic: { result: null, points: [], n: 0 },
+ },
}),
);
@@ -689,6 +710,15 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
path: "/insights/weight",
painted: (page) => page.locator(".recharts-wrapper").first(),
},
+ {
+ // Mood is event-driven, so its sub-page renders the heatmap and the mood
+ // line chart from `/api/mood/insights` rather than a MeasurementType series.
+ // Gate on the line chart's recharts wrapper — the same painted signal the
+ // weight sub-page uses — so the scan waits for the heavy content to land.
+ name: "/insights/mood metric subpage",
+ path: "/insights/mood",
+ painted: (page) => page.locator(".recharts-wrapper").first(),
+ },
{
// The two states this page's OWN render produces: the list, or the
// no-workouts empty state. Never `[role="status"]`, because the busy
diff --git a/messages/de.json b/messages/de.json
index 9e517ee7c..a7340279a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -9210,7 +9210,8 @@
"unreadable": "Gespeicherter Wert konnte nicht gelesen werden",
"save": "Speichern",
"savedToast": "Lebensstil-Kontext gespeichert",
- "saveError": "Lebensstil-Kontext konnte nicht gespeichert werden",
+ "savePartialError": "{kinds} konnte nicht gespeichert werden. Bitte erneut versuchen.",
+ "saveKindError": "Nicht gespeichert",
"loadError": "Lebensstil-Kontext konnte nicht geladen werden",
"remove": "{kind} entfernen",
"removeShort": "Entfernen",
diff --git a/messages/en.json b/messages/en.json
index 9d5cbbf28..953e6a93d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -9210,7 +9210,8 @@
"unreadable": "Stored value could not be read",
"save": "Save",
"savedToast": "Lifestyle context saved",
- "saveError": "Could not save lifestyle context",
+ "savePartialError": "Couldn't save {kinds}. Please try again.",
+ "saveKindError": "Not saved",
"loadError": "Could not load lifestyle context",
"remove": "Remove {kind}",
"removeShort": "Remove",
diff --git a/messages/es.json b/messages/es.json
index c82713070..07bd9f645 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -9210,7 +9210,8 @@
"unreadable": "No se pudo leer el valor guardado",
"save": "Guardar",
"savedToast": "Contexto del estilo de vida guardado",
- "saveError": "No se pudo guardar el contexto del estilo de vida",
+ "savePartialError": "No se pudo guardar {kinds}. Inténtalo de nuevo.",
+ "saveKindError": "No guardado",
"loadError": "No se pudo cargar el contexto del estilo de vida",
"remove": "Eliminar {kind}",
"removeShort": "Quitar",
diff --git a/messages/fr.json b/messages/fr.json
index ccd97dac0..d9d598454 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -9210,7 +9210,8 @@
"unreadable": "La valeur enregistrée n'a pas pu être lue",
"save": "Enregistrer",
"savedToast": "Contexte du mode de vie enregistré",
- "saveError": "Impossible d'enregistrer le contexte du mode de vie",
+ "savePartialError": "Impossible d'enregistrer {kinds}. Veuillez réessayer.",
+ "saveKindError": "Non enregistré",
"loadError": "Impossible de charger le contexte du mode de vie",
"remove": "Supprimer : {kind}",
"removeShort": "Supprimer",
diff --git a/messages/it.json b/messages/it.json
index 34115c96c..bc486c3f8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -9210,7 +9210,8 @@
"unreadable": "Impossibile leggere il valore salvato",
"save": "Salva",
"savedToast": "Contesto dello stile di vita salvato",
- "saveError": "Impossibile salvare il contesto dello stile di vita",
+ "savePartialError": "Impossibile salvare {kinds}. Riprova.",
+ "saveKindError": "Non salvato",
"loadError": "Impossibile caricare il contesto dello stile di vita",
"remove": "Rimuovi {kind}",
"removeShort": "Rimuovi",
diff --git a/messages/pl.json b/messages/pl.json
index cf7ab2e7d..dcf8cf7b1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -9210,7 +9210,8 @@
"unreadable": "Nie udało się odczytać zapisanej wartości",
"save": "Zapisz",
"savedToast": "Zapisano kontekst stylu życia",
- "saveError": "Nie udało się zapisać kontekstu stylu życia",
+ "savePartialError": "Nie udało się zapisać {kinds}. Spróbuj ponownie.",
+ "saveKindError": "Nie zapisano",
"loadError": "Nie udało się wczytać kontekstu stylu życia",
"remove": "Usuń: {kind}",
"removeShort": "Usuń",
diff --git a/package.json b/package.json
index c428a467a..22522bfc2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.37.10",
+ "version": "1.37.11",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 9a8f4dedf..d7689489c 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -17,8 +17,9 @@ import {
import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
-import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
+import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
@@ -1034,7 +1035,7 @@ export function MoodChart({
back to side-by-side. */}
- {displayTitle}
+
{activeBucket !== "day" && (
{t(
diff --git a/src/components/records/__tests__/health-profile-facts-manager.test.tsx b/src/components/records/__tests__/health-profile-facts-manager.test.tsx
index 47dff67c2..e55a54b18 100644
--- a/src/components/records/__tests__/health-profile-facts-manager.test.tsx
+++ b/src/components/records/__tests__/health-profile-facts-manager.test.tsx
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -10,8 +11,12 @@ const {
MockApiError,
apiDelete,
apiPatch,
+ apiPost,
+ toastError,
+ toastWritten,
confirmButtons,
renderedButtons,
+ selects,
removedAt,
} = vi.hoisted(() => {
const removedAt = "2026-07-28T12:00:00.000Z";
@@ -31,8 +36,12 @@ const {
removedAt,
})),
apiPatch: vi.fn(),
+ apiPost: vi.fn(),
+ toastError: vi.fn(),
+ toastWritten: vi.fn(),
confirmButtons: [] as Record[],
renderedButtons: [] as Record[],
+ selects: [] as Record[],
removedAt,
};
});
@@ -42,11 +51,11 @@ vi.mock("@/lib/api/api-fetch", () => ({
apiDelete,
apiGet: vi.fn(),
apiPatch,
- apiPost: vi.fn(),
+ apiPost,
}));
-vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
+vi.mock("sonner", () => ({ toast: { error: toastError } }));
vi.mock("@/components/outcome/outcome-toast", () => ({
- toastWrittenOutcome: vi.fn(),
+ toastWrittenOutcome: toastWritten,
}));
vi.mock("@/components/ui/button", () => ({
Button: (props: Record) => {
@@ -55,6 +64,27 @@ vi.mock("@/components/ui/button", () => ({
},
}));
+// The real Radix Select hides its `onValueChange` behind the trigger, so the
+// draft edits the interactions drive can't reach it. A thin mock exposes the
+// callback (kinds render in `HEALTH_PROFILE_FACT_KINDS` order) so a test can set
+// a draft the one footer Save then writes.
+vi.mock("@/components/ui/select", () => ({
+ Select: (props: Record) => {
+ selects.push(props);
+ return
{props.children as ReactNode}
;
+ },
+ SelectContent: (props: Record) => (
+ <>{props.children as ReactNode}>
+ ),
+ SelectItem: (props: Record) => (
+ <>{props.children as ReactNode}>
+ ),
+ SelectTrigger: (props: Record) => (
+ <>{props.children as ReactNode}>
+ ),
+ SelectValue: () => null,
+}));
+
vi.mock("@/components/ui/confirm-button", () => ({
ConfirmButton: (props: Record) => {
confirmButtons.push(props);
@@ -99,38 +129,49 @@ function render() {
return { client, html };
}
+// The one primary action: default variant, a button-type, its own onClick.
+function saveButton() {
+ return renderedButtons.find(
+ (props) =>
+ props.type === "button" &&
+ props.variant === undefined &&
+ typeof props.onClick === "function",
+ );
+}
+
+function editDraft(index: number, value: string) {
+ (selects[index].onValueChange as (next: string) => void)(value);
+}
+
beforeEach(() => {
confirmButtons.length = 0;
renderedButtons.length = 0;
+ selects.length = 0;
apiDelete.mockClear();
- apiPatch.mockClear();
+ apiPatch.mockReset();
+ apiPost.mockReset();
+ toastError.mockClear();
+ toastWritten.mockClear();
});
-describe("health-profile fact removal control", () => {
- it("uses one Save label for new and existing lifestyle facts", () => {
+describe("health-profile fact save + removal controls", () => {
+ it("renders exactly one Save for the whole lifestyle-context tile", () => {
render();
- const actionLabels = renderedButtons
- .filter(
- (props) =>
- props.type === "button" &&
- // The Save control is the entry's PRIMARY action, so it carries the
- // default variant. Matching on `variant === "outline"` used to be the
- // discriminator and silently stopped matching anything when the
- // action row was rebuilt — the button was still there, the selector
- // was not.
- props.variant === undefined &&
- typeof props.onClick === "function",
- )
- .map((props) => {
- const children = Array.isArray(props.children)
- ? props.children
- : [props.children];
- return children.filter((child) => typeof child === "string").join("");
- });
-
- expect(actionLabels).toEqual(["Save", "Save", "Save"]);
- expect(actionLabels).not.toContain("Correct");
+ const actionButtons = renderedButtons.filter(
+ (props) =>
+ props.type === "button" &&
+ props.variant === undefined &&
+ typeof props.onClick === "function",
+ );
+ const labels = actionButtons.map((props) => {
+ const children = Array.isArray(props.children)
+ ? props.children
+ : [props.children];
+ return children.filter((child) => typeof child === "string").join("");
+ });
+
+ expect(labels).toEqual(["Save"]);
});
it("renders a confirmed removal action only for each recorded current fact", () => {
@@ -178,24 +219,44 @@ describe("health-profile fact removal control", () => {
});
});
- it("invalidates advisor data after a successful save or correction", async () => {
+ it("invalidates advisor data after a successful save", async () => {
+ apiPatch.mockResolvedValueOnce({ ...fact, value: "NEVER" });
const { client } = render();
const invalidate = vi.spyOn(client, "invalidateQueries");
- const saveButton = renderedButtons.find(
- (props) =>
- props.type === "button" &&
- props.variant === undefined &&
- typeof props.onClick === "function",
- );
- expect(saveButton).toBeDefined();
- (saveButton!.onClick as () => void)();
+ // Revise the recorded smoking status, then hit the one Save.
+ editDraft(0, "NEVER");
+ (saveButton()!.onClick as () => void)();
await vi.waitFor(() =>
- expect(invalidate).toHaveBeenCalledWith({
- queryKey: queryKeys.insightsAdvisor(),
+ expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
+ value: "NEVER",
}),
);
+ expect(invalidate).toHaveBeenCalledWith({
+ queryKey: queryKeys.insightsAdvisor(),
+ });
+ expect(toastWritten).toHaveBeenCalledWith(
+ "success",
+ "Lifestyle context saved",
+ );
+ });
+
+ it("invalidates the facts read when a save finds a stale 404 target", async () => {
+ apiPatch.mockRejectedValueOnce(new MockApiError("Not found", 404));
+ const { client } = render();
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+
+ editDraft(0, "NEVER");
+ (saveButton()!.onClick as () => void)();
+
+ await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
+ expect(invalidate).toHaveBeenCalledWith({
+ queryKey: queryKeys.healthProfileFacts(),
+ });
+ // A stale target is a failure, never a green toast.
+ expect(toastWritten).not.toHaveBeenCalled();
+ expect(toastError).toHaveBeenCalled();
});
it("invalidates advisor data after a successful removal", async () => {
@@ -211,36 +272,62 @@ describe("health-profile fact removal control", () => {
);
});
- it("invalidates the facts read when save or correction finds a stale 404 target", async () => {
- apiPatch.mockRejectedValueOnce(new MockApiError("Not found", 404));
+ 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");
- const saveButton = renderedButtons.find(
- (props) =>
- props.type === "button" &&
- props.variant === undefined &&
- typeof props.onClick === "function",
- );
- expect(saveButton).toBeDefined();
- (saveButton!.onClick as () => void)();
+ (confirmButtons[0].onConfirm as () => void)();
- await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
+ await vi.waitFor(() => expect(apiDelete).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");
+ it("writes every dirty kind and keeps a conflicted kind while the other persists", async () => {
+ // Two dirty kinds: SMOKING revises an existing fact (PATCH), ALCOHOL is a
+ // first value (POST). Force the PATCH to 409 and prove the POST still lands,
+ // the failure is surfaced per-kind, and the conflicted draft survives.
+ apiPatch.mockRejectedValue(new MockApiError("Stale", 409));
+ apiPost.mockResolvedValue({
+ id: "fact-2",
+ kind: "ALCOHOL_PATTERN",
+ value: "WEEKLY",
+ });
+ render();
- (confirmButtons[0].onConfirm as () => void)();
+ editDraft(0, "NEVER"); // SMOKING_STATUS, differs from FORMER
+ editDraft(1, "WEEKLY"); // ALCOHOL_PATTERN, first value
+ (saveButton()!.onClick as () => void)();
- await vi.waitFor(() => expect(apiDelete).toHaveBeenCalledOnce());
- expect(invalidate).toHaveBeenCalledWith({
- queryKey: queryKeys.healthProfileFacts(),
+ // Both writes reach the real endpoints in one Save.
+ await vi.waitFor(() => expect(toastError).toHaveBeenCalled());
+ expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
+ value: "NEVER",
+ });
+ expect(apiPost).toHaveBeenCalledWith("/api/anamnesis/facts", {
+ kind: "ALCOHOL_PATTERN",
+ value: "WEEKLY",
+ });
+
+ // The partial failure is not hidden behind a green toast: the error names
+ // the kind that did not land, and no success toast fired.
+ expect(toastError).toHaveBeenCalledWith(
+ expect.stringContaining("Smoking status"),
+ );
+ expect(toastWritten).not.toHaveBeenCalled();
+
+ // The conflicted draft survives while the persisted one is consumed: a
+ // second Save re-attempts only SMOKING and never re-POSTs ALCOHOL.
+ apiPatch.mockClear();
+ apiPost.mockClear();
+ (saveButton()!.onClick as () => void)();
+
+ await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
+ expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
+ value: "NEVER",
});
+ expect(apiPost).not.toHaveBeenCalled();
});
});
diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx
index c08a3eada..249270931 100644
--- a/src/components/records/health-profile-facts-manager.tsx
+++ b/src/components/records/health-profile-facts-manager.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -84,6 +84,24 @@ export function HealthProfileFactsManager() {
const [drafts, setDrafts] = useState<
Partial>
>({});
+ // Kinds whose last save attempt failed. A per-kind flag rather than a single
+ // banner, so a partial failure names which entry the user still has to redo
+ // instead of hiding it behind one green toast.
+ const [failedKinds, setFailedKinds] = useState<
+ Partial>
+ >({});
+ // Live copy of the draft set. State drives the controlled Selects; this ref
+ // is what the one footer Save reads at click time, so a multi-field save
+ // always writes the latest selections and never a value a render lagged on.
+ const draftsRef = useRef>>({});
+
+ const editDraft = (kind: HealthProfileFactKind, value: string) => {
+ draftsRef.current = { ...draftsRef.current, [kind]: value };
+ setDrafts(draftsRef.current);
+ // Editing a kind clears its stale failure marker; the next Save decides its
+ // outcome afresh.
+ setFailedKinds((old) => (old[kind] ? { ...old, [kind]: undefined } : old));
+ };
const query = useQuery({
queryKey: queryKeys.healthProfileFacts(),
@@ -92,38 +110,78 @@ export function HealthProfileFactsManager() {
const save = useMutation({
mutationKey: queryKeys.healthProfileFacts(),
- mutationFn: async (input: {
- kind: HealthProfileFactKind;
- value: string;
- current: FactDto | null;
- }) => {
- if (input.current) {
- return apiPatch(`/api/anamnesis/facts/${input.current.id}`, {
- value: input.value,
- });
- }
- return apiPost("/api/anamnesis/facts", {
+ // One Save for the whole tile: iterate only the dirty kinds, fire the right
+ // bitemporal call per kind (PATCH a revision, POST a first value), and settle
+ // them together so a single 409 cannot take the healthy writes down with it.
+ mutationFn: async (
+ inputs: Array<{
+ kind: HealthProfileFactKind;
+ value: string;
+ current: FactDto | null;
+ }>,
+ ) => {
+ const settled = await Promise.allSettled(
+ inputs.map((input) =>
+ input.current
+ ? apiPatch(`/api/anamnesis/facts/${input.current.id}`, {
+ value: input.value,
+ })
+ : apiPost("/api/anamnesis/facts", {
+ kind: input.kind,
+ value: input.value,
+ }),
+ ),
+ );
+ return inputs.map((input, index) => ({
kind: input.kind,
- value: input.value,
- });
+ outcome: settled[index],
+ }));
},
- 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(),
+ onSuccess: (results) => {
+ const succeeded = results.filter((r) => r.outcome.status === "fulfilled");
+ const failed = results.filter((r) => r.outcome.status === "rejected");
+
+ // Clear the drafts and the failure flags only for the kinds that landed.
+ // A kind that 409'd keeps its draft on screen so the user can retry it.
+ const remaining = { ...draftsRef.current };
+ for (const { kind } of succeeded) delete remaining[kind];
+ draftsRef.current = remaining;
+ setDrafts(remaining);
+ setFailedKinds((current) => {
+ const next = { ...current };
+ for (const { kind } of succeeded) next[kind] = undefined;
+ for (const { kind } of failed) next[kind] = true;
+ return next;
});
- },
- onError: (error) => {
- if (isStaleFactTarget(error)) {
+
+ if (succeeded.length > 0) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.healthProfileFacts(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsAdvisor(),
+ });
+ } else if (
+ failed.some((r) =>
+ isStaleFactTarget((r.outcome as PromiseRejectedResult).reason),
+ )
+ ) {
+ // Nothing persisted, but a stale target means our view is behind the
+ // server; refetch so the next attempt revises the current revision.
queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
}
- toast.error(t("records.profileFacts.saveError"));
+
+ if (failed.length === 0) {
+ toastWrittenOutcome("success", t("records.profileFacts.savedToast"));
+ } else {
+ toast.error(
+ t("records.profileFacts.savePartialError", {
+ kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "),
+ }),
+ );
+ }
},
});
@@ -191,14 +249,36 @@ export function HealthProfileFactsManager() {
(revision) => revision.validUntil !== null,
);
+ // A kind is dirty when its draft differs from the stored value and is not the
+ // empty placeholder. `isDirty` drives the render (Save enabled state); the
+ // footer re-collects the same set from the ref at click time.
+ const isDirty = (kind: HealthProfileFactKind, draft: string | undefined) => {
+ const current = query.data?.current[kind] ?? null;
+ return draft !== undefined && draft !== "" && draft !== current?.value;
+ };
+ const dirtyCount = HEALTH_PROFILE_FACT_KINDS.filter((kind) =>
+ isDirty(kind, drafts[kind]),
+ ).length;
+ const collectDirtyInputs = () =>
+ HEALTH_PROFILE_FACT_KINDS.flatMap((kind) => {
+ const draft = draftsRef.current[kind];
+ if (!isDirty(kind, draft)) return [];
+ return [
+ {
+ kind,
+ value: draft as string,
+ current: query.data?.current[kind] ?? null,
+ },
+ ];
+ });
+
return (