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 (
{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 (
+ {/* 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. */} +
+ +
+ {closedHistory.length > 0 && (