mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #771 from MBombeck/release/v1.37.11
v1.37.11 — one save for the lifestyle context
This commit is contained in:
13 files changed
+345
-124
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user