release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -0,0 +1,157 @@
import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
mutationOptions: [] as Array<{
mutationFn: (enabled: boolean) => Promise<void>;
onMutate?: (enabled: boolean) => Promise<unknown>;
onError?: (error: Error, enabled: boolean, previous: unknown) => void;
}>,
apiFetchRaw: vi.fn(),
queryClient: {
cancelQueries: vi.fn(),
getQueryData: vi.fn(),
setQueryData: vi.fn(),
invalidateQueries: vi.fn(),
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly string[] }) => {
const key = queryKey.join("/");
if (key === "settings/webhook") {
return {
data: {
enabled: false,
url: "https://saved.example/hook",
headerName: "Authorization",
hasHeaderValue: true,
},
};
}
if (key === "settings/ntfy") {
return {
data: {
enabled: false,
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
hasAuthToken: true,
},
};
}
if (key === "settings/email") {
return {
data: {
enabled: false,
recipient: "saved@example.com",
smtpConfigured: true,
},
};
}
return {
data: { enabled: false, hasBotToken: true, chatId: "saved-chat" },
};
},
useMutation: (options: {
mutationFn: (enabled: boolean) => Promise<void>;
onMutate?: (enabled: boolean) => Promise<unknown>;
onError?: (error: Error, enabled: boolean, previous: unknown) => void;
}) => {
mocks.mutationOptions.push(options);
return { mutate: vi.fn(), isPending: false };
},
useQueryClient: () => mocks.queryClient,
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: mocks.apiFetchRaw,
apiGet: vi.fn(),
}));
vi.mock("../test-connection-button", () => ({
TestConnectionButton: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { WebhookCard } from "../webhook-card";
import { NtfyCard } from "../ntfy-card";
import { EmailCard } from "../email-card";
import { TelegramCard } from "../telegram-card";
function render(card: ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{card}</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.mutationOptions.length = 0;
mocks.queryClient.cancelQueries.mockResolvedValue(undefined);
mocks.apiFetchRaw.mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true }, error: null }), {
status: 200,
}),
);
});
describe("notification channel cards", () => {
it.each([
["webhook", <WebhookCard key="webhook" isAuthenticated />, 1],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />, 1],
["email", <EmailCard key="email" isAuthenticated />, 1],
["telegram", <TelegramCard key="telegram" isAuthenticated />, 0],
] as const)(
"%s switch invokes the enable-only mutation rather than the full save",
async (channel, card, toggleMutationIndex) => {
render(card);
await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
`/api/settings/${channel}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
},
);
},
);
it.each([
[<WebhookCard key="webhook" isAuthenticated />, 1, ["settings", "webhook"]],
[<NtfyCard key="ntfy" isAuthenticated />, 1, ["settings", "ntfy"]],
[<EmailCard key="email" isAuthenticated />, 1, ["settings", "email"]],
[
<TelegramCard key="telegram" isAuthenticated />,
0,
["telegram", "settings"],
],
] as const)(
"rolls each visible switch back to its previous server state on error",
async (card, toggleMutationIndex, queryKey) => {
const previous = { enabled: false, persistedConfig: "unchanged" };
mocks.queryClient.getQueryData.mockReturnValue(previous);
render(card);
const toggle = mocks.mutationOptions[toggleMutationIndex]!;
const context = await toggle.onMutate!(true);
const optimisticUpdater = mocks.queryClient.setQueryData.mock
.calls[0]![1] as (current: typeof previous) => typeof previous;
expect(optimisticUpdater(previous)).toEqual({
...previous,
enabled: true,
});
toggle.onError!(new Error("save failed"), true, context);
expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
queryKey,
previous,
);
},
);
});
@@ -0,0 +1,93 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "../notification-channel-toggle";
beforeEach(() => {
vi.clearAllMocks();
});
describe("notification channel enable-only mutation", () => {
it.each([
"/api/settings/webhook",
"/api/settings/ntfy",
"/api/settings/email",
"/api/settings/telegram",
])("sends only enabled to %s", async (endpoint) => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(
JSON.parse(vi.mocked(apiFetchRaw).mock.calls[0]![1]!.body as string),
).toEqual({ enabled: true });
});
it("changes only cached enabled state and leaves configuration fields intact", () => {
const client = new QueryClient();
const key = ["settings", "webhook"] as const;
const settings = {
enabled: false,
url: "https://saved.example/hook",
topic: "saved-topic",
recipient: "saved@example.com",
hasHeaderValue: true,
};
client.setQueryData(key, settings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(previous).toEqual(settings);
expect(client.getQueryData(key)).toEqual({ ...settings, enabled: true });
});
it("restores the exact previous cached settings after a failed toggle", () => {
const client = new QueryClient();
const key = ["settings", "telegram"] as const;
const previousSettings = {
enabled: false,
hasBotToken: true,
chatId: "123456",
};
client.setQueryData(key, previousSettings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(true);
rollbackChannelEnabled(client, key, previous);
expect(client.getQueryData(key)).toEqual(previousSettings);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(false);
});
it("surfaces the route error so the card mutation can roll back", async () => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(
JSON.stringify({ error: "Stored configuration is missing" }),
{
status: 422,
},
),
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
).rejects.toThrow("Stored configuration is missing");
});
});
@@ -1,11 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
} from "@/components/forms/use-seeded-form-dismissal";
import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
@@ -43,6 +47,10 @@ export function AccountSection() {
// settled (React #418 family).
const mounted = useMounted();
const router = useRouter();
const navigate = useCallback(
(target: string) => router.push(target),
[router],
);
const [email, setEmail] = useState("");
const [heightCm, setHeightCm] = useState("");
@@ -53,6 +61,16 @@ export function AccountSection() {
const [fullName, setFullName] = useState("");
const [insurerName, setInsurerName] = useState("");
const [insuranceNumber, setInsuranceNumber] = useState("");
const [profileSeed, setProfileSeed] = useState({
email: "",
heightCm: "",
dateOfBirth: "",
gender: "",
timezone: "Europe/Berlin",
fullName: "",
insurerName: "",
insuranceNumber: "",
});
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<StatusMessage | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
@@ -88,26 +106,62 @@ export function AccountSection() {
// satisfying the strict `react-hooks/set-state-in-effect` rule.
const [seededUserId, setSeededUserId] = useState<string | null>(null);
if (user && user.id !== seededUserId) {
setSeededUserId(user.id);
setEmail(user.email ?? "");
setHeightCm(user.heightCm?.toString() ?? "");
setDateOfBirth(
user.dateOfBirth
const seed = {
email: user.email ?? "",
heightCm: user.heightCm?.toString() ?? "",
dateOfBirth: user.dateOfBirth
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
: "",
);
setGender(user.gender ?? "");
setTimezone(resolveInitialTimezone(user.timezone, detectBrowserTimezone()));
setFullName(user.fullName ?? "");
setInsurerName(user.insurerName ?? "");
setInsuranceNumber(user.insuranceNumber ?? "");
gender: user.gender ?? "",
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
fullName: user.fullName ?? "",
insurerName: user.insurerName ?? "",
insuranceNumber: user.insuranceNumber ?? "",
};
setSeededUserId(user.id);
setEmail(seed.email);
setHeightCm(seed.heightCm);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
setTimezone(seed.timezone);
setFullName(seed.fullName);
setInsurerName(seed.insurerName);
setInsuranceNumber(seed.insuranceNumber);
setProfileSeed(seed);
}
const profileDismissal = useSeededFormDismissal({
seed: profileSeed,
value: {
email,
heightCm,
dateOfBirth,
gender,
timezone,
fullName,
insurerName,
insuranceNumber,
},
blocked: saving,
guardNavigation: true,
navigate,
});
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
const savedProfile = {
email: email.trim(),
heightCm,
dateOfBirth,
gender,
timezone,
fullName: fullName.trim(),
insurerName: insurerName.trim(),
insuranceNumber: insuranceNumber.trim(),
};
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
@@ -118,13 +172,15 @@ export function AccountSection() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim() || null,
heightCm: heightCm ? parseFloat(heightCm) : null,
dateOfBirth: dateOfBirth || null,
gender: gender || null,
fullName: fullName.trim() || null,
insurerName: insurerName.trim() || null,
insuranceNumber: insuranceNumber.trim() || null,
email: savedProfile.email || null,
heightCm: savedProfile.heightCm
? parseFloat(savedProfile.heightCm)
: null,
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
}),
user && timezone && timezone !== user.timezone
@@ -146,6 +202,11 @@ export function AccountSection() {
}
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
setEmail(savedProfile.email);
setFullName(savedProfile.fullName);
setInsurerName(savedProfile.insurerName);
setInsuranceNumber(savedProfile.insuranceNumber);
setProfileSeed(savedProfile);
await refetch();
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
@@ -579,6 +640,11 @@ export function AccountSection() {
</Button>
</form>
</ResponsiveSheet>
<SeededFormDiscardDialog
open={profileDismissal.discardDialogOpen}
onConfirm={profileDismissal.confirmDiscard}
onCancel={profileDismissal.cancelDiscard}
/>
</div>
);
}
@@ -4,7 +4,7 @@
* Anthropic form — API key + model dropdown.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -37,6 +37,7 @@ export function AnthropicProviderForm({
const [customModel, setCustomModel] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
@@ -85,10 +86,25 @@ export function AnthropicProviderForm({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div data-testid="ai-provider-config-anthropic" className="space-y-4">
<form
data-testid="ai-provider-config-anthropic"
className="space-y-4"
onSubmit={submit}
noValidate
>
<div>
<Label htmlFor="ai-anthropic-key">
{t("settings.ai.anthropicKeyLabel")}
@@ -148,9 +164,10 @@ export function AnthropicProviderForm({
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -167,6 +184,6 @@ export function AnthropicProviderForm({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -4,7 +4,7 @@
* Local (OpenAI-compatible) form — base URL + optional key + model.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -38,6 +38,7 @@ export function LocalProviderForm({
const [customModel, setCustomModel] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
@@ -85,10 +86,25 @@ export function LocalProviderForm({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div data-testid="ai-provider-config-local" className="space-y-4">
<form
data-testid="ai-provider-config-local"
className="space-y-4"
onSubmit={submit}
noValidate
>
{/* v1.28.28 (#470) — name the gateway path explicitly. The Local
provider is the ONE user-level custom-URL provider, so it is also
the documented way to reach LiteLLM / OpenRouter / vLLM and any
@@ -156,9 +172,10 @@ export function LocalProviderForm({
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -175,6 +192,6 @@ export function LocalProviderForm({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -7,7 +7,7 @@
* pick is visible to every code path that reads the row.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -42,6 +42,7 @@ export function OpenAIProviderForm({
const [showAdvanced, setShowAdvanced] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
@@ -92,10 +93,25 @@ export function OpenAIProviderForm({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div data-testid="ai-provider-config-openai" className="space-y-4">
<form
data-testid="ai-provider-config-openai"
className="space-y-4"
onSubmit={submit}
noValidate
>
<div>
<Label htmlFor="ai-openai-key">
{t("settings.ai.openai.apiKey")}
@@ -185,9 +201,10 @@ export function OpenAIProviderForm({
<div className="flex flex-wrap gap-2">
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -204,6 +221,6 @@ export function OpenAIProviderForm({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -11,7 +11,7 @@
* (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`).
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -35,6 +35,7 @@ export function ResponseTimeoutCard({
const [value, setValue] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
// Seed from the persisted value once it arrives (seed-on-data pattern).
const seededKey =
@@ -74,10 +75,24 @@ export function ResponseTimeoutCard({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
<form
className="bg-muted/50 space-y-3 rounded-lg p-4"
onSubmit={submit}
noValidate
>
<div>
<p className="text-sm font-medium">
{t("settings.ai.responseTimeoutHeading")}
@@ -104,9 +119,10 @@ export function ResponseTimeoutCard({
</div>
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -122,6 +138,6 @@ export function ResponseTimeoutCard({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -202,6 +202,7 @@ export function RuntimeActionsRow({
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
+41 -3
View File
@@ -11,6 +11,11 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -70,6 +75,39 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
EmailSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/email", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsEmail();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<EmailSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
},
});
// Operator hasn't configured SMTP — no transport, so no card. The dispatcher
// skips the channel too; surfacing an unusable toggle would only confuse.
if (settings && !settings.smtpConfigured) return null;
@@ -88,8 +126,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
@@ -131,7 +169,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending}
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (
+1 -1
View File
@@ -56,7 +56,7 @@ type ExportFormat = "CSV" | "JSON" | "FHIR";
* one place.
*/
async function downloadFromUrl(url: string, filename: string): Promise<void> {
const res = await fetch(url, { credentials: "include" });
const res = await apiFetchRaw(url, { credentials: "include" });
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { Locale } from "@/lib/i18n/config";
import { I18nProvider } from "@/lib/i18n/context";
import de from "../../../../../messages/de.json";
import en from "../../../../../messages/en.json";
import es from "../../../../../messages/es.json";
import fr from "../../../../../messages/fr.json";
import itMessages from "../../../../../messages/it.json";
import pl from "../../../../../messages/pl.json";
let statusPayload: { lastSyncedAt: string | null } | undefined;
let statusLoading = false;
let statusError = false;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: statusPayload,
isLoading: statusLoading,
isError: statusError,
}),
}));
import { AppleHealthCard } from "../apple-health-card";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AppleHealthCard enabled />
</I18nProvider>,
);
}
describe("<AppleHealthCard>", () => {
beforeEach(() => {
statusPayload = undefined;
statusLoading = false;
statusError = false;
});
it("explains that live sync uses the iOS app rather than OAuth", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render();
expect(html).toContain('data-testid="apple-health-card"');
expect(html).toContain("HealthLog iOS app");
expect(html).toContain("not a web or OAuth connection");
expect(html).toContain("Settings → Apple Health");
expect(html).toContain("when iOS grants background time");
expect(html).not.toContain(">Connected<");
});
it("keeps the status neutral when no Apple Health data has been observed", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render();
expect(html).toContain('data-testid="apple-health-status"');
expect(html).toContain('data-state="setup"');
expect(html).toContain("Set up in iOS app");
expect(html).not.toContain('data-state="connected"');
});
it("reports only observed data freshness without claiming a connection", () => {
statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" };
statusLoading = false;
const html = render();
expect(html).toContain('data-state="recent-data"');
expect(html).toContain("Apple Health data received");
expect(html).toContain("Last Apple Health data:");
expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"');
expect(html).not.toContain(">Connected<");
expect(html).not.toContain('data-state="connected"');
});
it("links to the existing one-shot Apple Health import fallback", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render();
expect(html).toContain('data-testid="apple-health-import-link"');
expect(html).toContain(
'href="/settings/export#settings-section-import-title"',
);
expect(html).toContain("Open one-shot import");
});
it("uses a non-committal checking state while status is loading", () => {
statusPayload = undefined;
statusLoading = true;
const html = render();
expect(html).toContain('data-state="checking"');
expect(html).toContain("Checking Apple Health data…");
expect(html).not.toContain("Connected");
});
it("does not turn a failed status read into a setup or connection claim", () => {
statusError = true;
const html = render();
expect(html).toContain('data-state="unavailable"');
expect(html).toContain("Apple Health status unavailable");
expect(html).not.toContain('data-state="setup"');
expect(html).not.toContain("Connected");
});
it("defines every card key in all six locale catalogs", () => {
const requiredKeys = [
"title",
"description",
"lastDataLabel",
"setupTitle",
"permissionStep",
"backgroundStep",
"importNote",
"importAction",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
const appleHealth = catalog.settings.appleHealth;
for (const key of requiredKeys) {
expect(appleHealth[key]).toBeTypeOf("string");
expect(appleHealth[key].length).toBeGreaterThan(0);
}
expect(Object.keys(appleHealth.status).sort()).toEqual([
"checking",
"dataReceived",
"setup",
"unavailable",
]);
}
});
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render(locale);
expect(html).toContain("Apple Health");
expect(html).not.toContain("settings.appleHealth.");
},
);
});
@@ -0,0 +1,123 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, HeartPulse, Info, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface HealthKitStatus {
lastSyncedAt: string | null;
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const format = useFormatters();
const {
data: status,
isLoading,
isError,
} = useQuery({
queryKey: queryKeys.healthKitStatus(),
queryFn: () => apiGet<HealthKitStatus>("/api/integrations/healthkit"),
enabled,
refetchOnWindowFocus: true,
});
const lastSyncedAt = status?.lastSyncedAt ?? null;
const statusState = isLoading
? "checking"
: isError
? "unavailable"
: lastSyncedAt
? "recent-data"
: "setup";
const statusLabel = isLoading
? t("settings.appleHealth.status.checking")
: isError
? t("settings.appleHealth.status.unavailable")
: lastSyncedAt
? t("settings.appleHealth.status.dataReceived")
: t("settings.appleHealth.status.setup");
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
className="space-y-4"
>
<SettingsCardHeader
icon={HeartPulse}
titleId="apple-health-card-title"
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
<Badge
variant={lastSyncedAt ? undefined : "outline"}
data-testid="apple-health-status"
data-state={statusState}
className={
lastSyncedAt
? "border-success/30 bg-success/15 text-success max-w-full whitespace-nowrap"
: "max-w-full whitespace-nowrap"
}
>
{lastSyncedAt ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<Info className="h-3 w-3" aria-hidden="true" />
)}
<span className="truncate">{statusLabel}</span>
</Badge>
}
/>
{lastSyncedAt && (
<p
className="text-muted-foreground text-xs"
data-testid="apple-health-last-data"
>
{t("settings.appleHealth.lastDataLabel")}{" "}
<time dateTime={lastSyncedAt}>{format.dateTime(lastSyncedAt)}</time>
</p>
)}
<div className="space-y-2">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</SettingsCard>
);
}
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
@@ -285,6 +286,9 @@ export function ConnectionsPanel() {
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard viewModel={googleHealthViewModel} />
</div>
<div id="apple-health" className="scroll-mt-28">
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
</div>
@@ -0,0 +1,44 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { apiFetchRaw } from "@/lib/api/api-fetch";
interface EnabledSettings {
enabled: boolean;
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const json = (await response.json()) as { error?: string };
throw new Error(json.error || fallbackError);
}
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
enabled: boolean,
): T | undefined {
const previous = queryClient.getQueryData<T>(queryKey);
queryClient.setQueryData<T>(queryKey, (current) =>
current ? { ...current, enabled } : current,
);
return previous;
}
export function rollbackChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
previous: T | undefined,
): void {
queryClient.setQueryData(queryKey, previous);
}
@@ -37,7 +37,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
@@ -97,7 +97,9 @@ export function NotificationStatusCard() {
const test = useMutation({
mutationFn: async (channel: ChannelStatus) => {
const res = await fetch(TEST_ENDPOINTS[channel.type], { method: "POST" });
const res = await apiFetchRaw(TEST_ENDPOINTS[channel.type], {
method: "POST",
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error((json as { error?: string }).error ?? "test_failed");
+41 -3
View File
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -82,6 +87,39 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
NtfySettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsNtfy();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<NtfySettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
@@ -96,8 +134,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Switch
id="ntfy-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
@@ -161,7 +199,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending}
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (
+59 -10
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -31,7 +36,6 @@ export function TelegramCard({
const queryClient = useQueryClient();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
@@ -48,23 +52,63 @@ export function TelegramCard({
// pattern instead of setState-in-effect. This avoids the strict
// `react-hooks/set-state-in-effect` lint failure that the legacy file
// disabled at module scope.
const settingsKey = settings
? `${settings.enabled}|${settings.chatId ?? ""}`
: null;
const settingsKey = settings ? (settings.chatId ?? "") : null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setEnabled(settings!.enabled);
if (settings!.chatId) setChatId(settings!.chatId);
}
const toggleEnabled = useMutation<
void,
Error,
boolean,
TelegramSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/telegram",
enabled,
t("settings.savingError"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.telegramSettings();
await queryClient.cancelQueries({ queryKey });
setMsg(null);
setMsgType(null);
return optimisticallySetChannelEnabled<TelegramSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setMsg(t("settings.saved"));
setMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.telegramSettings(),
previous,
);
setMsg(err.message);
setMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() });
},
});
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
const body: Record<string, unknown> = { enabled };
const body: Record<string, unknown> = {
enabled: settings?.enabled ?? false,
};
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
@@ -137,8 +181,9 @@ export function TelegramCard({
<div className="flex items-center gap-3">
<Switch
id="tg-enabled"
checked={enabled}
onCheckedChange={setEnabled}
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={saving || toggleEnabled.isPending}
/>
<Label htmlFor="tg-enabled" className="cursor-pointer">
{t("settings.enableNotifications")}
@@ -167,7 +212,11 @@ export function TelegramCard({
endpoint="/api/settings/telegram/test"
disabled={!settings?.hasBotToken}
/>
<Button type="submit" disabled={saving} className="min-h-11">
<Button
type="submit"
disabled={saving || toggleEnabled.isPending}
className="min-h-11"
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -5,6 +5,7 @@ import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Shared "Test connection" UX for the settings integrations + notifications
@@ -46,7 +47,7 @@ export function TestConnectionButton({
setTesting(true);
setResult(null);
try {
const res = await fetch(endpoint, { method: "POST" });
const res = await apiFetchRaw(endpoint, { method: "POST" });
const json = (await res.json().catch(() => ({}))) as TestResponse;
if (res.ok && json.data?.ok !== false) {
@@ -103,7 +103,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
const url = metric
? `/api/user/thresholds?metric=${metric}`
: "/api/user/thresholds";
const res = await fetch(url, { method: "DELETE" });
const res = await apiFetchRaw(url, { method: "DELETE" });
if (!res.ok) throw new Error("reset failed");
},
onSuccess: () => {
+49 -3
View File
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -82,6 +87,47 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
WebhookSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/webhook",
enabled,
t("common.error"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsWebhook();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<WebhookSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.settingsWebhook(),
previous,
);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
@@ -96,8 +142,8 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
@@ -165,7 +211,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending}
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (