+ {t("medications.detail.verlauf.addDialog.cancel")}
+
+
+
+
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
take as a standalone ad-hoc entry. */}
diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts
index 514274974..9bc86f1fc 100644
--- a/src/components/medications/dose-history-ledger-compute.ts
+++ b/src/components/medications/dose-history-ledger-compute.ts
@@ -36,6 +36,8 @@ export interface LedgerRow {
takenAt: string | null;
skipped: boolean;
autoMissed: boolean;
+ /** v1.16.4 — per-intake dose override; null/absent = configured dose. */
+ doseTaken?: string | null;
} | null;
}
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index 5d09312f8..875f561c4 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -96,6 +96,7 @@ import {
type LedgerPayload,
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
+import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -190,11 +191,7 @@ export function DoseHistoryLedger({
queryKey,
queryFn: async () => {
const search = new URLSearchParams({ from: fromIso, to: toIso });
- const res = await fetch(
- `/api/medications/${medicationId}/dose-history?${search.toString()}`,
- );
- if (!res.ok) throw new Error("dose_history_failed");
- return (await res.json()).data as LedgerPayload;
+ return apiGet
(`/api/medications/${medicationId}/dose-history?${search.toString()}`);
},
staleTime: 15_000,
});
@@ -245,37 +242,21 @@ export function DoseHistoryLedger({
}
try {
- const res = await fetch(`/api/medications/${medicationId}/intake`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- // Pin the displayed slot so the server marks THAT dose (the canonical
- // slot upsert), not a now-snap to the nearest one.
- body: JSON.stringify(
- action === "skipped"
- ? { skipped: true, scheduledFor: row.at }
- : {
- skipped: false,
- scheduledFor: row.at,
- takenAt: new Date().toISOString(),
- },
- ),
- });
- if (!res.ok) {
- if (prev) queryClient.setQueryData(queryKey, prev);
- toast.error(
- t("medications.intakeToastFailed", { name: medicationName }),
- );
- return;
- }
- // The POST returns the created event; its id drives the Undo
- // affordance (the same soft-delete route the cards use).
- let eventId: string | undefined;
- try {
- const json = await res.json();
- eventId = json?.data?.id as string | undefined;
- } catch {
- /* dose recorded; the body is best-effort for the id */
- }
+ // Pin the displayed slot so the server marks THAT dose (the canonical
+ // slot upsert), not a now-snap to the nearest one. The POST returns
+ // the created event; its id drives the Undo affordance (the same
+ // soft-delete route the cards use).
+ const created = await apiPost<{ id?: string } | undefined>(
+ `/api/medications/${medicationId}/intake`,
+ action === "skipped"
+ ? { skipped: true, scheduledFor: row.at }
+ : {
+ skipped: false,
+ scheduledFor: row.at,
+ takenAt: new Date().toISOString(),
+ },
+ );
+ const eventId: string | undefined = created?.id;
toast.success(
t(
action === "skipped"
@@ -333,18 +314,10 @@ export function DoseHistoryLedger({
takenAt: intake.scheduledFor ?? new Date().toISOString(),
}
: { skipped: true, takenAt: null };
- const res = await fetch(
+ await apiPut(
`/api/medications/${medicationId}/intake/${intake.id}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- },
+ body,
);
- if (!res.ok) {
- toast.error(t("medications.detail.intake.edit.failed"));
- return;
- }
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
@@ -366,18 +339,9 @@ export function DoseHistoryLedger({
const setAttribution = useCallback(
async (eventId: string, slotAtIso: string | null) => {
try {
- const res = await fetch(
- `/api/medications/${medicationId}/intake/${eventId}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ forceSlotInstant: slotAtIso }),
- },
- );
- if (!res.ok) {
- toast.error(t("medications.detail.intake.edit.failed"));
- return;
- }
+ await apiPut(`/api/medications/${medicationId}/intake/${eventId}`, {
+ forceSlotInstant: slotAtIso,
+ });
toast.success(
t(
slotAtIso
@@ -401,14 +365,7 @@ export function DoseHistoryLedger({
const id = pendingDeleteId;
setPendingDeleteId(null);
try {
- const res = await fetch(
- `/api/medications/${medicationId}/intake/${id}`,
- { method: "DELETE" },
- );
- if (!res.ok) {
- toast.error(t("medications.detail.intake.deleteRow.failed"));
- return;
- }
+ await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
toast.success(t("medications.detail.intake.deleteRow.toast"));
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
} catch {
@@ -659,6 +616,19 @@ function LedgerRowItem({
})}
)}
+ {/* v1.16.4 — quiet per-intake dose override note. Only rows whose
+ take recorded a deviating dose carry one; the configured dose
+ stays implicit, so the ledger's rhythm is undisturbed. */}
+ {intake?.doseTaken && (
+
+ {t("medications.detail.verlauf.doseTaken", {
+ dose: intake.doseTaken,
+ })}
+
+ )}
{dueContext && (
({
queryKey: queryKeys.medicationGlp1Details(medicationId),
queryFn: async () => {
- const res = await fetch(`/api/medications/${medicationId}/glp1`);
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as Glp1DetailsResponse;
+ try {
+ return await apiGet(
+ `/api/medications/${medicationId}/glp1`,
+ );
+ } catch {
+ return null;
+ }
},
staleTime: 60 * 1000,
});
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index aabb41e1a..099fc83ba 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -15,6 +15,7 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
+import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
@@ -204,10 +205,13 @@ export function Glp1MedicationCard({
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(medication.id),
queryFn: async () => {
- const res = await fetch(`/api/medications/${medication.id}/compliance`);
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as ComplianceData;
+ try {
+ return await apiGet(
+ `/api/medications/${medication.id}/compliance`,
+ );
+ } catch {
+ return null;
+ }
},
// v1.15.20 — same 5-minute window as the generic card: dose actions
// invalidate the key explicitly, so a short staleTime only re-fired
@@ -221,10 +225,13 @@ export function Glp1MedicationCard({
const { data: thresholds } = useQuery({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
- const res = await fetch("/api/settings/reminder-thresholds");
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as { lateMinutes: number; missedMinutes: number };
+ try {
+ return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
+ "/api/settings/reminder-thresholds",
+ );
+ } catch {
+ return null;
+ }
},
staleTime: 5 * 60 * 1000,
});
@@ -235,10 +242,13 @@ export function Glp1MedicationCard({
const { data: details } = useQuery({
queryKey: queryKeys.medicationGlp1Details(medication.id),
queryFn: async () => {
- const res = await fetch(`/api/medications/${medication.id}/glp1`);
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as DetailsResponse;
+ try {
+ return await apiGet(
+ `/api/medications/${medication.id}/glp1`,
+ );
+ } catch {
+ return null;
+ }
},
staleTime: 60 * 1000,
});
@@ -257,14 +267,15 @@ export function Glp1MedicationCard({
// v1.11.5 — keep the dialog open until the PATCH resolves. On failure
// surface a toast and re-throw so the dialog stays mounted with the
// chosen site instead of dismissing as though the site had been saved.
- const res = await fetch("/api/medications/intake", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
- });
- if (!res.ok) {
+ try {
+ await apiPost("/api/medications/intake", {
+ intakeId,
+ status: "taken",
+ injectionSite: site,
+ });
+ } catch (err) {
toast.error(t("medications.logInjectionSiteSaveFailed"));
- throw new Error("injection-site PATCH failed");
+ throw err;
}
await invalidateKeys(queryClient, medicationDependentKeys);
setSiteIntakeId(null);
diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx
index 5e4b77b27..07c102539 100644
--- a/src/components/medications/intake-edit-dialog.tsx
+++ b/src/components/medications/intake-edit-dialog.tsx
@@ -39,6 +39,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
+import { apiPut } from "@/lib/api/api-fetch";
export interface IntakeEditDialogProps {
medicationId: string;
@@ -132,18 +133,7 @@ function IntakeEditDialogBody({
} else {
body.takenAt = null;
}
- const res = await fetch(
- `/api/medications/${medicationId}/intake/${event.id}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- toast.error(t("medications.detail.intake.edit.failed"));
- return;
- }
+ await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.intake.edit.savedToast"));
onClose();
@@ -162,69 +152,80 @@ function IntakeEditDialogBody({
{t("medications.detail.intake.edit.dialogTitle")}
-
-
-
- {t("medications.detail.intake.edit.takenAtLabel")}
-
-
setTakenAt(e.target.value)}
- disabled={skipped}
- />
- {scheduledFor && (
-
- {t("medications.detail.intake.edit.scheduledForHint", {
- dateTime: fmt.dateTime(scheduledFor),
- })}
-
- )}
- {/* Non-blocking: a 48h+ gap to the slot is almost always a
+ {/* v1.16.4 — a real form so Enter in the datetime field submits;
+ the buttons carry explicit types so cancel never submits. */}
+
-
-
- {t("medications.detail.intake.edit.cancel")}
-
- void save()}
- disabled={busy}
- aria-busy={busy || undefined}
- >
- {busy && (
-
- )}
- {t("medications.detail.intake.edit.save")}
-
-
+
+
+ {t("medications.detail.intake.edit.cancel")}
+
+
+ {busy && (
+
+ )}
+ {t("medications.detail.intake.edit.save")}
+
+
+
);
diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx
index 78a440f10..0e162e955 100644
--- a/src/components/medications/intake-history-editable.tsx
+++ b/src/components/medications/intake-history-editable.tsx
@@ -41,6 +41,7 @@ import {
} from "@/components/medications/intake-history-list-v2";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
+import { apiDelete, apiPost } from "@/lib/api/api-fetch";
export interface IntakeHistoryEditableProps {
medicationId: string;
@@ -90,18 +91,9 @@ export function IntakeHistoryEditable({
setBulkBusy(true);
const ids = Array.from(selected);
try {
- const res = await fetch(
- `/api/medications/${medicationId}/intake/bulk-delete`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ eventIds: ids }),
- },
- );
- if (!res.ok) {
- toast.error(t("medications.detail.intake.bulkDelete.failed"));
- return;
- }
+ await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
+ eventIds: ids,
+ });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.intake.bulkDelete.toast"));
setBulkConfirmOpen(false);
@@ -118,13 +110,7 @@ export function IntakeHistoryEditable({
setRowDeleteBusy(true);
const id = pendingDeleteId;
try {
- const res = await fetch(`/api/medications/${medicationId}/intake/${id}`, {
- method: "DELETE",
- });
- if (!res.ok) {
- toast.error(t("medications.detail.intake.deleteRow.failed"));
- return;
- }
+ await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.intake.deleteRow.toast"));
setPendingDeleteId(null);
diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx
index a35dd9796..01a906a65 100644
--- a/src/components/medications/intake-history-list-v2.tsx
+++ b/src/components/medications/intake-history-list-v2.tsx
@@ -36,13 +36,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.36 W4a — Lite intake-history surface.
*
* The 886-LoC v1 was retired in v1.4.28 (commit 8c81af10) with the
* note "no other consumer". The standalone medication-history page
- * lost its dose timeline as a side effect — Marc reported the page
+ * lost its dose timeline as a side effect — the maintainer reported the page
* now shows only the heading for non-GLP-1 meds. This v2 restores a
* read-only timeline for ALL medication kinds without the inline CRUD
* the v1 carried (edit / delete still happen through the regular
@@ -187,11 +188,7 @@ export function IntakeHistoryListV2({
offset: String(offset),
status: STATUS_FILTER,
});
- const res = await fetch(
- `/api/medications/${medicationId}/intake?${params.toString()}`,
- );
- if (!res.ok) throw new Error("Failed to load intake history");
- return (await res.json()).data as IntakeListResponse;
+ return apiGet
(`/api/medications/${medicationId}/intake?${params.toString()}`);
},
enabled: isAuthenticated,
});
diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx
index cfc0bb8a7..591bf85eb 100644
--- a/src/components/medications/intake-import-dialog.tsx
+++ b/src/components/medications/intake-import-dialog.tsx
@@ -38,6 +38,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
+import { ApiError, apiPost } from "@/lib/api/api-fetch";
export interface IntakeImportDialogProps {
medicationId: string | null;
@@ -107,18 +108,12 @@ export function IntakeImportDialog({
else throw new Error(t("medications.importNoArray"));
}
- const res = await fetch(
- `/api/medications/${medicationId}/intake/import`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(data),
- },
- );
-
- const json = await res.json();
- if (res.ok) {
- const d = json.data;
+ try {
+ const d = await apiPost<{
+ imported: number;
+ skippedDuplicates: number;
+ skippedInvalid: number;
+ }>(`/api/medications/${medicationId}/intake/import`, data);
setResult(
t("medications.importResult", { imported: d.imported }) +
(d.skippedDuplicates > 0
@@ -130,8 +125,9 @@ export function IntakeImportDialog({
);
setResultType("success");
void invalidateKeys(queryClient, medicationDependentKeys);
- } else {
- setResult(json.error || t("medications.importFailed"));
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err;
+ setResult(err.message || t("medications.importFailed"));
setResultType("error");
}
} catch (err) {
diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx
index aa278ac4e..82916a4e2 100644
--- a/src/components/medications/log-intake-dialog.tsx
+++ b/src/components/medications/log-intake-dialog.tsx
@@ -127,6 +127,11 @@ export function LogIntakeDialog({
);
const [skipped, setSkipped] = useState(false);
const [busy, setBusy] = useState(false);
+ // v1.16.4 — per-intake dose override. `null` = untouched: the slot's
+ // schedule dose (when a slot is pinned and carries one) or the
+ // medication's configured dose prefills the field and nothing extra is
+ // submitted. An edit that deviates travels as `doseTaken`.
+ const [doseOverride, setDoseOverride] = useState(null);
const selected = useMemo(
() => medications.find((m) => m.id === medicationId),
@@ -134,11 +139,30 @@ export function LogIntakeDialog({
);
const slotTimes = useMemo(() => slotTimesFor(selected), [selected]);
+ // Configured baseline for the picked slot: a pinned slot prefers its
+ // schedule's own dose (titration schedules differ per slot); otherwise
+ // the medication's catalogue dose.
+ const configuredDose = useMemo(() => {
+ if (!selected) return "";
+ if (slot !== NO_SLOT) {
+ const scheduleForSlot = selected.schedules.find((s) =>
+ s.timesOfDay && s.timesOfDay.length > 0
+ ? s.timesOfDay.includes(slot)
+ : s.windowStart === slot,
+ );
+ if (scheduleForSlot?.dose) return scheduleForSlot.dose;
+ }
+ return selected.dose ?? "";
+ }, [selected, slot]);
+ const dose = doseOverride ?? configuredDose;
+
function handleMedicationChange(id: string) {
setMedicationId(id);
// Reset the slot when switching medications — the prior slot may not
- // exist on the new schedule.
+ // exist on the new schedule. The dose override resets too so the new
+ // medication's configured dose flows through.
setSlot(NO_SLOT);
+ setDoseOverride(null);
}
async function submit() {
@@ -158,12 +182,20 @@ export function LogIntakeDialog({
scheduledFor = iso;
}
setBusy(true);
+ // v1.16.4 — only a deliberate deviation travels; an untouched or
+ // configured-dose value keeps the row NULL (configured dose applies).
+ const trimmedDose = dose.trim();
+ const doseDeviates =
+ !skipped &&
+ trimmedDose.length > 0 &&
+ trimmedDose !== configuredDose.trim();
try {
const ok = await runLogIntake({
medication: { id: med.id, name: med.name },
skipped,
takenAt: new Date(takenAt).toISOString(),
scheduledFor,
+ ...(doseDeviates && { doseTaken: trimmedDose }),
t,
queryClient,
});
@@ -183,6 +215,16 @@ export function LogIntakeDialog({
+ {/* v1.16.4 — a real form so Enter in the dose / datetime fields
+ submits; mirrors the intake-edit and dose-history-add dialogs. */}
+
)}
+
+
+ {t("medications.logIntake.doseLabel")}
+
+
setDoseOverride(e.target.value)}
+ placeholder={configuredDose}
+ autoComplete="off"
+ disabled={skipped}
+ />
+
+ {t("medications.logIntake.doseHint")}
+
+
+
{t("medications.logIntake.takenAtLabel")}
@@ -263,6 +328,7 @@ export function LogIntakeDialog({
onOpenChange(false)}
disabled={busy}
@@ -270,7 +336,7 @@ export function LogIntakeDialog({
{t("medications.logIntake.cancel")}
void submit()}
+ type="submit"
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
@@ -280,6 +346,7 @@ export function LogIntakeDialog({
{t("medications.logIntake.submit")}
+
);
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index cd9f8f339..611d6728d 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -22,6 +22,7 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
+import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
@@ -149,10 +150,13 @@ export function MedicationCard({
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(medication.id),
queryFn: async () => {
- const res = await fetch(`/api/medications/${medication.id}/compliance`);
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as ComplianceData;
+ try {
+ return await apiGet(
+ `/api/medications/${medication.id}/compliance`,
+ );
+ } catch {
+ return null;
+ }
},
// v1.15.20 — the card renders two percentage rows that move on a
// dose action (which invalidates this key through
@@ -167,10 +171,13 @@ export function MedicationCard({
const { data: thresholds } = useQuery({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
- const res = await fetch("/api/settings/reminder-thresholds");
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as { lateMinutes: number; missedMinutes: number };
+ try {
+ return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
+ "/api/settings/reminder-thresholds",
+ );
+ } catch {
+ return null;
+ }
},
staleTime: 5 * 60 * 1000,
});
@@ -186,14 +193,15 @@ export function MedicationCard({
// v1.11.5 — keep the dialog open until the PATCH resolves. On failure
// surface a toast and re-throw so the dialog stays mounted with the
// chosen site instead of dismissing as though the site had been saved.
- const res = await fetch("/api/medications/intake", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
- });
- if (!res.ok) {
+ try {
+ await apiPost("/api/medications/intake", {
+ intakeId,
+ status: "taken",
+ injectionSite: site,
+ });
+ } catch (err) {
toast.error(t("medications.logInjectionSiteSaveFailed"));
- throw new Error("injection-site PATCH failed");
+ throw err;
}
await invalidateKeys(queryClient, medicationDependentKeys);
setSiteIntakeId(null);
diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx
index 2cae2fdb6..9ceb3f407 100644
--- a/src/components/medications/medication-detail-summary.tsx
+++ b/src/components/medications/medication-detail-summary.tsx
@@ -83,7 +83,7 @@ export function MedicationDetailSummary({
date: formatters.dateTime(startsOn),
})
: t("medications.detail.cadence.oneShotPending")
- : summariseCadence(hydrateWizardPayload(payload), t);
+ : summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
return (
diff --git a/src/components/medications/scheduling/DoseWindowEditor.tsx b/src/components/medications/scheduling/DoseWindowEditor.tsx
index b3553d379..fff33b26a 100644
--- a/src/components/medications/scheduling/DoseWindowEditor.tsx
+++ b/src/components/medications/scheduling/DoseWindowEditor.tsx
@@ -1,7 +1,7 @@
"use client";
/**
- * v1.15.18 — per-dose configurable on-time WINDOW editor (Marc's headline
+ * v1.15.18 — per-dose configurable on-time WINDOW editor (the maintainer's headline
* lever). Shared verbatim by the Zeitplan tab (everyday inline edit) and
* the wizard's Step 7 (structural create / edit) so the window semantics
* are identical wherever a dose time is set.
diff --git a/src/components/medications/scheduling/ScheduleTimesEditor.tsx b/src/components/medications/scheduling/ScheduleTimesEditor.tsx
index c01219947..48b5fcdb2 100644
--- a/src/components/medications/scheduling/ScheduleTimesEditor.tsx
+++ b/src/components/medications/scheduling/ScheduleTimesEditor.tsx
@@ -32,6 +32,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
+import { apiPut } from "@/lib/api/api-fetch";
/** The schedule fields the inline editor reads + round-trips on save. */
export interface EditableSchedule {
@@ -159,15 +160,7 @@ export function ScheduleTimesEditor({
}),
};
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- toast.error(t("medications.detail.zeitplan.failed"));
- return;
- }
+ await apiPut(`/api/medications/${medicationId}`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zeitplan.saved"));
} catch {
diff --git a/src/components/medications/scheduling/dose-window.ts b/src/components/medications/scheduling/dose-window.ts
index e260a1bb3..7e8b12367 100644
--- a/src/components/medications/scheduling/dose-window.ts
+++ b/src/components/medications/scheduling/dose-window.ts
@@ -1,5 +1,5 @@
/**
- * v1.15.18 — per-dose configurable on-time WINDOW helpers (Marc's
+ * v1.15.18 — per-dose configurable on-time WINDOW helpers (the maintainer's
* headline lever).
*
* A dose time can be expressed two ways:
diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx
index baf235b29..6d7b5f8e2 100644
--- a/src/components/medications/sections/api-tokens-row.tsx
+++ b/src/components/medications/sections/api-tokens-row.tsx
@@ -36,6 +36,7 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
+import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch";
export interface ApiTokensRowProps {
medicationId: string;
@@ -74,12 +75,13 @@ export function ApiTokensRow({
const { data: status } = useQuery
({
queryKey,
queryFn: async () => {
- const res = await fetch(`/api/medications/${medicationId}/api-endpoint`);
- if (!res.ok) throw new Error("status_failed");
- const json = await res.json();
+ const data = await apiGet<{
+ enabled?: boolean;
+ activeTokenCount?: number;
+ }>(`/api/medications/${medicationId}/api-endpoint`);
return {
- enabled: json.data.enabled === true,
- activeTokenCount: json.data.activeTokenCount ?? 0,
+ enabled: data.enabled === true,
+ activeTokenCount: data.activeTokenCount ?? 0,
};
},
staleTime: 0,
@@ -149,21 +151,16 @@ export function ApiTokensRow({
if (busy) return;
setBusy(true);
try {
- const res = await fetch(`/api/medications/${medicationId}/api-endpoint`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ enabled: true }),
- });
- const json = await res.json();
- if (!res.ok) {
- toast.error(json.error ?? t("medications.detail.api.mintFailed"));
- return;
- }
- if (json.data.token) {
- setMintedToken(json.data.token);
+ const data = await apiPut<{
+ token?: string;
+ enabled?: boolean;
+ activeTokenCount?: number;
+ }>(`/api/medications/${medicationId}/api-endpoint`, { enabled: true });
+ if (data.token) {
+ setMintedToken(data.token);
// Auto-copy on mint so the user has it without an extra tap.
try {
- await navigator.clipboard.writeText(json.data.token);
+ await navigator.clipboard.writeText(data.token);
} catch {
// Clipboard rejection is silent — the value is still visible
// in the read-only one-shot panel below.
@@ -174,14 +171,18 @@ export function ApiTokensRow({
queryKeys.tokens(),
]);
queryClient.setQueryData(queryKey, {
- enabled: json.data.enabled === true,
+ enabled: data.enabled === true,
activeTokenCount:
- typeof json.data.activeTokenCount === "number"
- ? json.data.activeTokenCount
+ typeof data.activeTokenCount === "number"
+ ? data.activeTokenCount
: (status?.activeTokenCount ?? 0) + 1,
});
- } catch {
- toast.error(t("medications.detail.api.mintFailed"));
+ } catch (err) {
+ toast.error(
+ err instanceof ApiError && err.message
+ ? err.message
+ : t("medications.detail.api.mintFailed"),
+ );
} finally {
setBusy(false);
}
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index c686e9f04..8a3765451 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -51,6 +51,7 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
+import { apiDelete, apiPut } from "@/lib/api/api-fetch";
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
@@ -87,16 +88,7 @@ export function LifecycleManageBody({
try {
// v1.5.5 C-E3-2 — body carries `{ active }` only; server derives
// pausedAt. No client-side pausedAt literal.
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ active: !pauseNext }),
- });
- if (!res.ok) {
- setPaused(previous);
- toast.error(t("medications.detail.zone.pause.failed"));
- return;
- }
+ await apiPut(`/api/medications/${medicationId}`, { active: !pauseNext });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
pauseNext
@@ -117,15 +109,7 @@ export function LifecycleManageBody({
setTier2Busy(true);
try {
const today = new Date().toISOString();
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ endsOn: today }),
- });
- if (!res.ok) {
- toast.error(t("medications.detail.zone.end.failed"));
- return;
- }
+ await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
@@ -261,13 +245,7 @@ export function DangerZoneBody({
if (tier3aBusy) return;
setTier3aBusy(true);
try {
- const res = await fetch(`/api/medications/${medicationId}/intake/purge`, {
- method: "DELETE",
- });
- if (!res.ok) {
- toast.error(t("medications.detail.zone.purge.failed"));
- return;
- }
+ await apiDelete(`/api/medications/${medicationId}/intake/purge`);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zone.purge.toast"));
setPurgeDialogOpen(false);
@@ -283,14 +261,7 @@ export function DangerZoneBody({
if (tier3bBusy) return;
setTier3bBusy(true);
try {
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "DELETE",
- });
- if (!res.ok) {
- toast.error(t("medications.detail.zone.delete.failed"));
- setTier3bBusy(false);
- return;
- }
+ await apiDelete(`/api/medications/${medicationId}`);
// Drop the deleted medication's own detail query before the
// prefix-invalidate below. `medicationDependentKeys` carries
// `["medications"]`, which covers the active `["medications", id]`
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index 3e7e1c02f..195763ce1 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -42,6 +42,7 @@ import { Input } from "@/components/ui/input";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
@@ -79,9 +80,7 @@ export function InventorySection({
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationInventory(medicationId),
queryFn: async () => {
- const res = await fetch(`/api/medications/${medicationId}/inventory`);
- if (!res.ok) throw new Error("inventory_failed");
- return (await res.json()).data as InventoryResponse;
+ return apiGet(`/api/medications/${medicationId}/inventory`);
},
staleTime: 30_000,
});
@@ -242,20 +241,12 @@ function AddInventoryDialog({
if (!quantityValid || busy) return;
setBusy(true);
try {
- const res = await fetch(`/api/medications/${medicationId}/inventory`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- dosesTotal: parsed,
- printedExpiry: expiry
- ? new Date(`${expiry}T00:00:00`).toISOString()
- : null,
- }),
+ await apiPost(`/api/medications/${medicationId}/inventory`, {
+ dosesTotal: parsed,
+ printedExpiry: expiry
+ ? new Date(`${expiry}T00:00:00`).toISOString()
+ : null,
});
- if (!res.ok) {
- toast.error(t("medications.detail.bestand.addFailed"));
- return;
- }
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
});
@@ -373,18 +364,9 @@ function AdjustInventoryDialog({
if (!valid || busy) return;
setBusy(true);
try {
- const res = await fetch(
- `/api/medications/${medicationId}/inventory/${item.id}`,
- {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ dosesRemaining: parsed }),
- },
- );
- if (!res.ok) {
- toast.error(t("medications.detail.bestand.adjustFailed"));
- return;
- }
+ await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
+ dosesRemaining: parsed,
+ });
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
});
diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx
index 07d2eccff..f5d2e2480 100644
--- a/src/components/medications/sections/notifications-section.tsx
+++ b/src/components/medications/sections/notifications-section.tsx
@@ -34,6 +34,7 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
export interface NotificationsSectionProps {
medicationId: string;
@@ -103,9 +104,7 @@ export function NotificationsBody({
const { data: status } = useQuery({
queryKey: queryKeys.notificationsStatus(),
queryFn: async () => {
- const res = await fetch("/api/notifications/status");
- if (!res.ok) throw new Error("status_failed");
- return (await res.json()).data as NotificationStatusResponse;
+ return apiGet("/api/notifications/status");
},
enabled: isAuthenticated,
staleTime: 60_000,
@@ -114,9 +113,7 @@ export function NotificationsBody({
const { data: prefs } = useQuery({
queryKey: queryKeys.authMe(),
queryFn: async () => {
- const res = await fetch("/api/auth/me");
- if (!res.ok) throw new Error("auth_failed");
- return (await res.json()).data as UserPrefsResponse;
+ return apiGet("/api/auth/me");
},
enabled: isAuthenticated,
staleTime: 60_000,
@@ -131,16 +128,9 @@ export function NotificationsBody({
const previous = localEnabled;
setLocalEnabled(next);
try {
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ notificationsEnabled: next }),
+ await apiPut(`/api/medications/${medicationId}`, {
+ notificationsEnabled: next,
});
- if (!res.ok) {
- setLocalEnabled(previous);
- toast.error(t("medications.detail.notifications.toggleFailed"));
- return;
- }
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
next
diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx
index b88786888..b40933e85 100644
--- a/src/components/medications/sections/phase-config-sheet.tsx
+++ b/src/components/medications/sections/phase-config-sheet.tsx
@@ -47,6 +47,7 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
+import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch";
type PhaseMode = "MINUTES" | "PERCENT";
@@ -138,9 +139,11 @@ export function PhaseConfigSheet({
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.medicationPhaseConfig(medicationId),
queryFn: async () => {
- const res = await fetch(`/api/medications/${medicationId}/phase-config`);
- if (!res.ok) throw new Error("phase_config_failed");
- return fromApi((await res.json()).data as PhaseConfigApiPayload);
+ return fromApi(
+ await apiGet(
+ `/api/medications/${medicationId}/phase-config`,
+ ),
+ );
},
enabled: open,
staleTime: 0,
@@ -216,27 +219,19 @@ function PhaseConfigForm({
async function save() {
setSaving(true);
try {
- const res = await fetch(`/api/medications/${medicationId}/phase-config`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(toApi(cfg)),
- });
- if (!res.ok) {
- const json = await res.json().catch(() => ({}));
- toast.error(
- (json as { error?: string }).error ??
- t("medications.detail.phases.saveFailed"),
- );
- return;
- }
+ await apiPut(`/api/medications/${medicationId}/phase-config`, toApi(cfg));
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKeys.medicationPhaseConfig(medicationId),
]);
toast.success(t("medications.phaseSaved"));
onClose();
- } catch {
- toast.error(t("medications.detail.phases.saveFailed"));
+ } catch (err) {
+ toast.error(
+ err instanceof ApiError && err.message
+ ? err.message
+ : t("medications.detail.phases.saveFailed"),
+ );
} finally {
setSaving(false);
}
diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx
index c9cbaa91e..cea70218a 100644
--- a/src/components/medications/sections/settings-section.tsx
+++ b/src/components/medications/sections/settings-section.tsx
@@ -29,6 +29,7 @@ import { PhaseConfigSheet } from "@/components/medications/sections/phase-config
import { useTranslations } from "@/lib/i18n/context";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
+import { apiPut } from "@/lib/api/api-fetch";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
/** The schedule fields the grace save round-trips on a wholesale PUT. */
@@ -122,15 +123,7 @@ export function GraceRow({
}),
}
: { reminderGraceMinutes: graceValue };
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- toast.error(t("medications.detail.settings.grace.failed"));
- return;
- }
+ await apiPut(`/api/medications/${medicationId}`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.settings.grace.saved"));
} catch {
@@ -210,18 +203,10 @@ export function DrugCodingRow({
try {
// Empty input clears the column (null); a non-empty value is sent
// verbatim for the server to validate (422 on a malformed code).
- const res = await fetch(`/api/medications/${medicationId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- atcCode: atcValue.trim() === "" ? null : atcValue.trim(),
- rxNormCode: rxValue.trim() === "" ? null : rxValue.trim(),
- }),
+ await apiPut(`/api/medications/${medicationId}`, {
+ atcCode: atcValue.trim() === "" ? null : atcValue.trim(),
+ rxNormCode: rxValue.trim() === "" ? null : rxValue.trim(),
});
- if (!res.ok) {
- toast.error(t("medications.detail.settings.codes.failed"));
- return;
- }
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.settings.codes.saved"));
} catch {
diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts
index 08cb2b21d..96055fdab 100644
--- a/src/components/medications/use-medication-intake.ts
+++ b/src/components/medications/use-medication-intake.ts
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
+import { apiDelete, apiPost } from "@/lib/api/api-fetch";
type Translator = (
key: string,
@@ -97,37 +98,26 @@ export async function runRecordIntake(deps: {
setIntakeLoading(skipped ? "skip" : "take");
try {
- const res = await fetch(`/api/medications/${medication.id}/intake`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- // The route's Zod schema accepts an ISO `scheduledFor`; the server
- // snaps it to the canonical slot. Only send it when the card
- // identified the displayed dose's slot — otherwise omit so the
- // PRN / unscheduled now-snap path is preserved.
- body: JSON.stringify(
- scheduledFor
- ? { skipped, scheduledFor: scheduledFor.toISOString() }
- : { skipped },
- ),
- });
+ // The route's Zod schema accepts an ISO `scheduledFor`; the server
+ // snaps it to the canonical slot. Only send it when the card
+ // identified the displayed dose's slot — otherwise omit so the
+ // PRN / unscheduled now-snap path is preserved.
+ //
// v1.11.3 C1 — a failed POST used to clear the spinner silently, so the
- // user believed the dose was logged when it was not. Surface the failure
- // and never show the success confirmation in that case. (The GLP-1 card
- // missed this port until v1.12.2 lifted the logic here.)
- if (!res.ok) {
- toast.error(t("medications.intakeToastFailed", { name: medication.name }));
- return;
- }
+ // user believed the dose was logged when it was not. apiPost throws on
+ // non-OK, so the catch below surfaces the failure and the success
+ // confirmation never shows in that case.
+ //
// The POST returns the created event (`apiSuccess(event, 201)`); its id
// drives both the Undo affordance and the card's post-success hook
// (the optional injection-site prompt).
- let eventId: string | undefined;
- try {
- const json = await res.json();
- eventId = json?.data?.id as string | undefined;
- } catch {
- /* dose recorded; the body is best-effort for the id */
- }
+ const created = await apiPost<{ id?: string } | undefined>(
+ `/api/medications/${medication.id}/intake`,
+ scheduledFor
+ ? { skipped, scheduledFor: scheduledFor.toISOString() }
+ : { skipped },
+ );
+ const eventId: string | undefined = created?.id;
// v1.11.3 C2 — the success toast carries an Undo action so a misclicked
// take / skip no longer needs a history dive to correct.
toast.success(
@@ -181,23 +171,23 @@ export async function runLogIntake(deps: {
* slot. Omit/undefined to route through the unscheduled/PRN path.
*/
scheduledFor?: string;
+ /**
+ * v1.16.4 — per-intake dose override. Sent only on a taken write; the
+ * caller passes it only when the user's dose edit deviates from the
+ * configured medication / schedule dose.
+ */
+ doseTaken?: string;
t: Translator;
queryClient: QueryClient;
}): Promise {
- const { medication, skipped, takenAt, scheduledFor, t, queryClient } = deps;
+ const { medication, skipped, takenAt, scheduledFor, doseTaken, t, queryClient } =
+ deps;
try {
const body: Record = { skipped };
if (!skipped) body.takenAt = takenAt;
if (scheduledFor) body.scheduledFor = scheduledFor;
- const res = await fetch(`/api/medications/${medication.id}/intake`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- toast.error(t("medications.intakeToastFailed", { name: medication.name }));
- return false;
- }
+ if (!skipped && doseTaken) body.doseTaken = doseTaken;
+ await apiPost(`/api/medications/${medication.id}/intake`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
t(
@@ -226,14 +216,7 @@ export async function runUndoIntake(deps: {
}): Promise {
const { medication, eventId, t, queryClient } = deps;
try {
- const res = await fetch(
- `/api/medications/${medication.id}/intake/${eventId}`,
- { method: "DELETE" },
- );
- if (!res.ok) {
- toast.error(t("medications.intakeUndoFailed"));
- return;
- }
+ await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`);
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.intakeUndone"));
} catch {
diff --git a/src/components/medications/wizard/steps/Step2Class.tsx b/src/components/medications/wizard/steps/Step2Class.tsx
index 52e2644b7..b33f4f959 100644
--- a/src/components/medications/wizard/steps/Step2Class.tsx
+++ b/src/components/medications/wizard/steps/Step2Class.tsx
@@ -24,7 +24,7 @@ import {
} from "../wizard-payload";
import type { StepProps } from "./Step1Name";
-// Each row's Lucide glyph. Marc-confirmed assignment in D-1 §3 Step 2.
+// Each row's Lucide glyph. maintainer-confirmed assignment in D-1 §3 Step 2.
const ROW_ICONS: Record<
WizardTreatmentRow,
ComponentType
diff --git a/src/components/medications/wizard/steps/Step8Summary.tsx b/src/components/medications/wizard/steps/Step8Summary.tsx
index 29f9c2230..c082b5c70 100644
--- a/src/components/medications/wizard/steps/Step8Summary.tsx
+++ b/src/components/medications/wizard/steps/Step8Summary.tsx
@@ -7,8 +7,9 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
-import { useTranslations } from "@/lib/i18n/context";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiGet } from "@/lib/api/api-fetch";
import {
type MedicationPayload,
@@ -55,7 +56,8 @@ export function Step8Summary({
onAddSchedule,
}: Step8SummaryProps) {
const { t } = useTranslations();
- const topSummary = summariseCadence(payload, t);
+ const formatters = useFormatters();
+ const topSummary = summariseCadence(payload, t, formatters.date);
const canRemove = payload.schedules.length > 1;
// Reminders need at least one delivery channel. Fetch the channel
@@ -67,10 +69,10 @@ export function Step8Summary({
const { data: notificationChannels } = useQuery({
queryKey: queryKeys.notificationsStatus(),
queryFn: async () => {
- const res = await fetch("/api/notifications/status");
- if (!res.ok) throw new Error("Failed");
- return ((await res.json()).data as { channels: Array<{ enabled: boolean }> })
- .channels;
+ const data = await apiGet<{ channels: Array<{ enabled: boolean }> }>(
+ "/api/notifications/status",
+ );
+ return data.channels;
},
enabled: payload.notificationsEnabled,
staleTime: 60_000,
diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts
index 7a7cffa44..c9f3bf8a5 100644
--- a/src/components/medications/wizard/wizard-payload.ts
+++ b/src/components/medications/wizard/wizard-payload.ts
@@ -91,7 +91,7 @@ export type WizardTreatmentRow =
| "other";
/**
- * The ten taxonomy rows in their visible order. Marc-confirmed (D-1
+ * The ten taxonomy rows in their visible order. maintainer-confirmed (D-1
* §3 Step 2): Blutdruck · Diabetes · Hormone · GLP-1-Injektion ·
* Schmerz · Allergie · Vitamine · Nahrungsergänzung · Antibiotikum ·
* Sonstiges.
@@ -833,6 +833,11 @@ function cadenceLineFor(
export function summariseCadence(
payload: WizardPayload,
t: (key: string, params?: Record) => string,
+ // v1.16.4 — UI callers pass `useFormatters().date` so the course-window
+ // dates render in the active locale ("11.06.2026" / "Jun 11, 2026")
+ // instead of the raw ISO string. The ISO default keeps the helper pure
+ // for non-React callers.
+ formatDate: (d: Date) => string = dateToIsoString,
): string {
const cadenceLine = cadenceLineFor(payload, t);
const timesPhrase =
@@ -843,7 +848,7 @@ export function summariseCadence(
: "";
const startPhrase = payload.startsOn
? t("medications.wizard.summary.startsOn", {
- date: dateToIsoString(payload.startsOn),
+ date: formatDate(payload.startsOn),
})
: "";
const endPhrase =
@@ -851,7 +856,7 @@ export function summariseCadence(
? ""
: payload.endsOn
? t("medications.wizard.summary.endsOn", {
- date: dateToIsoString(payload.endsOn),
+ date: formatDate(payload.endsOn),
})
: t("medications.wizard.summary.noEndDate");
return [cadenceLine, timesPhrase, startPhrase, endPhrase]
diff --git a/src/components/monitoring/bootstrap.tsx b/src/components/monitoring/bootstrap.tsx
index b58c52142..0598dcff5 100644
--- a/src/components/monitoring/bootstrap.tsx
+++ b/src/components/monitoring/bootstrap.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { UmamiScript } from "@/components/monitoring/umami-script";
import { GlitchtipReporter } from "@/components/monitoring/glitchtip-reporter";
+import { apiGet } from "@/lib/api/api-fetch";
interface MonitoringSettings {
umamiEnabled: boolean;
@@ -16,11 +17,12 @@ export function MonitoringBootstrap() {
useEffect(() => {
let active = true;
- fetch("/api/monitoring/settings", { cache: "no-store" })
- .then((response) => (response.ok ? response.json() : null))
- .then((json) => {
- if (!active || !json?.data) return;
- setSettings(json.data as MonitoringSettings);
+ apiGet("/api/monitoring/settings", {
+ cache: "no-store",
+ })
+ .then((data) => {
+ if (!active || !data) return;
+ setSettings(data);
})
.catch(() => {
if (!active) return;
diff --git a/src/components/monitoring/glitchtip-reporter.tsx b/src/components/monitoring/glitchtip-reporter.tsx
index a544bfbdc..a70ef2e99 100644
--- a/src/components/monitoring/glitchtip-reporter.tsx
+++ b/src/components/monitoring/glitchtip-reporter.tsx
@@ -2,6 +2,8 @@
import { useEffect } from "react";
+import { apiPost } from "@/lib/api/api-fetch";
+
interface GlitchtipReporterProps {
enabled: boolean;
}
@@ -12,16 +14,15 @@ function sendClientError(payload: {
level?: "error" | "warning" | "info";
type?: string;
}) {
- fetch("/api/monitoring/glitchtip", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ apiPost(
+ "/api/monitoring/glitchtip",
+ {
...payload,
url: window.location.href,
userAgent: navigator.userAgent,
- }),
- keepalive: true,
- }).catch(() => {});
+ },
+ { keepalive: true },
+ ).catch(() => {});
}
export function GlitchtipReporter({ enabled }: GlitchtipReporterProps) {
diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx
index 64438ff32..ef0e2b8c9 100644
--- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx
+++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx
@@ -54,7 +54,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
- user: { id: "u1", username: "marc", role: "USER" },
+ user: { id: "u1", username: "testuser", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx
index 6477be53e..3373b754d 100644
--- a/src/components/mood/mood-form.tsx
+++ b/src/components/mood/mood-form.tsx
@@ -29,6 +29,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
+import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { moodFaceIcon } from "./mood-tag-icons";
@@ -182,33 +183,26 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const trimmedNote = note.trim();
- const res = await fetch("/api/mood-entries", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- mood,
- tags: tags.length > 0 ? tags : undefined,
- tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
- // v1.12.0 — rated factors as a parallel [{key,rating}] array.
- ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
- note: trimmedNote.length > 0 ? trimmedNote : undefined,
- moodLoggedAt: timestamp,
- }),
+ await apiPost("/api/mood-entries", {
+ mood,
+ tags: tags.length > 0 ? tags : undefined,
+ tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
+ // v1.12.0 — rated factors as a parallel [{key,rating}] array.
+ ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
+ note: trimmedNote.length > 0 ? trimmedNote : undefined,
+ moodLoggedAt: timestamp,
});
- const json = await res.json();
- if (!res.ok) {
- setError(json.error);
- setLoading(false);
- return;
- }
-
resetForm();
await invalidateKeys(queryClient, moodDependentKeys);
toast.success(t("common.saved"));
onSuccess?.();
- } catch {
- setError(t("mood.saveError"));
+ } catch (err) {
+ setError(
+ err instanceof ApiError && err.message
+ ? err.message
+ : t("mood.saveError"),
+ );
} finally {
setLoading(false);
}
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 57b87fbfe..e550e326b 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -55,7 +55,7 @@ import {
ChevronRight,
MoreHorizontal,
} from "lucide-react";
-import { useId, useState } from "react";
+import { useCallback, useId, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { formatDateTime } from "@/lib/format";
@@ -76,6 +76,7 @@ import {
} from "@/components/data-list";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker } from "./mood-tag-picker";
+import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
// Re-export the score map under the legacy local name to keep the
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
@@ -235,26 +236,43 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
params.set("offset", String((page - 1) * PAGE_SIZE));
params.set("sortBy", sortBy);
params.set("sortDir", sortDir);
- const res = await fetch(`/api/mood-entries?${params}`);
- if (!res.ok) throw new Error("Failed to fetch");
- const json = await res.json();
- return json.data as {
+ return apiGet<{
entries: MoodEntry[];
meta: { total: number };
- };
+ }>(`/api/mood-entries?${params}`);
},
enabled: isAuthenticated,
});
+ // v1.16.4 — deletes are soft (tombstones), so the success toast can
+ // carry a real Undo: it POSTs the ids to `/api/mood-entries/restore`,
+ // which clears `deletedAt` and re-fires the same dependent-key bundle.
+ // Mirrors the measurements list and the intake-Undo pattern.
+ const restoreEntries = useCallback(
+ async (ids: string[]) => {
+ try {
+ await apiPost("/api/mood-entries/restore", { ids });
+ await invalidateKeys(queryClient, moodDependentKeys);
+ toast.success(t("mood.restoredToast"));
+ } catch {
+ toast.error(t("mood.restoreError"));
+ }
+ },
+ [queryClient, t],
+ );
+
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
- const res = await fetch(`/api/mood-entries/${id}`, {
- method: "DELETE",
- });
- if (!res.ok) throw new Error("Delete failed");
+ await apiDelete(`/api/mood-entries/${id}`);
},
- onSuccess: () => {
+ onSuccess: (_data, id) => {
void invalidateKeys(queryClient, moodDependentKeys);
+ toast.success(t("mood.deletedToast"), {
+ action: {
+ label: t("common.undo"),
+ onClick: () => void restoreEntries([id]),
+ },
+ });
},
// v1.11.5 — a failed delete used to fail silently (no onError handler),
// leaving the row in place with no signal that the request was rejected.
@@ -268,19 +286,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// v1.15.13 — page-scoped bulk soft-delete, mirroring the measurements list.
const bulkDeleteMutation = useMutation({
mutationFn: async (ids: string[]) => {
- const res = await fetch("/api/mood-entries/bulk-delete", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ids }),
- });
- if (!res.ok) throw new Error("Bulk delete failed");
- const json = (await res.json()) as { data: { deleted: number } };
- return json.data.deleted;
+ const data = await apiPost<{ deleted: number }>(
+ "/api/mood-entries/bulk-delete",
+ { ids },
+ );
+ return data.deleted;
},
- onSuccess: async (deleted) => {
+ onSuccess: async (deleted, ids) => {
await invalidateKeys(queryClient, moodDependentKeys);
clearSelection();
- toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }));
+ toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }), {
+ action: {
+ label: t("common.undo"),
+ onClick: () => void restoreEntries(ids),
+ },
+ });
},
onError: () => {
toast.error(t("mood.bulkDeleteError"));
@@ -324,16 +344,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
note: string | null;
moodLoggedAt: string;
}) => {
- const res = await fetch(`/api/mood-entries/${id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ mood, tags, tagKeys, note, moodLoggedAt }),
- });
-
- const json = await res.json();
- if (!res.ok) {
- throw new Error(json.error ?? "Update failed");
- }
+ await apiPut(`/api/mood-entries/${id}`, { mood, tags, tagKeys, note, moodLoggedAt });
},
onSuccess: async () => {
await invalidateKeys(queryClient, moodDependentKeys);
@@ -659,28 +670,23 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3"
>
- {/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
- `
`; a wrapping `` does
- NOT forward taps to a button, so the effective tap
- target was 16px (fails WCAG 2.5.5). The 44px button
- owns the whole hit area and is the single toggle
- source; the inner Checkbox is a `pointer-events-none`
- controlled visual, so a tap fires the handler once. */}
- onToggleRow(entry.id)}
- className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
- >
+ {/* v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
+ (itself a ``) inside a 44px
+ wrapper `` for WCAG 2.5.5 — but a button may
+ not nest inside a button, and the invalid markup made
+ React 19 fail hydration on every list paint. The
+ wrapper is now a plain layout ``; the Checkbox
+ stays the single control and owns the 44px hit area
+ via an `after` hit-slop (clicks on a pseudo-element
+ hit-test against its host button). */}
+
onToggleRow(entry.id)}
+ aria-label={t("dataList.selectRow")}
+ className="relative after:absolute after:-inset-3.5"
/>
-
+
{
- const res = await fetch("/api/mood/tags");
- if (!res.ok) throw new Error("Failed");
- const json = await res.json();
- return json.data as CatalogResponse;
+ return apiGet("/api/mood/tags");
},
enabled: isAuthenticated,
// The catalog only changes on a migration / admin edit.
diff --git a/src/components/onboarding/BaselineForm.tsx b/src/components/onboarding/BaselineForm.tsx
index f1ddbba08..ba933c755 100644
--- a/src/components/onboarding/BaselineForm.tsx
+++ b/src/components/onboarding/BaselineForm.tsx
@@ -19,7 +19,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
-import { readError } from "@/lib/api/read-error";
+import { apiPost, apiPut } from "@/lib/api/api-fetch";
/**
* v1.4.25 W14b-Content — onboarding step 3 (baseline).
@@ -90,20 +90,10 @@ export function BaselineForm() {
profileBody.gender = form.gender;
}
if (Object.keys(profileBody).length > 0) {
- const profileRes = await fetch("/api/auth/profile", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(profileBody),
- });
- if (!profileRes.ok) throw new Error(await readError(profileRes));
+ await apiPut("/api/auth/profile", profileBody);
}
}
- const stepRes = await fetch("/api/onboarding/step", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ step: 4 }),
- });
- if (!stepRes.ok) throw new Error(await readError(stepRes));
+ await apiPost("/api/onboarding/step", { step: 4 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/4");
} catch (err) {
diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx
index 8274f834c..63708d0b3 100644
--- a/src/components/onboarding/GoalsChipPicker.tsx
+++ b/src/components/onboarding/GoalsChipPicker.tsx
@@ -17,8 +17,8 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
-import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
+import { apiPost } from "@/lib/api/api-fetch";
/**
* v1.4.25 W14b-Content — onboarding step 1 (goals).
@@ -122,12 +122,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
if (advancing) return;
setAdvancing(true);
try {
- const res = await fetch("/api/onboarding/step", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ step: 2 }),
- });
- if (!res.ok) throw new Error(await readError(res));
+ await apiPost("/api/onboarding/step", { step: 2 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/2");
} catch (err) {
diff --git a/src/components/onboarding/SourceCardGrid.tsx b/src/components/onboarding/SourceCardGrid.tsx
index f4ea15554..0a225bfa5 100644
--- a/src/components/onboarding/SourceCardGrid.tsx
+++ b/src/components/onboarding/SourceCardGrid.tsx
@@ -15,8 +15,8 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
-import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
+import { apiPost } from "@/lib/api/api-fetch";
/**
* v1.4.25 W14b-Content — onboarding step 2 (source).
@@ -99,12 +99,7 @@ export function SourceCardGrid() {
if (advancing) return;
setAdvancing(true);
try {
- const res = await fetch("/api/onboarding/step", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ step: 3 }),
- });
- if (!res.ok) throw new Error(await readError(res));
+ await apiPost("/api/onboarding/step", { step: 3 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/3");
} catch (err) {
diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx
index b97146bd6..937e0fa0d 100644
--- a/src/components/onboarding/WelcomeCarousel.tsx
+++ b/src/components/onboarding/WelcomeCarousel.tsx
@@ -15,9 +15,9 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
-import { readError } from "@/lib/api/read-error";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
+import { apiPost } from "@/lib/api/api-fetch";
/**
* v1.4.25 W14b-Content — onboarding welcome carousel.
@@ -131,12 +131,7 @@ export function WelcomeCarousel() {
if (advancing) return;
setAdvancing(true);
try {
- const res = await fetch("/api/onboarding/step", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ step: 1 }),
- });
- if (!res.ok) throw new Error(await readError(res));
+ await apiPost("/api/onboarding/step", { step: 1 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/1");
} catch (err) {
diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx
index cdbdf9da1..ba1b2fe68 100644
--- a/src/components/onboarding/getting-started-checklist.tsx
+++ b/src/components/onboarding/getting-started-checklist.tsx
@@ -27,6 +27,7 @@ import {
visibleChecklist,
type ChecklistItemId,
} from "@/lib/onboarding/checklist";
+import { apiGet } from "@/lib/api/api-fetch";
const DISMISSED_ITEMS_KEY = "healthlog-getting-started-dismissed";
const DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
@@ -213,10 +214,7 @@ export function GettingStartedChecklist() {
const { data: medsData } = useQuery>({
queryKey: queryKeys.medications(),
queryFn: async () => {
- const res = await fetch("/api/medications");
- if (!res.ok) throw new Error("Failed");
- const json = await res.json();
- return json.data;
+ return apiGet("/api/medications");
},
enabled: checklistRelevant,
});
@@ -232,10 +230,7 @@ export function GettingStartedChecklist() {
const { data: withingsData } = useQuery({
queryKey: ["withings", "status"],
queryFn: async () => {
- const res = await fetch("/api/withings/status");
- if (!res.ok) throw new Error("Failed");
- const json = await res.json();
- return json.data;
+ return apiGet("/api/withings/status");
},
enabled: onboardingPending,
});
@@ -243,10 +238,7 @@ export function GettingStartedChecklist() {
const { data: notificationsData } = useQuery({
queryKey: ["notifications", "preferences"],
queryFn: async () => {
- const res = await fetch("/api/notifications/preferences");
- if (!res.ok) throw new Error("Failed");
- const json = await res.json();
- return json.data;
+ return apiGet("/api/notifications/preferences");
},
enabled: onboardingPending,
});
@@ -329,7 +321,7 @@ export function GettingStartedChecklist() {
aria-expanded={expanded}
aria-controls="getting-started-body"
// v1.4.37 W-CI — lift the toggle to clear the WCAG 2.5.5 mobile
- // tap-target floor on Pixel-5 (Marc's iOS-textarea-zoom sweep
+ // tap-target floor on Pixel-5 (the maintainer's iOS-textarea-zoom sweep
// pinned the floor at 44 px; the previous `p-1` padding left
// the toggle at 32 px). `sm:min-h-10` returns to the desktop
// 40 px tier so the dashboard header rhythm is unchanged.
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index f1fc4b694..73ade6e28 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -51,6 +51,7 @@ import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { apiPost } from "@/lib/api/api-fetch";
import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
@@ -360,11 +361,7 @@ export function TourLauncher({ ready }: TourLauncherProps) {
setShowTour(false);
if (user?.id) writeSessionDismissed(user.id);
try {
- await fetch("/api/onboarding/tour", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ completed: true, outcome }),
- });
+ await apiPost("/api/onboarding/tour", { completed: true, outcome });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
} catch {
// Swallow — the session-dismiss flag prevents a re-open.
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 9f6167d22..59b2f89d5 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -114,9 +114,12 @@ function ThemeProvider({ children }: { children: ReactNode }) {
export function Providers({
children,
initialLocale,
+ initialMessages,
}: {
children: ReactNode;
initialLocale?: Locale;
+ /** Active locale's bundle, server-resolved by the root layout (RSC handoff). */
+ initialMessages?: Record;
}) {
const [queryClient] = useState(
() =>
@@ -135,7 +138,10 @@ export function Providers({
return (
-
+
{children}
diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx
index c3893e5ce..3df5f94a1 100644
--- a/src/components/settings/__tests__/ai-section.test.tsx
+++ b/src/components/settings/__tests__/ai-section.test.tsx
@@ -187,7 +187,7 @@ describe(" — dropdown-driven UX (B2)", () => {
expect(html).toContain("Mit ChatGPT verbinden");
expect(html).toContain("ChatGPT-Account (Codex)");
// v1.4.33 IW7 — the active-provider heading and the parent section
- // both ship without the "KI"/"AI" prefix per the Marc-Voice rule
+ // both ship without the "KI"/"AI" prefix per the project-voice rule
// ("Aktiver KI-Provider" → "Aktiver Provider"). The heading and the
// chain summary now share the same phrase, so we assert against the
// explicit `activeProviderLabel` slot below it ("Primärer Provider")
diff --git a/src/components/settings/__tests__/api-section-responsive.test.tsx b/src/components/settings/__tests__/api-section-responsive.test.tsx
index 66138197e..26243a3b0 100644
--- a/src/components/settings/__tests__/api-section-responsive.test.tsx
+++ b/src/components/settings/__tests__/api-section-responsive.test.tsx
@@ -61,8 +61,8 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
- username: "marc",
- email: "marc@example.com",
+ username: "testuser",
+ email: "user@example.com",
role: "USER",
},
isAuthenticated: true,
diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx
index f6bd6973f..cb4468abf 100644
--- a/src/components/settings/__tests__/disable-coach-card.test.tsx
+++ b/src/components/settings/__tests__/disable-coach-card.test.tsx
@@ -44,7 +44,7 @@ vi.mock("next/navigation", () => ({
function buildUser(disableCoach: boolean): AuthUser {
return {
id: "user-1",
- username: "marc",
+ username: "testuser",
email: null,
role: "USER",
heightCm: null,
@@ -134,7 +134,7 @@ describe("Settings — DisableCoachCard", () => {
it("Switch description string matches the QoL audit (M2) copy", () => {
const html = render();
- // The Marc-voice contract: "Hides the Coach button and drawer
+ // The project-voice contract: "Hides the Coach button and drawer
// everywhere." (English) — must surface verbatim because the QoL
// findings doc cites this exact phrasing.
expect(html).toContain("Hides the Coach button and drawer everywhere.");
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index e6e560f78..b26ef5596 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -1,7 +1,7 @@
/**
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
*
- * the maintainer's verbatim concern (paraphrased):
+ * The maintainer's verbatim concern (paraphrased):
* - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container.
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 38845efad..d1d436bff 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -46,14 +46,21 @@ vi.mock("@tanstack/react-query", () => ({
}));
// Stub the auth hook — sections check `isAuthenticated` to enable queries.
+// v1.16.4 — AccountSection gates its content on `useMounted()` so the
+// SSR pass and the hydration render agree (React #418 family). The
+// smoke test asserts the post-mount content, so pin the flag to true.
+vi.mock("@/hooks/use-mounted", () => ({
+ useMounted: () => true,
+}));
+
// We return `isAuthenticated: true` + a minimal user so the loading-spinner
// branch in AccountSection paints the real form.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
- username: "marc",
- email: "marc@example.com",
+ username: "testuser",
+ email: "user@example.com",
heightCm: 180,
dateOfBirth: "1990-01-01",
gender: "MALE",
@@ -153,7 +160,7 @@ describe("settings sections — SSR smoke", () => {
});
it(" renders the German Replay-tour label end-to-end", () => {
- // Marc-voice + i18n parity: the W5 spec explicitly names this
+ // project-voice + i18n parity: the W5 spec explicitly names this
// surface "Tour ansehen" in German. Lock the resolved copy so a
// future locale-bundle drift doesn't silently fall back to English.
const html = render( , "de");
diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx
index 3d70e6c1a..3afa805e6 100644
--- a/src/components/settings/about-me-section.tsx
+++ b/src/components/settings/about-me-section.tsx
@@ -37,6 +37,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
const FALLBACK_MAX_CHARS = 4000;
const FALLBACK_FIELD_MAX_CHARS = 500;
@@ -55,11 +56,7 @@ interface AboutMeData {
type StructuredKey = "conditions" | "allergies" | "coachFocus";
async function fetchAboutMe(): Promise {
- const res = await fetch("/api/coach/about-me");
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
- return (await res.json()).data as AboutMeData;
+ return apiGet("/api/coach/about-me");
}
const FIELD_CLASSES =
@@ -129,15 +126,7 @@ export function AboutMeSection({
allergies: string;
coachFocus: string;
}) => {
- const res = await fetch("/api/coach/about-me", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
- return (await res.json()).data as AboutMeData;
+ return apiPut("/api/coach/about-me", input);
},
onSuccess: (next) => {
const cleared =
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index 0c11895d6..2bb6127dd 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface VersionPayload {
version: string;
@@ -129,10 +130,7 @@ export function AboutSection() {
const { data: version, isLoading } = useQuery({
queryKey: queryKeys.apiVersion(),
queryFn: async () => {
- const res = await fetch("/api/version");
- if (!res.ok) throw new Error("version-fetch-failed");
- const json = await res.json();
- return json.data as VersionPayload;
+ return apiGet("/api/version");
},
// The endpoint is `force-static` — version doesn't change at runtime.
staleTime: Infinity,
@@ -187,7 +185,7 @@ export function AboutSection() {
async function runCheck(): Promise {
try {
- const res = await fetch("/api/version/check-updates");
+ const res = await apiFetchRaw("/api/version/check-updates");
if (!res.ok) {
setUpdateResult({
status: "unknown",
diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx
index d646b0a3b..36e36ecb9 100644
--- a/src/components/settings/account-section.tsx
+++ b/src/components/settings/account-section.tsx
@@ -42,6 +42,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
@@ -55,6 +56,7 @@ import { UnitPreferenceSelect } from "@/components/settings/unit-preference-sele
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface PasskeyInfo {
id: string;
@@ -109,9 +111,30 @@ export function resolveInitialTimezone(
return shouldAutoSeed ? detectedBrowserTimezone : stored;
}
+/**
+ * v1.16.4 — settings status hints store the i18n KEY (+ params), not
+ * the translated string: a locale switch re-renders the hint in the
+ * new language instead of freezing the old-language snapshot. Server-
+ * provided error text (which has no key) rides `text` verbatim.
+ */
+type StatusMessage =
+ | { key: string; params?: Record }
+ | { text: string };
+
+function statusText(
+ msg: StatusMessage,
+ t: (key: string, params?: Record) => string,
+): string {
+ return "key" in msg ? t(msg.key, msg.params) : msg.text;
+}
+
export function AccountSection() {
- const { t, locale, setLocale } = useTranslations();
+ const { t, locale, setLocale, pendingLocale } = useTranslations();
const { user, isLoading, isAuthenticated, refetch } = useAuth();
+ // v1.16.4 — see `useMounted`: keeps the hydration render identical to
+ // the SSR HTML when this boundary hydrates after `/api/auth/me`
+ // settled (React #418 family).
+ const mounted = useMounted();
const router = useRouter();
const [email, setEmail] = useState("");
@@ -124,7 +147,7 @@ export function AccountSection() {
const [insurerName, setInsurerName] = useState("");
const [insuranceNumber, setInsuranceNumber] = useState("");
const [saving, setSaving] = useState(false);
- const [saveMsg, setSaveMsg] = useState(null);
+ const [saveMsg, setSaveMsg] = useState(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
@@ -134,7 +157,7 @@ export function AccountSection() {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
- const [passwordMsg, setPasswordMsg] = useState(null);
+ const [passwordMsg, setPasswordMsg] = useState(null);
const [passwordMsgType, setPasswordMsgType] = useState<
"success" | "error" | null
>(null);
@@ -152,13 +175,13 @@ export function AccountSection() {
// replay button so the surface stays mirrored).
const [tourRestarting, setTourRestarting] = useState(false);
const [tourFeedback, setTourFeedback] = useState<{
- text: string;
+ key: string;
type: "success" | "error";
} | null>(null);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
- const [passkeyMsg, setPasskeyMsg] = useState(null);
+ const [passkeyMsg, setPasskeyMsg] = useState(null);
const [passkeyMsgType, setPasskeyMsgType] = useState<
"success" | "error" | null
>(null);
@@ -204,7 +227,7 @@ export function AccountSection() {
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
- fetch("/api/auth/profile", {
+ apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -218,7 +241,7 @@ export function AccountSection() {
}),
}),
user && timezone && timezone !== user.timezone
- ? fetch("/api/auth/me/timezone", {
+ ? apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
@@ -227,7 +250,7 @@ export function AccountSection() {
]);
if (profileRes.ok && tzRes.ok) {
- setSaveMsg(t("settings.profileSaved"));
+ setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
await refetch();
} else if (!tzRes.ok) {
@@ -237,14 +260,20 @@ export function AccountSection() {
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
- setSaveMsg(json.error || t("settings.timezoneInvalid"));
+ setSaveMsg(
+ json.error
+ ? { text: json.error }
+ : { key: "settings.timezoneInvalid" },
+ );
} catch {
- setSaveMsg(t("settings.timezoneInvalid"));
+ setSaveMsg({ key: "settings.timezoneInvalid" });
}
setSaveMsgType("error");
} else {
const json = await profileRes.json();
- setSaveMsg(json.error || t("settings.savingError"));
+ setSaveMsg(
+ json.error ? { text: json.error } : { key: "settings.savingError" },
+ );
setSaveMsgType("error");
}
setSaving(false);
@@ -256,12 +285,12 @@ export function AccountSection() {
setPasskeyMsgType(null);
try {
- const optRes = await fetch("/api/auth/passkey/register-options", {
+ const optRes = await apiFetchRaw("/api/auth/passkey/register-options", {
method: "POST",
});
if (!optRes.ok) {
- setPasskeyMsg(t("settings.passkeyOptionsError"));
+ setPasskeyMsg({ key: "settings.passkeyOptionsError" });
setPasskeyMsgType("error");
setPasskeyLoading(false);
return;
@@ -273,25 +302,27 @@ export function AccountSection() {
const { startRegistration } = await import("@simplewebauthn/browser");
const credential = await startRegistration({ optionsJSON: options });
- const verifyRes = await fetch("/api/auth/passkey/register-verify", {
+ const verifyRes = await apiFetchRaw("/api/auth/passkey/register-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challengeId, credential }),
});
if (verifyRes.ok) {
- setPasskeyMsg(t("settings.passkeyAdded"));
+ setPasskeyMsg({ key: "settings.passkeyAdded" });
setPasskeyMsgType("success");
} else {
const verifyJson = await verifyRes.json();
setPasskeyMsg(
- verifyJson.error || t("settings.passkeyRegistrationFailed"),
+ verifyJson.error
+ ? { text: verifyJson.error }
+ : { key: "settings.passkeyRegistrationFailed" },
);
setPasskeyMsgType("error");
}
} catch (err) {
const { key, params } = describePasskeyError(err);
- setPasskeyMsg(t(key, params));
+ setPasskeyMsg({ key, params });
setPasskeyMsgType("error");
} finally {
setPasskeyLoading(false);
@@ -312,11 +343,11 @@ export function AccountSection() {
if (result.ok) {
await refetch();
setTourFeedback({
- text: t("onboarding.tour.restartConfirmation"),
+ key: "onboarding.tour.restartConfirmation",
type: "success",
});
} else {
- setTourFeedback({ text: t(result.messageKey), type: "error" });
+ setTourFeedback({ key: result.messageKey, type: "error" });
}
setTourRestarting(false);
}
@@ -328,14 +359,14 @@ export function AccountSection() {
setPasswordMsgType(null);
if (newPassword !== confirmPassword) {
- setPasswordMsg(t("settings.passwordMismatch"));
+ setPasswordMsg({ key: "settings.passwordMismatch" });
setPasswordMsgType("error");
setPasswordSaving(false);
return;
}
try {
- const res = await fetch("/api/auth/password", {
+ const res = await apiFetchRaw("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -347,25 +378,27 @@ export function AccountSection() {
const json = await res.json();
if (!res.ok) {
- setPasswordMsg(json.error || t("settings.savingError"));
+ setPasswordMsg(
+ json.error ? { text: json.error } : { key: "settings.savingError" },
+ );
setPasswordMsgType("error");
return;
}
- setPasswordMsg(t("settings.passwordUpdated"));
+ setPasswordMsg({ key: "settings.passwordUpdated" });
setPasswordMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
- setPasswordMsg(t("common.networkError"));
+ setPasswordMsg({ key: "common.networkError" });
setPasswordMsgType("error");
} finally {
setPasswordSaving(false);
}
}
- if (isLoading) {
+ if (!mounted || isLoading) {
return (
@@ -490,9 +523,15 @@ export function AccountSection() {
{t("settings.language")}
+ {/* While a switch waits on its message bundle (dynamic
+ import per locale), show the target value and lock the
+ control — the context only flips locale + strings
+ together once the bundle arrived. */}
setLocale(e.target.value as Locale)}
>
{locales.map((loc) => (
@@ -580,7 +619,7 @@ export function AccountSection() {
saveMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
- {saveMsg}
+ {statusText(saveMsg, t)}
)}
@@ -639,7 +678,7 @@ export function AccountSection() {
passkeyMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
- {passkeyMsg}
+ {statusText(passkeyMsg, t)}
)}
@@ -721,7 +760,7 @@ export function AccountSection() {
: "text-destructive"
}`}
>
- {tourFeedback.text}
+ {t(tourFeedback.key)}
)}
@@ -790,7 +829,7 @@ export function AccountSection() {
: "text-destructive"
}`}
>
- {passwordMsg}
+ {statusText(passwordMsg, t)}
)}
@@ -817,18 +856,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => {
- const res = await fetch("/api/auth/passkeys");
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as PasskeyInfo[];
+ return apiGet
("/api/auth/passkeys");
},
enabled: isAuthenticated,
});
const deletePasskey = useMutation({
mutationFn: async (id: string) => {
- const res = await fetch(`/api/auth/passkeys/${id}`, { method: "DELETE" });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error || t("common.error"));
+ await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
@@ -1081,14 +1116,17 @@ function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
const fileInputRef = useRef(null);
- const [msg, setMsg] = useState(null);
+ const [msg, setMsg] = useState(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const upload = useMutation({
mutationFn: async (file: File) => {
const body = new FormData();
body.append("file", file);
- const res = await fetch("/api/user/avatar", { method: "POST", body });
+ const res = await apiFetchRaw("/api/user/avatar", {
+ method: "POST",
+ body,
+ });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415)
@@ -1099,18 +1137,18 @@ function AvatarSection() {
},
onSuccess: async () => {
setMsgType("success");
- setMsg(t("settings.avatar.uploaded"));
+ setMsg({ key: "settings.avatar.uploaded" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
- setMsg(err.message);
+ setMsg({ text: err.message });
},
});
const remove = useMutation({
mutationFn: async () => {
- const res = await fetch("/api/user/avatar", { method: "DELETE" });
+ const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || t("settings.avatar.error"));
@@ -1118,12 +1156,12 @@ function AvatarSection() {
},
onSuccess: async () => {
setMsgType("success");
- setMsg(t("settings.avatar.removed"));
+ setMsg({ key: "settings.avatar.removed" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
- setMsg(err.message);
+ setMsg({ text: err.message });
},
});
@@ -1136,12 +1174,12 @@ function AvatarSection() {
setMsgType(null);
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
setMsgType("error");
- setMsg(t("settings.avatar.invalidType"));
+ setMsg({ key: "settings.avatar.invalidType" });
return;
}
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
setMsgType("error");
- setMsg(t("settings.avatar.tooLarge"));
+ setMsg({ key: "settings.avatar.tooLarge" });
return;
}
upload.mutate(file);
@@ -1221,7 +1259,7 @@ function AvatarSection() {
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
- {msg}
+ {statusText(msg, t)}
)}
diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx
index 38a6c6a1e..22fa1c307 100644
--- a/src/components/settings/advanced-section.tsx
+++ b/src/components/settings/advanced-section.tsx
@@ -25,6 +25,7 @@ import {
type ResearchModeStatus,
researchModeGateState,
} from "@/lib/medications/research-mode-types";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Settings → Advanced.
@@ -104,7 +105,7 @@ function ResearchModeCard() {
const { data: status, isLoading } = useQuery({
queryKey: queryKeys.researchMode(),
queryFn: async () => {
- const res = await fetch("/api/auth/me/research-mode");
+ const res = await apiFetchRaw("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
@@ -133,7 +134,7 @@ function ResearchModeCard() {
// Toggle OFF → fire DELETE directly.
setToggleBusy(true);
try {
- const res = await fetch("/api/auth/me/research-mode", {
+ const res = await apiFetchRaw("/api/auth/me/research-mode", {
method: "DELETE",
});
if (!res.ok) {
@@ -243,7 +244,7 @@ function DataResetCard() {
setMsg(null);
setMsgType(null);
try {
- const res = await fetch("/api/settings/data", {
+ const res = await apiFetchRaw("/api/settings/data", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE" }),
@@ -383,7 +384,7 @@ function AccountDeleteCard() {
setMsg(null);
setMsgType(null);
try {
- const res = await fetch("/api/settings/account", {
+ const res = await apiFetchRaw("/api/settings/account", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx
index 6c132e8c5..19fa3722d 100644
--- a/src/components/settings/ai-section.tsx
+++ b/src/components/settings/ai-section.tsx
@@ -1,176 +1,22 @@
"use client";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- ArrowDown,
- ArrowUp,
- Loader2,
- PlusCircle,
- RefreshCw,
- RotateCcw,
- Save,
- Sparkles,
- Trash2,
- X,
-} from "lucide-react";
+/**
+ * Settings → AI section wrapper.
+ *
+ * v1.16.4 — the former 2k-LOC monolith is split into per-provider
+ * subcomponents under `src/components/settings/ai/` (one file per
+ * provider form, plus the chain / runtime / coach-toggle cards). This
+ * file only owns the section frame and the card order; behaviour lives
+ * in the parts.
+ */
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { NativeSelect } from "@/components/ui/native-select";
-import { Switch } from "@/components/ui/switch";
-import { PasswordInput } from "@/components/ui/password-input";
import { AboutMeSection } from "@/components/settings/about-me-section";
-import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
+import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
-import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
-import { queryKeys } from "@/lib/query-keys";
-import { toast } from "sonner";
-
-interface InsightsSettings {
- codexStatus: string;
- codexConnectedAt: string | null;
- hasAdminKey: boolean;
- /** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this
- * instance. The UI hides the "Connect with ChatGPT" button when
- * false to avoid the v1.4.2 dead-end where the click bounced the
- * user to chatgpt.com without any OAuth flow. */
- codexOauthConfigured?: boolean;
- privacyMode: string;
- lastInsightAt: string | null;
-}
-
-interface UserAIProvider {
- provider: string | null;
- model: string | null;
- baseUrl: string | null;
- hasAnthropicKey: boolean;
- anthropicKeyPreview: string | null;
- hasLocalKey: boolean;
- hasOpenaiKey: boolean;
- openaiKeyPreview: string | null;
-}
-
-/**
- * v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
- * `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
- * avoid pulling a server-only module into the client bundle.
- */
-const PROVIDER_TYPES = [
- "codex",
- "openai",
- "anthropic",
- "local",
- "admin-openai",
-] as const;
-type ProviderType = (typeof PROVIDER_TYPES)[number];
-
-interface ChainEntry {
- providerType: ProviderType;
- enabled: boolean;
-}
-
-interface ProviderChainData {
- activeProvider: ProviderType | null;
- cachedActiveProvider: ProviderType | null;
- configuredChain: {
- providerType: ProviderType;
- enabled: boolean;
- available: boolean;
- }[];
-}
-
-const DEFAULT_CHAIN: readonly ChainEntry[] = [
- { providerType: "codex", enabled: true },
- { providerType: "openai", enabled: true },
- { providerType: "anthropic", enabled: true },
- { providerType: "local", enabled: true },
- { providerType: "admin-openai", enabled: true },
-];
-
-/**
- * OpenAI model presets — the wire-up keeps users out of the freetext
- * box for the common case. The `__custom__` sentinel surfaces a
- * freetext input so power users can target preview models or
- * fine-tunes (e.g. `gpt-5`, which is admitted via the env override on
- * the Codex backend but not yet a documented OpenAI model id).
- */
-const OPENAI_MODEL_PRESETS = ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"] as const;
-const ANTHROPIC_MODEL_PRESETS = [
- "claude-sonnet-4-6",
- "claude-opus-4-7",
- "claude-haiku-4-5",
- "claude-3-5-sonnet-latest",
-] as const;
-const LOCAL_MODEL_PRESETS = [
- "llama3.1:8b",
- "llama3.1:70b",
- "mistral",
- "qwen2.5",
-] as const;
-const CUSTOM_MODEL_SENTINEL = "__custom__";
-
-/**
- * Map UI provider tag → the `aiProvider` enum understood by the
- * legacy single-result `resolveProvider()` resolver. `codex` and
- * `admin-openai` aren't user-level provider columns (they're handled
- * by Codex OAuth + admin-key fallback), so they map to null and the
- * legacy column is left blank.
- */
-function uiToLegacyProviderEnum(p: ProviderType): string | null {
- switch (p) {
- case "openai":
- return "OPENAI";
- case "anthropic":
- return "ANTHROPIC";
- case "local":
- return "LOCAL";
- case "codex":
- case "admin-openai":
- default:
- return null;
- }
-}
-
-// Map the connection-test failure category (a stable, secret-free code from
-// /api/ai/test) to a localised message. Falls back to the server's plain
-// English `reason` for any unmapped / legacy code so a German operator never
-// sees an untranslated sentence in the otherwise-localised Settings panel.
-function localiseTestReason(
- t: (key: string, params?: Record) => string,
- reasonCode: string | undefined,
- reason: string | undefined,
-): string | undefined {
- switch (reasonCode) {
- case "credentials":
- return t("settings.ai.testReasonCredentials");
- case "rate_limited":
- return t("settings.ai.testReasonRateLimited");
- case "server_error":
- return t("settings.ai.testReasonServerError");
- case "unreachable":
- return t("settings.ai.testReasonUnreachable");
- default:
- return reason;
- }
-}
export function AiSection() {
const { t } = useTranslations();
@@ -218,1800 +64,3 @@ export function AiSection() {
);
}
-
-/**
- * v1.4.47 W3 — "Hide Coach" toggle card.
- *
- * Persists via `PATCH /api/auth/me/disable-coach`; the response
- * invalidates `queryKeys.authMe()` so every Coach mount point on the
- * client (``, ``, the inline
- * `` pill, the `/targets` page CTAs) re-renders
- * with the new `user.disableCoach` value on the next React Query
- * refetch tick — no full reload required.
- *
- * The optimistic-update pattern mirrors ``: the
- * Switch flips immediately, the mutation rolls back on error.
- */
-function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
- const { user } = useAuth();
-
- // Optimistic-update flag — null means "no in-flight change",
- // otherwise it overrides the wire value until the mutation
- // settles. Mirrors the `` pattern so the Switch
- // reacts instantly to the user's tap.
- const [optimistic, setOptimistic] = useState(null);
- const [msg, setMsg] = useState(null);
- const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
-
- // v1.4.48 M2 — auto-clear the inline `` line 3 s
- // after the mutation settles so a Settings card the user scrolled
- // past minutes earlier doesn't keep echoing a stale "Coach hidden"
- // banner. The ref tracks the in-flight timer so we can clear it on
- // unmount + on a follow-up toggle (otherwise a rapid double-tap
- // could leave a stray timer pointing at a stale message).
- const clearTimerRef = useRef | null>(null);
-
- useEffect(() => {
- return () => {
- if (clearTimerRef.current !== null) {
- clearTimeout(clearTimerRef.current);
- clearTimerRef.current = null;
- }
- };
- }, []);
-
- function scheduleClear() {
- if (clearTimerRef.current !== null) {
- clearTimeout(clearTimerRef.current);
- }
- clearTimerRef.current = setTimeout(() => {
- clearTimerRef.current = null;
- setMsg(null);
- setMsgType(null);
- }, 3000);
- }
-
- const checked = optimistic ?? user?.disableCoach ?? false;
-
- const mutation = useMutation({
- mutationFn: async (next: boolean) => {
- const res = await fetch("/api/auth/me/disable-coach", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ disableCoach: next }),
- });
- if (!res.ok) {
- const json = (await res.json().catch(() => ({}))) as {
- error?: string;
- };
- throw new Error(json.error ?? `HTTP ${res.status}`);
- }
- return next;
- },
- onSuccess: (next) => {
- setMsg(
- next
- ? t("settings.ai.disableCoach.savedHidden")
- : t("settings.ai.disableCoach.savedShown"),
- );
- setMsgType("success");
- // Surface the new value to every Coach gate via the shared
- // `useAuth()` query — the gates re-render once React Query
- // re-fetches /api/auth/me with the updated `disableCoach` field.
- queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
- setOptimistic(null);
- scheduleClear();
- },
- onError: (err) => {
- setOptimistic(null);
- setMsg(
- err instanceof Error
- ? err.message
- : t("settings.ai.disableCoach.saveError"),
- );
- setMsgType("error");
- scheduleClear();
- },
- });
-
- function handleToggle(next: boolean) {
- setOptimistic(next);
- setMsg(null);
- setMsgType(null);
- if (clearTimerRef.current !== null) {
- clearTimeout(clearTimerRef.current);
- clearTimerRef.current = null;
- }
- mutation.mutate(next);
- }
-
- return (
-
-
-
-
- {t("settings.ai.disableCoach.title")}
-
-
- {t("settings.ai.disableCoach.description")}
-
-
-
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-/**
- * The single dropdown-driven configuration card. Replaces the
- * v1.4.15-vintage two-card layout (Codex connect at the top, personal
- * provider form at the bottom) per the maintainer's
- * `feedback_settings_no_split.md`: one provider Select, one matching
- * form below, one fallback-chain card at the bottom.
- *
- * The active-provider Select is URL-synced via `?provider=…` so deep
- * links to a specific config form work, the SSR test can drive the
- * branch deterministically, and a refresh keeps the user on the form
- * they were editing.
- */
-function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
-
- const { data: insightsSettings } = useQuery({
- queryKey: queryKeys.insightsSettings(),
- queryFn: async () => {
- const res = await fetch("/api/insights/settings");
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as InsightsSettings;
- },
- enabled: isAuthenticated,
- });
-
- const { data: userProvider } = useQuery({
- queryKey: queryKeys.userAiProvider(),
- queryFn: async () => {
- const res = await fetch("/api/user/ai-provider");
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as UserAIProvider;
- },
- enabled: isAuthenticated,
- });
-
- const { data: chainData } = useQuery({
- queryKey: queryKeys.insightsProviderChain(),
- queryFn: async () => {
- const res = await fetch("/api/insights/provider-chain");
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as ProviderChainData;
- },
- enabled: isAuthenticated,
- });
-
- // The Select is URL-driven so the SSR test can pick the branch and
- // a deep link works. Default = `?provider=…` query param when the
- // user navigated here intentionally, else the chain's active entry,
- // else "codex". The seed-on-data-arrival pattern below avoids
- // setState-in-effect (lint rule `react-hooks/set-state-in-effect`)
- // by reading the resolved value at render time and consulting a
- // `seededFor` marker so we only re-seed when the relevant inputs
- // actually changed.
- const queryProvider = searchParams?.get("provider") ?? null;
- const [selectedProvider, setSelectedProvider] = useState(() =>
- isProviderType(queryProvider) ? queryProvider : "codex",
- );
- const seedKey = `${queryProvider ?? ""}|${chainData?.activeProvider ?? ""}`;
- const [seededFor, setSeededFor] = useState(seedKey);
- if (seedKey !== seededFor) {
- setSeededFor(seedKey);
- if (isProviderType(queryProvider)) {
- setSelectedProvider(queryProvider);
- } else if (chainData?.activeProvider) {
- setSelectedProvider(chainData.activeProvider);
- }
- }
-
- function pickProvider(next: ProviderType) {
- setSelectedProvider(next);
- if (typeof window === "undefined") return;
- const params = new URLSearchParams(searchParams?.toString() ?? "");
- params.set("provider", next);
- router.replace(`${pathname}?${params.toString()}`);
- }
-
- return (
-
- {/* The card used to render an icon-only header row (Sparkles +
- status badges, no title), which left the tile unanchored next
- to its titled siblings. It now follows the shared
- `SettingsCardHeader` contract: icon column, title + description
- in the content column, status badges top-right — and the body
- below indents to the title column (`pl-7`). */}
-
- }
- />
-
-
-
-
-
-
-
-
-
- queryClient.invalidateQueries({
- queryKey: queryKeys.insightsRoot(),
- })
- }
- onPrivacyChanged={() =>
- queryClient.invalidateQueries({
- queryKey: queryKeys.insightsRoot(),
- })
- }
- />
-
-
- );
-}
-
-function isProviderType(v: string | null): v is ProviderType {
- return v != null && (PROVIDER_TYPES as readonly string[]).includes(v);
-}
-
-function ProviderStatusBadges({
- settings,
- activeProvider,
-}: {
- settings: InsightsSettings | null | undefined;
- activeProvider: ProviderType | null;
-}) {
- const { t } = useTranslations();
- return (
-
- {settings?.codexStatus === "connected" && activeProvider === "codex" && (
-
- {t("settings.ai.chatgptConnectedBadge")}
-
- )}
- {settings?.codexStatus !== "connected" &&
- settings?.hasAdminKey &&
- activeProvider !== "codex" && (
-
- {t("settings.ai.adminAiActiveBadge")}
-
- )}
- {settings?.codexStatus === "expired" && (
-
- {t("settings.ai.connectionExpiredBadge")}
-
- )}
- {settings?.lastInsightAt && (
-
- {t("settings.lastGeneratedAt")}:{" "}
- {formatDateTime(settings.lastInsightAt)}
-
- )}
-
- );
-}
-
-/**
- * The single Pulldown that drives every form below it. Uses the
- * shared `` primitive so the SSR-only settings test
- * renders deterministically without a portal-based Radix tree, and so
- * the visual contract stays uniform with the other settings pickers
- * that landed behind the same primitive in MB7 / CF-52. Mobile:
- * full-width, height matched to the shared 36 px input contract used
- * everywhere else in Settings; tap target stays comfortable thanks to
- * the full-width chevron region.
- */
-function ActiveProviderSelect({
- value,
- onChange,
-}: {
- value: ProviderType;
- onChange: (next: ProviderType) => void;
-}) {
- const { t } = useTranslations();
- return (
-
-
- {t("settings.ai.activeProviderHeading")}
-
-
- {t("settings.ai.activeProviderBody")}
-
-
- {t("settings.ai.activeProviderLabel")}
-
-
{
- const next = e.target.value;
- if (isProviderType(next)) onChange(next);
- }}
- className="mt-1 sm:max-w-md"
- >
- {PROVIDER_TYPES.map((p) => (
-
- {t(`settings.ai.providerSelect.${p}` as const)}
-
- ))}
-
-
- );
-}
-
-/**
- * Switch-rendered provider configuration. The component is the
- * "form below adapts dynamically" half of the maintainer's pulldown contract:
- * pick Codex → see Codex form; pick OpenAI → see API-key + model
- * dropdown + base-URL override. The card frame is the same in every
- * branch so transitions feel like updating a single concept rather
- * than swapping pages.
- */
-function ProviderConfigCard({
- provider,
- insightsSettings,
- userProvider,
-}: {
- provider: ProviderType;
- insightsSettings: InsightsSettings | null | undefined;
- userProvider: UserAIProvider | null | undefined;
-}) {
- const { t } = useTranslations();
- return (
-
-
- {t("settings.ai.providerConfigTitle")}
-
- {provider === "codex" && (
-
- )}
- {provider === "openai" && (
-
- )}
- {provider === "anthropic" && (
-
- )}
- {provider === "local" && (
-
- )}
- {provider === "admin-openai" && (
-
- )}
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * Codex (ChatGPT account) form — connect / disconnect / status.
- * ──────────────────────────────────────────────────────────────── */
-
-function CodexProviderForm({
- settings,
-}: {
- settings: InsightsSettings | null | undefined;
-}) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
-
- const [msg, setMsg] = useState(null);
- const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
- const [disconnecting, setDisconnecting] = useState(false);
- const [deviceCode, setDeviceCode] = useState<{
- userCode: string;
- verificationUrl: string;
- intervalSeconds: number;
- } | null>(null);
- const [devicePolling, setDevicePolling] = useState(false);
-
- // OAuth callback handler — reads `?codex_connected=true|codex_error=…`
- // from the URL and surfaces an inline message.
- const [oauthOutcome] = useState<
- { kind: "connected" } | { kind: "error" } | null
- >(() => {
- if (typeof window === "undefined") return null;
- const params = new URLSearchParams(window.location.search);
- if (params.get("codex_connected") === "true") return { kind: "connected" };
- if (params.get("codex_error")) return { kind: "error" };
- return null;
- });
-
- useEffect(() => {
- if (!oauthOutcome) return;
- const url = new URL(window.location.href);
- url.searchParams.delete("codex_connected");
- url.searchParams.delete("codex_error");
- window.history.replaceState({}, "", url.toString());
- if (oauthOutcome.kind === "connected") {
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- }
- }, [oauthOutcome, queryClient]);
-
- const [oauthMsgSeeded, setOauthMsgSeeded] = useState(false);
- if (!oauthMsgSeeded && oauthOutcome) {
- setOauthMsgSeeded(true);
- if (oauthOutcome.kind === "connected") {
- setMsg(t("settings.codexConnected"));
- setMsgType("success");
- } else {
- setMsg(t("settings.codexConnectionFailed"));
- setMsgType("error");
- }
- }
-
- async function handleConnect() {
- setMsg(null);
- setDevicePolling(true);
- try {
- const res = await fetch("/api/auth/codex/device-start", {
- method: "POST",
- });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error || t("settings.savingError"));
- setDeviceCode({
- userCode: json.data.userCode,
- verificationUrl: json.data.verificationUrl,
- intervalSeconds: json.data.intervalSeconds,
- });
- } catch (err) {
- setMsg(err instanceof Error ? err.message : t("settings.savingError"));
- setMsgType("error");
- setDevicePolling(false);
- }
- }
-
- useEffect(() => {
- if (!deviceCode) return;
- let cancelled = false;
- const intervalMs = Math.max(deviceCode.intervalSeconds, 3) * 1000;
-
- async function tick() {
- try {
- const res = await fetch("/api/auth/codex/device-poll", {
- method: "POST",
- });
- const json = await res.json();
- if (cancelled) return;
- if (!res.ok) {
- throw new Error(json.error || t("settings.savingError"));
- }
- if (json.data?.status === "connected") {
- setDeviceCode(null);
- setDevicePolling(false);
- setMsg(t("settings.codexConnected"));
- setMsgType("success");
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- return;
- }
- if (!cancelled) setTimeout(tick, intervalMs);
- } catch (err) {
- if (cancelled) return;
- setMsg(err instanceof Error ? err.message : t("settings.savingError"));
- setMsgType("error");
- setDeviceCode(null);
- setDevicePolling(false);
- }
- }
-
- const handle = setTimeout(tick, intervalMs);
- return () => {
- cancelled = true;
- clearTimeout(handle);
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [deviceCode]);
-
- function handleCancelDevice() {
- setDeviceCode(null);
- setDevicePolling(false);
- }
-
- async function handleDisconnect() {
- setDisconnecting(true);
- setMsg(null);
- try {
- const res = await fetch("/api/auth/codex/disconnect", {
- method: "DELETE",
- });
- if (!res.ok) {
- const json = await res.json();
- throw new Error(json.error);
- }
- setMsg(t("settings.codexDisconnected"));
- setMsgType("success");
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- } catch (err) {
- setMsg(err instanceof Error ? err.message : t("settings.savingError"));
- setMsgType("error");
- } finally {
- setDisconnecting(false);
- }
- }
-
- const status = settings?.codexStatus;
- const isConnected = status === "connected";
-
- return (
-
-
-
- {t("settings.ai.codex.modelSlugLabel")}
-
- {isConnected ? (
-
- {t("settings.ai.codex.statusConnected")}
-
- ) : status === "expired" ? (
-
- {t("settings.ai.codex.statusExpired")}
-
- ) : (
-
- {t("settings.ai.codex.statusDisconnected")}
-
- )}
-
-
- {t("settings.ai.codex.modelSlugBody")}
-
- {settings?.lastInsightAt && (
-
- {t("settings.ai.codex.lastInsight", {
- when: formatDateTime(settings.lastInsightAt),
- })}
-
- )}
- {settings?.codexConnectedAt && isConnected && (
-
- {t("settings.ai.connectedSince", {
- when: formatDateTime(settings.codexConnectedAt),
- })}
-
- )}
-
- {settings?.codexOauthConfigured === false ? (
-
- {t("settings.ai.oauthNotConfigured")}
-
- ) : isConnected ? (
-
- {disconnecting ? (
-
- ) : (
-
- )}
- {t("settings.ai.codex.disconnectButton")}
-
- ) : deviceCode ? (
-
-
- {t("settings.ai.deviceCodeHeading")}
-
-
-
- {t("settings.ai.deviceCodeStep1")}{" "}
-
- {deviceCode.verificationUrl}
-
-
-
- {t("settings.ai.deviceCodeStep2")}
-
- {deviceCode.userCode}
-
- navigator.clipboard?.writeText(deviceCode.userCode)
- }
- className="text-muted-foreground hover:text-foreground text-xs underline"
- >
- {t("settings.ai.deviceCodeCopy")}
-
-
-
- {t("settings.ai.deviceCodeStep3")}
-
-
- {devicePolling && (
-
-
- {t("settings.ai.deviceCodeWaiting")}
-
- )}
-
- {t("settings.ai.deviceCodeCancel")}
-
-
-
- ) : (
-
- {devicePolling ? (
-
- ) : (
-
- )}
- {t("settings.ai.codex.connectButton")}
-
- )}
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * OpenAI form — API key + model select + collapsed Base URL override.
- * Save mutation flips both `aiProvider` (for the legacy single-result
- * resolver) and `aiOpenaiKeyEncrypted` (the user's key) so an OPENAI
- * pick is visible to every code path that reads the row.
- * ──────────────────────────────────────────────────────────────── */
-
-function OpenAIProviderForm({
- userProvider,
-}: {
- userProvider: UserAIProvider | null | undefined;
-}) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
-
- const [apiKey, setApiKey] = useState("");
- const [modelChoice, setModelChoice] = useState("");
- const [customModel, setCustomModel] = useState("");
- const [baseUrl, setBaseUrl] = useState("");
- const [showAdvanced, setShowAdvanced] = useState(false);
- const [msg, setMsg] = useState(null);
- const [ok, setOk] = useState(false);
-
- const seededKey =
- userProvider != null
- ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
- : null;
- const [previousSeed, setPreviousSeed] = useState(null);
- if (seededKey && seededKey !== previousSeed) {
- setPreviousSeed(seededKey);
- if (userProvider?.provider === "OPENAI") {
- const saved = userProvider.model ?? "";
- if (
- saved &&
- (OPENAI_MODEL_PRESETS as readonly string[]).includes(saved)
- ) {
- setModelChoice(saved);
- setCustomModel("");
- } else if (saved) {
- setModelChoice(CUSTOM_MODEL_SENTINEL);
- setCustomModel(saved);
- }
- setBaseUrl(userProvider.baseUrl ?? "");
- }
- }
-
- const effectiveModel =
- modelChoice === CUSTOM_MODEL_SENTINEL
- ? customModel.trim()
- : modelChoice.trim();
-
- const saveMutation = useMutation({
- mutationFn: async () => {
- const body: Record = {
- provider: uiToLegacyProviderEnum("openai"),
- model: effectiveModel || null,
- baseUrl: baseUrl.trim() || null,
- };
- if (apiKey.trim()) body.openaiKey = apiKey.trim();
- const res = await fetch("/api/user/ai-provider", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
- },
- onSuccess: () => {
- setOk(true);
- setMsg(t("settings.ai.saved"));
- setApiKey("");
- queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- },
- onError: (e) => {
- setOk(false);
- setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
- },
- });
-
- return (
-
-
-
- {t("settings.ai.openai.apiKey")}
- {userProvider?.hasOpenaiKey && (
-
- {t("settings.ai.savedPreview", {
- preview: userProvider.openaiKeyPreview ?? "",
- })}
-
- )}
-
-
setApiKey(e.target.value)}
- placeholder={t("settings.ai.openai.apiKeyPlaceholder")}
- className="mt-1"
- />
-
-
-
-
- {t("settings.ai.openai.modelSelect")}
-
- setModelChoice(e.target.value)}
- className="mt-1"
- >
- {t("settings.ai.modelOptionDefault")}
- {OPENAI_MODEL_PRESETS.map((m) => (
-
- {m}
-
- ))}
-
- {t("settings.ai.openai.modelOptionCustom")}
-
-
-
-
- {modelChoice === CUSTOM_MODEL_SENTINEL && (
-
-
- {t("settings.ai.openai.modelCustomLabel")}
-
- setCustomModel(e.target.value)}
- placeholder={t("settings.ai.openai.modelCustomPlaceholder")}
- className="mt-1"
- />
-
- )}
-
-
-
setShowAdvanced((v) => !v)}
- className="text-muted-foreground hover:text-foreground text-xs underline"
- >
- {showAdvanced
- ? t("settings.ai.openai.hideAdvanced")
- : t("settings.ai.openai.showAdvanced")}
-
- {showAdvanced && (
-
-
- {t("settings.ai.openai.baseUrlLabel")}
-
-
setBaseUrl(e.target.value)}
- placeholder={t("settings.ai.openai.baseUrlPlaceholder")}
- />
-
- {t("settings.ai.openai.baseUrlHelp")}
-
-
- )}
-
-
-
- saveMutation.mutate()}
- disabled={saveMutation.isPending}
- >
- {saveMutation.isPending ? (
-
- ) : (
-
- )}
- {t("settings.ai.saveCta")}
-
-
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * Anthropic form — API key + model dropdown.
- * ──────────────────────────────────────────────────────────────── */
-
-function AnthropicProviderForm({
- userProvider,
-}: {
- userProvider: UserAIProvider | null | undefined;
-}) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
-
- const [apiKey, setApiKey] = useState("");
- const [modelChoice, setModelChoice] = useState("");
- const [customModel, setCustomModel] = useState("");
- const [msg, setMsg] = useState(null);
- const [ok, setOk] = useState(false);
-
- const seededKey =
- userProvider != null
- ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}`
- : null;
- const [previousSeed, setPreviousSeed] = useState(null);
- if (seededKey && seededKey !== previousSeed) {
- setPreviousSeed(seededKey);
- if (userProvider?.provider === "ANTHROPIC") {
- const saved = userProvider.model ?? "";
- if (
- saved &&
- (ANTHROPIC_MODEL_PRESETS as readonly string[]).includes(saved)
- ) {
- setModelChoice(saved);
- setCustomModel("");
- } else if (saved) {
- setModelChoice(CUSTOM_MODEL_SENTINEL);
- setCustomModel(saved);
- }
- }
- }
-
- const effectiveModel =
- modelChoice === CUSTOM_MODEL_SENTINEL
- ? customModel.trim()
- : modelChoice.trim();
-
- const saveMutation = useMutation({
- mutationFn: async () => {
- const body: Record = {
- provider: uiToLegacyProviderEnum("anthropic"),
- model: effectiveModel || null,
- };
- if (apiKey.trim()) body.anthropicKey = apiKey.trim();
- const res = await fetch("/api/user/ai-provider", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
- },
- onSuccess: () => {
- setOk(true);
- setMsg(t("settings.ai.saved"));
- setApiKey("");
- queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- },
- onError: (e) => {
- setOk(false);
- setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
- },
- });
-
- return (
-
-
-
- {t("settings.ai.anthropicKeyLabel")}
- {userProvider?.hasAnthropicKey && (
-
- {t("settings.ai.savedPreview", {
- preview: userProvider.anthropicKeyPreview ?? "",
- })}
-
- )}
-
-
setApiKey(e.target.value)}
- placeholder="sk-ant-…"
- className="mt-1"
- />
-
-
-
-
- {t("settings.ai.modelLabel")}
-
- setModelChoice(e.target.value)}
- className="mt-1"
- >
- {t("settings.ai.modelOptionDefault")}
- {ANTHROPIC_MODEL_PRESETS.map((m) => (
-
- {m}
-
- ))}
-
- {t("settings.ai.modelOptionCustom")}
-
-
-
-
- {modelChoice === CUSTOM_MODEL_SENTINEL && (
-
-
- {t("settings.ai.customModelLabel")}
-
- setCustomModel(e.target.value)}
- placeholder="claude-3-5-sonnet-latest"
- className="mt-1"
- />
-
- )}
-
-
- saveMutation.mutate()}
- disabled={saveMutation.isPending}
- >
- {saveMutation.isPending ? (
-
- ) : (
-
- )}
- {t("settings.ai.saveCta")}
-
-
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * Local (OpenAI-compatible) form — base URL + optional key + model.
- * ──────────────────────────────────────────────────────────────── */
-
-function LocalProviderForm({
- userProvider,
-}: {
- userProvider: UserAIProvider | null | undefined;
-}) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
-
- const [baseUrl, setBaseUrl] = useState("");
- const [apiKey, setApiKey] = useState("");
- const [modelChoice, setModelChoice] = useState("");
- const [customModel, setCustomModel] = useState("");
- const [msg, setMsg] = useState(null);
- const [ok, setOk] = useState(false);
-
- const seededKey =
- userProvider != null
- ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
- : null;
- const [previousSeed, setPreviousSeed] = useState(null);
- if (seededKey && seededKey !== previousSeed) {
- setPreviousSeed(seededKey);
- if (userProvider?.provider === "LOCAL") {
- setBaseUrl(userProvider.baseUrl ?? "");
- const saved = userProvider.model ?? "";
- if (saved && (LOCAL_MODEL_PRESETS as readonly string[]).includes(saved)) {
- setModelChoice(saved);
- setCustomModel("");
- } else if (saved) {
- setModelChoice(CUSTOM_MODEL_SENTINEL);
- setCustomModel(saved);
- }
- }
- }
-
- const effectiveModel =
- modelChoice === CUSTOM_MODEL_SENTINEL
- ? customModel.trim()
- : modelChoice.trim();
-
- const saveMutation = useMutation({
- mutationFn: async () => {
- const body: Record = {
- provider: uiToLegacyProviderEnum("local"),
- baseUrl: baseUrl.trim() || null,
- model: effectiveModel || null,
- };
- if (apiKey.trim()) body.localKey = apiKey.trim();
- const res = await fetch("/api/user/ai-provider", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
- },
- onSuccess: () => {
- setOk(true);
- setMsg(t("settings.ai.saved"));
- setApiKey("");
- queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- },
- onError: (e) => {
- setOk(false);
- setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
- },
- });
-
- return (
-
-
-
- {t("settings.ai.baseUrlLabel")}
-
- setBaseUrl(e.target.value)}
- placeholder="http://localhost:11434/v1"
- className="mt-1"
- />
-
-
-
{t("settings.ai.localKeyLabel")}
-
setApiKey(e.target.value)}
- placeholder={
- userProvider?.hasLocalKey ? t("settings.ai.savedShort") : ""
- }
- className="mt-1"
- />
-
-
- {t("settings.ai.modelLabel")}
- setModelChoice(e.target.value)}
- className="mt-1"
- >
- {t("settings.ai.modelOptionDefault")}
- {LOCAL_MODEL_PRESETS.map((m) => (
-
- {m}
-
- ))}
-
- {t("settings.ai.modelOptionCustom")}
-
-
-
- {modelChoice === CUSTOM_MODEL_SENTINEL && (
-
-
- {t("settings.ai.customModelLabel")}
-
- setCustomModel(e.target.value)}
- placeholder="llama3:8b"
- className="mt-1"
- />
-
- )}
-
-
- saveMutation.mutate()}
- disabled={saveMutation.isPending}
- >
- {saveMutation.isPending ? (
-
- ) : (
-
- )}
- {t("settings.ai.saveCta")}
-
-
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * Admin OpenAI form — read-only "the operator has set this up".
- * ──────────────────────────────────────────────────────────────── */
-
-function AdminOpenAIProviderForm({
- hasAdminKey,
-}: {
- hasAdminKey: boolean | undefined;
-}) {
- const { t } = useTranslations();
- return (
-
-
- {t("settings.ai.adminOpenai.title")}
-
-
- {hasAdminKey
- ? t("settings.ai.adminOpenai.body")
- : t("settings.ai.adminOpenai.notConfigured")}
-
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * Fallback chain card — reorder via arrows, toggle, remove, add,
- * reset. Uses no new dependency (dnd-kit isn't in package.json).
- * ──────────────────────────────────────────────────────────────── */
-
-function FallbackChainCard({
- chain,
- selected,
- onSelect,
-}: {
- chain: {
- providerType: ProviderType;
- enabled: boolean;
- available: boolean;
- }[];
- selected: ProviderType;
- onSelect: (next: ProviderType) => void;
-}) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
-
- // Local working copy. Server-confirmed values arrive via `chain`
- // prop; we keep our own state so the user can shuffle multiple rows
- // before clicking "Save chain order".
- // v1.4.16 phase D reconcile (code-review H2) — `enabled` from the
- // wire is now the canonical state. The GET endpoint surfaces the
- // raw persisted chain so a disabled entry survives the round-trip.
- const seededKey = chain
- .map((c) => `${c.providerType}:${c.enabled ? 1 : 0}`)
- .join(",");
- const [seeded, setSeeded] = useState(null);
- const [entries, setEntries] = useState(() =>
- chain.map((c) => ({ providerType: c.providerType, enabled: c.enabled })),
- );
- if (seededKey !== seeded) {
- setSeeded(seededKey);
- setEntries(
- chain.map((c) => ({
- providerType: c.providerType,
- enabled: c.enabled,
- })),
- );
- }
-
- const [msg, setMsg] = useState(null);
- const [ok, setOk] = useState(false);
-
- const saveMutation = useMutation({
- mutationFn: async (next: ChainEntry[]) => {
- const body = {
- chain: next.map((entry, idx) => ({
- providerType: entry.providerType,
- priority: idx + 1,
- enabled: entry.enabled,
- })),
- };
- const res = await fetch("/api/insights/provider-chain", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- const json = await res.json();
- if (!res.ok) {
- throw new Error(
- json.error || t("settings.ai.providerChain.saveFailed"),
- );
- }
- },
- onSuccess: () => {
- setOk(true);
- setMsg(t("settings.ai.providerChain.saved"));
- queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
- },
- onError: (e) => {
- setOk(false);
- setMsg(
- e instanceof Error
- ? e.message
- : t("settings.ai.providerChain.saveFailed"),
- );
- },
- });
-
- function move(idx: number, delta: -1 | 1) {
- setEntries((prev) => {
- const next = [...prev];
- const target = idx + delta;
- if (target < 0 || target >= next.length) return prev;
- [next[idx], next[target]] = [next[target], next[idx]];
- return next;
- });
- }
-
- function toggle(idx: number) {
- setEntries((prev) => {
- const next = [...prev];
- next[idx] = { ...next[idx], enabled: !next[idx].enabled };
- return next;
- });
- }
-
- function remove(idx: number) {
- setEntries((prev) => prev.filter((_, i) => i !== idx));
- }
-
- const present = useMemo(
- () => new Set(entries.map((e) => e.providerType)),
- [entries],
- );
- const addable = PROVIDER_TYPES.filter((p) => !present.has(p));
-
- function add(p: ProviderType) {
- setEntries((prev) => [...prev, { providerType: p, enabled: true }]);
- }
-
- function resetToDefaults() {
- setEntries(DEFAULT_CHAIN.map((d) => ({ ...d })));
- }
-
- return (
-
-
-
- {t("settings.ai.providerChain.title")}
-
-
- {t("settings.ai.providerChain.description")}
-
-
-
-
- {entries.map((entry, idx) => (
-
-
- {idx + 1}.
-
- onSelect(entry.providerType)}
- className="flex-1 text-left text-sm font-medium hover:underline"
- >
- {t(`settings.ai.providerChain.types.${entry.providerType}`)}
-
- toggle(idx)}
- aria-label={t(
- `settings.ai.providerChain.types.${entry.providerType}`,
- )}
- />
- move(idx, -1)}
- >
-
-
- move(idx, 1)}
- >
-
-
- remove(idx)}
- disabled={entries.length <= 1}
- >
-
-
-
- ))}
-
-
-
- {addable.length > 0 ? (
-
- ) : (
-
- {t("settings.ai.providerChain.addNoneAvailable")}
-
- )}
-
saveMutation.mutate(entries)}
- disabled={saveMutation.isPending || entries.length === 0}
- data-testid="ai-fallback-chain-save"
- >
- {saveMutation.isPending ? (
-
- ) : (
-
- )}
- {t("settings.ai.providerChain.saveOrder")}
-
-
-
-
-
- {t("settings.ai.providerChain.resetDefaults")}
-
-
-
-
-
- {t("settings.ai.providerChain.resetConfirmTitle")}
-
-
- {t("settings.ai.providerChain.resetConfirmBody")}
-
-
-
- {t("common.cancel")}
-
- {t("settings.ai.providerChain.resetDefaults")}
-
-
-
-
-
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-function AddProviderControl({
- addable,
- onAdd,
-}: {
- addable: readonly ProviderType[];
- onAdd: (p: ProviderType) => void;
-}) {
- const { t } = useTranslations();
- const [picked, setPicked] = useState(addable[0] ?? "");
-
- return (
-
-
setPicked(e.target.value)}
- className="w-auto"
- >
- {addable.map((p) => (
-
- {t(`settings.ai.providerChain.types.${p}`)}
-
- ))}
-
-
{
- if (isProviderType(picked)) onAdd(picked);
- }}
- >
-
- {t("settings.ai.providerChain.addProvider")}
-
-
- );
-}
-
-/* ────────────────────────────────────────────────────────────────
- * Runtime actions — Test active provider, regenerate insights, raw-mode toggle.
- * ──────────────────────────────────────────────────────────────── */
-
-function RuntimeActionsRow({
- provider,
- userProvider,
- canRegenerate,
- privacyMode,
- lastInsightAt,
- onRegenerated,
- onPrivacyChanged,
-}: {
- provider: ProviderType;
- userProvider: UserAIProvider | null | undefined;
- canRegenerate: boolean;
- privacyMode: string;
- lastInsightAt: string | null;
- onRegenerated: () => void;
- onPrivacyChanged: () => void;
-}) {
- const { t } = useTranslations();
-
- const [testing, setTesting] = useState(false);
- const [testMsg, setTestMsg] = useState(null);
- const [testOk, setTestOk] = useState(false);
- const [regen, setRegen] = useState(false);
- const [regenMsg, setRegenMsg] = useState(null);
- const [regenOk, setRegenOk] = useState(false);
-
- async function runTest() {
- setTesting(true);
- setTestMsg(null);
- try {
- const res = await fetch("/api/ai/test", { method: "POST" });
- // Guard against a non-JSON body. A reverse proxy / Cloudflare can
- // rewrite an origin error to its own HTML page; parsing that as JSON
- // throws `Unexpected token '<'` (Safari: "did not match the
- // expected pattern"). Show a clean message instead of leaking that.
- const contentType = res.headers.get("content-type") ?? "";
- if (!contentType.includes("application/json")) {
- setTestOk(false);
- setTestMsg(t("settings.ai.testUnexpectedResponse"));
- return;
- }
- let json: {
- data?: {
- ok?: boolean;
- providerType?: string;
- model?: string;
- reasonCode?: string;
- reason?: string;
- } | null;
- error?: string | null;
- };
- try {
- json = await res.json();
- } catch {
- setTestOk(false);
- setTestMsg(t("settings.ai.testUnexpectedResponse"));
- return;
- }
- // 4xx config errors still arrive via the error envelope.
- if (!res.ok) {
- setTestOk(false);
- setTestMsg(
- t("settings.ai.testFailedShort", {
- message: json.error ?? `HTTP ${res.status}`,
- }),
- );
- return;
- }
- // Provider-call failures now arrive as 200 + { ok:false, reasonCode }
- // so the body is never rewritten by a proxy. Map the stable code to a
- // localised string; fall back to the server's plain `reason` for any
- // unmapped / legacy code, then to a generic message.
- if (json.data && json.data.ok === false) {
- setTestOk(false);
- setTestMsg(
- localiseTestReason(t, json.data.reasonCode, json.data.reason) ??
- t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }),
- );
- return;
- }
- setTestOk(true);
- setTestMsg(
- t("settings.ai.testSuccess", {
- provider: json.data?.providerType ?? "",
- model: json.data?.model ?? "",
- }),
- );
- } catch (e) {
- setTestOk(false);
- setTestMsg(
- t("settings.ai.testFailedShort", {
- message: e instanceof Error ? e.message : "fetch error",
- }),
- );
- } finally {
- setTesting(false);
- }
- }
-
- async function regenerate() {
- setRegen(true);
- setRegenMsg(null);
- try {
- const res = await fetch("/api/insights/generate", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ force: true }),
- });
- // Same defensive guard as the connection test: a proxy can rewrite
- // a 5xx (e.g. all-providers-failed) to an HTML page, which would
- // crash `res.json()` with `Unexpected token '<'`.
- const contentType = res.headers.get("content-type") ?? "";
- if (!contentType.includes("application/json")) {
- setRegenOk(false);
- setRegenMsg(
- res.status === 429
- ? t("settings.regenerateRateLimit")
- : t("settings.ai.testUnexpectedResponse"),
- );
- return;
- }
- let json: { error?: string | null };
- try {
- json = await res.json();
- } catch {
- setRegenOk(false);
- setRegenMsg(t("settings.ai.testUnexpectedResponse"));
- return;
- }
- if (!res.ok) {
- setRegenOk(false);
- setRegenMsg(
- res.status === 429
- ? t("settings.regenerateRateLimit")
- : json.error || t("settings.savingError"),
- );
- return;
- }
- setRegenOk(true);
- setRegenMsg(t("settings.regenerateSuccess"));
- onRegenerated();
- } catch {
- setRegenOk(false);
- setRegenMsg(t("settings.savingError"));
- } finally {
- setRegen(false);
- }
- }
-
- async function togglePrivacy() {
- const next = privacyMode === "raw" ? "aggregated" : "raw";
- // Privacy mode is a sensitive control: only reflect the new mode
- // once the server confirms it. A swallowed 4xx/5xx would leave the
- // UI showing a setting the server never accepted.
- try {
- const res = await fetch("/api/insights/settings", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ privacyMode: next }),
- });
- if (!res.ok) throw new Error("request failed");
- onPrivacyChanged();
- } catch {
- toast.error(t("settings.savingError"));
- }
- }
-
- const lastInsightLine = lastInsightAt
- ? `${t("settings.lastGeneratedAt")}: ${formatDateTime(lastInsightAt)}`
- : null;
-
- // Reserved for a future iteration that gates the Test button per
- // provider (e.g. disable when admin-openai is selected and no admin
- // key is present). For v1.4.16 the API decides.
- void provider;
- void userProvider;
-
- return (
-
-
-
- {testing ? (
-
- ) : (
-
- )}
- {t("settings.ai.testProvider")}
-
- {canRegenerate && (
-
- {regen ? (
-
- ) : (
-
- )}
- {t("settings.regenerateInsights")}
-
- )}
- {lastInsightLine && (
-
- {lastInsightLine}
-
- )}
-
-
- {canRegenerate && (
-
-
-
-
{t("settings.rawData")}
-
- {privacyMode === "raw"
- ? t("settings.rawDataOnDescription")
- : t("settings.rawDataOffDescription")}
-
-
-
-
- {privacyMode === "raw" && (
-
- {t("settings.rawDataWarning")}
-
- )}
-
- )}
-
- {testMsg && (
-
- {testMsg}
-
- )}
- {regenMsg && (
-
- {regenMsg}
-
- )}
-
- );
-}
diff --git a/src/components/settings/ai/admin-openai-provider-form.tsx b/src/components/settings/ai/admin-openai-provider-form.tsx
new file mode 100644
index 000000000..38229d5d4
--- /dev/null
+++ b/src/components/settings/ai/admin-openai-provider-form.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * Admin OpenAI form — read-only "the operator has set this up".
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useTranslations } from "@/lib/i18n/context";
+
+export function AdminOpenAIProviderForm({
+ hasAdminKey,
+}: {
+ hasAdminKey: boolean | undefined;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("settings.ai.adminOpenai.title")}
+
+
+ {hasAdminKey
+ ? t("settings.ai.adminOpenai.body")
+ : t("settings.ai.adminOpenai.notConfigured")}
+
+
+ );
+}
diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx
new file mode 100644
index 000000000..615b7578c
--- /dev/null
+++ b/src/components/settings/ai/ai-insights-card.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+/**
+ * The single dropdown-driven configuration card. Replaces the
+ * v1.4.15-vintage two-card layout (Codex connect at the top, personal
+ * provider form at the bottom) per the maintainer's
+ * `feedback_settings_no_split.md`: one provider Select, one matching
+ * form below, one fallback-chain card at the bottom.
+ *
+ * The active-provider Select is URL-synced via `?provider=…` so deep
+ * links to a specific config form work, the SSR test can drive the
+ * branch deterministically, and a refresh keeps the user on the form
+ * they were editing.
+ */
+
+import { useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Sparkles } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
+import { formatDateTime } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
+import { AnthropicProviderForm } from "./anthropic-provider-form";
+import { CodexProviderForm } from "./codex-provider-form";
+import { FallbackChainCard } from "./fallback-chain-card";
+import { LocalProviderForm } from "./local-provider-form";
+import { OpenAIProviderForm } from "./openai-provider-form";
+import { RuntimeActionsRow } from "./runtime-actions-row";
+import {
+ PROVIDER_TYPES,
+ isProviderType,
+ type InsightsSettings,
+ type ProviderChainData,
+ type ProviderType,
+ type UserAIProvider,
+} from "./shared";
+
+export function AiInsightsCard({
+ isAuthenticated,
+}: {
+ isAuthenticated: boolean;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const { data: insightsSettings } = useQuery({
+ queryKey: queryKeys.insightsSettings(),
+ queryFn: async () => {
+ const res = await apiFetchRaw("/api/insights/settings");
+ if (!res.ok) return null;
+ const json = await res.json();
+ return json.data as InsightsSettings;
+ },
+ enabled: isAuthenticated,
+ });
+
+ const { data: userProvider } = useQuery({
+ queryKey: queryKeys.userAiProvider(),
+ queryFn: async () => {
+ const res = await apiFetchRaw("/api/user/ai-provider");
+ if (!res.ok) return null;
+ const json = await res.json();
+ return json.data as UserAIProvider;
+ },
+ enabled: isAuthenticated,
+ });
+
+ const { data: chainData } = useQuery({
+ queryKey: queryKeys.insightsProviderChain(),
+ queryFn: async () => {
+ const res = await apiFetchRaw("/api/insights/provider-chain");
+ if (!res.ok) return null;
+ const json = await res.json();
+ return json.data as ProviderChainData;
+ },
+ enabled: isAuthenticated,
+ });
+
+ // The Select is URL-driven so the SSR test can pick the branch and
+ // a deep link works. Default = `?provider=…` query param when the
+ // user navigated here intentionally, else the chain's active entry,
+ // else "codex". The seed-on-data-arrival pattern below avoids
+ // setState-in-effect (lint rule `react-hooks/set-state-in-effect`)
+ // by reading the resolved value at render time and consulting a
+ // `seededFor` marker so we only re-seed when the relevant inputs
+ // actually changed.
+ const queryProvider = searchParams?.get("provider") ?? null;
+ const [selectedProvider, setSelectedProvider] = useState(() =>
+ isProviderType(queryProvider) ? queryProvider : "codex",
+ );
+ const seedKey = `${queryProvider ?? ""}|${chainData?.activeProvider ?? ""}`;
+ const [seededFor, setSeededFor] = useState(seedKey);
+ if (seedKey !== seededFor) {
+ setSeededFor(seedKey);
+ if (isProviderType(queryProvider)) {
+ setSelectedProvider(queryProvider);
+ } else if (chainData?.activeProvider) {
+ setSelectedProvider(chainData.activeProvider);
+ }
+ }
+
+ function pickProvider(next: ProviderType) {
+ setSelectedProvider(next);
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(searchParams?.toString() ?? "");
+ params.set("provider", next);
+ router.replace(`${pathname}?${params.toString()}`);
+ }
+
+ return (
+
+ {/* The card used to render an icon-only header row (Sparkles +
+ status badges, no title), which left the tile unanchored next
+ to its titled siblings. It now follows the shared
+ `SettingsCardHeader` contract: icon column, title + description
+ in the content column, status badges top-right — and the body
+ below indents to the title column (`pl-7`). */}
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsRoot(),
+ })
+ }
+ onPrivacyChanged={() =>
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsRoot(),
+ })
+ }
+ />
+
+
+ );
+}
+
+function ProviderStatusBadges({
+ settings,
+ activeProvider,
+}: {
+ settings: InsightsSettings | null | undefined;
+ activeProvider: ProviderType | null;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ {settings?.codexStatus === "connected" && activeProvider === "codex" && (
+
+ {t("settings.ai.chatgptConnectedBadge")}
+
+ )}
+ {settings?.codexStatus !== "connected" &&
+ settings?.hasAdminKey &&
+ activeProvider !== "codex" && (
+
+ {t("settings.ai.adminAiActiveBadge")}
+
+ )}
+ {settings?.codexStatus === "expired" && (
+
+ {t("settings.ai.connectionExpiredBadge")}
+
+ )}
+ {settings?.lastInsightAt && (
+
+ {t("settings.lastGeneratedAt")}:{" "}
+ {formatDateTime(settings.lastInsightAt)}
+
+ )}
+
+ );
+}
+
+/**
+ * The single Pulldown that drives every form below it. Uses the
+ * shared `` primitive so the SSR-only settings test
+ * renders deterministically without a portal-based Radix tree, and so
+ * the visual contract stays uniform with the other settings pickers
+ * that landed behind the same primitive in MB7 / CF-52. Mobile:
+ * full-width, height matched to the shared 36 px input contract used
+ * everywhere else in Settings; tap target stays comfortable thanks to
+ * the full-width chevron region.
+ */
+function ActiveProviderSelect({
+ value,
+ onChange,
+}: {
+ value: ProviderType;
+ onChange: (next: ProviderType) => void;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("settings.ai.activeProviderHeading")}
+
+
+ {t("settings.ai.activeProviderBody")}
+
+
+ {t("settings.ai.activeProviderLabel")}
+
+
{
+ const next = e.target.value;
+ if (isProviderType(next)) onChange(next);
+ }}
+ className="mt-1 sm:max-w-md"
+ >
+ {PROVIDER_TYPES.map((p) => (
+
+ {t(`settings.ai.providerSelect.${p}` as const)}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Switch-rendered provider configuration. The component is the
+ * "form below adapts dynamically" half of the maintainer's pulldown contract:
+ * pick Codex → see Codex form; pick OpenAI → see API-key + model
+ * dropdown + base-URL override. The card frame is the same in every
+ * branch so transitions feel like updating a single concept rather
+ * than swapping pages.
+ */
+function ProviderConfigCard({
+ provider,
+ insightsSettings,
+ userProvider,
+}: {
+ provider: ProviderType;
+ insightsSettings: InsightsSettings | null | undefined;
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("settings.ai.providerConfigTitle")}
+
+ {provider === "codex" && (
+
+ )}
+ {provider === "openai" && (
+
+ )}
+ {provider === "anthropic" && (
+
+ )}
+ {provider === "local" && (
+
+ )}
+ {provider === "admin-openai" && (
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx
new file mode 100644
index 000000000..c7e8b810f
--- /dev/null
+++ b/src/components/settings/ai/anthropic-provider-form.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * Anthropic form — API key + model dropdown.
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Save } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { PasswordInput } from "@/components/ui/password-input";
+import { apiPatch } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import {
+ ANTHROPIC_MODEL_PRESETS,
+ CUSTOM_MODEL_SENTINEL,
+ uiToLegacyProviderEnum,
+ type UserAIProvider,
+} from "./shared";
+
+export function AnthropicProviderForm({
+ userProvider,
+}: {
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [apiKey, setApiKey] = useState("");
+ const [modelChoice, setModelChoice] = useState("");
+ const [customModel, setCustomModel] = useState("");
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const seededKey =
+ userProvider != null
+ ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}`
+ : null;
+ const [previousSeed, setPreviousSeed] = useState(null);
+ if (seededKey && seededKey !== previousSeed) {
+ setPreviousSeed(seededKey);
+ if (userProvider?.provider === "ANTHROPIC") {
+ const saved = userProvider.model ?? "";
+ if (
+ saved &&
+ (ANTHROPIC_MODEL_PRESETS as readonly string[]).includes(saved)
+ ) {
+ setModelChoice(saved);
+ setCustomModel("");
+ } else if (saved) {
+ setModelChoice(CUSTOM_MODEL_SENTINEL);
+ setCustomModel(saved);
+ }
+ }
+ }
+
+ const effectiveModel =
+ modelChoice === CUSTOM_MODEL_SENTINEL
+ ? customModel.trim()
+ : modelChoice.trim();
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const body: Record = {
+ provider: uiToLegacyProviderEnum("anthropic"),
+ model: effectiveModel || null,
+ };
+ if (apiKey.trim()) body.anthropicKey = apiKey.trim();
+ await apiPatch("/api/user/ai-provider", body);
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.saved"));
+ setApiKey("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
+ },
+ });
+
+ return (
+
+
+
+ {t("settings.ai.anthropicKeyLabel")}
+ {userProvider?.hasAnthropicKey && (
+
+ {t("settings.ai.savedPreview", {
+ preview: userProvider.anthropicKeyPreview ?? "",
+ })}
+
+ )}
+
+
setApiKey(e.target.value)}
+ placeholder="sk-ant-…"
+ className="mt-1"
+ />
+
+
+
+
+ {t("settings.ai.modelLabel")}
+
+ setModelChoice(e.target.value)}
+ className="mt-1"
+ >
+ {t("settings.ai.modelOptionDefault")}
+ {ANTHROPIC_MODEL_PRESETS.map((m) => (
+
+ {m}
+
+ ))}
+
+ {t("settings.ai.modelOptionCustom")}
+
+
+
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && (
+
+
+ {t("settings.ai.customModelLabel")}
+
+ setCustomModel(e.target.value)}
+ placeholder="claude-3-5-sonnet-latest"
+ className="mt-1"
+ />
+
+ )}
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.saveCta")}
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx
new file mode 100644
index 000000000..ef10525f3
--- /dev/null
+++ b/src/components/settings/ai/codex-provider-form.tsx
@@ -0,0 +1,298 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * Codex (ChatGPT account) form — connect / disconnect / status.
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useEffect, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { Loader2, Sparkles, Trash2 } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
+import { formatDateTime } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import type { InsightsSettings } from "./shared";
+
+export function CodexProviderForm({
+ settings,
+}: {
+ settings: InsightsSettings | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [msg, setMsg] = useState(null);
+ const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
+ const [disconnecting, setDisconnecting] = useState(false);
+ const [deviceCode, setDeviceCode] = useState<{
+ userCode: string;
+ verificationUrl: string;
+ intervalSeconds: number;
+ } | null>(null);
+ const [devicePolling, setDevicePolling] = useState(false);
+
+ // OAuth callback handler — reads `?codex_connected=true|codex_error=…`
+ // from the URL and surfaces an inline message.
+ const [oauthOutcome] = useState<
+ { kind: "connected" } | { kind: "error" } | null
+ >(() => {
+ if (typeof window === "undefined") return null;
+ const params = new URLSearchParams(window.location.search);
+ if (params.get("codex_connected") === "true") return { kind: "connected" };
+ if (params.get("codex_error")) return { kind: "error" };
+ return null;
+ });
+
+ useEffect(() => {
+ if (!oauthOutcome) return;
+ const url = new URL(window.location.href);
+ url.searchParams.delete("codex_connected");
+ url.searchParams.delete("codex_error");
+ window.history.replaceState({}, "", url.toString());
+ if (oauthOutcome.kind === "connected") {
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ }
+ }, [oauthOutcome, queryClient]);
+
+ const [oauthMsgSeeded, setOauthMsgSeeded] = useState(false);
+ if (!oauthMsgSeeded && oauthOutcome) {
+ setOauthMsgSeeded(true);
+ if (oauthOutcome.kind === "connected") {
+ setMsg(t("settings.codexConnected"));
+ setMsgType("success");
+ } else {
+ setMsg(t("settings.codexConnectionFailed"));
+ setMsgType("error");
+ }
+ }
+
+ async function handleConnect() {
+ setMsg(null);
+ setDevicePolling(true);
+ try {
+ const res = await apiFetchRaw("/api/auth/codex/device-start", {
+ method: "POST",
+ });
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || t("settings.savingError"));
+ setDeviceCode({
+ userCode: json.data.userCode,
+ verificationUrl: json.data.verificationUrl,
+ intervalSeconds: json.data.intervalSeconds,
+ });
+ } catch (err) {
+ setMsg(err instanceof Error ? err.message : t("settings.savingError"));
+ setMsgType("error");
+ setDevicePolling(false);
+ }
+ }
+
+ useEffect(() => {
+ if (!deviceCode) return;
+ let cancelled = false;
+ const intervalMs = Math.max(deviceCode.intervalSeconds, 3) * 1000;
+
+ async function tick() {
+ try {
+ const res = await apiFetchRaw("/api/auth/codex/device-poll", {
+ method: "POST",
+ });
+ const json = await res.json();
+ if (cancelled) return;
+ if (!res.ok) {
+ throw new Error(json.error || t("settings.savingError"));
+ }
+ if (json.data?.status === "connected") {
+ setDeviceCode(null);
+ setDevicePolling(false);
+ setMsg(t("settings.codexConnected"));
+ setMsgType("success");
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ return;
+ }
+ if (!cancelled) setTimeout(tick, intervalMs);
+ } catch (err) {
+ if (cancelled) return;
+ setMsg(err instanceof Error ? err.message : t("settings.savingError"));
+ setMsgType("error");
+ setDeviceCode(null);
+ setDevicePolling(false);
+ }
+ }
+
+ const handle = setTimeout(tick, intervalMs);
+ return () => {
+ cancelled = true;
+ clearTimeout(handle);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [deviceCode]);
+
+ function handleCancelDevice() {
+ setDeviceCode(null);
+ setDevicePolling(false);
+ }
+
+ async function handleDisconnect() {
+ setDisconnecting(true);
+ setMsg(null);
+ try {
+ const res = await apiFetchRaw("/api/auth/codex/disconnect", {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const json = await res.json();
+ throw new Error(json.error);
+ }
+ setMsg(t("settings.codexDisconnected"));
+ setMsgType("success");
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ } catch (err) {
+ setMsg(err instanceof Error ? err.message : t("settings.savingError"));
+ setMsgType("error");
+ } finally {
+ setDisconnecting(false);
+ }
+ }
+
+ const status = settings?.codexStatus;
+ const isConnected = status === "connected";
+
+ return (
+
+
+
+ {t("settings.ai.codex.modelSlugLabel")}
+
+ {isConnected ? (
+
+ {t("settings.ai.codex.statusConnected")}
+
+ ) : status === "expired" ? (
+
+ {t("settings.ai.codex.statusExpired")}
+
+ ) : (
+
+ {t("settings.ai.codex.statusDisconnected")}
+
+ )}
+
+
+ {t("settings.ai.codex.modelSlugBody")}
+
+ {settings?.lastInsightAt && (
+
+ {t("settings.ai.codex.lastInsight", {
+ when: formatDateTime(settings.lastInsightAt),
+ })}
+
+ )}
+ {settings?.codexConnectedAt && isConnected && (
+
+ {t("settings.ai.connectedSince", {
+ when: formatDateTime(settings.codexConnectedAt),
+ })}
+
+ )}
+
+ {settings?.codexOauthConfigured === false ? (
+
+ {t("settings.ai.oauthNotConfigured")}
+
+ ) : isConnected ? (
+
+ {disconnecting ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.codex.disconnectButton")}
+
+ ) : deviceCode ? (
+
+
+ {t("settings.ai.deviceCodeHeading")}
+
+
+
+ {t("settings.ai.deviceCodeStep1")}{" "}
+
+ {deviceCode.verificationUrl}
+
+
+
+ {t("settings.ai.deviceCodeStep2")}
+
+ {deviceCode.userCode}
+
+ navigator.clipboard?.writeText(deviceCode.userCode)
+ }
+ className="text-muted-foreground hover:text-foreground text-xs underline"
+ >
+ {t("settings.ai.deviceCodeCopy")}
+
+
+
+ {t("settings.ai.deviceCodeStep3")}
+
+
+ {devicePolling && (
+
+
+ {t("settings.ai.deviceCodeWaiting")}
+
+ )}
+
+ {t("settings.ai.deviceCodeCancel")}
+
+
+
+ ) : (
+
+ {devicePolling ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.codex.connectButton")}
+
+ )}
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx
new file mode 100644
index 000000000..aa6615fd3
--- /dev/null
+++ b/src/components/settings/ai/disable-coach-card.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+/**
+ * v1.4.47 W3 — "Hide Coach" toggle card.
+ *
+ * Persists via `PATCH /api/auth/me/disable-coach`; the response
+ * invalidates `queryKeys.authMe()` so every Coach mount point on the
+ * client (``, ``, the inline
+ * `` pill, the `/targets` page CTAs) re-renders
+ * with the new `user.disableCoach` value on the next React Query
+ * refetch tick — no full reload required.
+ *
+ * The optimistic-update pattern mirrors ``: the
+ * Switch flips immediately, the mutation rolls back on error.
+ *
+ * v1.16.4 — header follows the shared `SettingsCardHeader` contract
+ * (icon column + title + switch in the status slot) so the card reads
+ * like its siblings instead of carrying its own bespoke header row.
+ */
+
+import { useEffect, useRef, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { MessageCircleOff } from "lucide-react";
+
+import { Switch } from "@/components/ui/switch";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { apiPatch } from "@/lib/api/api-fetch";
+import { useAuth } from "@/hooks/use-auth";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+export function DisableCoachCard({
+ isAuthenticated,
+}: {
+ isAuthenticated: boolean;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
+
+ // Optimistic-update flag — null means "no in-flight change",
+ // otherwise it overrides the wire value until the mutation
+ // settles. Mirrors the `` pattern so the Switch
+ // reacts instantly to the user's tap.
+ const [optimistic, setOptimistic] = useState(null);
+ const [msg, setMsg] = useState(null);
+ const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
+
+ // v1.4.48 M2 — auto-clear the inline `` line 3 s
+ // after the mutation settles so a Settings card the user scrolled
+ // past minutes earlier doesn't keep echoing a stale "Coach hidden"
+ // banner. The ref tracks the in-flight timer so we can clear it on
+ // unmount + on a follow-up toggle (otherwise a rapid double-tap
+ // could leave a stray timer pointing at a stale message).
+ const clearTimerRef = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (clearTimerRef.current !== null) {
+ clearTimeout(clearTimerRef.current);
+ clearTimerRef.current = null;
+ }
+ };
+ }, []);
+
+ function scheduleClear() {
+ if (clearTimerRef.current !== null) {
+ clearTimeout(clearTimerRef.current);
+ }
+ clearTimerRef.current = setTimeout(() => {
+ clearTimerRef.current = null;
+ setMsg(null);
+ setMsgType(null);
+ }, 3000);
+ }
+
+ const checked = optimistic ?? user?.disableCoach ?? false;
+
+ const mutation = useMutation({
+ mutationFn: async (next: boolean) => {
+ await apiPatch("/api/auth/me/disable-coach", { disableCoach: next });
+ return next;
+ },
+ onSuccess: (next) => {
+ setMsg(
+ next
+ ? t("settings.ai.disableCoach.savedHidden")
+ : t("settings.ai.disableCoach.savedShown"),
+ );
+ setMsgType("success");
+ // Surface the new value to every Coach gate via the shared
+ // `useAuth()` query — the gates re-render once React Query
+ // re-fetches /api/auth/me with the updated `disableCoach` field.
+ queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
+ setOptimistic(null);
+ scheduleClear();
+ },
+ onError: (err) => {
+ setOptimistic(null);
+ setMsg(
+ err instanceof Error
+ ? err.message
+ : t("settings.ai.disableCoach.saveError"),
+ );
+ setMsgType("error");
+ scheduleClear();
+ },
+ });
+
+ function handleToggle(next: boolean) {
+ setOptimistic(next);
+ setMsg(null);
+ setMsgType(null);
+ if (clearTimerRef.current !== null) {
+ clearTimeout(clearTimerRef.current);
+ clearTimerRef.current = null;
+ }
+ mutation.mutate(next);
+ }
+
+ return (
+
+
+ }
+ />
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx
new file mode 100644
index 000000000..06122f91d
--- /dev/null
+++ b/src/components/settings/ai/fallback-chain-card.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * Fallback chain card — reorder via arrows, toggle, remove, add,
+ * reset. Uses no new dependency (dnd-kit isn't in package.json).
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useMemo, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowDown,
+ ArrowUp,
+ Loader2,
+ PlusCircle,
+ RotateCcw,
+ Save,
+ X,
+} from "lucide-react";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { NativeSelect } from "@/components/ui/native-select";
+import { Switch } from "@/components/ui/switch";
+import { apiPut } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import {
+ DEFAULT_CHAIN,
+ PROVIDER_TYPES,
+ isProviderType,
+ type ChainEntry,
+ type ProviderType,
+} from "./shared";
+
+export function FallbackChainCard({
+ chain,
+ selected,
+ onSelect,
+}: {
+ chain: {
+ providerType: ProviderType;
+ enabled: boolean;
+ available: boolean;
+ }[];
+ selected: ProviderType;
+ onSelect: (next: ProviderType) => void;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ // Local working copy. Server-confirmed values arrive via `chain`
+ // prop; we keep our own state so the user can shuffle multiple rows
+ // before clicking "Save chain order".
+ // v1.4.16 phase D reconcile (code-review H2) — `enabled` from the
+ // wire is now the canonical state. The GET endpoint surfaces the
+ // raw persisted chain so a disabled entry survives the round-trip.
+ const seededKey = chain
+ .map((c) => `${c.providerType}:${c.enabled ? 1 : 0}`)
+ .join(",");
+ const [seeded, setSeeded] = useState(null);
+ const [entries, setEntries] = useState(() =>
+ chain.map((c) => ({ providerType: c.providerType, enabled: c.enabled })),
+ );
+ if (seededKey !== seeded) {
+ setSeeded(seededKey);
+ setEntries(
+ chain.map((c) => ({
+ providerType: c.providerType,
+ enabled: c.enabled,
+ })),
+ );
+ }
+
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const saveMutation = useMutation({
+ mutationFn: async (next: ChainEntry[]) => {
+ const body = {
+ chain: next.map((entry, idx) => ({
+ providerType: entry.providerType,
+ priority: idx + 1,
+ enabled: entry.enabled,
+ })),
+ };
+ await apiPut("/api/insights/provider-chain", body);
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.providerChain.saved"));
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(
+ e instanceof Error
+ ? e.message
+ : t("settings.ai.providerChain.saveFailed"),
+ );
+ },
+ });
+
+ function move(idx: number, delta: -1 | 1) {
+ setEntries((prev) => {
+ const next = [...prev];
+ const target = idx + delta;
+ if (target < 0 || target >= next.length) return prev;
+ [next[idx], next[target]] = [next[target], next[idx]];
+ return next;
+ });
+ }
+
+ function toggle(idx: number) {
+ setEntries((prev) => {
+ const next = [...prev];
+ next[idx] = { ...next[idx], enabled: !next[idx].enabled };
+ return next;
+ });
+ }
+
+ function remove(idx: number) {
+ setEntries((prev) => prev.filter((_, i) => i !== idx));
+ }
+
+ const present = useMemo(
+ () => new Set(entries.map((e) => e.providerType)),
+ [entries],
+ );
+ const addable = PROVIDER_TYPES.filter((p) => !present.has(p));
+
+ function add(p: ProviderType) {
+ setEntries((prev) => [...prev, { providerType: p, enabled: true }]);
+ }
+
+ function resetToDefaults() {
+ setEntries(DEFAULT_CHAIN.map((d) => ({ ...d })));
+ }
+
+ return (
+
+
+
+ {t("settings.ai.providerChain.title")}
+
+
+ {t("settings.ai.providerChain.description")}
+
+
+
+
+ {entries.map((entry, idx) => (
+
+
+ {idx + 1}.
+
+ onSelect(entry.providerType)}
+ className="flex-1 text-left text-sm font-medium hover:underline"
+ >
+ {t(`settings.ai.providerChain.types.${entry.providerType}`)}
+
+ toggle(idx)}
+ aria-label={t(
+ `settings.ai.providerChain.types.${entry.providerType}`,
+ )}
+ />
+ move(idx, -1)}
+ >
+
+
+ move(idx, 1)}
+ >
+
+
+ remove(idx)}
+ disabled={entries.length <= 1}
+ >
+
+
+
+ ))}
+
+
+
+ {addable.length > 0 ? (
+
+ ) : (
+
+ {t("settings.ai.providerChain.addNoneAvailable")}
+
+ )}
+
saveMutation.mutate(entries)}
+ disabled={saveMutation.isPending || entries.length === 0}
+ data-testid="ai-fallback-chain-save"
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.providerChain.saveOrder")}
+
+
+
+
+
+ {t("settings.ai.providerChain.resetDefaults")}
+
+
+
+
+
+ {t("settings.ai.providerChain.resetConfirmTitle")}
+
+
+ {t("settings.ai.providerChain.resetConfirmBody")}
+
+
+
+ {t("common.cancel")}
+
+ {t("settings.ai.providerChain.resetDefaults")}
+
+
+
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
+
+function AddProviderControl({
+ addable,
+ onAdd,
+}: {
+ addable: readonly ProviderType[];
+ onAdd: (p: ProviderType) => void;
+}) {
+ const { t } = useTranslations();
+ const [picked, setPicked] = useState(addable[0] ?? "");
+
+ return (
+
+
setPicked(e.target.value)}
+ className="w-auto"
+ >
+ {addable.map((p) => (
+
+ {t(`settings.ai.providerChain.types.${p}`)}
+
+ ))}
+
+
{
+ if (isProviderType(picked)) onAdd(picked);
+ }}
+ >
+
+ {t("settings.ai.providerChain.addProvider")}
+
+
+ );
+}
diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx
new file mode 100644
index 000000000..7c18a92f7
--- /dev/null
+++ b/src/components/settings/ai/local-provider-form.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * Local (OpenAI-compatible) form — base URL + optional key + model.
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Save } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { PasswordInput } from "@/components/ui/password-input";
+import { apiPatch } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import {
+ CUSTOM_MODEL_SENTINEL,
+ LOCAL_MODEL_PRESETS,
+ uiToLegacyProviderEnum,
+ type UserAIProvider,
+} from "./shared";
+
+export function LocalProviderForm({
+ userProvider,
+}: {
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [baseUrl, setBaseUrl] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const [modelChoice, setModelChoice] = useState("");
+ const [customModel, setCustomModel] = useState("");
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const seededKey =
+ userProvider != null
+ ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
+ : null;
+ const [previousSeed, setPreviousSeed] = useState(null);
+ if (seededKey && seededKey !== previousSeed) {
+ setPreviousSeed(seededKey);
+ if (userProvider?.provider === "LOCAL") {
+ setBaseUrl(userProvider.baseUrl ?? "");
+ const saved = userProvider.model ?? "";
+ if (saved && (LOCAL_MODEL_PRESETS as readonly string[]).includes(saved)) {
+ setModelChoice(saved);
+ setCustomModel("");
+ } else if (saved) {
+ setModelChoice(CUSTOM_MODEL_SENTINEL);
+ setCustomModel(saved);
+ }
+ }
+ }
+
+ const effectiveModel =
+ modelChoice === CUSTOM_MODEL_SENTINEL
+ ? customModel.trim()
+ : modelChoice.trim();
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const body: Record = {
+ provider: uiToLegacyProviderEnum("local"),
+ baseUrl: baseUrl.trim() || null,
+ model: effectiveModel || null,
+ };
+ if (apiKey.trim()) body.localKey = apiKey.trim();
+ await apiPatch("/api/user/ai-provider", body);
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.saved"));
+ setApiKey("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
+ },
+ });
+
+ return (
+
+
+
+ {t("settings.ai.baseUrlLabel")}
+
+ setBaseUrl(e.target.value)}
+ placeholder="http://localhost:11434/v1"
+ className="mt-1"
+ />
+
+
+
{t("settings.ai.localKeyLabel")}
+
setApiKey(e.target.value)}
+ placeholder={
+ userProvider?.hasLocalKey ? t("settings.ai.savedShort") : ""
+ }
+ className="mt-1"
+ />
+
+
+ {t("settings.ai.modelLabel")}
+ setModelChoice(e.target.value)}
+ className="mt-1"
+ >
+ {t("settings.ai.modelOptionDefault")}
+ {LOCAL_MODEL_PRESETS.map((m) => (
+
+ {m}
+
+ ))}
+
+ {t("settings.ai.modelOptionCustom")}
+
+
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && (
+
+
+ {t("settings.ai.customModelLabel")}
+
+ setCustomModel(e.target.value)}
+ placeholder="llama3:8b"
+ className="mt-1"
+ />
+
+ )}
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.saveCta")}
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx
new file mode 100644
index 000000000..98110edec
--- /dev/null
+++ b/src/components/settings/ai/openai-provider-form.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * OpenAI form — API key + model select + collapsed Base URL override.
+ * Save mutation flips both `aiProvider` (for the legacy single-result
+ * resolver) and `aiOpenaiKeyEncrypted` (the user's key) so an OPENAI
+ * pick is visible to every code path that reads the row.
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Save } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { PasswordInput } from "@/components/ui/password-input";
+import { apiPatch } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import {
+ CUSTOM_MODEL_SENTINEL,
+ OPENAI_MODEL_PRESETS,
+ uiToLegacyProviderEnum,
+ type UserAIProvider,
+} from "./shared";
+
+export function OpenAIProviderForm({
+ userProvider,
+}: {
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [apiKey, setApiKey] = useState("");
+ const [modelChoice, setModelChoice] = useState("");
+ const [customModel, setCustomModel] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const seededKey =
+ userProvider != null
+ ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
+ : null;
+ const [previousSeed, setPreviousSeed] = useState(null);
+ if (seededKey && seededKey !== previousSeed) {
+ setPreviousSeed(seededKey);
+ if (userProvider?.provider === "OPENAI") {
+ const saved = userProvider.model ?? "";
+ if (
+ saved &&
+ (OPENAI_MODEL_PRESETS as readonly string[]).includes(saved)
+ ) {
+ setModelChoice(saved);
+ setCustomModel("");
+ } else if (saved) {
+ setModelChoice(CUSTOM_MODEL_SENTINEL);
+ setCustomModel(saved);
+ }
+ setBaseUrl(userProvider.baseUrl ?? "");
+ }
+ }
+
+ const effectiveModel =
+ modelChoice === CUSTOM_MODEL_SENTINEL
+ ? customModel.trim()
+ : modelChoice.trim();
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const body: Record = {
+ provider: uiToLegacyProviderEnum("openai"),
+ model: effectiveModel || null,
+ baseUrl: baseUrl.trim() || null,
+ };
+ if (apiKey.trim()) body.openaiKey = apiKey.trim();
+ await apiPatch("/api/user/ai-provider", body);
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.saved"));
+ setApiKey("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
+ },
+ });
+
+ return (
+
+
+
+ {t("settings.ai.openai.apiKey")}
+ {userProvider?.hasOpenaiKey && (
+
+ {t("settings.ai.savedPreview", {
+ preview: userProvider.openaiKeyPreview ?? "",
+ })}
+
+ )}
+
+
setApiKey(e.target.value)}
+ placeholder={t("settings.ai.openai.apiKeyPlaceholder")}
+ className="mt-1"
+ />
+
+
+
+
+ {t("settings.ai.openai.modelSelect")}
+
+ setModelChoice(e.target.value)}
+ className="mt-1"
+ >
+ {t("settings.ai.modelOptionDefault")}
+ {OPENAI_MODEL_PRESETS.map((m) => (
+
+ {m}
+
+ ))}
+
+ {t("settings.ai.openai.modelOptionCustom")}
+
+
+
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && (
+
+
+ {t("settings.ai.openai.modelCustomLabel")}
+
+ setCustomModel(e.target.value)}
+ placeholder={t("settings.ai.openai.modelCustomPlaceholder")}
+ className="mt-1"
+ />
+
+ )}
+
+
+
setShowAdvanced((v) => !v)}
+ className="text-muted-foreground hover:text-foreground text-xs underline"
+ >
+ {showAdvanced
+ ? t("settings.ai.openai.hideAdvanced")
+ : t("settings.ai.openai.showAdvanced")}
+
+ {showAdvanced && (
+
+
+ {t("settings.ai.openai.baseUrlLabel")}
+
+
setBaseUrl(e.target.value)}
+ placeholder={t("settings.ai.openai.baseUrlPlaceholder")}
+ />
+
+ {t("settings.ai.openai.baseUrlHelp")}
+
+
+ )}
+
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.saveCta")}
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx
new file mode 100644
index 000000000..265f1fc4b
--- /dev/null
+++ b/src/components/settings/ai/runtime-actions-row.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * Runtime actions — Test active provider, regenerate insights, raw-mode toggle.
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useState } from "react";
+import { Loader2, RefreshCw, Sparkles } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
+import { formatDateTime } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+
+import {
+ localiseTestReason,
+ type ProviderType,
+ type UserAIProvider,
+} from "./shared";
+
+export function RuntimeActionsRow({
+ provider,
+ userProvider,
+ canRegenerate,
+ privacyMode,
+ lastInsightAt,
+ onRegenerated,
+ onPrivacyChanged,
+}: {
+ provider: ProviderType;
+ userProvider: UserAIProvider | null | undefined;
+ canRegenerate: boolean;
+ privacyMode: string;
+ lastInsightAt: string | null;
+ onRegenerated: () => void;
+ onPrivacyChanged: () => void;
+}) {
+ const { t } = useTranslations();
+
+ const [testing, setTesting] = useState(false);
+ const [testMsg, setTestMsg] = useState(null);
+ const [testOk, setTestOk] = useState(false);
+ const [regen, setRegen] = useState(false);
+ const [regenMsg, setRegenMsg] = useState(null);
+ const [regenOk, setRegenOk] = useState(false);
+
+ async function runTest() {
+ setTesting(true);
+ setTestMsg(null);
+ try {
+ const res = await apiFetchRaw("/api/ai/test", { method: "POST" });
+ // Guard against a non-JSON body. A reverse proxy / Cloudflare can
+ // rewrite an origin error to its own HTML page; parsing that as JSON
+ // throws `Unexpected token '<'` (Safari: "did not match the
+ // expected pattern"). Show a clean message instead of leaking that.
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) {
+ setTestOk(false);
+ setTestMsg(t("settings.ai.testUnexpectedResponse"));
+ return;
+ }
+ let json: {
+ data?: {
+ ok?: boolean;
+ providerType?: string;
+ model?: string;
+ reasonCode?: string;
+ reason?: string;
+ } | null;
+ error?: string | null;
+ };
+ try {
+ json = await res.json();
+ } catch {
+ setTestOk(false);
+ setTestMsg(t("settings.ai.testUnexpectedResponse"));
+ return;
+ }
+ // 4xx config errors still arrive via the error envelope.
+ if (!res.ok) {
+ setTestOk(false);
+ setTestMsg(
+ t("settings.ai.testFailedShort", {
+ message: json.error ?? `HTTP ${res.status}`,
+ }),
+ );
+ return;
+ }
+ // Provider-call failures now arrive as 200 + { ok:false, reasonCode }
+ // so the body is never rewritten by a proxy. Map the stable code to a
+ // localised string; fall back to the server's plain `reason` for any
+ // unmapped / legacy code, then to a generic message.
+ if (json.data && json.data.ok === false) {
+ setTestOk(false);
+ setTestMsg(
+ localiseTestReason(t, json.data.reasonCode, json.data.reason) ??
+ t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }),
+ );
+ return;
+ }
+ setTestOk(true);
+ setTestMsg(
+ t("settings.ai.testSuccess", {
+ provider: json.data?.providerType ?? "",
+ model: json.data?.model ?? "",
+ }),
+ );
+ } catch (e) {
+ setTestOk(false);
+ setTestMsg(
+ t("settings.ai.testFailedShort", {
+ message: e instanceof Error ? e.message : "fetch error",
+ }),
+ );
+ } finally {
+ setTesting(false);
+ }
+ }
+
+ async function regenerate() {
+ setRegen(true);
+ setRegenMsg(null);
+ try {
+ const res = await apiFetchRaw("/api/insights/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ force: true }),
+ });
+ // Same defensive guard as the connection test: a proxy can rewrite
+ // a 5xx (e.g. all-providers-failed) to an HTML page, which would
+ // crash `res.json()` with `Unexpected token '<'`.
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) {
+ setRegenOk(false);
+ setRegenMsg(
+ res.status === 429
+ ? t("settings.regenerateRateLimit")
+ : t("settings.ai.testUnexpectedResponse"),
+ );
+ return;
+ }
+ let json: { error?: string | null };
+ try {
+ json = await res.json();
+ } catch {
+ setRegenOk(false);
+ setRegenMsg(t("settings.ai.testUnexpectedResponse"));
+ return;
+ }
+ if (!res.ok) {
+ setRegenOk(false);
+ setRegenMsg(
+ res.status === 429
+ ? t("settings.regenerateRateLimit")
+ : json.error || t("settings.savingError"),
+ );
+ return;
+ }
+ setRegenOk(true);
+ setRegenMsg(t("settings.regenerateSuccess"));
+ onRegenerated();
+ } catch {
+ setRegenOk(false);
+ setRegenMsg(t("settings.savingError"));
+ } finally {
+ setRegen(false);
+ }
+ }
+
+ async function togglePrivacy() {
+ const next = privacyMode === "raw" ? "aggregated" : "raw";
+ // Privacy mode is a sensitive control: only reflect the new mode
+ // once the server confirms it. A swallowed 4xx/5xx would leave the
+ // UI showing a setting the server never accepted.
+ try {
+ await apiPut("/api/insights/settings", { privacyMode: next });
+ onPrivacyChanged();
+ } catch {
+ toast.error(t("settings.savingError"));
+ }
+ }
+
+ const lastInsightLine = lastInsightAt
+ ? `${t("settings.lastGeneratedAt")}: ${formatDateTime(lastInsightAt)}`
+ : null;
+
+ // Reserved for a future iteration that gates the Test button per
+ // provider (e.g. disable when admin-openai is selected and no admin
+ // key is present). For v1.4.16 the API decides.
+ void provider;
+ void userProvider;
+
+ return (
+
+
+
+ {testing ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.testProvider")}
+
+ {canRegenerate && (
+
+ {regen ? (
+
+ ) : (
+
+ )}
+ {t("settings.regenerateInsights")}
+
+ )}
+ {lastInsightLine && (
+
+ {lastInsightLine}
+
+ )}
+
+
+ {canRegenerate && (
+
+
+
+
{t("settings.rawData")}
+
+ {privacyMode === "raw"
+ ? t("settings.rawDataOnDescription")
+ : t("settings.rawDataOffDescription")}
+
+
+
+
+ {privacyMode === "raw" && (
+
+ {t("settings.rawDataWarning")}
+
+ )}
+
+ )}
+
+ {testMsg && (
+
+ {testMsg}
+
+ )}
+ {regenMsg && (
+
+ {regenMsg}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts
new file mode 100644
index 000000000..4e1e796ce
--- /dev/null
+++ b/src/components/settings/ai/shared.ts
@@ -0,0 +1,142 @@
+/**
+ * Shared types, constants, and pure helpers for the Settings → AI
+ * provider components. Extracted from the former 2k-LOC
+ * `ai-section.tsx` monolith; one provider form per file lives next to
+ * this module under `src/components/settings/ai/`.
+ */
+
+export interface InsightsSettings {
+ codexStatus: string;
+ codexConnectedAt: string | null;
+ hasAdminKey: boolean;
+ /** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this
+ * instance. The UI hides the "Connect with ChatGPT" button when
+ * false to avoid the v1.4.2 dead-end where the click bounced the
+ * user to chatgpt.com without any OAuth flow. */
+ codexOauthConfigured?: boolean;
+ privacyMode: string;
+ lastInsightAt: string | null;
+}
+
+export interface UserAIProvider {
+ provider: string | null;
+ model: string | null;
+ baseUrl: string | null;
+ hasAnthropicKey: boolean;
+ anthropicKeyPreview: string | null;
+ hasLocalKey: boolean;
+ hasOpenaiKey: boolean;
+ openaiKeyPreview: string | null;
+}
+
+/**
+ * v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
+ * `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
+ * avoid pulling a server-only module into the client bundle.
+ */
+export const PROVIDER_TYPES = [
+ "codex",
+ "openai",
+ "anthropic",
+ "local",
+ "admin-openai",
+] as const;
+export type ProviderType = (typeof PROVIDER_TYPES)[number];
+
+export interface ChainEntry {
+ providerType: ProviderType;
+ enabled: boolean;
+}
+
+export interface ProviderChainData {
+ activeProvider: ProviderType | null;
+ cachedActiveProvider: ProviderType | null;
+ configuredChain: {
+ providerType: ProviderType;
+ enabled: boolean;
+ available: boolean;
+ }[];
+}
+
+export const DEFAULT_CHAIN: readonly ChainEntry[] = [
+ { providerType: "codex", enabled: true },
+ { providerType: "openai", enabled: true },
+ { providerType: "anthropic", enabled: true },
+ { providerType: "local", enabled: true },
+ { providerType: "admin-openai", enabled: true },
+];
+
+/**
+ * OpenAI model presets — the wire-up keeps users out of the freetext
+ * box for the common case. The `__custom__` sentinel surfaces a
+ * freetext input so power users can target preview models or
+ * fine-tunes (e.g. `gpt-5`, which is admitted via the env override on
+ * the Codex backend but not yet a documented OpenAI model id).
+ */
+export const OPENAI_MODEL_PRESETS = [
+ "gpt-4o",
+ "gpt-4o-mini",
+ "gpt-4-turbo",
+] as const;
+export const ANTHROPIC_MODEL_PRESETS = [
+ "claude-sonnet-4-6",
+ "claude-opus-4-7",
+ "claude-haiku-4-5",
+ "claude-3-5-sonnet-latest",
+] as const;
+export const LOCAL_MODEL_PRESETS = [
+ "llama3.1:8b",
+ "llama3.1:70b",
+ "mistral",
+ "qwen2.5",
+] as const;
+export const CUSTOM_MODEL_SENTINEL = "__custom__";
+
+/**
+ * Map UI provider tag → the `aiProvider` enum understood by the
+ * legacy single-result `resolveProvider()` resolver. `codex` and
+ * `admin-openai` aren't user-level provider columns (they're handled
+ * by Codex OAuth + admin-key fallback), so they map to null and the
+ * legacy column is left blank.
+ */
+export function uiToLegacyProviderEnum(p: ProviderType): string | null {
+ switch (p) {
+ case "openai":
+ return "OPENAI";
+ case "anthropic":
+ return "ANTHROPIC";
+ case "local":
+ return "LOCAL";
+ case "codex":
+ case "admin-openai":
+ default:
+ return null;
+ }
+}
+
+// Map the connection-test failure category (a stable, secret-free code from
+// /api/ai/test) to a localised message. Falls back to the server's plain
+// English `reason` for any unmapped / legacy code so a German operator never
+// sees an untranslated sentence in the otherwise-localised Settings panel.
+export function localiseTestReason(
+ t: (key: string, params?: Record) => string,
+ reasonCode: string | undefined,
+ reason: string | undefined,
+): string | undefined {
+ switch (reasonCode) {
+ case "credentials":
+ return t("settings.ai.testReasonCredentials");
+ case "rate_limited":
+ return t("settings.ai.testReasonRateLimited");
+ case "server_error":
+ return t("settings.ai.testReasonServerError");
+ case "unreachable":
+ return t("settings.ai.testReasonUnreachable");
+ default:
+ return reason;
+ }
+}
+
+export function isProviderType(v: string | null): v is ProviderType {
+ return v != null && (PROVIDER_TYPES as readonly string[]).includes(v);
+}
diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx
index 4cfaacd3b..bfa732060 100644
--- a/src/components/settings/api-section.tsx
+++ b/src/components/settings/api-section.tsx
@@ -24,6 +24,7 @@ import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface ApiTokenInfo {
id: string;
@@ -168,9 +169,7 @@ function ApiTokensCard() {
const { data: tokens } = useQuery({
queryKey: queryKeys.tokens(),
queryFn: async () => {
- const res = await fetch("/api/tokens");
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as ApiTokenInfo[];
+ return apiGet("/api/tokens");
},
enabled: isAuthenticated,
});
@@ -182,7 +181,7 @@ function ApiTokensCard() {
setNewToken(null);
try {
- const res = await fetch("/api/tokens", {
+ const res = await apiFetchRaw("/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
@@ -218,7 +217,9 @@ function ApiTokensCard() {
async function handleRevoke(tokenId: string) {
try {
- const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" });
+ const res = await apiFetchRaw(`/api/tokens/${tokenId}`, {
+ method: "DELETE",
+ });
if (!res.ok) {
toast.error(t("settings.tokenRevokeFailed"));
return;
diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx
index c341c1f08..78e61d55c 100644
--- a/src/components/settings/coach-memory-section.tsx
+++ b/src/components/settings/coach-memory-section.tsx
@@ -38,6 +38,7 @@ import { formatDateOrRelative } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "sonner";
+import { apiDelete, apiGet } from "@/lib/api/api-fetch";
/** Closed enum mirrored from the server `CoachFact.category` column. */
const FACT_CATEGORIES = [
@@ -66,12 +67,10 @@ const CATEGORY_LABEL_KEY: Record = {
};
async function fetchFacts(): Promise {
- const res = await fetch("/api/insights/coach/facts");
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
- const json = (await res.json()) as { data?: { facts?: CoachFact[] } };
- return json.data?.facts ?? [];
+ const data = await apiGet<{ facts?: CoachFact[] } | undefined>(
+ "/api/insights/coach/facts",
+ );
+ return data?.facts ?? [];
}
export function CoachMemorySection({
@@ -91,13 +90,7 @@ export function CoachMemorySection({
const forgetOne = useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async (id: string) => {
- const res = await fetch(
- `/api/insights/coach/facts/${encodeURIComponent(id)}`,
- { method: "DELETE" },
- );
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
+ await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`);
return id;
},
onSuccess: () => {
@@ -112,14 +105,10 @@ export function CoachMemorySection({
const forgetAll = useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async () => {
- const res = await fetch("/api/insights/coach/facts", {
- method: "DELETE",
- });
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
- const json = (await res.json()) as { data?: { cleared?: number } };
- return json.data?.cleared ?? 0;
+ const data = await apiDelete<{ cleared?: number } | undefined>(
+ "/api/insights/coach/facts",
+ );
+ return data?.cleared ?? 0;
},
onSuccess: (cleared) => {
toast.success(
diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx
index 7e8e83fff..fa3edba3a 100644
--- a/src/components/settings/coach-nudge-card.tsx
+++ b/src/components/settings/coach-nudge-card.tsx
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface NotificationPrefsShape {
coach: { nudgesEnabled: boolean };
@@ -60,9 +61,7 @@ export function CoachNudgeCard({
const { data: prefs } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () => {
- const res = await fetch("/api/auth/me/notification-prefs");
- if (!res.ok) throw new Error("Failed to load notification prefs");
- return (await res.json()).data as NotificationPrefsShape;
+ return apiGet("/api/auth/me/notification-prefs");
},
enabled: isAuthenticated,
});
@@ -79,7 +78,7 @@ export function CoachNudgeCard({
clearTimerRef.current = null;
}
- const res = await fetch("/api/auth/me/notification-prefs", {
+ const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coach: { nudgesEnabled: next } }),
diff --git a/src/components/settings/cycle-tracking-card.tsx b/src/components/settings/cycle-tracking-card.tsx
index b6aab2f7f..575f019ba 100644
--- a/src/components/settings/cycle-tracking-card.tsx
+++ b/src/components/settings/cycle-tracking-card.tsx
@@ -10,6 +10,7 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiGet, apiPatch } from "@/lib/api/api-fetch";
/**
* v1.15.0 — the cycle-tracking enable on-ramp.
@@ -39,21 +40,13 @@ export function CycleTrackingCard({
queryKey: queryKeys.cyclePrefs(),
enabled: isAuthenticated,
queryFn: async () => {
- const res = await fetch("/api/auth/me/cycle-prefs");
- if (!res.ok) throw new Error(`Request failed: ${res.status}`);
- return (await res.json()).data as CyclePrefsShape;
+ return apiGet("/api/auth/me/cycle-prefs");
},
});
const toggle = useMutation({
mutationFn: async (enabled: boolean) => {
- const res = await fetch("/api/auth/me/cycle-prefs", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ enabled }),
- });
- if (!res.ok) throw new Error(`Request failed: ${res.status}`);
- return (await res.json()).data as CyclePrefsShape;
+ return apiPatch("/api/auth/me/cycle-prefs", { enabled });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() });
diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx
index 0e9afd854..c375bed78 100644
--- a/src/components/settings/dashboard-layout-section.tsx
+++ b/src/components/settings/dashboard-layout-section.tsx
@@ -48,6 +48,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
@@ -171,10 +172,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const { data: remote, isLoading } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
- const res = await fetch("/api/dashboard/widgets");
- if (!res.ok) throw new Error("failed");
- const json = await res.json();
- return json.data as DashboardLayout;
+ return apiGet("/api/dashboard/widgets");
},
});
@@ -187,13 +185,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const saveMutation = useMutation({
mutationFn: async (next: DashboardLayout) => {
- const res = await fetch("/api/dashboard/widgets", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(next),
- });
- if (!res.ok) throw new Error("save failed");
- return (await res.json()).data as DashboardLayout;
+ return apiPut("/api/dashboard/widgets", next);
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
@@ -205,9 +197,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const resetMutation = useMutation({
mutationFn: async () => {
- const res = await fetch("/api/dashboard/widgets", { method: "DELETE" });
- if (!res.ok) throw new Error("reset failed");
- return (await res.json()).data as DashboardLayout;
+ return apiDelete("/api/dashboard/widgets");
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
@@ -230,7 +220,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
* v1.4.15 Fix 5 — independent toggle for the *strip tile* (the upper
* row of trend cards). Until v1.4.14 a single switch controlled both
* the tile AND the chart for the same metric, which the maintainer found too
- * coarse: he wanted a chart visible without the tile (for metrics he
+ * coarse: they wanted a chart visible without the tile (for metrics they
* tracks without wanting the at-a-glance number) or vice versa.
*/
function toggleTile(widgetId: DashboardWidgetId, tileVisible: boolean) {
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index 68da3f3a6..7652965e6 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -46,6 +46,7 @@ import { HealthRecordExportPanel } from "@/components/settings/health-record-exp
import { ImportPanel } from "@/components/settings/import-panel";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
type ExportFormat = "CSV" | "JSON" | "FHIR";
@@ -504,11 +505,7 @@ function CycleExportCard() {
const prefsQuery = useQuery({
queryKey: queryKeys.cyclePrefs(),
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
- const res = await fetch("/api/auth/me/cycle-prefs", {
- credentials: "include",
- });
- if (!res.ok) throw new Error(`prefs-${res.status}`);
- return (await res.json()).data as { cycleTrackingEnabled: boolean };
+ return apiGet<{ cycleTrackingEnabled: boolean }>("/api/auth/me/cycle-prefs", { credentials: "include" });
},
staleTime: 5 * 60 * 1000,
});
@@ -521,7 +518,7 @@ function CycleExportCard() {
setBusy(true);
setError(null);
try {
- const res = await fetch("/api/export/health-record", {
+ const res = await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx
index 9da103dea..dd1a5a25b 100644
--- a/src/components/settings/health-record-export-panel.tsx
+++ b/src/components/settings/health-record-export-panel.tsx
@@ -30,6 +30,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -146,7 +147,7 @@ export function HealthRecordExportPanel() {
setBusy(true);
setError(null);
try {
- const res = await fetch("/api/export/health-record", {
+ const res = await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index 5310b16cd..3a9fba2e3 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -43,6 +43,7 @@ import { Textarea } from "@/components/ui/textarea";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
// ─────────────────────────── Section wrapper ───────────────────────────
@@ -143,14 +144,7 @@ function AppleHealthImportCard() {
return 2000;
},
queryFn: async (): Promise => {
- const res = await fetch(
- `/api/import/apple-health-export/${jobId}/status`,
- { credentials: "include" },
- );
- if (!res.ok) {
- throw new Error(`status-${res.status}`);
- }
- return (await res.json()).data as JobStatus;
+ return apiGet(`/api/import/apple-health-export/${jobId}/status`, { credentials: "include" });
},
});
@@ -162,7 +156,7 @@ function AppleHealthImportCard() {
try {
const form = new FormData();
form.append("file", file);
- const res = await fetch("/api/import/apple-health-export", {
+ const res = await apiFetchRaw("/api/import/apple-health-export", {
method: "POST",
credentials: "include",
body: form,
@@ -481,7 +475,7 @@ function JsonImportCard() {
const payload = parsed.value;
setBusy(true);
try {
- const res = await fetch("/api/import", {
+ const res = await apiFetchRaw("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx
index 18a8cb809..89549d7d4 100644
--- a/src/components/settings/injection-sites-card.tsx
+++ b/src/components/settings/injection-sites-card.tsx
@@ -12,6 +12,7 @@ import {
describeInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
+import { apiFetchRaw, apiPatch } from "@/lib/api/api-fetch";
/**
* v1.8.5 — Settings → global injection-site exclusion.
@@ -33,7 +34,7 @@ export function InjectionSitesCard({
const { data } = useQuery({
queryKey: queryKeys.injectionSitePrefs(),
queryFn: async () => {
- const res = await fetch("/api/auth/me/injection-site-prefs");
+ const res = await apiFetchRaw("/api/auth/me/injection-site-prefs");
if (!res.ok) return [] as InjectionSiteKey[];
const json = await res.json();
return (json.data?.globalExcludedInjectionSites ??
@@ -46,12 +47,7 @@ export function InjectionSitesCard({
const mutation = useMutation({
mutationFn: async (next: InjectionSiteKey[]) => {
- const res = await fetch("/api/auth/me/injection-site-prefs", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ globalExcludedInjectionSites: next }),
- });
- if (!res.ok) throw new Error("patch_failed");
+ await apiPatch("/api/auth/me/injection-site-prefs", { globalExcludedInjectionSites: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({
diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx
index 14004bf24..c4bf81792 100644
--- a/src/components/settings/insights-pill-order-section.tsx
+++ b/src/components/settings/insights-pill-order-section.tsx
@@ -42,6 +42,7 @@ import {
type ManagerGroup,
} from "@/lib/insights/sub-page-metric";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
+import { apiPut } from "@/lib/api/api-fetch";
/**
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
@@ -123,19 +124,13 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
const saveMutation = useMutation({
mutationFn: async (tiles: InsightsTileConfig[]) => {
- const res = await fetch("/api/insights/layout", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ return apiPut("/api/insights/layout", {
version: 2,
// Preserve the overview section order/visibility verbatim — this
// surface owns the PILL order only.
sections: layout.sections,
tiles,
- }),
- });
- if (!res.ok) throw new Error("save failed");
- return (await res.json()).data as InsightsLayout;
+ });
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index eff568b5c..3a8548709 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -3,175 +3,19 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
-import {
- Activity,
- AlertCircle,
- HeartPulse,
- Link2,
- Loader2,
- RefreshCw,
- Save,
- Unlink,
-} from "lucide-react";
+import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { PasswordInput } from "@/components/ui/password-input";
-import { TestConnectionButton } from "@/components/settings/test-connection-button";
-import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
-import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
+ pickStatus,
+ useIntegrationStatuses,
+} from "@/components/settings/integrations/shared";
+import { WhoopCard } from "@/components/settings/integrations/whoop-card";
+import { WithingsCard } from "@/components/settings/integrations/withings-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
-import {
- invalidateKeys,
- measurementDependentKeys,
- queryKeys,
-} from "@/lib/query-keys";
-
-// v1.4.15 Phase B2: shared status payload for both integration cards.
-// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
-// IntegrationStatusPill now owns state + last-sync presentation, and
-// the actionable error message is shown inline above the action row.
-type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
-type IntegrationState =
- | "connected"
- | "error_transient"
- | "error_reauth"
- | "disconnected"
- | "parked";
-
-interface IntegrationStatusViewModel {
- integration: IntegrationKey;
- state: IntegrationState;
- lastSuccessAt: string | null;
- lastAttemptAt: string | null;
- lastError: string | null;
- consecutiveFailuresByKind?: {
- transient: number;
- reauth_required: number;
- persistent: number;
- } | null;
- configured?: boolean;
- connected?: boolean;
- connectedAt?: string | null;
- legacyLastSyncedAt?: string | null;
- tokenExpiresAt?: string | null;
- tokenExpired?: boolean | null;
- enabled?: boolean;
- // Withings activity-scope reconnect banner.
- scope?: string | null;
- hasActivityScope?: boolean;
- // WHOOP / Fitbit backfill-in-progress note.
- backfillCompleted?: boolean | null;
- // moodLog webhook secret + entry count.
- webhookSecret?: string | null;
- entryCount?: number;
-}
-
-interface IntegrationStatusEnvelope {
- threshold: number;
- integrations: IntegrationStatusViewModel[];
-}
-
-/**
- * Shared status fetch for the Settings → Integrations card. Returns
- * the per-integration view-model AND the global threshold so the
- * "{n}/{threshold} consecutive failures" string in the UI is single-
- * sourced from the server.
- */
-function useIntegrationStatuses(enabled: boolean) {
- return useQuery({
- queryKey: queryKeys.integrationsStatus(),
- queryFn: async () => {
- const res = await fetch("/api/integrations/status");
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as IntegrationStatusEnvelope;
- },
- enabled,
- refetchOnWindowFocus: true,
- });
-}
-
-function pickStatus(
- envelope: IntegrationStatusEnvelope | undefined,
- integration: IntegrationKey,
-): IntegrationStatusViewModel | undefined {
- return envelope?.integrations.find((i) => i.integration === integration);
-}
-
-/**
- * Collapse the API's five-state machine into the four states the
- * pill UI cares about. `error_transient` and `error_reauth` both
- * surface as the same "Error — reconnect" pill, the actionable
- * difference (whether the user must reconnect vs wait for the next
- * retry) is conveyed via the inline error text underneath. `parked`
- * (v1.4.43 W14) is its own pill state — the integration has been
- * disabled after 24h of persistent failures and needs an explicit
- * "Wieder verbinden" click to resume.
- */
-function pillStateFor(
- status: IntegrationStatusViewModel | undefined,
-): IntegrationPillState {
- if (!status) return "disconnected";
- switch (status.state) {
- case "connected":
- return "connected";
- case "error_transient":
- // v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings
- // rate-limit 601 / contract-mismatch 293/294) maps to the same
- // `error_transient` DB state as a normal retryable failure but
- // tells the user a different story: the access token still
- // works, the upstream is responding with a non-recoverable
- // status. Surfacing it as a "warning" pill (orange) instead of
- // the red "Fehler — neu verbinden" stops the user from clicking
- // reconnect ten times when reconnect can't fix it.
- if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) {
- return "warning";
- }
- return "error";
- case "error_reauth":
- return "error";
- case "parked":
- return "parked";
- case "disconnected":
- return "disconnected";
- }
-}
-
-/**
- * Inline actionable error message that surfaces under the pill when a
- * sync attempt failed. The pill conveys "something is wrong"; this
- * line tells the user *what* is wrong so they can act on it. Keeping
- * it deliberately small (one icon + one line) so it doesn't recreate
- * the v1.4.18 redundant banner the maintainer removed.
- */
-function IntegrationErrorMessage({ message }: { message: string }) {
- return (
-
-
- {message}
-
- );
-}
+import { queryKeys } from "@/lib/query-keys";
/**
* The Withings OAuth callback (`/api/withings/callback`) redirects back
@@ -286,1340 +130,3 @@ export function IntegrationsSection() {
);
}
-
-function WithingsCard({
- viewModel,
-}: {
- viewModel: IntegrationStatusViewModel | undefined;
-}) {
- const { t } = useTranslations();
- const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
- const [clientId, setClientId] = useState("");
- const [clientSecret, setClientSecret] = useState("");
- const [credsSaving, setCredsSaving] = useState(false);
- const [credsMsg, setCredsMsg] = useState(null);
- const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
- null,
- );
- const queryClient = useQueryClient();
-
- // v1.12.1 — the Withings status is read off the consolidated
- // /api/integrations/status envelope (the `viewModel` prop). The
- // per-card /api/withings/status round-trip is gone; the envelope
- // carries every field this card renders (connected / configured /
- // last-sync / activity scope).
- const status = viewModel;
-
- const disconnect = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/withings/disconnect", { method: "POST" });
- if (!res.ok) throw new Error("Failed");
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- },
- });
-
- // v1.4.43 W14 — clear a parked integration via the resume endpoint.
- // The CTA is rendered inside the parked banner below; success
- // invalidates both the per-card status (so the pill flips back to
- // connected immediately) and the cross-integration envelope (so any
- // other view picks up the change on its next focus).
- const resume = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/integrations/withings/resume", {
- method: "POST",
- });
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as {
- resumed: boolean;
- wasParked: boolean;
- };
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- },
- });
-
- async function handleSync(fullSync = false) {
- setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
- try {
- const res = await fetch("/api/withings/sync", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ fullSync }),
- });
- const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- fullSync
- ? t("settings.withingsFullSyncResult", {
- count: json.data.imported,
- })
- : t("settings.withingsSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
- void invalidateKeys(queryClient, measurementDependentKeys);
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- } else {
- setSyncMsg(json.error || t("settings.withingsSyncFailed"));
- setSyncMsgType("error");
- }
- } catch {
- setSyncMsg(t("settings.withingsSyncFailed"));
- setSyncMsgType("error");
- } finally {
- setSyncing(false);
- }
- }
-
- async function handleSaveCredentials(e: React.FormEvent) {
- e.preventDefault();
- setCredsSaving(true);
- setCredsMsg(null);
- setCredsMsgType(null);
-
- try {
- const res = await fetch("/api/withings/credentials", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- clientId: clientId.trim(),
- clientSecret: clientSecret.trim(),
- }),
- });
-
- if (res.ok) {
- setCredsMsg(t("settings.withingsCredentialsSaved"));
- setCredsMsgType("success");
- setClientId("");
- setClientSecret("");
- queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
- } else {
- try {
- const json = await res.json();
- setCredsMsg(json.error || t("settings.savingError"));
- } catch {
- setCredsMsg(t("settings.savingError"));
- }
- setCredsMsgType("error");
- }
- } catch {
- setCredsMsg(t("common.networkError"));
- setCredsMsgType("error");
- }
- setCredsSaving(false);
- }
-
- // The pill state derives from the cross-integration status
- // envelope. `legacyLastSyncedAt` is the connection row's own
- // last-sync timestamp (carried in the same envelope) so the pill
- // still paints a fresh "X min ago" right after a manual sync once
- // the envelope refetches. When the envelope hasn't answered yet we
- // fall back to "disconnected" so the card never renders status-less.
- const pillState: IntegrationPillState = status?.connected
- ? pillStateFor(viewModel)
- : "disconnected";
- const pillLastSyncAt =
- status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
- // v1.4.43 W14 — `parked` and `error` both want the underlying error
- // message surfaced under the pill (the pill says "what" — the
- // message says "why"). Other states leave the inline line off.
- const errorMessage =
- (pillState === "error" || pillState === "parked") && viewModel?.lastError
- ? viewModel.lastError
- : null;
-
- return (
-
-
- }
- />
-
-
-
-
- {errorMessage &&
}
- {/* v1.4.25 W5d — Withings activity-scope reconnect banner.
- Only paints when (a) the user has an active Withings
- connection (so the credentials are already saved + a token
- exists) AND (b) the persisted scope is missing
- `user.activity`. Reconnecting takes the user through the
- standard /api/withings/connect → Withings → callback flow;
- the callback persists the upgraded scope and the banner
- vanishes on the next status poll. */}
- {status?.connected && status?.hasActivityScope === false && (
-
-
- {t("settings.integrations.withings.reconnect.banner.title")}
-
-
- {t("settings.integrations.withings.reconnect.banner.body")}
-
-
- {t("settings.integrations.withings.reconnect.banner.action")}
- {" →"}
-
-
- )}
- {/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
- when the row state is `parked` (>24h of persistent
- failures). The button POSTs to /api/integrations/withings/
- resume which calls `resumeIntegrationFromPark`; on success
- the per-card status invalidates and the pill flips back to
- connected without a page refresh. The button is the
- primary action the user can take from this card — wider
- tap target than the inline action row so it's reachable
- on a Pixel 5 viewport. */}
- {pillState === "parked" && (
-
-
- {t("settings.integrationPill.parkedReconnect")}
-
- resume.mutate()}
- disabled={resume.isPending}
- data-testid="withings-resume-button"
- className="min-h-11"
- >
- {resume.isPending ? (
-
- ) : (
-
- )}
- {t("settings.integrationPill.resumeCta")}
-
-
- )}
- {resume.isError && (
-
- {t("settings.integrationPill.resumeError")}
-
- )}
- {resume.isSuccess && resume.data?.wasParked && (
-
- {t("settings.integrationPill.resumeSuccess")}
-
- )}
-
-
- {t("settings.withingsCredentials")}
-
-
-
-
- {status?.connected ? (
- <>
- {/* v1.4.27 MB7 / CF-57 — the action row already wraps via
- `flex-wrap`, but on Pixel 5 the four AlertDialog +
- test-connection triggers each took a fractional slot
- that read jagged. Force each button to a `min-w-[10rem]`
- on `
- handleSync(false)}
- disabled={syncing}
- >
- {syncing ? (
-
- ) : (
-
- )}
- {t("settings.withingsSync")}
-
-
-
-
- {syncing ? (
-
- ) : (
-
- )}
- {t("settings.withingsFullSync")}
-
-
-
-
-
- {t("settings.withingsFullSyncTitle")}
-
-
- {t("settings.withingsFullSyncDescription")}
-
-
-
- {t("common.cancel")}
- handleSync(true)}>
- {t("settings.withingsSynchronize")}
-
-
-
-
-
-
-
-
-
- {t("settings.withingsDisconnect")}
-
-
-
-
-
- {t("settings.withingsDisconnectTitle")}
-
-
- {t("settings.withingsDisconnectDescription")}
-
-
-
- {t("common.cancel")}
- disconnect.mutate()}
- >
- {t("settings.withingsDisconnect")}
-
-
-
-
-
- {syncMsg && (
-
- {syncMsg}
-
- )}
- >
- ) : status?.configured ? (
-
{
- window.location.href = "/api/withings/connect";
- }}
- >
-
- {t("settings.withingsConnect")}
-
- ) : (
-
- {t("settings.withingsNoCredentials")}
-
- )}
-
-
- );
-}
-
-function WhoopCard({
- viewModel,
-}: {
- viewModel: IntegrationStatusViewModel | undefined;
-}) {
- const { t } = useTranslations();
- const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
- const [clientId, setClientId] = useState("");
- const [clientSecret, setClientSecret] = useState("");
- const [credsSaving, setCredsSaving] = useState(false);
- const [credsMsg, setCredsMsg] = useState(null);
- const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
- null,
- );
- const queryClient = useQueryClient();
-
- // v1.12.1 — read off the consolidated /api/integrations/status
- // envelope; the per-card /api/whoop/status round-trip is gone.
- const status = viewModel;
-
- const disconnect = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/whoop/disconnect", { method: "POST" });
- if (!res.ok) throw new Error("Failed");
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- },
- });
-
- // Clear a parked integration via the resume endpoint. The CTA is
- // rendered inside the parked banner below; success invalidates both
- // the per-card status (so the pill flips back to connected
- // immediately) and the cross-integration envelope (so any other view
- // picks up the change on its next focus).
- const resume = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/integrations/whoop/resume", {
- method: "POST",
- });
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as {
- resumed: boolean;
- wasParked: boolean;
- };
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- },
- });
-
- async function handleSync(fullSync = false) {
- setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
- try {
- const res = await fetch("/api/whoop/sync", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ fullSync }),
- });
- const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- t("settings.whoopSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
- void invalidateKeys(queryClient, measurementDependentKeys);
- queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- } else {
- setSyncMsg(json.error || t("settings.whoopSyncFailed"));
- setSyncMsgType("error");
- }
- } catch {
- setSyncMsg(t("settings.whoopSyncFailed"));
- setSyncMsgType("error");
- } finally {
- setSyncing(false);
- }
- }
-
- async function handleSaveCredentials(e: React.FormEvent) {
- e.preventDefault();
- setCredsSaving(true);
- setCredsMsg(null);
- setCredsMsgType(null);
-
- try {
- const res = await fetch("/api/whoop/credentials", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- clientId: clientId.trim(),
- clientSecret: clientSecret.trim(),
- }),
- });
-
- if (res.ok) {
- setCredsMsg(t("settings.whoopCredentialsSaved"));
- setCredsMsgType("success");
- setClientId("");
- setClientSecret("");
- queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
- } else {
- try {
- const json = await res.json();
- setCredsMsg(json.error || t("settings.savingError"));
- } catch {
- setCredsMsg(t("settings.savingError"));
- }
- setCredsMsgType("error");
- }
- } catch {
- setCredsMsg(t("common.networkError"));
- setCredsMsgType("error");
- }
- setCredsSaving(false);
- }
-
- const pillState: IntegrationPillState = status?.connected
- ? pillStateFor(viewModel)
- : "disconnected";
- const pillLastSyncAt =
- status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
- const errorMessage =
- (pillState === "error" || pillState === "parked") && viewModel?.lastError
- ? viewModel.lastError
- : null;
-
- return (
-
-
- {t("settings.whoopDescription")}
-
- {t("settings.whoopOverlapNote")}
-
- >
- }
- status={
-
- }
- />
-
-
-
-
- {errorMessage &&
}
- {/* Parked-integration resume CTA. Surfaces only when the row
- state is `parked` (>24h of persistent failures). The button
- POSTs to /api/integrations/whoop/resume which calls
- `resumeIntegrationFromPark`; on success the per-card status
- invalidates and the pill flips back to connected without a
- page refresh. Wider tap target than the inline action row so
- it stays reachable on a Pixel 5 viewport. */}
- {pillState === "parked" && (
-
-
- {t("settings.integrationPill.parkedReconnect")}
-
- resume.mutate()}
- disabled={resume.isPending}
- data-testid="whoop-resume-button"
- className="min-h-11"
- >
- {resume.isPending ? (
-
- ) : (
-
- )}
- {t("settings.integrationPill.resumeCta")}
-
-
- )}
- {resume.isError && (
-
- {t("settings.integrationPill.resumeError")}
-
- )}
- {resume.isSuccess && resume.data?.wasParked && (
-
- {t("settings.integrationPill.resumeSuccess")}
-
- )}
-
-
-
- {t("settings.whoopCredentials")}
-
-
- {t("settings.whoopCredentialsHelp")}
-
-
-
-
- {status?.connected ? (
- <>
-
-
handleSync(false)}
- disabled={syncing}
- >
- {syncing ? (
-
- ) : (
-
- )}
- {t("settings.whoopSync")}
-
-
-
-
- {syncing ? (
-
- ) : (
-
- )}
- {t("settings.whoopFullSync")}
-
-
-
-
-
- {t("settings.whoopFullSyncTitle")}
-
-
- {t("settings.whoopFullSyncDescription")}
-
-
-
- {t("common.cancel")}
- handleSync(true)}>
- {t("settings.whoopSynchronize")}
-
-
-
-
-
-
-
-
-
- {t("settings.whoopDisconnect")}
-
-
-
-
-
- {t("settings.whoopDisconnectTitle")}
-
-
- {t("settings.whoopDisconnectDescription")}
-
-
-
- {t("common.cancel")}
- disconnect.mutate()}
- >
- {t("settings.whoopDisconnect")}
-
-
-
-
-
- {status?.backfillCompleted === false && (
-
- {t("settings.whoopBackfillInProgress")}
-
- )}
- {syncMsg && (
-
- {syncMsg}
-
- )}
- >
- ) : status?.configured ? (
-
{
- window.location.href = "/api/whoop/connect";
- }}
- >
-
- {t("settings.whoopConnect")}
-
- ) : (
-
- {t("settings.whoopNoCredentials")}
-
- )}
-
-
- );
-}
-
-// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
-// BYO-key credentials form first, then an OAuth connect, then the
-// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
-// reads off the consolidated /api/integrations/status envelope (the per-card
-// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off
-// the same view-model like WHOOP.
-function FitbitCard({
- viewModel,
-}: {
- viewModel: IntegrationStatusViewModel | undefined;
-}) {
- const { t } = useTranslations();
- const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
- const [clientId, setClientId] = useState("");
- const [clientSecret, setClientSecret] = useState("");
- const [credsSaving, setCredsSaving] = useState(false);
- const [credsMsg, setCredsMsg] = useState(null);
- const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
- null,
- );
- const queryClient = useQueryClient();
-
- const status = viewModel;
-
- const disconnect = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/fitbit/disconnect", { method: "POST" });
- if (!res.ok) throw new Error("Failed");
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- },
- });
-
- // Clear a parked integration via the resume endpoint. The CTA is
- // rendered inside the parked banner below; success invalidates both
- // the per-card status (so the pill flips back to connected
- // immediately) and the cross-integration envelope (so any other view
- // picks up the change on its next focus).
- const resume = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/integrations/fitbit/resume", {
- method: "POST",
- });
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as {
- resumed: boolean;
- wasParked: boolean;
- };
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- },
- });
-
- async function handleSync(fullSync = false) {
- setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
- try {
- const res = await fetch("/api/fitbit/sync", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ fullSync }),
- });
- const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- t("settings.fitbitSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
- void invalidateKeys(queryClient, measurementDependentKeys);
- queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.integrationsStatus(),
- });
- } else {
- setSyncMsg(json.error || t("settings.fitbitSyncFailed"));
- setSyncMsgType("error");
- }
- } catch {
- setSyncMsg(t("settings.fitbitSyncFailed"));
- setSyncMsgType("error");
- } finally {
- setSyncing(false);
- }
- }
-
- async function handleSaveCredentials(e: React.FormEvent) {
- e.preventDefault();
- setCredsSaving(true);
- setCredsMsg(null);
- setCredsMsgType(null);
-
- try {
- const res = await fetch("/api/fitbit/credentials", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- clientId: clientId.trim(),
- clientSecret: clientSecret.trim(),
- }),
- });
-
- if (res.ok) {
- setCredsMsg(t("settings.fitbitCredentialsSaved"));
- setCredsMsgType("success");
- setClientId("");
- setClientSecret("");
- queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
- } else {
- try {
- const json = await res.json();
- setCredsMsg(json.error || t("settings.savingError"));
- } catch {
- setCredsMsg(t("settings.savingError"));
- }
- setCredsMsgType("error");
- }
- } catch {
- setCredsMsg(t("common.networkError"));
- setCredsMsgType("error");
- }
- setCredsSaving(false);
- }
-
- const pillState: IntegrationPillState = status?.connected
- ? pillStateFor(viewModel)
- : "disconnected";
- const pillLastSyncAt =
- status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
- const errorMessage =
- (pillState === "error" || pillState === "parked") && viewModel?.lastError
- ? viewModel.lastError
- : null;
-
- return (
-
-
-
- {t("settings.fitbitTag")}
-
-
- {t("settings.fitbitExperimentalBadge")}
-
- >
- }
- description={
- <>
- {t("settings.fitbitDescription")}
-
- {t("settings.fitbitExperimentalNote")}
-
-
- {t("settings.fitbitOverlapNote")}
-
- >
- }
- status={
-
- }
- />
-
-
-
-
- {errorMessage &&
}
- {/* Parked-integration resume CTA. Surfaces only when the row
- state is `parked` (>24h of persistent failures). The button
- POSTs to /api/integrations/fitbit/resume which calls
- `resumeIntegrationFromPark`; on success the per-card status
- invalidates and the pill flips back to connected without a
- page refresh. */}
- {pillState === "parked" && (
-
-
- {t("settings.integrationPill.parkedReconnect")}
-
- resume.mutate()}
- disabled={resume.isPending}
- data-testid="fitbit-resume-button"
- className="min-h-11"
- >
- {resume.isPending ? (
-
- ) : (
-
- )}
- {t("settings.integrationPill.resumeCta")}
-
-
- )}
- {resume.isError && (
-
- {t("settings.integrationPill.resumeError")}
-
- )}
- {resume.isSuccess && resume.data?.wasParked && (
-
- {t("settings.integrationPill.resumeSuccess")}
-
- )}
-
-
-
- {t("settings.fitbitCredentials")}
-
-
- {t("settings.fitbitCredentialsHelp")}
-
-
-
-
- {status?.connected ? (
- <>
-
-
handleSync(false)}
- disabled={syncing}
- >
- {syncing ? (
-
- ) : (
-
- )}
- {t("settings.fitbitSync")}
-
-
-
-
- {syncing ? (
-
- ) : (
-
- )}
- {t("settings.fitbitFullSync")}
-
-
-
-
-
- {t("settings.fitbitFullSyncTitle")}
-
-
- {t("settings.fitbitFullSyncDescription")}
-
-
-
- {t("common.cancel")}
- handleSync(true)}>
- {t("settings.fitbitSynchronize")}
-
-
-
-
-
-
-
-
-
- {t("settings.fitbitDisconnect")}
-
-
-
-
-
- {t("settings.fitbitDisconnectTitle")}
-
-
- {t("settings.fitbitDisconnectDescription")}
-
-
-
- {t("common.cancel")}
- disconnect.mutate()}
- >
- {t("settings.fitbitDisconnect")}
-
-
-
-
-
- {status?.backfillCompleted === false && (
-
- {t("settings.fitbitBackfillInProgress")}
-
- )}
- {syncMsg && (
-
- {syncMsg}
-
- )}
- >
- ) : status?.configured ? (
-
{
- window.location.href = "/api/fitbit/connect";
- }}
- >
-
- {t("settings.fitbitConnect")}
-
- ) : (
-
- {t("settings.fitbitNoCredentials")}
-
- )}
-
-
- );
-}
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx
new file mode 100644
index 000000000..22f1c95ca
--- /dev/null
+++ b/src/components/settings/integrations/fitbit-card.tsx
@@ -0,0 +1,483 @@
+"use client";
+
+// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
+// BYO-key credentials form first, then an OAuth connect, then the
+// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
+// reads off the consolidated /api/integrations/status envelope (the per-card
+// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off
+// the same view-model like WHOOP.
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { HeartPulse, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
+import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
+import { TestConnectionButton } from "@/components/settings/test-connection-button";
+import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ invalidateKeys,
+ measurementDependentKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+
+import {
+ IntegrationErrorMessage,
+ pillStateFor,
+ type IntegrationStatusViewModel,
+} from "./shared";
+
+export function FitbitCard({
+ viewModel,
+}: {
+ viewModel: IntegrationStatusViewModel | undefined;
+}) {
+ const { t } = useTranslations();
+ const [syncing, setSyncing] = useState(false);
+ const [syncMsg, setSyncMsg] = useState(null);
+ const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
+ const [clientId, setClientId] = useState("");
+ const [clientSecret, setClientSecret] = useState("");
+ const [credsSaving, setCredsSaving] = useState(false);
+ const [credsMsg, setCredsMsg] = useState(null);
+ const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
+ const queryClient = useQueryClient();
+
+ const status = viewModel;
+
+ const disconnect = useMutation({
+ mutationFn: async () => {
+ await apiPost("/api/fitbit/disconnect");
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ },
+ });
+
+ // Clear a parked integration via the resume endpoint. The CTA is
+ // rendered inside the parked banner below; success invalidates both
+ // the per-card status (so the pill flips back to connected
+ // immediately) and the cross-integration envelope (so any other view
+ // picks up the change on its next focus).
+ const resume = useMutation({
+ mutationFn: async () => {
+ return apiPost<{ resumed: boolean; wasParked: boolean }>(
+ "/api/integrations/fitbit/resume",
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ },
+ });
+
+ async function handleSync(fullSync = false) {
+ setSyncing(true);
+ setSyncMsg(null);
+ setSyncMsgType(null);
+ try {
+ const res = await apiFetchRaw("/api/fitbit/sync", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fullSync }),
+ });
+ const json = await res.json();
+ if (res.ok) {
+ setSyncMsg(
+ t("settings.fitbitSyncResult", { count: json.data.imported }),
+ );
+ setSyncMsgType("success");
+ void invalidateKeys(queryClient, measurementDependentKeys);
+ queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ } else {
+ setSyncMsg(json.error || t("settings.fitbitSyncFailed"));
+ setSyncMsgType("error");
+ }
+ } catch {
+ setSyncMsg(t("settings.fitbitSyncFailed"));
+ setSyncMsgType("error");
+ } finally {
+ setSyncing(false);
+ }
+ }
+
+ async function handleSaveCredentials(e: React.FormEvent) {
+ e.preventDefault();
+ setCredsSaving(true);
+ setCredsMsg(null);
+ setCredsMsgType(null);
+
+ try {
+ const res = await apiFetchRaw("/api/fitbit/credentials", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ clientId: clientId.trim(),
+ clientSecret: clientSecret.trim(),
+ }),
+ });
+
+ if (res.ok) {
+ setCredsMsg(t("settings.fitbitCredentialsSaved"));
+ setCredsMsgType("success");
+ setClientId("");
+ setClientSecret("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
+ } else {
+ try {
+ const json = await res.json();
+ setCredsMsg(json.error || t("settings.savingError"));
+ } catch {
+ setCredsMsg(t("settings.savingError"));
+ }
+ setCredsMsgType("error");
+ }
+ } catch {
+ setCredsMsg(t("common.networkError"));
+ setCredsMsgType("error");
+ }
+ setCredsSaving(false);
+ }
+
+ const pillState: IntegrationPillState = status?.connected
+ ? pillStateFor(viewModel)
+ : "disconnected";
+ const pillLastSyncAt =
+ status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
+ const errorMessage =
+ (pillState === "error" || pillState === "parked") && viewModel?.lastError
+ ? viewModel.lastError
+ : null;
+
+ return (
+
+
+
+ {t("settings.fitbitTag")}
+
+
+ {t("settings.fitbitExperimentalBadge")}
+
+ >
+ }
+ description={
+ <>
+ {t("settings.fitbitDescription")}
+
+ {t("settings.fitbitExperimentalNote")}
+
+
+ {t("settings.fitbitOverlapNote")}
+
+ >
+ }
+ status={
+
+ }
+ />
+
+
+
+
+ {errorMessage &&
}
+ {/* Parked-integration resume CTA. Surfaces only when the row
+ state is `parked` (>24h of persistent failures). The button
+ POSTs to /api/integrations/fitbit/resume which calls
+ `resumeIntegrationFromPark`; on success the per-card status
+ invalidates and the pill flips back to connected without a
+ page refresh. */}
+ {pillState === "parked" && (
+
+
+ {t("settings.integrationPill.parkedReconnect")}
+
+ resume.mutate()}
+ disabled={resume.isPending}
+ data-testid="fitbit-resume-button"
+ className="min-h-11"
+ >
+ {resume.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.integrationPill.resumeCta")}
+
+
+ )}
+ {resume.isError && (
+
+ {t("settings.integrationPill.resumeError")}
+
+ )}
+ {resume.isSuccess && resume.data?.wasParked && (
+
+ {t("settings.integrationPill.resumeSuccess")}
+
+ )}
+
+
+
+ {t("settings.fitbitCredentials")}
+
+
+ {t("settings.fitbitCredentialsHelp")}
+
+
+
+
+ {status?.connected ? (
+ <>
+
+
handleSync(false)}
+ disabled={syncing}
+ >
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {t("settings.fitbitSync")}
+
+
+
+
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {t("settings.fitbitFullSync")}
+
+
+
+
+
+ {t("settings.fitbitFullSyncTitle")}
+
+
+ {t("settings.fitbitFullSyncDescription")}
+
+
+
+ {t("common.cancel")}
+ handleSync(true)}>
+ {t("settings.fitbitSynchronize")}
+
+
+
+
+
+
+
+
+
+ {t("settings.fitbitDisconnect")}
+
+
+
+
+
+ {t("settings.fitbitDisconnectTitle")}
+
+
+ {t("settings.fitbitDisconnectDescription")}
+
+
+
+ {t("common.cancel")}
+ disconnect.mutate()}
+ >
+ {t("settings.fitbitDisconnect")}
+
+
+
+
+
+ {status?.backfillCompleted === false && (
+
+ {t("settings.fitbitBackfillInProgress")}
+
+ )}
+ {syncMsg && (
+
+ {syncMsg}
+
+ )}
+ >
+ ) : status?.configured ? (
+
{
+ window.location.href = "/api/fitbit/connect";
+ }}
+ >
+
+ {t("settings.fitbitConnect")}
+
+ ) : (
+
+ {t("settings.fitbitNoCredentials")}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx
new file mode 100644
index 000000000..8990d6652
--- /dev/null
+++ b/src/components/settings/integrations/shared.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+/**
+ * Shared types + status plumbing for the Settings → Integrations cards.
+ * Extracted from the former 1.6k-LOC `integrations-section.tsx`
+ * monolith; one card per integration lives next to this module under
+ * `src/components/settings/integrations/`.
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { AlertCircle } from "lucide-react";
+
+import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+
+// v1.4.15 Phase B2: shared status payload for both integration cards.
+// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
+// IntegrationStatusPill now owns state + last-sync presentation, and
+// the actionable error message is shown inline above the action row.
+export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
+export type IntegrationState =
+ | "connected"
+ | "error_transient"
+ | "error_reauth"
+ | "disconnected"
+ | "parked";
+
+export interface IntegrationStatusViewModel {
+ integration: IntegrationKey;
+ state: IntegrationState;
+ lastSuccessAt: string | null;
+ lastAttemptAt: string | null;
+ lastError: string | null;
+ consecutiveFailuresByKind?: {
+ transient: number;
+ reauth_required: number;
+ persistent: number;
+ } | null;
+ configured?: boolean;
+ connected?: boolean;
+ connectedAt?: string | null;
+ legacyLastSyncedAt?: string | null;
+ tokenExpiresAt?: string | null;
+ tokenExpired?: boolean | null;
+ enabled?: boolean;
+ // Withings activity-scope reconnect banner.
+ scope?: string | null;
+ hasActivityScope?: boolean;
+ // WHOOP / Fitbit backfill-in-progress note.
+ backfillCompleted?: boolean | null;
+ // moodLog webhook secret + entry count.
+ webhookSecret?: string | null;
+ entryCount?: number;
+}
+
+export interface IntegrationStatusEnvelope {
+ threshold: number;
+ integrations: IntegrationStatusViewModel[];
+}
+
+/**
+ * Shared status fetch for the Settings → Integrations card. Returns
+ * the per-integration view-model AND the global threshold so the
+ * "{n}/{threshold} consecutive failures" string in the UI is single-
+ * sourced from the server.
+ */
+export function useIntegrationStatuses(enabled: boolean) {
+ return useQuery({
+ queryKey: queryKeys.integrationsStatus(),
+ queryFn: async () => {
+ return apiGet("/api/integrations/status");
+ },
+ enabled,
+ refetchOnWindowFocus: true,
+ });
+}
+
+export function pickStatus(
+ envelope: IntegrationStatusEnvelope | undefined,
+ integration: IntegrationKey,
+): IntegrationStatusViewModel | undefined {
+ return envelope?.integrations.find((i) => i.integration === integration);
+}
+
+/**
+ * Collapse the API's five-state machine into the four states the
+ * pill UI cares about. `error_transient` and `error_reauth` both
+ * surface as the same "Error — reconnect" pill, the actionable
+ * difference (whether the user must reconnect vs wait for the next
+ * retry) is conveyed via the inline error text underneath. `parked`
+ * (v1.4.43 W14) is its own pill state — the integration has been
+ * disabled after 24h of persistent failures and needs an explicit
+ * "Wieder verbinden" click to resume.
+ */
+export function pillStateFor(
+ status: IntegrationStatusViewModel | undefined,
+): IntegrationPillState {
+ if (!status) return "disconnected";
+ switch (status.state) {
+ case "connected":
+ return "connected";
+ case "error_transient":
+ // v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings
+ // rate-limit 601 / contract-mismatch 293/294) maps to the same
+ // `error_transient` DB state as a normal retryable failure but
+ // tells the user a different story: the access token still
+ // works, the upstream is responding with a non-recoverable
+ // status. Surfacing it as a "warning" pill (orange) instead of
+ // the red "Fehler — neu verbinden" stops the user from clicking
+ // reconnect ten times when reconnect can't fix it.
+ if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) {
+ return "warning";
+ }
+ return "error";
+ case "error_reauth":
+ return "error";
+ case "parked":
+ return "parked";
+ case "disconnected":
+ return "disconnected";
+ }
+}
+
+/**
+ * Inline actionable error message that surfaces under the pill when a
+ * sync attempt failed. The pill conveys "something is wrong"; this
+ * line tells the user *what* is wrong so they can act on it. Keeping
+ * it deliberately small (one icon + one line) so it doesn't recreate
+ * the v1.4.18 redundant banner the maintainer removed.
+ */
+export function IntegrationErrorMessage({ message }: { message: string }) {
+ return (
+
+
+ {message}
+
+ );
+}
diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx
new file mode 100644
index 000000000..054461479
--- /dev/null
+++ b/src/components/settings/integrations/whoop-card.tsx
@@ -0,0 +1,455 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Activity, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
+import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
+import { TestConnectionButton } from "@/components/settings/test-connection-button";
+import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ invalidateKeys,
+ measurementDependentKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+
+import {
+ IntegrationErrorMessage,
+ pillStateFor,
+ type IntegrationStatusViewModel,
+} from "./shared";
+
+export function WhoopCard({
+ viewModel,
+}: {
+ viewModel: IntegrationStatusViewModel | undefined;
+}) {
+ const { t } = useTranslations();
+ const [syncing, setSyncing] = useState(false);
+ const [syncMsg, setSyncMsg] = useState(null);
+ const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
+ const [clientId, setClientId] = useState("");
+ const [clientSecret, setClientSecret] = useState("");
+ const [credsSaving, setCredsSaving] = useState(false);
+ const [credsMsg, setCredsMsg] = useState(null);
+ const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
+ const queryClient = useQueryClient();
+
+ // v1.12.1 — read off the consolidated /api/integrations/status
+ // envelope; the per-card /api/whoop/status round-trip is gone.
+ const status = viewModel;
+
+ const disconnect = useMutation({
+ mutationFn: async () => {
+ await apiPost("/api/whoop/disconnect");
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ },
+ });
+
+ // Clear a parked integration via the resume endpoint. The CTA is
+ // rendered inside the parked banner below; success invalidates both
+ // the per-card status (so the pill flips back to connected
+ // immediately) and the cross-integration envelope (so any other view
+ // picks up the change on its next focus).
+ const resume = useMutation({
+ mutationFn: async () => {
+ return apiPost<{ resumed: boolean; wasParked: boolean }>(
+ "/api/integrations/whoop/resume",
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ },
+ });
+
+ async function handleSync(fullSync = false) {
+ setSyncing(true);
+ setSyncMsg(null);
+ setSyncMsgType(null);
+ try {
+ const res = await apiFetchRaw("/api/whoop/sync", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fullSync }),
+ });
+ const json = await res.json();
+ if (res.ok) {
+ setSyncMsg(
+ t("settings.whoopSyncResult", { count: json.data.imported }),
+ );
+ setSyncMsgType("success");
+ void invalidateKeys(queryClient, measurementDependentKeys);
+ queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ } else {
+ setSyncMsg(json.error || t("settings.whoopSyncFailed"));
+ setSyncMsgType("error");
+ }
+ } catch {
+ setSyncMsg(t("settings.whoopSyncFailed"));
+ setSyncMsgType("error");
+ } finally {
+ setSyncing(false);
+ }
+ }
+
+ async function handleSaveCredentials(e: React.FormEvent) {
+ e.preventDefault();
+ setCredsSaving(true);
+ setCredsMsg(null);
+ setCredsMsgType(null);
+
+ try {
+ const res = await apiFetchRaw("/api/whoop/credentials", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ clientId: clientId.trim(),
+ clientSecret: clientSecret.trim(),
+ }),
+ });
+
+ if (res.ok) {
+ setCredsMsg(t("settings.whoopCredentialsSaved"));
+ setCredsMsgType("success");
+ setClientId("");
+ setClientSecret("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
+ } else {
+ try {
+ const json = await res.json();
+ setCredsMsg(json.error || t("settings.savingError"));
+ } catch {
+ setCredsMsg(t("settings.savingError"));
+ }
+ setCredsMsgType("error");
+ }
+ } catch {
+ setCredsMsg(t("common.networkError"));
+ setCredsMsgType("error");
+ }
+ setCredsSaving(false);
+ }
+
+ const pillState: IntegrationPillState = status?.connected
+ ? pillStateFor(viewModel)
+ : "disconnected";
+ const pillLastSyncAt =
+ status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
+ const errorMessage =
+ (pillState === "error" || pillState === "parked") && viewModel?.lastError
+ ? viewModel.lastError
+ : null;
+
+ return (
+
+
+ {t("settings.whoopDescription")}
+
+ {t("settings.whoopOverlapNote")}
+
+ >
+ }
+ status={
+
+ }
+ />
+
+
+
+
+ {errorMessage &&
}
+ {/* Parked-integration resume CTA. Surfaces only when the row
+ state is `parked` (>24h of persistent failures). The button
+ POSTs to /api/integrations/whoop/resume which calls
+ `resumeIntegrationFromPark`; on success the per-card status
+ invalidates and the pill flips back to connected without a
+ page refresh. Wider tap target than the inline action row so
+ it stays reachable on a Pixel 5 viewport. */}
+ {pillState === "parked" && (
+
+
+ {t("settings.integrationPill.parkedReconnect")}
+
+ resume.mutate()}
+ disabled={resume.isPending}
+ data-testid="whoop-resume-button"
+ className="min-h-11"
+ >
+ {resume.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.integrationPill.resumeCta")}
+
+
+ )}
+ {resume.isError && (
+
+ {t("settings.integrationPill.resumeError")}
+
+ )}
+ {resume.isSuccess && resume.data?.wasParked && (
+
+ {t("settings.integrationPill.resumeSuccess")}
+
+ )}
+
+
+
+ {t("settings.whoopCredentials")}
+
+
+ {t("settings.whoopCredentialsHelp")}
+
+
+
+
+ {status?.connected ? (
+ <>
+
+
handleSync(false)}
+ disabled={syncing}
+ >
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {t("settings.whoopSync")}
+
+
+
+
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {t("settings.whoopFullSync")}
+
+
+
+
+
+ {t("settings.whoopFullSyncTitle")}
+
+
+ {t("settings.whoopFullSyncDescription")}
+
+
+
+ {t("common.cancel")}
+ handleSync(true)}>
+ {t("settings.whoopSynchronize")}
+
+
+
+
+
+
+
+
+
+ {t("settings.whoopDisconnect")}
+
+
+
+
+
+ {t("settings.whoopDisconnectTitle")}
+
+
+ {t("settings.whoopDisconnectDescription")}
+
+
+
+ {t("common.cancel")}
+ disconnect.mutate()}
+ >
+ {t("settings.whoopDisconnect")}
+
+
+
+
+
+ {status?.backfillCompleted === false && (
+
+ {t("settings.whoopBackfillInProgress")}
+
+ )}
+ {syncMsg && (
+
+ {syncMsg}
+
+ )}
+ >
+ ) : status?.configured ? (
+
{
+ window.location.href = "/api/whoop/connect";
+ }}
+ >
+
+ {t("settings.whoopConnect")}
+
+ ) : (
+
+ {t("settings.whoopNoCredentials")}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
new file mode 100644
index 000000000..7722c1a97
--- /dev/null
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -0,0 +1,497 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
+import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
+import { TestConnectionButton } from "@/components/settings/test-connection-button";
+import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ invalidateKeys,
+ measurementDependentKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+
+import {
+ IntegrationErrorMessage,
+ pillStateFor,
+ type IntegrationStatusViewModel,
+} from "./shared";
+
+export function WithingsCard({
+ viewModel,
+}: {
+ viewModel: IntegrationStatusViewModel | undefined;
+}) {
+ const { t } = useTranslations();
+ const [syncing, setSyncing] = useState(false);
+ const [syncMsg, setSyncMsg] = useState(null);
+ const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
+ const [clientId, setClientId] = useState("");
+ const [clientSecret, setClientSecret] = useState("");
+ const [credsSaving, setCredsSaving] = useState(false);
+ const [credsMsg, setCredsMsg] = useState(null);
+ const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
+ const queryClient = useQueryClient();
+
+ // v1.12.1 — the Withings status is read off the consolidated
+ // /api/integrations/status envelope (the `viewModel` prop). The
+ // per-card /api/withings/status round-trip is gone; the envelope
+ // carries every field this card renders (connected / configured /
+ // last-sync / activity scope).
+ const status = viewModel;
+
+ const disconnect = useMutation({
+ mutationFn: async () => {
+ await apiPost("/api/withings/disconnect");
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ },
+ });
+
+ // v1.4.43 W14 — clear a parked integration via the resume endpoint.
+ // The CTA is rendered inside the parked banner below; success
+ // invalidates both the per-card status (so the pill flips back to
+ // connected immediately) and the cross-integration envelope (so any
+ // other view picks up the change on its next focus).
+ const resume = useMutation({
+ mutationFn: async () => {
+ return apiPost<{ resumed: boolean; wasParked: boolean }>(
+ "/api/integrations/withings/resume",
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ },
+ });
+
+ async function handleSync(fullSync = false) {
+ setSyncing(true);
+ setSyncMsg(null);
+ setSyncMsgType(null);
+ try {
+ const res = await apiFetchRaw("/api/withings/sync", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fullSync }),
+ });
+ const json = await res.json();
+ if (res.ok) {
+ setSyncMsg(
+ fullSync
+ ? t("settings.withingsFullSyncResult", {
+ count: json.data.imported,
+ })
+ : t("settings.withingsSyncResult", { count: json.data.imported }),
+ );
+ setSyncMsgType("success");
+ void invalidateKeys(queryClient, measurementDependentKeys);
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
+ } else {
+ setSyncMsg(json.error || t("settings.withingsSyncFailed"));
+ setSyncMsgType("error");
+ }
+ } catch {
+ setSyncMsg(t("settings.withingsSyncFailed"));
+ setSyncMsgType("error");
+ } finally {
+ setSyncing(false);
+ }
+ }
+
+ async function handleSaveCredentials(e: React.FormEvent) {
+ e.preventDefault();
+ setCredsSaving(true);
+ setCredsMsg(null);
+ setCredsMsgType(null);
+
+ try {
+ const res = await apiFetchRaw("/api/withings/credentials", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ clientId: clientId.trim(),
+ clientSecret: clientSecret.trim(),
+ }),
+ });
+
+ if (res.ok) {
+ setCredsMsg(t("settings.withingsCredentialsSaved"));
+ setCredsMsgType("success");
+ setClientId("");
+ setClientSecret("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
+ } else {
+ try {
+ const json = await res.json();
+ setCredsMsg(json.error || t("settings.savingError"));
+ } catch {
+ setCredsMsg(t("settings.savingError"));
+ }
+ setCredsMsgType("error");
+ }
+ } catch {
+ setCredsMsg(t("common.networkError"));
+ setCredsMsgType("error");
+ }
+ setCredsSaving(false);
+ }
+
+ // The pill state derives from the cross-integration status
+ // envelope. `legacyLastSyncedAt` is the connection row's own
+ // last-sync timestamp (carried in the same envelope) so the pill
+ // still paints a fresh "X min ago" right after a manual sync once
+ // the envelope refetches. When the envelope hasn't answered yet we
+ // fall back to "disconnected" so the card never renders status-less.
+ const pillState: IntegrationPillState = status?.connected
+ ? pillStateFor(viewModel)
+ : "disconnected";
+ const pillLastSyncAt =
+ status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
+ // v1.4.43 W14 — `parked` and `error` both want the underlying error
+ // message surfaced under the pill (the pill says "what" — the
+ // message says "why"). Other states leave the inline line off.
+ const errorMessage =
+ (pillState === "error" || pillState === "parked") && viewModel?.lastError
+ ? viewModel.lastError
+ : null;
+
+ return (
+
+
+ }
+ />
+
+
+
+
+ {errorMessage &&
}
+ {/* v1.4.25 W5d — Withings activity-scope reconnect banner.
+ Only paints when (a) the user has an active Withings
+ connection (so the credentials are already saved + a token
+ exists) AND (b) the persisted scope is missing
+ `user.activity`. Reconnecting takes the user through the
+ standard /api/withings/connect → Withings → callback flow;
+ the callback persists the upgraded scope and the banner
+ vanishes on the next status poll. */}
+ {status?.connected && status?.hasActivityScope === false && (
+
+
+ {t("settings.integrations.withings.reconnect.banner.title")}
+
+
+ {t("settings.integrations.withings.reconnect.banner.body")}
+
+
+ {t("settings.integrations.withings.reconnect.banner.action")}
+ {" →"}
+
+
+ )}
+ {/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
+ when the row state is `parked` (>24h of persistent
+ failures). The button POSTs to /api/integrations/withings/
+ resume which calls `resumeIntegrationFromPark`; on success
+ the per-card status invalidates and the pill flips back to
+ connected without a page refresh. The button is the
+ primary action the user can take from this card — wider
+ tap target than the inline action row so it's reachable
+ on a Pixel 5 viewport. */}
+ {pillState === "parked" && (
+
+
+ {t("settings.integrationPill.parkedReconnect")}
+
+ resume.mutate()}
+ disabled={resume.isPending}
+ data-testid="withings-resume-button"
+ className="min-h-11"
+ >
+ {resume.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.integrationPill.resumeCta")}
+
+
+ )}
+ {resume.isError && (
+
+ {t("settings.integrationPill.resumeError")}
+
+ )}
+ {resume.isSuccess && resume.data?.wasParked && (
+
+ {t("settings.integrationPill.resumeSuccess")}
+
+ )}
+
+
+ {t("settings.withingsCredentials")}
+
+
+
+
+ {status?.connected ? (
+ <>
+ {/* v1.4.27 MB7 / CF-57 — the action row already wraps via
+ `flex-wrap`, but on Pixel 5 the four AlertDialog +
+ test-connection triggers each took a fractional slot
+ that read jagged. Force each button to a `min-w-[10rem]`
+ on `
+ handleSync(false)}
+ disabled={syncing}
+ >
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {t("settings.withingsSync")}
+
+
+
+
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {t("settings.withingsFullSync")}
+
+
+
+
+
+ {t("settings.withingsFullSyncTitle")}
+
+
+ {t("settings.withingsFullSyncDescription")}
+
+
+
+ {t("common.cancel")}
+ handleSync(true)}>
+ {t("settings.withingsSynchronize")}
+
+
+
+
+
+
+
+
+
+ {t("settings.withingsDisconnect")}
+
+
+
+
+
+ {t("settings.withingsDisconnectTitle")}
+
+
+ {t("settings.withingsDisconnectDescription")}
+
+
+
+ {t("common.cancel")}
+ disconnect.mutate()}
+ >
+ {t("settings.withingsDisconnect")}
+
+
+
+
+
+ {syncMsg && (
+
+ {syncMsg}
+
+ )}
+ >
+ ) : status?.configured ? (
+
{
+ window.location.href = "/api/withings/connect";
+ }}
+ >
+
+ {t("settings.withingsConnect")}
+
+ ) : (
+
+ {t("settings.withingsNoCredentials")}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx
index 9c7396bff..8d4d0fa0c 100644
--- a/src/components/settings/mood-reminder-card.tsx
+++ b/src/components/settings/mood-reminder-card.tsx
@@ -9,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface ProfileShape {
moodReminderEnabled: boolean;
@@ -65,9 +66,7 @@ export function MoodReminderCard({
const { data: profile } = useQuery({
queryKey: queryKeys.userProfile(),
queryFn: async () => {
- const res = await fetch("/api/user/profile");
- if (!res.ok) throw new Error("Failed to load profile");
- return (await res.json()).data as ProfileShape;
+ return apiGet("/api/user/profile");
},
enabled: isAuthenticated,
});
@@ -77,9 +76,7 @@ export function MoodReminderCard({
const { data: prefs } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () => {
- const res = await fetch("/api/auth/me/notification-prefs");
- if (!res.ok) throw new Error("Failed to load notification prefs");
- return (await res.json()).data as NotificationPrefsShape;
+ return apiGet("/api/auth/me/notification-prefs");
},
enabled: isAuthenticated,
});
@@ -97,7 +94,7 @@ export function MoodReminderCard({
clearTimerRef.current = null;
}
- const res = await fetch("/api/user/profile", {
+ const res = await apiFetchRaw("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ moodReminderEnabled: next }),
@@ -132,7 +129,7 @@ export function MoodReminderCard({
clearTimerRef.current = null;
}
- const res = await fetch("/api/auth/me/notification-prefs", {
+ const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mood: { reminderHour: next } }),
diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx
index 9b1c10fa4..1791f7036 100644
--- a/src/components/settings/notification-status-card.tsx
+++ b/src/components/settings/notification-status-card.tsx
@@ -34,6 +34,7 @@ import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiGet, apiPost } from "@/lib/api/api-fetch";
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
@@ -73,10 +74,10 @@ export function NotificationStatusCard() {
const { data, isLoading } = useQuery({
queryKey: queryKeys.notificationsStatus(),
queryFn: async () => {
- const res = await fetch("/api/notifications/status");
- if (!res.ok) throw new Error("Failed");
- return ((await res.json()).data as { channels: ChannelStatus[] })
- .channels;
+ const data = await apiGet<{ channels: ChannelStatus[] }>(
+ "/api/notifications/status",
+ );
+ return data.channels;
},
enabled: isAuthenticated,
refetchInterval: 30_000,
@@ -84,12 +85,7 @@ export function NotificationStatusCard() {
const reEnable = useMutation({
mutationFn: async (channelId: string) => {
- const res = await fetch("/api/notifications/status", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ channelId }),
- });
- if (!res.ok) throw new Error("Failed");
+ await apiPost("/api/notifications/status", { channelId });
},
onSuccess: () => {
queryClient.invalidateQueries({
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx
index f4bf8b6a5..ff95eb74f 100644
--- a/src/components/settings/notifications-section.tsx
+++ b/src/components/settings/notifications-section.tsx
@@ -13,6 +13,7 @@ import { NotificationStatusCard } from "@/components/settings/notification-statu
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
import { WebPushCard } from "@/components/settings/web-push-card";
+import { apiGet } from "@/lib/api/api-fetch";
interface GlobalServiceAvailability {
telegramGlobal: boolean;
@@ -29,9 +30,7 @@ export function NotificationsSection() {
const { data: globalServices } = useQuery({
queryKey: queryKeys.settingsGlobalServices(),
queryFn: async () => {
- const res = await fetch("/api/settings/global-services");
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as GlobalServiceAvailability;
+ return apiGet("/api/settings/global-services");
},
enabled: isAuthenticated,
});
diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx
index 8d6319dc6..7453a0fe5 100644
--- a/src/components/settings/ntfy-card.tsx
+++ b/src/components/settings/ntfy-card.tsx
@@ -13,6 +13,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface NtfySettings {
enabled: boolean;
@@ -35,10 +36,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { data: settings } = useQuery({
queryKey: queryKeys.settingsNtfy(),
queryFn: async () => {
- const res = await fetch("/api/settings/ntfy");
- if (!res.ok) throw new Error("Failed");
- const json = await res.json();
- return json.data as NtfySettings;
+ return apiGet("/api/settings/ntfy");
},
enabled: isAuthenticated,
});
@@ -56,7 +54,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const save = useMutation({
mutationFn: async (enabled: boolean) => {
- const res = await fetch("/api/settings/ntfy", {
+ const res = await apiFetchRaw("/api/settings/ntfy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 249e04aa6..1ca71948b 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -289,10 +289,14 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
`/settings/account` (Geschlecht / Gender) and
`/settings/ai` (Aktiver Provider) is not eaten by the
floating mobile bottom-nav. Desktop reverts to the parent's
- own padding because the bottom-nav doesn't render on `md+`. */}
-
+ own padding because the bottom-nav doesn't render on `md+`.
+
+ v1.16.4 — a ``, not `
`: the surrounding AuthShell
+ already provides the page's single `` landmark and a
+ nested second one is an a11y violation. */}
+
{children}
-
+
);
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 2799e30c5..2598d6b36 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -17,7 +17,7 @@
* straight into iOS-era analytics without an extra migration step.
*
* v1.4.25 W8c — two-axis extension. The same screen now hosts three
- * vertically-stacked sections (Marc no-split directive):
+ * vertically-stacked sections (the maintainer no-split directive):
* 1. Global default ladder per metric class (existing).
* 2. Per-metric override expander — collapsed by default; an
* explicit knob for power users (the global ladder already lives
@@ -53,6 +53,7 @@ import {
SOURCE_PRIORITY_METRIC_KEYS,
type SourcePriorityMetricKey,
} from "@/lib/validations/source-priority";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* Resolved shape returned by `GET /api/auth/me/source-priority`. Mirrors
@@ -125,10 +126,7 @@ export function SourcesSection() {
const { data: remote, isLoading } = useQuery({
queryKey: queryKeys.sourcePriority(),
queryFn: async () => {
- const res = await fetch("/api/auth/me/source-priority");
- if (!res.ok) throw new Error("failed");
- const json = await res.json();
- return json.data as ResolvedPriority;
+ return apiGet("/api/auth/me/source-priority");
},
});
@@ -151,13 +149,7 @@ export function SourcesSection() {
metricPriority: next.metricPriority,
deviceTypePriority: next.deviceTypePriority,
};
- const res = await fetch("/api/auth/me/source-priority", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) throw new Error("save failed");
- return (await res.json()).data as ResolvedPriority;
+ return apiPut("/api/auth/me/source-priority", body);
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.sourcePriority(), saved);
@@ -176,13 +168,7 @@ export function SourcesSection() {
// Sending the empty W8c shape clears every per-user override; the
// server's `parseSourcePriority` then returns the constant
// defaults verbatim. Keeps the wire payload tiny.
- const res = await fetch("/api/auth/me/source-priority", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- if (!res.ok) throw new Error("reset failed");
- return (await res.json()).data as ResolvedPriority;
+ return apiPut("/api/auth/me/source-priority", {});
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.sourcePriority(), saved);
diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx
index 0af940ef7..0c828fda4 100644
--- a/src/components/settings/telegram-card.tsx
+++ b/src/components/settings/telegram-card.tsx
@@ -13,6 +13,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface TelegramSettings {
enabled: boolean;
@@ -37,10 +38,7 @@ export function TelegramCard({
const { data: settings } = useQuery({
queryKey: queryKeys.telegramSettings(),
queryFn: async () => {
- const res = await fetch("/api/settings/telegram");
- if (!res.ok) throw new Error("Failed");
- const json = await res.json();
- return json.data as TelegramSettings;
+ return apiGet("/api/settings/telegram");
},
enabled: isAuthenticated,
});
@@ -69,7 +67,7 @@ export function TelegramCard({
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
- const res = await fetch("/api/settings/telegram", {
+ const res = await apiFetchRaw("/api/settings/telegram", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx
index 6736bacbc..ef45d8aa8 100644
--- a/src/components/settings/thresholds-editor-section.tsx
+++ b/src/components/settings/thresholds-editor-section.tsx
@@ -18,6 +18,7 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface ThresholdsApiResponse {
effective: Record;
@@ -65,10 +66,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
- const res = await fetch("/api/user/thresholds");
- if (!res.ok) throw new Error("failed");
- const json = await res.json();
- return json.data as ThresholdsApiResponse;
+ return apiGet("/api/user/thresholds");
},
});
@@ -76,7 +74,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
mutationFn: async (
payload: Partial>,
) => {
- const res = await fetch("/api/user/thresholds", {
+ const res = await apiFetchRaw("/api/user/thresholds", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
diff --git a/src/components/settings/time-format-select.tsx b/src/components/settings/time-format-select.tsx
index 67edd7da3..11e1c851c 100644
--- a/src/components/settings/time-format-select.tsx
+++ b/src/components/settings/time-format-select.tsx
@@ -10,6 +10,7 @@ import type { TimeFormatPreference } from "@/lib/format-locale";
import { storeTimeFormat } from "@/lib/time-format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Hour-cycle (12h/24h) preference as a Profile dropdown.
@@ -67,7 +68,7 @@ export function TimeFormatSelect({
const mutation = useMutation({
mutationFn: async (next: TimeFormatPreference) => {
- const res = await fetch("/api/user/profile", {
+ const res = await apiFetchRaw("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeFormat: next }),
diff --git a/src/components/settings/unit-preference-select.tsx b/src/components/settings/unit-preference-select.tsx
index fa76cab1e..43a435715 100644
--- a/src/components/settings/unit-preference-select.tsx
+++ b/src/components/settings/unit-preference-select.tsx
@@ -8,6 +8,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
type UnitPreference = "metric" | "imperial";
@@ -63,7 +64,7 @@ export function UnitPreferenceSelect({
const mutation = useMutation({
mutationFn: async (next: UnitPreference) => {
- const res = await fetch("/api/auth/me/unit-preference", {
+ const res = await apiFetchRaw("/api/auth/me/unit-preference", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unitPreference: next }),
diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx
index c2c76fedf..c09fb7400 100644
--- a/src/components/settings/web-push-card.tsx
+++ b/src/components/settings/web-push-card.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
@@ -70,7 +71,7 @@ export function WebPushCard() {
setMsgType(null);
try {
- const vapidRes = await fetch("/api/notifications/vapid");
+ const vapidRes = await apiFetchRaw("/api/notifications/vapid");
if (!vapidRes.ok) {
setMsg(t("settings.webPushNotConfigured"));
setMsgType("error");
@@ -90,7 +91,7 @@ export function WebPushCard() {
const subJson = subscription.toJSON();
- const res = await fetch("/api/notifications/web-push", {
+ const res = await apiFetchRaw("/api/notifications/web-push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -134,7 +135,7 @@ export function WebPushCard() {
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
- await fetch("/api/notifications/web-push", {
+ await apiFetchRaw("/api/notifications/web-push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
diff --git a/src/components/targets/consistency-strip.tsx b/src/components/targets/consistency-strip.tsx
index cfde3f643..38dda9237 100644
--- a/src/components/targets/consistency-strip.tsx
+++ b/src/components/targets/consistency-strip.tsx
@@ -24,7 +24,7 @@ import { useTranslations } from "@/lib/i18n/context";
*
* Hidden by the parent when the target's `insufficientData` flag is
* true — there is no "— not enough data" empty-state mode here; the
- * card surfaces that copy in a different slot per Marc's "no empty hint
+ * card surfaces that copy in a different slot per the maintainer's "no empty hint
* inside the strip" directive.
*
* Visual rationale (frontend-design skill): seven dots over the v1.4.22
diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx
index 6174acf4d..c43bfec75 100644
--- a/src/components/targets/target-edit-sheet.tsx
+++ b/src/components/targets/target-edit-sheet.tsx
@@ -17,6 +17,7 @@ import {
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
+import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* Per-metric target editor, mounted inline on the Insights metric
@@ -145,10 +146,7 @@ function TargetEditSheetBody({
const { data: thresholdsData } = useQuery({
queryKey: ["user", "thresholds"],
queryFn: async () => {
- const res = await fetch("/api/user/thresholds");
- if (!res.ok) throw new Error("failed");
- const json = await res.json();
- return json.data as ThresholdsApiResponse;
+ return apiGet("/api/user/thresholds");
},
});
@@ -205,6 +203,10 @@ function TargetEditSheetBody({
// primary input as soon as the portal paints.
const firstInputRef = useRef(null);
const dialogContentId = useId();
+ // v1.16.4 — the body is a real form and the footer save button
+ // associates via the `form` attribute (the footer renders in a
+ // separate slot), so Enter in any numeric field saves.
+ const formId = useId();
useEffect(() => {
// Defer one frame so Radix has finished placing the portal before
// we steal focus — otherwise the focus jumps to the close-X on
@@ -222,15 +224,7 @@ function TargetEditSheetBody({
mutationFn: async (
payload: Partial>,
) => {
- const res = await fetch("/api/user/thresholds", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- if (!res.ok) {
- const err = await res.json().catch(() => null);
- throw new Error(err?.error ?? "save failed");
- }
+ await apiPut("/api/user/thresholds", payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
@@ -251,18 +245,12 @@ function TargetEditSheetBody({
if (isBp) {
// Reset BOTH sys + dia.
for (const m of ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] as const) {
- const res = await fetch(`/api/user/thresholds?metric=${m}`, {
- method: "DELETE",
- });
- if (!res.ok) throw new Error("reset failed");
+ await apiDelete(`/api/user/thresholds?metric=${m}`);
}
return;
}
if (!metric) return;
- const res = await fetch(`/api/user/thresholds?metric=${metric}`, {
- method: "DELETE",
- });
- if (!res.ok) throw new Error("reset failed");
+ await apiDelete(`/api/user/thresholds?metric=${metric}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
@@ -388,8 +376,9 @@ function TargetEditSheetBody({
{t("common.cancel")}
@@ -399,10 +388,15 @@ function TargetEditSheetBody({