Merge pull request #771 from MBombeck/release/v1.37.11

v1.37.11 — one save for the lifestyle context
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-10 10:43:54 +02:00
commit ca88d8c1a9
13 files changed
+345 -124

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -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.
+33 -3
View File
@@ -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
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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ń",
+1 -1
View File
@@ -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",
+3 -2
View File
@@ -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. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{displayTitle}</CardTitle>
<TileHeader title={displayTitle} />
{activeBucket !== "day" && (
<TagChip className="hidden tracking-wide uppercase sm:inline-flex">
{t(
@@ -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<string, unknown>[],
renderedButtons: [] as Record<string, unknown>[],
selects: [] as Record<string, unknown>[],
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<string, unknown>) => {
@@ -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<string, unknown>) => {
selects.push(props);
return <div>{props.children as ReactNode}</div>;
},
SelectContent: (props: Record<string, unknown>) => (
<>{props.children as ReactNode}</>
),
SelectItem: (props: Record<string, unknown>) => (
<>{props.children as ReactNode}</>
),
SelectTrigger: (props: Record<string, unknown>) => (
<>{props.children as ReactNode}</>
),
SelectValue: () => null,
}));
vi.mock("@/components/ui/confirm-button", () => ({
ConfirmButton: (props: Record<string, unknown>) => {
confirmButtons.push(props);
@@ -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("");
});
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(actionLabels).toEqual(["Save", "Save", "Save"]);
expect(actionLabels).not.toContain("Correct");
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,26 +272,6 @@ 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));
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)();
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();
@@ -243,4 +284,50 @@ describe("health-profile fact removal control", () => {
queryKey: queryKeys.healthProfileFacts(),
});
});
it("writes every dirty kind and keeps a conflicted kind while the other persists", async () => {
// Two dirty kinds: SMOKING revises an existing fact (PATCH), ALCOHOL is a
// first value (POST). Force the PATCH to 409 and prove the POST still lands,
// the failure is surfaced per-kind, and the conflicted draft survives.
apiPatch.mockRejectedValue(new MockApiError("Stale", 409));
apiPost.mockResolvedValue({
id: "fact-2",
kind: "ALCOHOL_PATTERN",
value: "WEEKLY",
});
render();
editDraft(0, "NEVER"); // SMOKING_STATUS, differs from FORMER
editDraft(1, "WEEKLY"); // ALCOHOL_PATTERN, first value
(saveButton()!.onClick as () => void)();
// Both writes reach the real endpoints in one Save.
await vi.waitFor(() => expect(toastError).toHaveBeenCalled());
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
value: "NEVER",
});
expect(apiPost).toHaveBeenCalledWith("/api/anamnesis/facts", {
kind: "ALCOHOL_PATTERN",
value: "WEEKLY",
});
// The partial failure is not hidden behind a green toast: the error names
// the kind that did not land, and no success toast fired.
expect(toastError).toHaveBeenCalledWith(
expect.stringContaining("Smoking status"),
);
expect(toastWritten).not.toHaveBeenCalled();
// The conflicted draft survives while the persisted one is consumed: a
// second Save re-attempts only SMOKING and never re-POSTs ALCOHOL.
apiPatch.mockClear();
apiPost.mockClear();
(saveButton()!.onClick as () => void)();
await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
value: "NEVER",
});
expect(apiPost).not.toHaveBeenCalled();
});
});
@@ -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<Record<HealthProfileFactKind, string>>
>({});
// Kinds whose last save attempt failed. A per-kind flag rather than a single
// banner, so a partial failure names which entry the user still has to redo
// instead of hiding it behind one green toast.
const [failedKinds, setFailedKinds] = useState<
Partial<Record<HealthProfileFactKind, boolean>>
>({});
// Live copy of the draft set. State drives the controlled Selects; this ref
// is what the one footer Save reads at click time, so a multi-field save
// always writes the latest selections and never a value a render lagged on.
const draftsRef = useRef<Partial<Record<HealthProfileFactKind, string>>>({});
const editDraft = (kind: HealthProfileFactKind, value: string) => {
draftsRef.current = { ...draftsRef.current, [kind]: value };
setDrafts(draftsRef.current);
// Editing a kind clears its stale failure marker; the next Save decides its
// outcome afresh.
setFailedKinds((old) => (old[kind] ? { ...old, [kind]: undefined } : old));
};
const query = useQuery({
queryKey: queryKeys.healthProfileFacts(),
@@ -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<FactDto>(`/api/anamnesis/facts/${input.current.id}`, {
value: input.value,
});
}
return apiPost<FactDto>("/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<FactDto>(`/api/anamnesis/facts/${input.current.id}`, {
value: input.value,
})
: apiPost<FactDto>("/api/anamnesis/facts", {
kind: input.kind,
value: input.value,
}),
),
);
return inputs.map((input, index) => ({
kind: input.kind,
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(),
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;
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
},
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 (
<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;
const failed = failedKinds[kind] === true;
return (
<div key={kind} className="space-y-2">
<Label htmlFor={`health-profile-fact-${kind}`}>
@@ -207,9 +287,7 @@ export function HealthProfileFactsManager() {
<Select
value={value}
disabled={disabled}
onValueChange={(next) =>
setDrafts((old) => ({ ...old, [kind]: next }))
}
onValueChange={(next) => editDraft(kind, next)}
>
<SelectTrigger id={`health-profile-fact-${kind}`}>
<SelectValue
@@ -228,14 +306,22 @@ export function HealthProfileFactsManager() {
))}
</SelectContent>
</Select>
{/* One action row per entry (UI-STANDARDS §11 + the action-row
rule): right-aligned, primary last, the quiet destructive
beside it. Two full-width stacked blocks — a grey disabled
slab over a red-outlined one — carried the same visual weight
as the field they belong to and ran into each other when
scanned, which is what "Save" directly above "Remove smoking
status" read as. */}
<div className="flex items-center justify-end gap-2 pt-1">
{/* Per-entry row now carries only the quiet destructive action:
saving is one shared footer button (UI-STANDARDS §11), so the
three fields no longer each stack their own grey Save slab.
When a shared Save partially fails, the kind that didn't land
says so here rather than behind the toast. */}
<div className="flex items-center justify-between gap-2 pt-1">
{failed ? (
<span
className="text-destructive text-xs"
data-slot={`health-profile-fact-error-${kind}`}
>
{t("records.profileFacts.saveKindError")}
</span>
) : (
<span aria-hidden />
)}
{current ? (
<ConfirmButton
// Short label inside the row; the full phrase stays the
@@ -264,27 +350,32 @@ export function HealthProfileFactsManager() {
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
<Button
type="button"
size="sm"
className="min-h-11 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
/>
)}
{t("records.profileFacts.save")}
</Button>
</div>
</div>
);
})}
</div>
{/* One Save for the whole tile. It writes only the dirty kinds and stays
disabled until at least one field differs from its stored value. */}
<div className="flex justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || dirtyCount === 0}
onClick={() => save.mutate(collectDirtyInputs())}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</div>
{closedHistory.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium">