mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'feat/v144-settings' into release/v1.44.0
# Conflicts: # src/components/settings/privacy-section.tsx
This commit is contained in:
commit
e1b70537cc
129 files changed
+5138
-3169
No files matched your search
@@ -1,24 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
ProviderChainData,
|
||||
UserAIProvider,
|
||||
} from "@/components/settings/ai/shared";
|
||||
import type { UserAIProvider } from "@/components/settings/ai/shared";
|
||||
|
||||
import {
|
||||
chainWithFirst,
|
||||
modelControlFor,
|
||||
selectableProviders,
|
||||
switchDidNotTake,
|
||||
} from "../coach-model-picker";
|
||||
|
||||
const CHAIN: ProviderChainData["configuredChain"] = [
|
||||
{ providerType: "codex", enabled: true, available: false },
|
||||
{ providerType: "openai", enabled: true, available: true },
|
||||
{ providerType: "anthropic", enabled: false, available: true },
|
||||
{ providerType: "local", enabled: true, available: true },
|
||||
{ providerType: "admin-openai", enabled: true, available: true },
|
||||
];
|
||||
import { modelControlFor } from "../coach-model-picker";
|
||||
|
||||
function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
|
||||
return {
|
||||
@@ -40,55 +24,6 @@ function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
describe("chainWithFirst", () => {
|
||||
it("moves the chosen provider to the front and keeps the rest in order", () => {
|
||||
expect(chainWithFirst(CHAIN, "local")).toEqual([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
{ providerType: "codex", priority: 2, enabled: true },
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
{ providerType: "anthropic", priority: 4, enabled: false },
|
||||
{ providerType: "admin-openai", priority: 5, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never sends a reasoning setting, so the server keeps the stored one", () => {
|
||||
for (const entry of chainWithFirst(CHAIN, "openai")) {
|
||||
expect(Object.keys(entry).sort()).toEqual([
|
||||
"enabled",
|
||||
"priority",
|
||||
"providerType",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectableProviders", () => {
|
||||
it("offers only enabled providers that can answer", () => {
|
||||
expect(selectableProviders(CHAIN)).toEqual([
|
||||
"openai",
|
||||
"local",
|
||||
"admin-openai",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchDidNotTake", () => {
|
||||
it("is quiet when the chosen provider leads the chain the server resolves", () => {
|
||||
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks up when the server still answers with another provider", () => {
|
||||
// A provider without a working key was saved to the front of the chain;
|
||||
// the resolver skipped it and the select would jump back silently.
|
||||
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelControlFor", () => {
|
||||
it("offers the presets for the provider whose key the person saved", () => {
|
||||
const control = modelControlFor("openai", userProvider());
|
||||
|
||||
@@ -70,41 +70,32 @@ describe("<CoachSettingsBody>", () => {
|
||||
expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings</);
|
||||
});
|
||||
|
||||
it("offers only the providers that can answer, with the active one selected", () => {
|
||||
it("shows the provider and model in effect and links to the one place they are chosen", () => {
|
||||
const html = render(<CoachSettingsBody />);
|
||||
const select = html.match(
|
||||
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
|
||||
)?.[0];
|
||||
expect(select).toBeDefined();
|
||||
expect(select).toContain('value="local"');
|
||||
expect(select).toContain('value="admin-openai"');
|
||||
expect(select).not.toContain('value="codex"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-quick-provider"[^>]*data-value="local"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-quick-model"[^>]*data-value="qwen2.5"/,
|
||||
);
|
||||
expect(html).toContain('href="/settings/ai#ai-provider"');
|
||||
// v1.44 — no second copy of the selects here.
|
||||
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-provider"/);
|
||||
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-model"/);
|
||||
expect(html).toContain('id="coach-quick-reasoning"');
|
||||
// Keys and base URLs are not here.
|
||||
expect(html).not.toContain("http://localhost:11434/v1");
|
||||
expect(html).not.toMatch(/type="password"/);
|
||||
});
|
||||
|
||||
it("shows the operator's central Codex as the active provider when it answers", () => {
|
||||
// `admin-codex` is never a chain entry, so no option matched it and the
|
||||
// select showed its first option as the one answering.
|
||||
it("names the operator's central Codex when it answers", () => {
|
||||
const html = render(<CoachSettingsBody />, {
|
||||
...CHAIN,
|
||||
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
|
||||
});
|
||||
const select = html.match(
|
||||
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
|
||||
)?.[0];
|
||||
expect(select).toMatch(
|
||||
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-quick-provider"[^>]*data-value="admin-codex"[^>]*>ChatGPT \(shared by the operator\)</,
|
||||
);
|
||||
expect(select).not.toMatch(/value="local"[^>]*selected/);
|
||||
expect(html).toContain('data-slot="coach-quick-provider-operator"');
|
||||
});
|
||||
|
||||
it("shows the model and reasoning controls the active provider owns", () => {
|
||||
const html = render(<CoachSettingsBody />);
|
||||
expect(html).toContain('data-slot="coach-quick-model"');
|
||||
expect(html).toContain('id="coach-quick-reasoning"');
|
||||
// Keys, base URLs and the chain order are not here.
|
||||
expect(html).not.toContain("http://localhost:11434/v1");
|
||||
expect(html).not.toMatch(/type="password"/);
|
||||
});
|
||||
|
||||
it("locks the reasoning select while a change is saving", async () => {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
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 {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
LOCAL_MODEL_PRESETS,
|
||||
@@ -17,85 +13,23 @@ import {
|
||||
type ProviderType,
|
||||
type UserAIProvider,
|
||||
} from "@/components/settings/ai/shared";
|
||||
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The quick model choice in the Coach's settings overlay: which provider
|
||||
* answers and which model it uses. How hard the Coach thinks is the Coach's
|
||||
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
|
||||
* reasoning select stays in Settings → AI for everything else.
|
||||
* Which provider answers the Coach and which model it uses, shown in the
|
||||
* Coach's settings overlay with a link to where it is chosen.
|
||||
*
|
||||
* It is a shortcut onto the same settings Settings → AI writes, never a second
|
||||
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
|
||||
* fallback chain stay there; this reads the same two queries and writes
|
||||
* through the same two routes:
|
||||
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
|
||||
* to the front and every other entry in its current order. The entries'
|
||||
* reasoning settings are not sent, so the server keeps what it stored.
|
||||
* - Model: `PATCH /api/user/ai-provider`, only where a person
|
||||
* owns the setting (their own key's provider, or their gateway). The
|
||||
* operator's shared provider and Codex show their state read-only.
|
||||
* Every write invalidates the AI input keys, so the capability on
|
||||
* `/api/auth/me` comes back from the server rather than being guessed here.
|
||||
* The model is chosen in one place, Settings > AI. Until v1.44 this overlay
|
||||
* carried a second copy of the provider and model selects, writing through
|
||||
* the same routes; two controls for one setting meant a change in one place
|
||||
* surprised the person in the other. The overlay now reads the same two
|
||||
* queries and says what is in effect.
|
||||
*/
|
||||
|
||||
/** The configured chain with `provider` first; everything else keeps order. */
|
||||
export function chainWithFirst(
|
||||
chain: ProviderChainData["configuredChain"],
|
||||
provider: ProviderType,
|
||||
): { providerType: ProviderType; priority: number; enabled: boolean }[] {
|
||||
const rest = chain.filter((entry) => entry.providerType !== provider);
|
||||
const ordered = [
|
||||
{ providerType: provider, enabled: true },
|
||||
...rest.map((entry) => ({
|
||||
providerType: entry.providerType,
|
||||
enabled: entry.enabled,
|
||||
})),
|
||||
];
|
||||
return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
|
||||
}
|
||||
|
||||
/** The providers a person can switch to: enabled and able to answer. */
|
||||
export function selectableProviders(
|
||||
chain: ProviderChainData["configuredChain"],
|
||||
): ProviderType[] {
|
||||
return chain
|
||||
.filter((entry) => entry.enabled && entry.available)
|
||||
.map((entry) => entry.providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a saved switch did not take: the chain the server resolves after
|
||||
* the save is not led by the provider the person chose. A provider whose key
|
||||
* stops working between the read and the save lands here.
|
||||
*/
|
||||
export function switchDidNotTake(
|
||||
chosen: ProviderType,
|
||||
fresh: Pick<ProviderChainData, "activeProvider">,
|
||||
): boolean {
|
||||
return fresh.activeProvider !== chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the server answers with when the select has no option for it:
|
||||
* the operator's central Codex (never a chain entry), or an entry that answers
|
||||
* without being offered. Shown as a disabled option so the select tells the
|
||||
* truth instead of showing its first option as active.
|
||||
*/
|
||||
export function activeOutsideOptions(
|
||||
active: string | null,
|
||||
options: readonly string[],
|
||||
): string | null {
|
||||
return active !== null && !options.includes(active) ? active : null;
|
||||
}
|
||||
|
||||
export type ModelControl =
|
||||
| { kind: "preset"; presets: readonly string[]; value: string | null }
|
||||
| { kind: "gateway"; value: string | null }
|
||||
@@ -130,7 +64,6 @@ export function modelControlFor(
|
||||
|
||||
export function CoachModelPicker() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const chainQuery = useQuery({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
@@ -141,59 +74,6 @@ export function CoachModelPicker() {
|
||||
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const onSaved = () => {
|
||||
setError(null);
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
};
|
||||
const onFailed = (e: unknown) =>
|
||||
setError(
|
||||
e instanceof Error && e.message
|
||||
? e.message
|
||||
: t("insights.coach.frame.saveFailed"),
|
||||
);
|
||||
|
||||
const switchProvider = useMutation({
|
||||
mutationFn: async (provider: ProviderType) => {
|
||||
setNotice(null);
|
||||
await apiPut("/api/insights/provider-chain", {
|
||||
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
|
||||
});
|
||||
// Read back what the server now resolves, so a switch that did not
|
||||
// take is said rather than shown as the select jumping back.
|
||||
return apiGet<ProviderChainData>("/api/insights/provider-chain");
|
||||
},
|
||||
onSuccess: (fresh, provider) => {
|
||||
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
|
||||
onSaved();
|
||||
if (switchDidNotTake(provider, fresh)) {
|
||||
setNotice(
|
||||
t("insights.coach.frame.providerNotActive", {
|
||||
provider: t(`settings.ai.providerChain.types.${provider}`),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: onFailed,
|
||||
});
|
||||
const patchProvider = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiPatch("/api/user/ai-provider", body),
|
||||
onSuccess: onSaved,
|
||||
onError: onFailed,
|
||||
});
|
||||
|
||||
const chain = chainQuery.data;
|
||||
const userProvider = providerQuery.data;
|
||||
const active = chain?.activeProvider ?? null;
|
||||
const options = chain ? selectableProviders(chain.configuredChain) : [];
|
||||
const model = modelControlFor(active, userProvider);
|
||||
const busy = switchProvider.isPending || patchProvider.isPending;
|
||||
// The server may answer with `admin-codex`, which the UI's type omits.
|
||||
const unlisted = activeOutsideOptions(active as string | null, options);
|
||||
|
||||
if (chainQuery.isError || providerQuery.isError) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
@@ -208,6 +88,8 @@ export function CoachModelPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
const chain = chainQuery.data;
|
||||
const userProvider = providerQuery.data;
|
||||
if (!chain || !userProvider) {
|
||||
return (
|
||||
<p
|
||||
@@ -223,159 +105,45 @@ export function CoachModelPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
// The server may answer with `admin-codex`, which the UI's type omits.
|
||||
const active = (chain.activeProvider ?? null) as string | null;
|
||||
const model = modelControlFor(chain.activeProvider ?? null, userProvider);
|
||||
const providerLabel =
|
||||
active === null
|
||||
? t("insights.coach.frame.noProvider")
|
||||
: active === "admin-codex"
|
||||
? t("insights.coach.frame.operatorCodex")
|
||||
: t(`settings.ai.providerChain.types.${active}`);
|
||||
const modelLabel =
|
||||
model.kind === "managed"
|
||||
? t("insights.coach.frame.modelManaged")
|
||||
: (model.value ?? t("settings.ai.modelOptionDefault"));
|
||||
|
||||
return (
|
||||
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="coach-quick-provider" noColon>
|
||||
<div data-slot="coach-model-picker" className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("insights.coach.frame.provider")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="coach-quick-provider"
|
||||
data-slot="coach-quick-provider"
|
||||
className="mt-1"
|
||||
value={active ?? ""}
|
||||
disabled={busy || options.length === 0}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as ProviderType;
|
||||
if (next && next !== active) switchProvider.mutate(next);
|
||||
}}
|
||||
</dt>
|
||||
<dd data-slot="coach-quick-provider" data-value={active ?? ""}>
|
||||
{providerLabel}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("settings.ai.modelLabel")}</dt>
|
||||
<dd
|
||||
data-slot="coach-quick-model"
|
||||
data-value={model.kind === "managed" ? "" : (model.value ?? "")}
|
||||
>
|
||||
{active === null ? (
|
||||
<option value="" disabled>
|
||||
{t("insights.coach.frame.noProvider")}
|
||||
</option>
|
||||
) : null}
|
||||
{unlisted ? (
|
||||
<option value={unlisted} disabled>
|
||||
{unlisted === "admin-codex"
|
||||
? t("insights.coach.frame.operatorCodex")
|
||||
: t(`settings.ai.providerChain.types.${unlisted}`)}
|
||||
</option>
|
||||
) : null}
|
||||
{options.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`settings.ai.providerChain.types.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{unlisted === "admin-codex" ? (
|
||||
<p
|
||||
data-slot="coach-quick-provider-operator"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t("insights.coach.frame.operatorManaged")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{model.kind === "preset" ? (
|
||||
<div>
|
||||
<Label htmlFor="coach-quick-model" noColon>
|
||||
{t("settings.ai.modelLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="coach-quick-model"
|
||||
data-slot="coach-quick-model"
|
||||
className="mt-1"
|
||||
value={model.value ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
patchProvider.mutate({ model: e.target.value || null })
|
||||
}
|
||||
>
|
||||
<option value="">{t("settings.ai.modelOptionDefault")}</option>
|
||||
{model.value && !model.presets.includes(model.value) ? (
|
||||
<option value={model.value}>{model.value}</option>
|
||||
) : null}
|
||||
{model.presets.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
) : model.kind === "gateway" ? (
|
||||
<GatewayModelField
|
||||
key={model.value ?? ""}
|
||||
value={model.value}
|
||||
disabled={busy}
|
||||
onSave={(next) => patchProvider.mutate({ compatModel: next })}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
data-slot="coach-quick-model-managed"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.coach.frame.modelManaged")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{busy ? (
|
||||
<p role="status" className="text-muted-foreground text-xs">
|
||||
{t("insights.coach.frame.saving")}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="coach-quick-provider-notice"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{modelLabel}
|
||||
</dd>
|
||||
</dl>
|
||||
<p>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link href="/settings/ai#ai-provider" data-slot="coach-quick-change">
|
||||
{t("insights.coach.frame.changeModel")}
|
||||
<ChevronRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A gateway names its own models, so its model is free text with a save. */
|
||||
function GatewayModelField({
|
||||
value,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
value: string | null;
|
||||
disabled: boolean;
|
||||
onSave: (next: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [draft, setDraft] = useState(value ?? "");
|
||||
const changed = draft.trim() !== (value ?? "");
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (changed) onSave(draft.trim() || null);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="coach-quick-gateway-model" noColon>
|
||||
{t("settings.ai.modelLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="coach-quick-gateway-model"
|
||||
data-slot="coach-quick-gateway-model"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || !changed}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("settings.ai.saveCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 — the Labs module's own settings page body.
|
||||
* The Labs module's settings, in two places since v1.44:
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user:
|
||||
* - pick the card-vs-list page view (MOD-03),
|
||||
* - choose the sort order (by most-recent reading, descending / ascending,
|
||||
* or manual) — MOD-04,
|
||||
* - reorder biomarkers when sort = manual (MOD-03/04),
|
||||
* - and create / edit / delete biomarkers via the existing
|
||||
* `BiomarkerManager` (MOD-04).
|
||||
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
|
||||
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
|
||||
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
|
||||
* order of the biomarkers. Order and view only, persisted per account through
|
||||
* `useModuleListPrefs("labs")`.
|
||||
*
|
||||
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
|
||||
* recognition opt-in and the biomarker catalog (`BiomarkerManager`). Reached
|
||||
* from the wrench on the labs page.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
@@ -52,12 +51,6 @@ export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
|
||||
|
||||
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
|
||||
// can't read images still scan a report: the image is OCR'd on-device and
|
||||
// only the extracted text is sent to the text-only provider.
|
||||
const localOcr = useLabsLocalOcr();
|
||||
const updateLocalOcr = useUpdateLabsLocalOcr();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
@@ -130,7 +123,20 @@ export function LabsSettings() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabsModuleSettings() {
|
||||
const { t } = useTranslations();
|
||||
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
|
||||
// can't read images still scan a report: the image is OCR'd on-device and
|
||||
// only the extracted text is sent to the text-only provider.
|
||||
const localOcr = useLabsLocalOcr();
|
||||
const updateLocalOcr = useUpdateLabsLocalOcr();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
|
||||
<SettingsCard id="labs-local-ocr" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 — the Vorsorge module's own settings page body.
|
||||
* The checkups list in Settings, in two parts since v1.44:
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user reorder reminders + pick the card-vs-list page view, and re-homes the
|
||||
* per-reminder enable/disable toggles that used to live in the page's
|
||||
* "Anpassen" sheet. Order + view persist client-side via
|
||||
* `useModuleListPrefs("vorsorge")`; the toggles persist through the existing
|
||||
* `VorsorgeSettings` — Settings > Arrangement > Checkups: the card-vs-list
|
||||
* view and the manual order, persisted per account through
|
||||
* `useModuleListPrefs("vorsorge")`.
|
||||
*
|
||||
* `VorsorgeReminderSwitchesCard` — the per-reminder on/off switches, under
|
||||
* Settings > Notifications with the other reminders, persisted through the
|
||||
* reminder PATCH route.
|
||||
*/
|
||||
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
|
||||
@@ -47,7 +48,6 @@ function resolveLabel(
|
||||
export function VorsorgeSettings() {
|
||||
const { t } = useTranslations();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders(true);
|
||||
const { update } = useMeasurementReminderMutations();
|
||||
const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge");
|
||||
|
||||
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
|
||||
@@ -59,7 +59,7 @@ export function VorsorgeSettings() {
|
||||
: t("measurementReminders.selfPlanned"),
|
||||
}));
|
||||
|
||||
// v1.18.10 — the three Vorsorge settings groups adopt the shared
|
||||
// v1.18.10 — the Vorsorge settings groups adopt the shared
|
||||
// `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered
|
||||
// surface, neutral muted icon, `text-lg` title, short muted description)
|
||||
// so this surface reads like every other Settings section instead of bare
|
||||
@@ -96,67 +96,80 @@ export function VorsorgeSettings() {
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ListChecks}
|
||||
title={t("measurementReminders.manage.title")}
|
||||
description={t("measurementReminders.manage.description")}
|
||||
/>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (reminders?.length ?? 0) === 0 ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
title={t("measurementReminders.manage.empty")}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{ordered.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
id={`vorsorge-reminder-label-${reminder.id}`}
|
||||
className="min-w-0 truncate text-sm"
|
||||
>
|
||||
{resolveLabel(reminder, t)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate(
|
||||
{ id: reminder.id, body: { enabled: next } },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("settings.saved"),
|
||||
),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={update.isPending}
|
||||
// Label each row's Switch by its own name span so SR
|
||||
// users hear the reminder name (plus the switch role's
|
||||
// on/off state) instead of N identical generic labels.
|
||||
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The on/off switch of each preventive-care reminder. A reminder, so since
|
||||
* v1.44 it lives with the other reminders under Settings > Notifications
|
||||
* (`#vorsorge-reminders`, the wrapper there); the checkups page links
|
||||
* there.
|
||||
*/
|
||||
export function VorsorgeReminderSwitchesCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders(true);
|
||||
const { update } = useMeasurementReminderMutations();
|
||||
const { prefs } = useModuleListPrefs("vorsorge");
|
||||
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ListChecks}
|
||||
title={t("notifications.checkupReminders.title")}
|
||||
description={t("notifications.checkupReminders.description")}
|
||||
/>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (reminders?.length ?? 0) === 0 ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
title={t("measurementReminders.manage.empty")}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{ordered.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
id={`vorsorge-reminder-label-${reminder.id}`}
|
||||
className="min-w-0 truncate text-sm"
|
||||
>
|
||||
{resolveLabel(reminder, t)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate(
|
||||
{ id: reminder.id, body: { enabled: next } },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome("success", t("settings.saved")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={update.isPending}
|
||||
// Label each row's Switch by its own name span so SR
|
||||
// users hear the reminder name (plus the switch role's
|
||||
// on/off state) instead of N identical generic labels.
|
||||
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — screening hints from the life circumstances, in Settings →
|
||||
* Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound,
|
||||
* each with its status, the reasons in the account's language and the rule's
|
||||
* source and country. A hint, never a reminder: nothing is scheduled here,
|
||||
* and the card says so. Resolved on the server; rendered as delivered.
|
||||
* Health profile → Medical history (under Checkups until v1.44). Low-dose CT
|
||||
* for lung cancer and the abdominal-aorta ultrasound, each with its status,
|
||||
* the reasons in the account's language and the rule's source and country.
|
||||
* A hint, never a reminder: nothing is scheduled here, and the card says so.
|
||||
* Resolved on the server; rendered as delivered.
|
||||
*
|
||||
* The card stays away until it has something to say: the person has said
|
||||
* something about smoking, or a hint already applies without it (the German
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
// both ship without the "KI"/"AI" prefix per the project-voice rule
|
||||
// ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is
|
||||
// labelled for what it does: it chooses the form, not the order.
|
||||
expect(html).toContain("Aktiver Provider");
|
||||
expect(html).toContain("Aktiver Anbieter");
|
||||
expect(html).toContain("Anbieter einrichten");
|
||||
});
|
||||
|
||||
@@ -243,6 +243,6 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
it("no longer mounts the Coach cards (moved to the Coach section)", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-testid="settings-disable-coach-card"');
|
||||
expect(html).not.toContain('data-slot="settings-coach-state-card"');
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
|
||||
vi.mock("@/components/records/conditions-manager", () => ({
|
||||
ConditionsManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/screening-hints-card", () => ({
|
||||
ScreeningHintsCard: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/life-circumstances-manager", () => ({
|
||||
LifeCircumstancesManager: () => null,
|
||||
}));
|
||||
|
||||
@@ -18,6 +18,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
|
||||
vi.mock("@/components/records/conditions-manager", () => ({
|
||||
ConditionsManager: () => <div data-slot="conditions" />,
|
||||
}));
|
||||
vi.mock("@/components/records/screening-hints-card", () => ({
|
||||
ScreeningHintsCard: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/life-circumstances-manager", () => ({
|
||||
LifeCircumstancesManager: () => null,
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("channel form dirty state", () => {
|
||||
headerName: "X-Gotify-Key",
|
||||
headerValue: "",
|
||||
format: "gotify" as const,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
it("webhook: a form that matches the saved config is clean", () => {
|
||||
@@ -56,7 +57,12 @@ describe("channel form dirty state", () => {
|
||||
|
||||
it("ntfy: server, topic and a typed auth token count", () => {
|
||||
const saved = { serverUrl: "https://ntfy.sh", topic: "t" };
|
||||
const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
|
||||
const form = {
|
||||
serverUrl: "https://ntfy.sh",
|
||||
topic: "t",
|
||||
authToken: "",
|
||||
enabled: false,
|
||||
};
|
||||
expect(isNtfyFormDirty(form, saved)).toBe(false);
|
||||
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
|
||||
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
|
||||
@@ -67,39 +73,86 @@ describe("channel form dirty state", () => {
|
||||
|
||||
it("email: the recipient counts", () => {
|
||||
expect(
|
||||
isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
|
||||
isEmailFormDirty(
|
||||
{ recipient: "a@x.io", enabled: false },
|
||||
{ recipient: "a@x.io" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }),
|
||||
isEmailFormDirty(
|
||||
{ recipient: "b@x.io", enabled: false },
|
||||
{ recipient: "a@x.io" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => {
|
||||
expect(
|
||||
isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "", chatId: "1", enabled: false },
|
||||
{ chatId: "1" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "123:abc", chatId: "1" },
|
||||
{ botToken: " ", chatId: "", enabled: false },
|
||||
{ chatId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "123:abc", chatId: "1", enabled: false },
|
||||
{ chatId: "1" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }),
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "", chatId: "2", enabled: false },
|
||||
{ chatId: "1" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("a flipped enable switch is a change on every channel, since it saves with the form", () => {
|
||||
expect(
|
||||
isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNtfyFormDirty(
|
||||
{ serverUrl: "s", topic: "t", authToken: "", enabled: true },
|
||||
{ serverUrl: "s", topic: "t", enabled: false },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isEmailFormDirty(
|
||||
{ recipient: "a@x.io", enabled: false },
|
||||
{ recipient: "a@x.io", enabled: true },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "", chatId: "1", enabled: true },
|
||||
{ chatId: "1", enabled: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("nothing loaded yet is never a change", () => {
|
||||
expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false);
|
||||
expect(
|
||||
isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
|
||||
isNtfyFormDirty(
|
||||
{ serverUrl: "", topic: "", authToken: "x", enabled: true },
|
||||
undefined,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
|
||||
expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
|
||||
expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "x", chatId: "", enabled: true },
|
||||
undefined,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,7 @@
|
||||
* The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts`
|
||||
* → `queryKeys.coachFacts()` invalidation + toast) is pinned by the
|
||||
* route-shape contract in the component plus the typecheck gate; this
|
||||
* suite guards the render contract the way the sibling
|
||||
* `disable-coach-card.test.tsx` does for the toggle.
|
||||
* suite guards the render contract.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* v1.18.0 — Settings → Coach section.
|
||||
*
|
||||
* The Coach preference cards (disable toggle, preferences, memory) moved
|
||||
* out of the AI / Assistant section into their own dedicated, module-gated
|
||||
* entry. SSR-only smoke test — interactive contracts live in the cards'
|
||||
* own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`).
|
||||
* The Coach preference cards (state, preferences, memory) moved out of the
|
||||
* AI / Assistant section into their own dedicated, module-gated entry.
|
||||
* SSR-only smoke test — interactive contracts live in the cards' own suites
|
||||
* (`coach-memory-section.test.tsx`; the switch in `modules-section.test.tsx`).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -78,34 +78,51 @@ function render(locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<CoachSection> — SSR smoke", () => {
|
||||
it("renders the section scaffold + the Activate-Coach toggle card", () => {
|
||||
it("shows the Coach state with a link to the one switch, not a second switch", () => {
|
||||
authState.disableCoach = false;
|
||||
const html = render();
|
||||
expect(html).toContain('id="settings-section-coach-title"');
|
||||
expect(html).toContain('data-testid="settings-disable-coach-card"');
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// v1.18.1 — polarity flipped to activate/default-on.
|
||||
expect(html).toContain("Activate Coach");
|
||||
expect(html).toContain("Show the Coach button and drawer");
|
||||
// v1.18.6 — the proactive Coach nudge card moved here from
|
||||
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
expect(html).toContain('data-slot="settings-coach-state-card"');
|
||||
expect(html).toContain('data-enabled="true"');
|
||||
expect(html).toContain('href="/settings/modules#module-coach"');
|
||||
// v1.44 — no switch of its own: one state, one control, in Modules.
|
||||
const card = html.match(
|
||||
/data-slot="settings-coach-state-card"[\s\S]*?<\/a>/,
|
||||
)?.[0];
|
||||
expect(card).toBeDefined();
|
||||
expect(card).not.toContain('role="switch"');
|
||||
expect(html).not.toContain("settings-disable-coach");
|
||||
// Raw key never leaks past i18n.
|
||||
expect(html).not.toContain("settings.sections.coach.");
|
||||
expect(html).not.toContain("settings.coach.activate.");
|
||||
expect(html).not.toContain("settings.coach.state.");
|
||||
});
|
||||
|
||||
it("renders the German Activate-Coach copy", () => {
|
||||
authState.disableCoach = false;
|
||||
it("reads off once the account has the Coach off", () => {
|
||||
mountState.mounted = true;
|
||||
authState.disableCoach = true;
|
||||
try {
|
||||
expect(render()).toContain('data-enabled="false"');
|
||||
} finally {
|
||||
mountState.mounted = false;
|
||||
authState.disableCoach = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("points the nudges at Settings > Notifications", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('href="/settings/notifications#coach-nudge"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
|
||||
it("renders the German copy", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Coach aktivieren");
|
||||
expect(html).not.toContain("settings.sections.coach.");
|
||||
expect(html).not.toContain("settings.coach.activate.");
|
||||
expect(html).not.toContain("settings.coach.state.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachSection> — the tuning follows the record, not the account", () => {
|
||||
it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => {
|
||||
it("hides the nudge pointer when the record has the Coach off, whatever the viewer's own opt-out says", () => {
|
||||
// A guardian with the Coach on, inside a managed record that has it off:
|
||||
// `/me` publishes the viewer's own `disableCoach` and the RECORD's module
|
||||
// map. The record's answer decides.
|
||||
@@ -113,10 +130,10 @@ describe("<CoachSection> — the tuning follows the record, not the account", ()
|
||||
authState.disableCoach = false;
|
||||
authState.modules = { coach: false };
|
||||
try {
|
||||
expect(render()).not.toContain('id="coach-nudge"');
|
||||
expect(render()).not.toContain('data-slot="settings-coach-nudge-link"');
|
||||
authState.disableCoach = true;
|
||||
authState.modules = { coach: true };
|
||||
expect(render()).toContain('id="coach-nudge"');
|
||||
expect(render()).toContain('data-slot="settings-coach-nudge-link"');
|
||||
} finally {
|
||||
mountState.mounted = false;
|
||||
authState.disableCoach = false;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* One confirm style across Settings.
|
||||
*
|
||||
* A destructive act (delete, revoke, remove, reset, sign out) confirms through
|
||||
* `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`),
|
||||
* which owns the dialog's anatomy and wears the destructive style when the
|
||||
* caller says the act is destructive. A hand-built `AlertDialog` is how the
|
||||
* same question ended up styled three different ways on neighbouring cards.
|
||||
*
|
||||
* So no file under `src/components/settings` imports the raw `AlertDialog`
|
||||
* unless it is listed below with the reason the primitive does not fit.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(process.cwd(), "src", "components", "settings");
|
||||
|
||||
const ALLOWED: Readonly<Record<string, string>> = {
|
||||
"security-section/existing-factor-reauth-dialog.tsx":
|
||||
"a re-authentication form, not a destructive confirm",
|
||||
"security-section/passkey-list-section.tsx":
|
||||
"the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
|
||||
"security-section/totp-card.tsx":
|
||||
"the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
|
||||
};
|
||||
|
||||
/** Owned by the integration cards, which follow their own card kit. */
|
||||
const ALLOWED_PREFIXES: readonly string[] = ["integrations/"];
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) {
|
||||
return name === "__tests__" ? [] : walk(path);
|
||||
}
|
||||
return /\.tsx?$/.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/;
|
||||
|
||||
describe("settings confirm style", () => {
|
||||
const files = walk(ROOT);
|
||||
|
||||
it("scans the settings tree", () => {
|
||||
expect(files.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("imports the raw AlertDialog only where the primitive does not fit", () => {
|
||||
const offenders = files
|
||||
.map((path) => relative(ROOT, path))
|
||||
.filter((rel) =>
|
||||
IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
|
||||
)
|
||||
.filter(
|
||||
(rel) =>
|
||||
!(rel in ALLOWED) &&
|
||||
!ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)),
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
"Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no file that no longer needs the exemption", () => {
|
||||
const stale = Object.keys(ALLOWED).filter(
|
||||
(rel) =>
|
||||
!IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* v1.4.47 — Settings → Coach activation toggle persistence contract.
|
||||
*
|
||||
* v1.18.1 — the card reads "Activate Coach" (default ON). The persisted
|
||||
* column is still `User.disableCoach`; the Switch shows the activated view
|
||||
* (`checked = !disableCoach`). The SSR render is `mounted=false`, where the
|
||||
* card paints the default-ON (checked) state so the default-on contract holds
|
||||
* before the wire value resolves — both default and opted-out users render
|
||||
* checked under `renderToStaticMarkup`.
|
||||
*
|
||||
* The card is a small client-only `<Switch>` that PATCHes
|
||||
* `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so
|
||||
* every Coach mount point in the app reacts on the next /me refetch.
|
||||
*
|
||||
* Test strategy: use the real `useMutation` + `QueryClient` from
|
||||
* `@tanstack/react-query`, stub `fetch`, and drive the Switch via a
|
||||
* `renderToStaticMarkup` pass plus a hand-invoked mutation call. The
|
||||
* file SSR-renders inside a `<QueryClientProvider>` so the hook
|
||||
* branches execute and the spies fire.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: buildUser(false), isAuthenticated: true }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => authSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the navigation hooks the parent `<CoachSection>` (and its child
|
||||
// cards) reach for so the SSR pass doesn't blow up. The disable-coach card
|
||||
// itself doesn't trigger any GET — it only writes through PATCH on click.
|
||||
//
|
||||
// v1.18.0 — the Coach cards moved from `<AiSection>` to the dedicated
|
||||
// `<CoachSection>`; this contract test renders the new parent.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/coach",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
function buildUser(disableCoach: boolean): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
disableCoach,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
}
|
||||
|
||||
import { CoachSection } from "../coach-section";
|
||||
|
||||
function makeFetch() {
|
||||
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: { disableCoach: body.disableCoach ?? false },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authSpy.mockClear();
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(false),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
vi.stubGlobal("fetch", makeFetch());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Settings — DisableCoachCard", () => {
|
||||
it("renders the activation Switch checked at SSR (default-on contract)", () => {
|
||||
const html = render();
|
||||
// v1.18.1 — the SSR render is `mounted=false`, where the card paints
|
||||
// the default-ON (activated → checked) state regardless of the wire value,
|
||||
// so the Switch never flashes off before /me resolves.
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("paints the activated (checked) SSR state even for an opted-out user", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(true),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The activated state resolves on the first client re-render once
|
||||
// `mounted` flips; the SSR pass stays on the default-ON shape.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("renders the Coach-activation title + description", () => {
|
||||
const html = render();
|
||||
// v1.18.1 — polarity flipped to activate/default-on.
|
||||
expect(html).toContain("Activate Coach");
|
||||
expect(html).toContain("Show the Coach button and drawer");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../dashboard-section", () => ({ DashboardSection: () => null }));
|
||||
vi.mock("../insights-section", () => ({ InsightsSection: () => null }));
|
||||
vi.mock("../medications-section", () => ({ MedicationsSection: () => null }));
|
||||
vi.mock("../mood-section", () => ({ MoodSection: () => null }));
|
||||
vi.mock("../labs-section", () => ({ LabsSection: () => null }));
|
||||
vi.mock("../illness-section", () => ({ IllnessSection: () => null }));
|
||||
vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null }));
|
||||
@@ -29,12 +28,13 @@ import {
|
||||
} from "../layout-groups";
|
||||
|
||||
describe("layout-groups registry", () => {
|
||||
it("exposes the eight Appearance modules as statically-generated subpage params", () => {
|
||||
it("exposes the seven Arrangement modules as statically-generated subpage params", () => {
|
||||
// v1.44 — mood left: its page was tag management, which lives under
|
||||
// Settings > Modules > Mood now (`/settings/layout/mood` redirects).
|
||||
expect(LAYOUT_GROUP_IDS).toEqual([
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"documents",
|
||||
@@ -45,7 +45,6 @@ describe("layout-groups registry", () => {
|
||||
{ module: "dashboard" },
|
||||
{ module: "insights" },
|
||||
{ module: "medications" },
|
||||
{ module: "mood" },
|
||||
{ module: "labs" },
|
||||
{ module: "illness" },
|
||||
{ module: "documents" },
|
||||
@@ -66,7 +65,7 @@ describe("layout-groups registry", () => {
|
||||
? layoutGroupModule(id)
|
||||
: "not-a-group";
|
||||
expect(gate("medications")).toBe("medications");
|
||||
expect(gate("mood")).toBe("mood");
|
||||
expect(gate("mood")).toBe("not-a-group");
|
||||
expect(gate("labs")).toBe("labs");
|
||||
expect(gate("illness")).toBe("illness");
|
||||
// dashboard / insights / vorsorge are never gated.
|
||||
|
||||
@@ -54,7 +54,6 @@ describe("<LayoutSection> hub", () => {
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
@@ -68,7 +67,7 @@ describe("<LayoutSection> hub", () => {
|
||||
expect(html).not.toContain('href="/settings/layout/medications"');
|
||||
expect(html).not.toContain('href="/settings/layout/labs"');
|
||||
// Other modules unaffected; Vorsorge is never gated.
|
||||
expect(html).toContain('href="/settings/layout/mood"');
|
||||
expect(html).toContain('href="/settings/layout/illness"');
|
||||
expect(html).toContain('href="/settings/layout/vorsorge"');
|
||||
});
|
||||
|
||||
@@ -82,6 +81,7 @@ describe("<LayoutSection> hub", () => {
|
||||
expect(html).toContain('href="/settings/layout/dashboard"');
|
||||
expect(html).toContain('href="/settings/layout/insights"');
|
||||
expect(html).toContain('href="/settings/layout/vorsorge"');
|
||||
// v1.44 — mood is tag management and left Arrangement for Modules.
|
||||
expect(html).not.toContain('href="/settings/layout/mood"');
|
||||
});
|
||||
|
||||
|
||||
@@ -137,11 +137,11 @@ describe("<MedicationsSection> — SSR smoke", () => {
|
||||
expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin"));
|
||||
});
|
||||
|
||||
it("gathers the injection-sites card (moved from the account profile)", () => {
|
||||
// v1.18.0 — injection-site exclusions are a medication setting and
|
||||
// now live on this screen alongside the list view + order.
|
||||
it("holds order and view only; the injection sites live under Modules", () => {
|
||||
// v1.44 — Arrangement holds order and view; the injection-site
|
||||
// exclusions moved to Settings > Modules > Medications.
|
||||
const html = render();
|
||||
expect(html).toContain("Globally excluded injection sites");
|
||||
expect(html).not.toContain("Globally excluded injection sites");
|
||||
});
|
||||
|
||||
it("feeds the editor the saved order (active block re-sorted by layout.order)", () => {
|
||||
|
||||
@@ -131,7 +131,6 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import { MoodSection } from "../mood-section";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
@@ -142,9 +141,7 @@ function render(locale: "en" | "de" = "en"): string {
|
||||
// shared frame the route wraps the section in.
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<SettingsSectionFrame slug="mood">
|
||||
<MoodSection />
|
||||
</SettingsSectionFrame>
|
||||
<MoodSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
@@ -159,7 +156,8 @@ describe("<MoodSection> — SSR smoke", () => {
|
||||
// v1.18.1 — the section blurb was dropped; the sr-only heading + the
|
||||
// card content carry the meaning.
|
||||
const html = render();
|
||||
expect(html).toContain("settings-section-mood-title");
|
||||
// v1.44 — the page lives under Settings > Modules > Mood; its heading
|
||||
// comes from the route, not from a frame here.
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
expect(html).not.toContain("mood.manage.");
|
||||
});
|
||||
|
||||
@@ -121,60 +121,27 @@ describe("notification channel cards", () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", <WebhookCard key="webhook" isAuthenticated />, 1],
|
||||
["ntfy", <NtfyCard key="ntfy" isAuthenticated />, 1],
|
||||
["email", <EmailCard key="email" isAuthenticated />, 1],
|
||||
["telegram", <TelegramCard key="telegram" isAuthenticated />, 0],
|
||||
["webhook", <WebhookCard key="webhook" isAuthenticated />],
|
||||
["ntfy", <NtfyCard key="ntfy" isAuthenticated />],
|
||||
["email", <EmailCard key="email" isAuthenticated />],
|
||||
["telegram", <TelegramCard key="telegram" isAuthenticated />],
|
||||
] as const)(
|
||||
"%s switch invokes the enable-only mutation rather than the full save",
|
||||
async (channel, card, toggleMutationIndex) => {
|
||||
render(card);
|
||||
|
||||
await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
|
||||
|
||||
expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
|
||||
`/api/settings/${channel}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[<WebhookCard key="webhook" isAuthenticated />, 1, ["settings", "webhook"]],
|
||||
[<NtfyCard key="ntfy" isAuthenticated />, 1, ["settings", "ntfy"]],
|
||||
[<EmailCard key="email" isAuthenticated />, 1, ["settings", "email"]],
|
||||
[
|
||||
<TelegramCard key="telegram" isAuthenticated />,
|
||||
0,
|
||||
["telegram", "settings"],
|
||||
],
|
||||
] as const)(
|
||||
"rolls each visible switch back to its previous server state on error",
|
||||
async (card, toggleMutationIndex, queryKey) => {
|
||||
const previous = { enabled: false, persistedConfig: "unchanged" };
|
||||
mocks.queryClient.getQueryData.mockReturnValue(previous);
|
||||
render(card);
|
||||
const toggle = mocks.mutationOptions[toggleMutationIndex]!;
|
||||
|
||||
const context = await toggle.onMutate!(true);
|
||||
const optimisticUpdater = mocks.queryClient.setQueryData.mock
|
||||
.calls[0]![1] as (current: typeof previous) => typeof previous;
|
||||
expect(optimisticUpdater(previous)).toEqual({
|
||||
...previous,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
toggle.onError!(new Error("save failed"), true, context);
|
||||
|
||||
expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
|
||||
queryKey,
|
||||
previous,
|
||||
);
|
||||
"%s enable switch is a form field: it writes nothing on its own",
|
||||
async (_channel, card) => {
|
||||
const html = render(card);
|
||||
// Rendering the switch writes nothing, and the card mounts no
|
||||
// switch-only mutation: every write it can make is the full form.
|
||||
expect(html).toContain('role="switch"');
|
||||
expect(mocks.apiFetchRaw).not.toHaveBeenCalled();
|
||||
for (const mutation of mocks.mutationOptions) {
|
||||
await mutation.mutationFn(true);
|
||||
}
|
||||
for (const [, init] of mocks.apiFetchRaw.mock.calls as Array<
|
||||
[string, { body: string }]
|
||||
>) {
|
||||
const body = JSON.parse(init.body) as Record<string, unknown>;
|
||||
expect(Object.keys(body).length).toBeGreaterThan(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -185,9 +152,9 @@ describe("notification channel cards", () => {
|
||||
expect(html).toMatch(/<option value="generic" selected="">/);
|
||||
expect(html).toContain('<option value="gotify">');
|
||||
|
||||
// The full save is the first mutation of the last render, whose closure
|
||||
// holds the values seeded from the server.
|
||||
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
|
||||
// The full save is the mutation of the last render, whose closure holds
|
||||
// the values seeded from the server, the enable switch included.
|
||||
const save = mocks.mutationOptions.at(-1)!;
|
||||
await save.mutationFn(false);
|
||||
|
||||
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: vi.fn(),
|
||||
}));
|
||||
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} from "../notification-channel-toggle";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("notification channel enable-only mutation", () => {
|
||||
it.each([
|
||||
"/api/settings/webhook",
|
||||
"/api/settings/ntfy",
|
||||
"/api/settings/email",
|
||||
"/api/settings/telegram",
|
||||
])("sends only enabled to %s", async (endpoint) => {
|
||||
vi.mocked(apiFetchRaw).mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await persistChannelEnabled(endpoint, true, "save failed");
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
expect(
|
||||
JSON.parse(vi.mocked(apiFetchRaw).mock.calls[0]![1]!.body as string),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("changes only cached enabled state and leaves configuration fields intact", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["settings", "webhook"] as const;
|
||||
const settings = {
|
||||
enabled: false,
|
||||
url: "https://saved.example/hook",
|
||||
topic: "saved-topic",
|
||||
recipient: "saved@example.com",
|
||||
hasHeaderValue: true,
|
||||
};
|
||||
client.setQueryData(key, settings);
|
||||
|
||||
const previous = optimisticallySetChannelEnabled(client, key, true);
|
||||
|
||||
expect(previous).toEqual(settings);
|
||||
expect(client.getQueryData(key)).toEqual({ ...settings, enabled: true });
|
||||
});
|
||||
|
||||
it("restores the exact previous cached settings after a failed toggle", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["settings", "telegram"] as const;
|
||||
const previousSettings = {
|
||||
enabled: false,
|
||||
hasBotToken: true,
|
||||
chatId: "123456",
|
||||
};
|
||||
client.setQueryData(key, previousSettings);
|
||||
|
||||
const previous = optimisticallySetChannelEnabled(client, key, true);
|
||||
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(true);
|
||||
|
||||
rollbackChannelEnabled(client, key, previous);
|
||||
|
||||
expect(client.getQueryData(key)).toEqual(previousSettings);
|
||||
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces the route error so the card mutation can roll back", async () => {
|
||||
vi.mocked(apiFetchRaw).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: "Stored configuration is missing" }),
|
||||
{
|
||||
status: 422,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
|
||||
).rejects.toThrow("Stored configuration is missing");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* v1.18.0 — module-gated per-type visibility on the consolidated
|
||||
* "Benachrichtigungen" reminder-types home.
|
||||
* Settings > Notifications is the one home for every reminder kind, each
|
||||
* card gated on its module and failing open.
|
||||
*
|
||||
* The mood reminder shows only when the `mood` module is enabled. Low-stock
|
||||
* maps to the medications domain (shown unless explicitly disabled). The gate
|
||||
* fails OPEN, so a `/me` payload without a module map keeps every card
|
||||
* visible.
|
||||
*
|
||||
* v1.18.6 — the proactive Coach nudge card moved to Settings → Coach, so
|
||||
* it no longer appears on this screen.
|
||||
* v1.18.0 — mood reminder on `mood`, low-stock on `medications`.
|
||||
* v1.44 — the Coach nudges came back (gated on the Coach surface), the
|
||||
* checkup reminder switches joined (not a module, always shown), and a card
|
||||
* names the reminders set where their subject lives (dose times on the
|
||||
* medication, the cycle reminders in the per-event matrix).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -25,8 +23,9 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The auth mock is driven per-test via a mutable ref so each render can pick
|
||||
// a different module map without re-mocking the module.
|
||||
// Mounted, so the Coach gate reads the module map like a hydrated page.
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
|
||||
const authRef: { modules?: Record<string, boolean> } = {};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -49,38 +48,43 @@ function render(modules?: Record<string, boolean>) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("<NotificationsSection> module-gated reminder types", () => {
|
||||
it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => {
|
||||
describe("<NotificationsSection> reminder kinds", () => {
|
||||
it("shows every reminder card when no module map (fails open)", () => {
|
||||
const html = render(undefined);
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
// v1.18.6 — the coach nudge moved to Settings → Coach.
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
expect(html).toContain('id="vorsorge-reminders"');
|
||||
expect(html).toContain('data-slot="notifications-elsewhere"');
|
||||
expect(html).toContain('id="cycle-reminders"');
|
||||
});
|
||||
|
||||
it("hides the mood card when the mood module is disabled", () => {
|
||||
const html = render({ mood: false });
|
||||
expect(html).not.toContain('id="mood-reminder"');
|
||||
// Low-stock is unaffected.
|
||||
expect(html).toContain('id="low-stock"');
|
||||
});
|
||||
|
||||
it("always shows low-stock unless medications is disabled", () => {
|
||||
const html = render({ mood: false });
|
||||
expect(html).toContain('id="low-stock"');
|
||||
expect(html).not.toContain('id="mood-reminder"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
it("hides the low-stock card and the dose row when medications is disabled", () => {
|
||||
const html = render({ medications: false });
|
||||
expect(html).not.toContain('id="low-stock"');
|
||||
expect(html).not.toContain('href="/medications"');
|
||||
});
|
||||
|
||||
it("drops the channels / inbox cross-links and the Vorsorge block (D5)", () => {
|
||||
// v1.18.1 — the page is lean: no section blurb, no channels/inbox
|
||||
// cross-links, and no embedded Vorsorge editor (it has its own page).
|
||||
it("hides the Coach nudges when the Coach is off", () => {
|
||||
expect(render({ coach: false })).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
|
||||
it("drops the elsewhere card when neither of its reminders applies", () => {
|
||||
const html = render({ medications: false, cycle: false });
|
||||
expect(html).not.toContain('data-slot="notifications-elsewhere"');
|
||||
// The checkup reminders are not a module and stay.
|
||||
expect(html).toContain('id="vorsorge-reminders"');
|
||||
});
|
||||
|
||||
it("links to the per-event matrix and to the delivery channels", () => {
|
||||
const html = render({});
|
||||
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
|
||||
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
|
||||
// The remaining reminder-type cards (no coach nudge here anymore).
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
expect(html).toContain('href="/notifications"');
|
||||
expect(html).toContain('href="/settings/integrations#channels"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PreviewSection } from "../preview-section";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
|
||||
});
|
||||
if (topics !== undefined) {
|
||||
client.setQueryData(queryKeys.previewFeatures(), { topics });
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>
|
||||
<PreviewSection />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<PreviewSection>", () => {
|
||||
it("shows the Topics switch off by default, saved on change", () => {
|
||||
const html = render(false);
|
||||
expect(html).toContain('data-slot="settings-preview-topics-switch"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/,
|
||||
);
|
||||
expect(html).toContain("Topics (beta)");
|
||||
// A switch, no Save: one save model per card.
|
||||
expect(html).not.toContain('type="submit"');
|
||||
});
|
||||
|
||||
it("reads on once the person switched it on", () => {
|
||||
expect(render(true)).toMatch(
|
||||
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the existing pages stay as they are and nothing raw leaks", () => {
|
||||
const html = render(false, "de");
|
||||
expect(html).toContain("Themen (Beta)");
|
||||
expect(html).not.toContain("settings.preview.");
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
|
||||
it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => {
|
||||
it("renders the assembled sections (encryption, retention, delete)", () => {
|
||||
const html = render(<PrivacySection />);
|
||||
// Encryption-at-rest block driven from the seeded summary.
|
||||
expect(html).toContain("AES-256-GCM");
|
||||
@@ -63,15 +63,14 @@ describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
|
||||
expect(html).toContain("365");
|
||||
// The honest backup↔deletion lag disclosure is present.
|
||||
expect(html.toLowerCase()).toContain("backup");
|
||||
// Links to the existing export + destructive surfaces (assembly, not rebuild).
|
||||
expect(html).toContain('href="/settings/export"');
|
||||
expect(html).toContain('href="/settings/gesundheitsakte"');
|
||||
// Links to the destructive surface (assembly, not rebuild).
|
||||
expect(html).toContain('href="/settings/advanced"');
|
||||
// Embedded active-session + security-activity cards.
|
||||
expect(html).toContain("settings-security-sessions-card");
|
||||
// Trusted devices now lives here (and only here) — its single home is the
|
||||
// Privacy dashboard, not the Security section.
|
||||
expect(html).toContain("settings-trusted-devices-card");
|
||||
// v1.44 — sessions, trusted devices and the activity feed live under
|
||||
// Security, and the duplicate export card is gone: Export & import and
|
||||
// the Health record sit beside this page in the Data group.
|
||||
expect(html).not.toContain("settings-security-sessions-card");
|
||||
expect(html).not.toContain("settings-trusted-devices-card");
|
||||
expect(html).not.toContain('href="/settings/export"');
|
||||
});
|
||||
|
||||
it("shows a loading placeholder for the retention block before the summary resolves", () => {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* One save model per card.
|
||||
*
|
||||
* A settings card either saves each control the moment it changes (switches,
|
||||
* selects, segmented choices) or collects a multi-field draft behind one
|
||||
* explicit Save. Never both: a card with an instant switch beside a Save
|
||||
* button leaves a person guessing which of their changes the button covers,
|
||||
* and the channel cards (Telegram, ntfy, webhook, email) did exactly that —
|
||||
* the enable switch wrote on tap while the rest of the form waited for Save.
|
||||
*
|
||||
* The guard splits every settings source into card units (each
|
||||
* `<SettingsCard>…</SettingsCard>` block; a file without one is a single
|
||||
* unit) and fails a unit that both
|
||||
* - saves explicitly: a `type="submit"` control or the `common.save` label,
|
||||
* - saves instantly: a Switch / NativeSelect / Select / Segmented / Checkbox
|
||||
* whose change handler does more than set local draft state.
|
||||
*
|
||||
* Its limit, written down: the handler check is textual. It follows calls to
|
||||
* functions declared in the same file two levels deep; a write hidden behind
|
||||
* a callback handed in through props, or behind an imported helper whose
|
||||
* name carries none of the write markers, reads as a draft field.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SETTINGS_DIR = join(ROOT, "src", "components", "settings");
|
||||
const EXTRA_FILES = [
|
||||
"src/components/labs/labs-settings.tsx",
|
||||
"src/components/illness/illness-settings.tsx",
|
||||
"src/components/measurement-reminders/vorsorge-settings.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
* `<file>#<card index>` → why the unit may mix both models. Empty on purpose;
|
||||
* an entry needs a reason a reviewer can check.
|
||||
*/
|
||||
const ALLOWLIST: Readonly<Record<string, string>> = {};
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
out.push(...walk(full));
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Each `<SettingsCard>` block with its nested cards counted. */
|
||||
function splitCardUnits(source: string): string[] {
|
||||
const open = /<SettingsCard\b/g;
|
||||
const units: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
let consumedTo = 0;
|
||||
while ((match = open.exec(source)) !== null) {
|
||||
if (match.index < consumedTo) continue;
|
||||
let depth = 0;
|
||||
const token = /<SettingsCard\b|<\/SettingsCard>/g;
|
||||
token.lastIndex = match.index;
|
||||
let t: RegExpExecArray | null;
|
||||
let end = source.length;
|
||||
while ((t = token.exec(source)) !== null) {
|
||||
depth += t[0] === "</SettingsCard>" ? -1 : 1;
|
||||
if (depth === 0) {
|
||||
end = t.index + t[0].length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
units.push(source.slice(match.index, end));
|
||||
consumedTo = end;
|
||||
}
|
||||
return units.length > 0 ? units : [source];
|
||||
}
|
||||
|
||||
/** The JSX opening tag starting at `start`, braces and strings respected. */
|
||||
function openingTag(source: string, start: number): string {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (quote) {
|
||||
if (c === quote && source[i - 1] !== "\\") quote = null;
|
||||
continue;
|
||||
}
|
||||
if (depth > 0 && (c === '"' || c === "'" || c === "`")) {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
else if (c === ">" && depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
return source.slice(start);
|
||||
}
|
||||
|
||||
/** The `{…}` expression of `attr=` inside a tag, or null. */
|
||||
function attributeExpression(tag: string, attr: string): string | null {
|
||||
const at = tag.search(new RegExp(`\\b${attr}=\\{`));
|
||||
if (at < 0) return null;
|
||||
const open = tag.indexOf("{", at);
|
||||
let depth = 0;
|
||||
for (let i = open; i < tag.length; i++) {
|
||||
if (tag[i] === "{") depth++;
|
||||
else if (tag[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return tag.slice(open + 1, i).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A call that writes to the server (or the locale) on its own. */
|
||||
const WRITE_MARKER =
|
||||
/\.mutate(?:Async)?\(|\bapi(?:FetchRaw|Fetch|Get|Put|Patch|Post|Delete)\(|\bsafeFetch\(|\bpersist\w*\(/;
|
||||
|
||||
/** The body of a function or arrow const declared in `source`, or null. */
|
||||
function localDefinition(source: string, name: string): string | null {
|
||||
const decl = new RegExp(
|
||||
`(?:function\\s+${name}\\s*\\(|(?:const|let)\\s+${name}\\s*=)`,
|
||||
).exec(source);
|
||||
if (!decl) return null;
|
||||
// A braceless arrow (`const f = (v) => g(v);`) ends at its line.
|
||||
const rest = source.slice(decl.index);
|
||||
const arrow = /^[^\n{]*=>\s*(?!\{)([^\n]*)/.exec(rest);
|
||||
if (arrow) return arrow[0];
|
||||
const open = source.indexOf("{", decl.index);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(decl.index, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a change handler writes on change: the handler itself carries a
|
||||
* write call, or a function it calls that is declared in the same file does
|
||||
* (followed two levels deep). A handler that only touches local state, or
|
||||
* calls a callback handed in through props, reads as a draft field.
|
||||
*/
|
||||
function writesOnChange(handler: string, source: string, depth = 0): boolean {
|
||||
if (WRITE_MARKER.test(handler)) return true;
|
||||
if (depth >= 2) return false;
|
||||
const names = new Set<string>();
|
||||
if (/^[A-Za-z_]\w*$/.test(handler)) names.add(handler);
|
||||
for (const m of handler.matchAll(/\b([A-Za-z_]\w*)\s*\(/g)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
for (const name of names) {
|
||||
const body = localDefinition(source, name);
|
||||
if (body && writesOnChange(body, source, depth + 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const CONTROL = /<(Switch|NativeSelect|Select|Segmented|Checkbox)\b/g;
|
||||
const HANDLERS = ["onCheckedChange", "onChange", "onValueChange"];
|
||||
|
||||
function savesInstantly(unit: string, source: string = unit): boolean {
|
||||
CONTROL.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CONTROL.exec(unit)) !== null) {
|
||||
const tag = openingTag(unit, match.index);
|
||||
for (const attr of HANDLERS) {
|
||||
const handler = attributeExpression(tag, attr);
|
||||
if (handler !== null && writesOnChange(handler, source)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function savesExplicitly(unit: string): boolean {
|
||||
return /type="submit"/.test(unit) || /t\("common\.save"\)/.test(unit);
|
||||
}
|
||||
|
||||
function scan() {
|
||||
const files = [
|
||||
...walk(SETTINGS_DIR),
|
||||
...EXTRA_FILES.map((f) => join(ROOT, f)),
|
||||
];
|
||||
const mixed: string[] = [];
|
||||
let units = 0;
|
||||
for (const file of files) {
|
||||
const rel = relative(ROOT, file);
|
||||
const source = readFileSync(file, "utf8");
|
||||
splitCardUnits(source).forEach((unit, index) => {
|
||||
units++;
|
||||
if (savesInstantly(unit, source) && savesExplicitly(unit)) {
|
||||
const key = `${rel}#${index}`;
|
||||
if (!ALLOWLIST[key]) mixed.push(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { mixed, units, files: files.length };
|
||||
}
|
||||
|
||||
describe("settings save model", () => {
|
||||
it("classifies handlers", () => {
|
||||
const source = `
|
||||
function handleToggle(next) { mutation.mutate(next); }
|
||||
function toggleItem(kind, on) { setDraft({ kind, on }); }
|
||||
const flip = (v) => handleToggle(v);
|
||||
`;
|
||||
expect(writesOnChange("setChatId", source)).toBe(false);
|
||||
expect(writesOnChange("(e) => setUrl(e.target.value)", source)).toBe(false);
|
||||
expect(writesOnChange("(v) => toggleItem('a', v)", source)).toBe(false);
|
||||
expect(
|
||||
writesOnChange("(checked) => toggleEnabled.mutate(checked)", source),
|
||||
).toBe(true);
|
||||
expect(writesOnChange("handleToggle", source)).toBe(true);
|
||||
expect(writesOnChange("(v) => flip(v)", source)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a card that both saves on change and behind a Save button", () => {
|
||||
const mixedCard = `<SettingsCard><Switch onCheckedChange={(v) => save.mutate(v)} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
|
||||
const draftCard = `<SettingsCard><Switch onCheckedChange={setEnabled} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
|
||||
expect(
|
||||
savesInstantly(splitCardUnits(mixedCard)[0]) &&
|
||||
savesExplicitly(splitCardUnits(mixedCard)[0]),
|
||||
).toBe(true);
|
||||
expect(savesInstantly(splitCardUnits(draftCard)[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("finds no card that mixes instant and explicit saving", () => {
|
||||
const { mixed, units, files } = scan();
|
||||
// An empty scan would pass for the wrong reason.
|
||||
expect(files).toBeGreaterThan(50);
|
||||
expect(units).toBeGreaterThan(100);
|
||||
expect(
|
||||
mixed,
|
||||
`cards mixing both save models:\n${mixed.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every allowlist entry pointing at a unit that still mixes", () => {
|
||||
for (const key of Object.keys(ALLOWLIST)) {
|
||||
const [file, index] = key.split("#");
|
||||
const source = readFileSync(join(ROOT, file), "utf8");
|
||||
const unit = splitCardUnits(source)[Number(index)];
|
||||
expect(unit, key).toBeDefined();
|
||||
expect(savesInstantly(unit, source) && savesExplicitly(unit), key).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -246,7 +246,8 @@ describe("settings sections — SSR smoke", () => {
|
||||
// card is gone.
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
// v1.44 — the Coach nudges are back with the other reminders.
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
// v1.25.7 — delivery channels moved to Settings → Integrationen, so the
|
||||
// channels group + panel no longer render on this surface.
|
||||
expect(html).not.toContain('id="channels"');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { join } from "node:path";
|
||||
const mockPathnameRef = { value: "/settings/account" };
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathnameRef.value,
|
||||
useRouter: () => ({ replace: () => {}, push: () => {} }),
|
||||
}));
|
||||
|
||||
// v1.18.0 — the shell module-gates per-submodule nav entries off
|
||||
@@ -169,6 +170,8 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
// encryption / session / activity pieces into one pane, after `advanced`.
|
||||
"privacy",
|
||||
// v1.44 — per-user preview opt-ins, in the System group before About.
|
||||
"preview",
|
||||
"about",
|
||||
]);
|
||||
});
|
||||
@@ -193,23 +196,59 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
});
|
||||
|
||||
describe("<SettingsShell>", () => {
|
||||
it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => {
|
||||
it("renders the nine groups as non-interactive sidebar headers, in order", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
for (const label of [
|
||||
const labels = [
|
||||
"Account",
|
||||
"Your data",
|
||||
"Display",
|
||||
"Tracking",
|
||||
"Arrangement",
|
||||
"Notifications",
|
||||
"Health profile",
|
||||
"AI",
|
||||
"Connectivity",
|
||||
"Connections",
|
||||
"Data",
|
||||
"System",
|
||||
]) {
|
||||
expect(html).toContain(`>${label}</li>`);
|
||||
];
|
||||
let cursor = -1;
|
||||
for (const label of labels) {
|
||||
const at = html.indexOf(`>${label}</li>`);
|
||||
expect(at, label).toBeGreaterThan(cursor);
|
||||
cursor = at;
|
||||
}
|
||||
// Seven groups produce six quiet separators in the mobile strip.
|
||||
expect(
|
||||
html.match(/aria-hidden="true"/g)?.length ?? 0,
|
||||
).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("puts every section in the group the plan names", () => {
|
||||
const groupOf = Object.fromEntries(
|
||||
SETTINGS_SECTIONS.map((section) => [section.slug, section.group]),
|
||||
);
|
||||
expect(groupOf).toEqual({
|
||||
account: "account",
|
||||
security: "account",
|
||||
access: "account",
|
||||
modules: "tracking",
|
||||
layout: "arrangement",
|
||||
notifications: "notifications",
|
||||
score: "healthProfile",
|
||||
thresholds: "healthProfile",
|
||||
anamnesis: "healthProfile",
|
||||
environment: "healthProfile",
|
||||
ai: "ai",
|
||||
coach: "ai",
|
||||
integrations: "connections",
|
||||
sources: "connections",
|
||||
api: "connections",
|
||||
mcp: "connections",
|
||||
export: "data",
|
||||
gesundheitsakte: "data",
|
||||
privacy: "data",
|
||||
advanced: "data",
|
||||
preview: "system",
|
||||
about: "system",
|
||||
});
|
||||
// Every routable section has a nav entry, and nothing else does.
|
||||
expect([...SETTINGS_SECTIONS.map((s) => s.slug)].sort()).toEqual(
|
||||
[...SETTINGS_SECTION_SLUGS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the sticky desktop sidebar on the heading row", () => {
|
||||
@@ -230,19 +269,14 @@ describe("<SettingsShell>", () => {
|
||||
expect(source).not.toMatch(/<aside[\s\S]*?md:row-start-2/);
|
||||
});
|
||||
|
||||
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
it("renders every navigable section link in the sidebar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
// Every slug — including `about`, which returned to the shell nav
|
||||
// as the last entry after living dropdown-only since v1.4.33 IW7 —
|
||||
// renders in both layouts.
|
||||
// The phone's section sheet mounts its list only when opened, so the
|
||||
// server markup carries each link once, in the desktop sidebar.
|
||||
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
|
||||
for (const slug of navigableSlugs) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
// Two renders — mobile strip + desktop sidebar — guarantee the link
|
||||
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
|
||||
// but the markup is always present so it's keyboard-discoverable
|
||||
// before media queries resolve.
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
expect(matches?.length ?? 0).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -259,9 +293,9 @@ describe("<SettingsShell>", () => {
|
||||
|
||||
it('marks the active section with aria-current="page" (and only that one)', () => {
|
||||
const html = renderShell({ active: "notifications" });
|
||||
// Both layouts emit the active link, so two `aria-current="page"`.
|
||||
// The sidebar marks the active link; the phone sheet is closed.
|
||||
const occurrences = html.match(/aria-current="page"/g);
|
||||
expect(occurrences?.length ?? 0).toBe(2);
|
||||
expect(occurrences?.length ?? 0).toBe(1);
|
||||
|
||||
// And the active link is on the notifications href. Attribute order
|
||||
// in React SSR is alphabetic, so `aria-current` precedes `href` —
|
||||
@@ -269,28 +303,38 @@ describe("<SettingsShell>", () => {
|
||||
// future React renderer changes.
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
|
||||
});
|
||||
|
||||
it("derives the active slug from the current pathname when `active` is omitted", () => {
|
||||
const html = renderShell({ pathname: "/settings/api" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
|
||||
});
|
||||
|
||||
it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => {
|
||||
// On `/settings/dashboard` (a Layout child reached through the hub) the
|
||||
// Layout nav entry must read active even though Dashboard has no nav
|
||||
// entry of its own. Both layouts emit the active link → two matches.
|
||||
for (const child of ["/settings/dashboard", "/settings/insights"]) {
|
||||
it("highlights the Arrangement hub on a legacy child path and on its sub-pages", () => {
|
||||
// `/settings/dashboard` (a redirected legacy child) and the sub-pages
|
||||
// under `/settings/layout/<id>` both read as the Arrangement entry.
|
||||
for (const child of [
|
||||
"/settings/dashboard",
|
||||
"/settings/insights",
|
||||
"/settings/layout/labs",
|
||||
]) {
|
||||
const html = renderShell({ pathname: child });
|
||||
const layoutActive =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
|
||||
expect(html.match(layoutActive)?.length ?? 0).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("highlights Modules on a module's own settings page", () => {
|
||||
const html = renderShell({ pathname: "/settings/modules/labs" });
|
||||
const modulesActive =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/modules"|<a\b[^>]*\bhref="\/settings\/modules"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(modulesActive)?.length ?? 0).toBe(1);
|
||||
});
|
||||
|
||||
// v1.37.19 — the Layout children left `SETTINGS_SECTION_SLUGS`, so no
|
||||
// page can pass one as the `active` override anymore; the pathname-based
|
||||
// case above is the remaining (defensive) mapping.
|
||||
@@ -424,7 +468,7 @@ describe("<SettingsShell>", () => {
|
||||
const html = renderShell({ pathname: "/settings/totally-bogus" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — English", () => {
|
||||
@@ -440,7 +484,8 @@ describe("<SettingsShell>", () => {
|
||||
// "Layout": it arranges and picks what each module shows and holds no
|
||||
// theme, so "Appearance" promised a control it does not have.
|
||||
expect(html).toContain('href="/settings/layout"');
|
||||
expect(html).toContain(">Layout</a>");
|
||||
// v1.44 — the hub holds only order and view, and says so.
|
||||
expect(html).toContain(">Arrangement</a>");
|
||||
// v1.25.3 — Channels folded into Notifications, so it is no longer a
|
||||
// left-side entry; Sources keeps its own.
|
||||
expect(html).not.toContain('href="/settings/channels"');
|
||||
@@ -484,7 +529,7 @@ describe("<SettingsShell>", () => {
|
||||
// (route stays `/settings/layout`). v1.42 — "Layout" in German too;
|
||||
// "Darstellung" now names the account page's language-and-units card.
|
||||
expect(html).toContain('href="/settings/layout"');
|
||||
expect(html).toContain(">Layout</a>");
|
||||
expect(html).toContain(">Anordnung</a>");
|
||||
// v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps
|
||||
// its own left-side entry.
|
||||
expect(html).not.toContain('href="/settings/channels"');
|
||||
@@ -512,17 +557,14 @@ describe("<SettingsShell>", () => {
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
|
||||
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
|
||||
// The 10-section strip is wider than 393 CSS px, so without
|
||||
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
|
||||
// scrollbar at the top of every settings page. The class is
|
||||
// defined in `globals.css` and combines `scrollbar-width: none`
|
||||
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
|
||||
// else) — scroll behaviour is preserved.
|
||||
const html = renderShell({ active: "account" });
|
||||
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
|
||||
it("gives the phone one button naming the section, not a strip of every section", () => {
|
||||
const html = renderShell({ active: "security" });
|
||||
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>[\s\S]*?<\/nav>/);
|
||||
expect(nav).not.toBeNull();
|
||||
expect(nav![0]).toMatch(/\bno-scrollbar\b/);
|
||||
expect(nav![0]).toMatch(/\boverflow-x-auto\b/);
|
||||
expect(nav![0]).toContain('data-slot="settings-mobile-nav-trigger"');
|
||||
expect(nav![0]).toContain("Security");
|
||||
expect(nav![0]).toContain("Account");
|
||||
expect(nav![0]).not.toContain("overflow-x-auto");
|
||||
expect(nav![0]).not.toContain('href="/settings/');
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,14 @@ function read(rel: string): string {
|
||||
return readFileSync(join(process.cwd(), rel), "utf8");
|
||||
}
|
||||
|
||||
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
|
||||
/**
|
||||
* Every confirm the source spells: an `<AlertDialogAction …>…</AlertDialogAction>`
|
||||
* block, or a `<ConfirmButton … />` whose `confirmLabel` is the action label.
|
||||
*/
|
||||
function actions(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
|
||||
...source.matchAll(/<ConfirmButton\b[\s\S]*?\/>/g),
|
||||
].map((m) => m[0]);
|
||||
}
|
||||
|
||||
@@ -41,7 +45,9 @@ describe("token revoke labels", () => {
|
||||
);
|
||||
expect(revokes.length).toBeGreaterThan(0);
|
||||
for (const action of revokes) {
|
||||
expect(action).toContain('t("settings.tokenRevokeAction")');
|
||||
expect(action).toMatch(
|
||||
/(confirmLabel=\{|>\s*\{)t\("settings\.tokenRevokeAction"\)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -44,10 +44,15 @@ describe("Settings → Account display card", () => {
|
||||
"GlucoseUnitSelect",
|
||||
"TimeFormatSelect",
|
||||
"DateFormatSelect",
|
||||
// v1.44 — the timezone saves on change too, in the display card.
|
||||
"TimezoneSelect",
|
||||
"TimezonePicker",
|
||||
]) {
|
||||
expect(form).not.toContain(control);
|
||||
}
|
||||
// The timezone saves with the form, so it stays inside it.
|
||||
expect(form).toContain("TimezonePicker");
|
||||
const card = src.slice(
|
||||
src.indexOf("export function DisplayPreferencesCard"),
|
||||
);
|
||||
expect(card).toContain("<TimezoneSelect");
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,9 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
* browser actually IS in Berlin, leave it alone — the picker
|
||||
* stays on Berlin and the next save is a no-op.
|
||||
* - If the stored value is the default AND the browser reports a
|
||||
* non-Berlin zone, pre-fill the picker with the detected zone.
|
||||
* The form's existing submit handler persists the change on the
|
||||
* next save; no toast, no banner, no opt-in.
|
||||
* non-Berlin zone, that zone is the suggestion. Since v1.44 the
|
||||
* timezone saves on change in the Display card, which offers the
|
||||
* suggestion as a one-tap action under the select.
|
||||
*
|
||||
* The bootstrap deliberately runs inline during render (the strict
|
||||
* `react-hooks/set-state-in-effect` rule outlaws setState in an
|
||||
|
||||
@@ -28,17 +28,12 @@ import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { TimezoneSelect } from "@/components/settings/timezone-select";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
|
||||
import {
|
||||
detectBrowserTimezone,
|
||||
DEFAULT_TIMEZONE,
|
||||
userDayKey,
|
||||
} from "@/lib/tz/format";
|
||||
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
@@ -52,7 +47,6 @@ import {
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import {
|
||||
clearRejectedField,
|
||||
resolveInitialTimezone,
|
||||
statusText,
|
||||
type StatusMessage,
|
||||
} from "./account-section-utils";
|
||||
@@ -95,7 +89,6 @@ export function AccountSection() {
|
||||
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
|
||||
// The name the app addresses the account by; set in the setup flow, and
|
||||
// until now not editable anywhere after it.
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
@@ -109,7 +102,6 @@ export function AccountSection() {
|
||||
height: EMPTY_HEIGHT_DRAFT,
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
timezone: DEFAULT_TIMEZONE,
|
||||
fullName: "",
|
||||
insurerName: "",
|
||||
insuranceNumber: "",
|
||||
@@ -160,7 +152,6 @@ export function AccountSection() {
|
||||
? dateOnlyKey(new Date(user.dateOfBirth))
|
||||
: "",
|
||||
gender: user.gender ?? "",
|
||||
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
|
||||
fullName: user.fullName ?? "",
|
||||
insurerName: user.insurerName ?? "",
|
||||
insuranceNumber: user.insuranceNumber ?? "",
|
||||
@@ -171,7 +162,6 @@ export function AccountSection() {
|
||||
setHeight(seed.height);
|
||||
setDateOfBirth(seed.dateOfBirth);
|
||||
setGender(seed.gender);
|
||||
setTimezone(seed.timezone);
|
||||
setFullName(seed.fullName);
|
||||
setInsurerName(seed.insurerName);
|
||||
setInsuranceNumber(seed.insuranceNumber);
|
||||
@@ -203,7 +193,6 @@ export function AccountSection() {
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
fullName,
|
||||
insurerName,
|
||||
insuranceNumber,
|
||||
@@ -268,55 +257,36 @@ export function AccountSection() {
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
fullName: fullName.trim(),
|
||||
insurerName: insurerName.trim(),
|
||||
insuranceNumber: insuranceNumber.trim(),
|
||||
};
|
||||
|
||||
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
|
||||
// resolver cache can be invalidated without piping the flag
|
||||
// through the bigger profile patch path. Run the two PUTs in
|
||||
// parallel — they're independent.
|
||||
const [profileRes, tzRes] = await Promise.all([
|
||||
apiFetchRaw("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: savedProfile.email || null,
|
||||
// An emptied field clears the name; the greeting falls back to
|
||||
// the username, as it does for an account that never set one.
|
||||
displayName: savedProfile.displayName || null,
|
||||
...(emailChanged && emailPassword
|
||||
? { currentPassword: emailPassword }
|
||||
: {}),
|
||||
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
fullName: savedProfile.fullName || null,
|
||||
insurerName: savedProfile.insurerName || null,
|
||||
insuranceNumber: savedProfile.insuranceNumber || null,
|
||||
}),
|
||||
// v1.44 — the timezone left this form: it saves on change in the
|
||||
// Display card (`TimezoneSelect`), like the other display preferences.
|
||||
const profileRes = await apiFetchRaw("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: savedProfile.email || null,
|
||||
// An emptied field clears the name; the greeting falls back to
|
||||
// the username, as it does for an account that never set one.
|
||||
displayName: savedProfile.displayName || null,
|
||||
...(emailChanged && emailPassword
|
||||
? { currentPassword: emailPassword }
|
||||
: {}),
|
||||
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
fullName: savedProfile.fullName || null,
|
||||
insurerName: savedProfile.insurerName || null,
|
||||
insuranceNumber: savedProfile.insuranceNumber || null,
|
||||
}),
|
||||
user && timezone && timezone !== user.timezone
|
||||
? apiFetchRaw("/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ timezone }),
|
||||
})
|
||||
: Promise.resolve({ ok: true } as Response),
|
||||
]);
|
||||
});
|
||||
|
||||
setEmailPassword("");
|
||||
|
||||
if (profileRes.ok && tzRes.ok) {
|
||||
// Instant same-tab flip of every rendered timestamp (issue #490):
|
||||
// mirror the accepted zone before the `/me` refetch confirms it —
|
||||
// the same synchronous-update pattern the time/date-format selects
|
||||
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
|
||||
if (user && timezone && timezone !== user.timezone) {
|
||||
storeTimezone(timezone);
|
||||
}
|
||||
if (profileRes.ok) {
|
||||
const profileJson = (await profileRes.json().catch(() => null)) as {
|
||||
data?: { rejectedFields?: RejectedProfileField[] };
|
||||
} | null;
|
||||
@@ -351,22 +321,6 @@ export function AccountSection() {
|
||||
setProfileSeed(savedProfile);
|
||||
await refetch();
|
||||
}
|
||||
} else if (!tzRes.ok) {
|
||||
// The dedicated tz endpoint owns the IANA validation error
|
||||
// text. Surface its message verbatim so the user sees
|
||||
// "Not a valid IANA timezone." instead of the generic save
|
||||
// failure copy.
|
||||
try {
|
||||
const json = (await (tzRes as Response).json()) as { error?: string };
|
||||
setSaveMsg(
|
||||
json.error
|
||||
? { text: json.error }
|
||||
: { key: "settings.timezoneInvalid" },
|
||||
);
|
||||
} catch {
|
||||
setSaveMsg({ key: "settings.timezoneInvalid" });
|
||||
}
|
||||
setSaveMsgType("error");
|
||||
} else {
|
||||
// The server's top-level `error` string is intentionally generic
|
||||
// (never a raw validator message); resolve the specific, localized
|
||||
@@ -584,10 +538,9 @@ export function AccountSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date of birth + timezone share one paired grid row. The
|
||||
timezone stays on this card because it saves with the form
|
||||
(its own PUT runs beside the profile PUT); the display
|
||||
preferences that save on change live in their own card below. */}
|
||||
{/* Date of birth shares the grid of the fields above. The timezone
|
||||
moved to the Display card below in v1.44: it saves on change
|
||||
there, like the other display preferences. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
|
||||
@@ -609,7 +562,6 @@ export function AccountSection() {
|
||||
</p>
|
||||
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
|
||||
</div>
|
||||
<TimezonePicker value={timezone} onChange={setTimezone} />
|
||||
</div>
|
||||
|
||||
{/* v1.7.0 — optional patient-identity fields surfaced on the
|
||||
@@ -817,6 +769,7 @@ export function DisplayPreferencesCard({
|
||||
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
|
||||
<TimeFormatSelect isAuthenticated={isAuthenticated} />
|
||||
<DateFormatSelect isAuthenticated={isAuthenticated} />
|
||||
<TimezoneSelect isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -4,17 +4,7 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -137,62 +127,38 @@ function DataResetCard() {
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<AlertDialog
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the destructive mutation is in
|
||||
// flight so the in-dialog pending state stays visible and a
|
||||
// stray backdrop tap can't dismiss it mid-request.
|
||||
// Hold the dialog open while the request runs so the pending
|
||||
// state stays on screen and a stray dismissal cannot fire it twice.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.dangerZoneConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.dangerZoneConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAllData();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-data-reset-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
title={t("settings.dangerZoneConfirm")}
|
||||
body={t("settings.dangerZoneConfirmDescription")}
|
||||
confirmLabel={t("settings.finalDelete")}
|
||||
onConfirm={() => void handleDeleteAllData()}
|
||||
pending={deleting}
|
||||
slot="settings-data-reset"
|
||||
holdOpen
|
||||
destructive
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<AlertDialog
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
// Hold the dialog open while the request runs so the pending
|
||||
// state stays on screen and a stray dismissal cannot fire it twice.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
title={t("settings.deleteAccountConfirmTitle")}
|
||||
body={t("settings.deleteAccountConfirmDescription")}
|
||||
confirmLabel={t("settings.deleteAccountFinal")}
|
||||
onConfirm={() => void handleDeleteAccount()}
|
||||
pending={deleting}
|
||||
slot="settings-account-delete"
|
||||
holdOpen
|
||||
destructive
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.47 — Coach activation toggle card.
|
||||
*
|
||||
* v1.18.0 — moved out of Settings → AI into the dedicated Coach
|
||||
* section; its `<section>` id namespace is `settings-coach-activate-*`
|
||||
* accordingly. (The file path stays under `ai/` as pre-existing drift.)
|
||||
*
|
||||
* v1.18.1 — polarity flip. The card now reads "Activate Coach" with the
|
||||
* Switch ON by default (the Coach is on for everyone unless the user opts
|
||||
* out). The persisted state is still the single `User.disableCoach` field —
|
||||
* one source of truth — so the Switch's `checked` is `!disableCoach` and a
|
||||
* flip writes `disableCoach: !next`. Presenting it as activate/default-on is
|
||||
* a pure UI inversion over the same column.
|
||||
*
|
||||
* Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates
|
||||
* `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client
|
||||
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
|
||||
* 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 `<MoodReminderCard>`: 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 { MessageCircleHeart } from "lucide-react";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } 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 `<MoodReminderCard>` pattern so the Switch
|
||||
// reacts instantly to the user's tap.
|
||||
const [optimistic, setOptimistic] = useState<boolean | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
// v1.4.48 — auto-clear the inline `<p role="status">` 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<ReturnType<typeof setTimeout> | 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);
|
||||
}
|
||||
|
||||
// `mounted`-gated: `user` comes from the auth query, which can resolve
|
||||
// before this boundary hydrates — the Switch state must match the SSR HTML
|
||||
// during hydration (React #418) and pick up the wire value on the first
|
||||
// client re-render. v1.18.1 (D7) — the toggle now reads "Coach aktivieren"
|
||||
// (default ON), so the Switch is checked when the Coach is ACTIVE. The
|
||||
// persisted column is still `disableCoach`; `optimistic` and `checked` are
|
||||
// the activated-state view of it. The SSR default is ON (checked) so the
|
||||
// default-on contract holds before the wire value resolves.
|
||||
const mounted = useMounted();
|
||||
const activated = mounted
|
||||
? (optimistic ?? !(user?.disableCoach ?? false))
|
||||
: true;
|
||||
|
||||
const mutation = useMutation({
|
||||
// `next` is the desired ACTIVATED state; the column stores the inverse.
|
||||
mutationFn: async (next: boolean) => {
|
||||
await apiPatch("/api/auth/me/disable-coach", { disableCoach: !next });
|
||||
return next;
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
setMsg(
|
||||
next
|
||||
? t("settings.coach.activate.savedOn")
|
||||
: t("settings.coach.activate.savedOff"),
|
||||
);
|
||||
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.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.coach.activate.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 (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="settings-coach-activate-title"
|
||||
data-testid="settings-disable-coach-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
anchor="coach-activate"
|
||||
icon={MessageCircleHeart}
|
||||
title={t("settings.coach.activate.title")}
|
||||
titleId="settings-coach-activate-title"
|
||||
description={t("settings.coach.activate.description")}
|
||||
status={
|
||||
<Switch
|
||||
data-testid="settings-disable-coach-switch"
|
||||
checked={activated}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
aria-label={t("settings.coach.activate.toggleAria")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive mt-3 text-xs"
|
||||
: "text-muted-foreground mt-3 text-xs"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -18,18 +18,8 @@ import {
|
||||
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 { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -246,30 +236,17 @@ export function FallbackChainCard({
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="min-h-11 sm:min-h-9">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t("settings.ai.providerChain.resetDefaults")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.providerChain.resetConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.providerChain.resetConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={resetToDefaults}>
|
||||
{t("settings.ai.providerChain.resetDefaults")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label={t("settings.ai.providerChain.resetDefaults")}
|
||||
title={t("settings.ai.providerChain.resetConfirmTitle")}
|
||||
body={t("settings.ai.providerChain.resetConfirmBody")}
|
||||
confirmLabel={t("settings.ai.providerChain.resetDefaults")}
|
||||
onConfirm={() => void resetToDefaults()}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
icon={<RotateCcw className="h-4 w-4" />}
|
||||
destructive
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -41,6 +41,7 @@ import { EmergencyProfileManager } from "@/components/records/emergency-profile-
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager";
|
||||
import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager";
|
||||
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
|
||||
|
||||
export function AnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -112,6 +113,11 @@ export function AnamnesisSection() {
|
||||
<LifeCircumstancesManager />
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.43 (#614) — screening hints from the life circumstances, with
|
||||
their source. v1.44 — beside the circumstances they come from
|
||||
(they sat on the checkups arrangement page before). */}
|
||||
<ScreeningHintsCard />
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="allergies"
|
||||
|
||||
@@ -17,19 +17,9 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
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 { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
@@ -591,39 +581,19 @@ function ApiTokensCard() {
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-9 w-9"
|
||||
aria-label={t("settings.tokenRevokeAction")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label=""
|
||||
ariaLabel={t("settings.tokenRevokeAction")}
|
||||
title={t("settings.tokenRevoke")}
|
||||
body={t("settings.tokenRevokeDescription")}
|
||||
confirmLabel={t("settings.tokenRevokeAction")}
|
||||
onConfirm={() => handleRevoke(tok.id)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-9 w-9"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
destructive
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -690,39 +660,17 @@ function ApiTokensCard() {
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label={t("settings.tokenRevokeAction")}
|
||||
title={t("settings.tokenRevoke")}
|
||||
body={t("settings.tokenRevokeDescription")}
|
||||
confirmLabel={t("settings.tokenRevokeAction")}
|
||||
onConfirm={() => handleRevoke(tok.id)}
|
||||
size="sm"
|
||||
className="min-h-11 w-full"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
destructive
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,16 +7,29 @@
|
||||
* note left over from an earlier save would say the opposite of the truth.
|
||||
* A secret field (header value, auth token, bot token) is write-only: the
|
||||
* server never returns it, so any typed value counts as a change.
|
||||
*
|
||||
* The enable switch is a field of the same form: it saves with the one Save
|
||||
* button like every other field, so a flipped switch is a change too.
|
||||
*/
|
||||
|
||||
function enabledChanged(
|
||||
form: { enabled: boolean },
|
||||
saved: { enabled?: boolean },
|
||||
): boolean {
|
||||
return form.enabled !== (saved.enabled ?? false);
|
||||
}
|
||||
|
||||
export function isWebhookFormDirty(
|
||||
form: {
|
||||
url: string;
|
||||
headerName: string;
|
||||
headerValue: string;
|
||||
format: "generic" | "gotify";
|
||||
enabled: boolean;
|
||||
},
|
||||
saved: { url: string; headerName: string; format?: string } | undefined,
|
||||
saved:
|
||||
| { url: string; headerName: string; format?: string; enabled?: boolean }
|
||||
| undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
const savedFormat = saved.format === "gotify" ? "gotify" : "generic";
|
||||
@@ -24,34 +37,45 @@ export function isWebhookFormDirty(
|
||||
form.url !== saved.url ||
|
||||
form.headerName !== saved.headerName ||
|
||||
form.headerValue !== "" ||
|
||||
form.format !== savedFormat
|
||||
form.format !== savedFormat ||
|
||||
enabledChanged(form, saved)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNtfyFormDirty(
|
||||
form: { serverUrl: string; topic: string; authToken: string },
|
||||
saved: { serverUrl: string; topic: string } | undefined,
|
||||
form: {
|
||||
serverUrl: string;
|
||||
topic: string;
|
||||
authToken: string;
|
||||
enabled: boolean;
|
||||
},
|
||||
saved: { serverUrl: string; topic: string; enabled?: boolean } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
return (
|
||||
form.serverUrl !== saved.serverUrl ||
|
||||
form.topic !== saved.topic ||
|
||||
form.authToken !== ""
|
||||
form.authToken !== "" ||
|
||||
enabledChanged(form, saved)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmailFormDirty(
|
||||
form: { recipient: string },
|
||||
saved: { recipient: string } | undefined,
|
||||
form: { recipient: string; enabled: boolean },
|
||||
saved: { recipient: string; enabled?: boolean } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
return form.recipient !== saved.recipient;
|
||||
return form.recipient !== saved.recipient || enabledChanged(form, saved);
|
||||
}
|
||||
|
||||
export function isTelegramFormDirty(
|
||||
form: { botToken: string; chatId: string },
|
||||
saved: { chatId?: string | null } | undefined,
|
||||
form: { botToken: string; chatId: string; enabled: boolean },
|
||||
saved: { chatId?: string | null; enabled?: boolean } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
return form.botToken.trim() !== "" || form.chatId !== (saved.chatId ?? "");
|
||||
return (
|
||||
form.botToken.trim() !== "" ||
|
||||
form.chatId !== (saved.chatId ?? "") ||
|
||||
enabledChanged(form, saved)
|
||||
);
|
||||
}
|
||||
@@ -34,17 +34,6 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Brain, Loader2, Pencil, Trash2 } 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 { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -375,54 +364,20 @@ export function CoachMemorySection({
|
||||
</p>
|
||||
<SettingsCardActions>
|
||||
{facts.length > 0 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label={t("settings.ai.coachMemory.forgetAll")}
|
||||
title={t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
body={t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
confirmLabel={t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
onConfirm={() => forgetAll.mutate()}
|
||||
pending={forgetAll.isPending}
|
||||
disabled={!isAuthenticated}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
testId="settings-coach-memory-forget-all"
|
||||
destructive
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* their own dedicated entry so the AI section owns only provider / model /
|
||||
* BYOK configuration. This section gathers the Coach-specific surface:
|
||||
*
|
||||
* 1. Disable Coach — the per-user opt-out (kept first so a user who
|
||||
* wants the surface gone finds it immediately).
|
||||
* 1. Coach state — on or off, with a link to the one switch in
|
||||
* Settings > Modules (v1.44: the second switch that
|
||||
* used to sit here is gone; kept first so a person
|
||||
* who wants the surface gone finds the way at once).
|
||||
* 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`).
|
||||
* 3. Proactive nudge — the daily-nudge opt-out + per-group frequency
|
||||
* (moved here from Settings → Benachrichtigungen in
|
||||
* v1.18.6: it is a Coach setting, not a generic
|
||||
* notification).
|
||||
* 3. Proactive nudges — a pointer to Settings > Notifications, where the
|
||||
* nudge card lives with the other reminders since
|
||||
* v1.44 (`#coach-nudge` forwards there).
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge cards
|
||||
@@ -34,19 +35,30 @@
|
||||
* `<SettingsSectionFrame>` in the route; this body is the Coach cards only.
|
||||
*/
|
||||
|
||||
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import Link from "next/link";
|
||||
import { BellRing, ChevronRight, MessageCircleHeart } from "lucide-react";
|
||||
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { ModuleStateCard } from "@/components/settings/module-state-card";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
export function CoachSection() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// The same reading the Modules switch shows, so both say one thing.
|
||||
const moduleSwitchState = useModuleEnabledState();
|
||||
// Mirror the AI section's hydration-safe gating: the auth query can
|
||||
// resolve before this boundary hydrates, so gate the child props on
|
||||
// `useMounted()` to keep the hydration render matching the SSR HTML.
|
||||
@@ -62,16 +74,38 @@ export function CoachSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DisableCoachCard isAuthenticated={authed} />
|
||||
{/* Before hydration the state reads on, the default, so the server
|
||||
render and the first client paint agree. */}
|
||||
<ModuleStateCard
|
||||
moduleKey="coach"
|
||||
enabled={!mounted || moduleSwitchState("coach")}
|
||||
icon={MessageCircleHeart}
|
||||
title={t("settings.coach.state.title")}
|
||||
description={t("settings.coach.state.description")}
|
||||
anchor="coach-activate"
|
||||
slot="settings-coach-state-card"
|
||||
/>
|
||||
|
||||
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
|
||||
|
||||
{/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6).
|
||||
Keeps the `#coach-nudge` anchor so existing deep links resolve. */}
|
||||
{/* The proactive nudges are a reminder: set under Notifications with
|
||||
the others since v1.44. */}
|
||||
{coachEnabled && (
|
||||
<div id="coach-nudge" className="scroll-mt-28">
|
||||
<CoachNudgeCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
<SettingsCard data-slot="settings-coach-nudge-link">
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
title={t("notifications.coachNudge.title")}
|
||||
description={t("settings.coach.nudgesMoved")}
|
||||
/>
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm" className="min-h-11">
|
||||
<Link href="/settings/notifications#coach-nudge">
|
||||
{t("settings.coach.openNudges")}
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<CoachMemorySection isAuthenticated={authed} />
|
||||
|
||||
@@ -765,6 +765,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
confirmLabel={t("dashboard.layoutResetConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
destructive
|
||||
/>
|
||||
{dirty && (
|
||||
<>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -39,30 +42,6 @@ export function DateFormatSelect({
|
||||
const [optimistic, setOptimistic] = useState<DateFormatPreference | null>(
|
||||
null,
|
||||
);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO";
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -84,31 +63,19 @@ export function DateFormatSelect({
|
||||
// Repaint every formatter consumer right away; the /me refetch below
|
||||
// re-asserts the same value once it settles.
|
||||
storeDateFormat(next);
|
||||
setMsg(t("settings.dateFormat.saved"));
|
||||
setMsgType("success");
|
||||
toastWrittenOutcome("success", t("settings.dateFormat.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error ? err.message : t("settings.dateFormat.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
showErrorToast(t("settings.dateFormat.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelect(next: DateFormatPreference) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
@@ -128,16 +95,8 @@ export function DateFormatSelect({
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg ?? t("settings.dateFormat.description")}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dateFormat.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,11 +13,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} from "@/components/settings/notification-channel-toggle";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -33,10 +30,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
// The enable switch is a draft field: it saves with the form's one Save.
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.settingsEmail(),
|
||||
@@ -47,16 +43,19 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings ? settings.recipient : null;
|
||||
const settingsKey = settings
|
||||
? `${settings.recipient}|${settings.enabled}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey !== null && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setRecipient(settings!.recipient);
|
||||
setEnabled(settings!.enabled);
|
||||
}
|
||||
const dirty = isEmailFormDirty({ recipient }, settings);
|
||||
const dirty = isEmailFormDirty({ recipient, enabled }, settings);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/settings/email", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -69,45 +68,12 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
setSaveError(null);
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleEnabled = useMutation<
|
||||
void,
|
||||
Error,
|
||||
boolean,
|
||||
EmailSettings | undefined
|
||||
>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled("/api/settings/email", enabled, t("common.error")),
|
||||
onMutate: async (enabled) => {
|
||||
const queryKey = queryKeys.settingsEmail();
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
return optimisticallySetChannelEnabled<EmailSettings>(
|
||||
queryClient,
|
||||
queryKey,
|
||||
enabled,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous);
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
|
||||
setSaveError(err.message);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -128,16 +94,16 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
|
||||
<Switch
|
||||
id="email-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate(settings?.enabled ?? false);
|
||||
save.mutate();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
@@ -156,12 +122,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveMsg && (saveMsgType === "error" || !dirty) && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{saveMsg}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -173,7 +136,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
disabled={save.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -42,30 +45,6 @@ export function GlucoseUnitSelect({
|
||||
const { user } = useAuth();
|
||||
|
||||
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 value: GlucoseUnit =
|
||||
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
|
||||
|
||||
@@ -85,36 +64,24 @@ export function GlucoseUnitSelect({
|
||||
return next;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(t("settings.dashboard.glucoseUnit.saved"));
|
||||
setMsgType("success");
|
||||
toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userGlucoseUnit(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.dashboard.glucoseUnit.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
showErrorToast(t("settings.dashboard.glucoseUnit.saveError"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelect(next: GlucoseUnit) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
@@ -134,16 +101,8 @@ export function GlucoseUnitSelect({
|
||||
<option value="mg/dL">mg/dL</option>
|
||||
<option value="mmol/L">mmol/L</option>
|
||||
</NativeSelect>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg ?? t("settings.dashboard.glucoseUnit.description")}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dashboard.glucoseUnit.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -74,6 +74,7 @@ export function InjectionSitesCard({
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="injection-sites"
|
||||
icon={Syringe}
|
||||
title={t("settings.globalExcludedInjectionSitesLabel")}
|
||||
description={t("settings.globalExcludedInjectionSitesHint")}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { surfaceModule } from "@/lib/modules/surface";
|
||||
import { DashboardSection } from "./dashboard-section";
|
||||
import { InsightsSection } from "./insights-section";
|
||||
import { MedicationsSection } from "./medications-section";
|
||||
import { MoodSection } from "./mood-section";
|
||||
import { LabsSection } from "./labs-section";
|
||||
import { IllnessSection } from "./illness-section";
|
||||
import { DocumentsSection } from "./documents-section";
|
||||
@@ -30,6 +29,11 @@ import { VorsorgeSection } from "./vorsorge-section";
|
||||
* usable on the server. It holds references to the `"use client"` section
|
||||
* components, which a server component renders as ordinary client boundaries.
|
||||
*
|
||||
* v1.44 — the hub holds order and view only ("Arrangement"). The mood tag
|
||||
* management, the labs biomarker catalog and text recognition, and the
|
||||
* injection sites moved to the module pages under Settings > Modules
|
||||
* (`module-settings-pages.tsx`); `/settings/layout/mood` redirects there.
|
||||
*
|
||||
* Each module section keeps its module-gate: a disabled module's row / subpage
|
||||
* does not render, read from the resolved `useAuth().user.modules` map (the
|
||||
* same map the nav, the Modules hub, and the Insights pills gate off). The gate
|
||||
@@ -76,12 +80,6 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
|
||||
descriptionKey: "settings.sections.layout.medications.description",
|
||||
Body: MedicationsSection,
|
||||
},
|
||||
{
|
||||
id: "mood",
|
||||
titleKey: "settings.sections.layout.mood.title",
|
||||
descriptionKey: "settings.sections.layout.mood.description",
|
||||
Body: MoodSection,
|
||||
},
|
||||
{
|
||||
id: "labs",
|
||||
titleKey: "settings.sections.layout.labs.title",
|
||||
|
||||
@@ -17,22 +17,12 @@
|
||||
* least-privilege read scope and nothing more.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -42,15 +32,12 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ModuleStateCard } from "@/components/settings/module-state-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
@@ -169,39 +156,17 @@ function McpConnectionsCard() {
|
||||
? formatDateTime(conn.lastUsedAt)
|
||||
: t("settings.mcp.connectionNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.mcp.connectionRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(conn.id)}
|
||||
>
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label={t("settings.mcp.connectionRevoke")}
|
||||
className="min-h-11 w-full"
|
||||
title={t("settings.mcp.connectionRevoke")}
|
||||
body={t("settings.mcp.connectionRevokeDescription")}
|
||||
confirmLabel={t("settings.mcp.connectionRevoke")}
|
||||
onConfirm={() => void handleRevoke(conn.id)}
|
||||
slot="mcp-connection-revoke"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
destructive
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -211,10 +176,16 @@ function McpConnectionsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The connector's state, with a link to the one switch in Settings > Modules.
|
||||
* Turning the module off pauses the endpoint: it refuses every call, while the
|
||||
* tokens and connections below stay as they are and work again once it is on.
|
||||
* Nothing here or in Modules revokes a token as a side effect (#1170); a token
|
||||
* goes only through its own revoke.
|
||||
*/
|
||||
function McpEnableCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is
|
||||
// the resolved state (true only when the user turned it on).
|
||||
@@ -222,53 +193,29 @@ function McpEnableCard() {
|
||||
const endpoint =
|
||||
typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp";
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationKey: queryKeys.modulesPrefs(),
|
||||
mutationFn: async (next: boolean) =>
|
||||
apiPatch("/api/auth/me/modules", { mcp: next }),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.sections.modules.error"), { cause: failure }),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="mcp-enable"
|
||||
icon={Plug}
|
||||
title={t("settings.mcp.enableTitle")}
|
||||
description={t("settings.mcp.enableDescription")}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.mcp.enableToggleLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-label={t("settings.mcp.enableToggleLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
|
||||
{t("settings.mcp.endpointLabel")}
|
||||
</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
|
||||
{endpoint}
|
||||
</code>
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
||||
{t("settings.mcp.connectDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<ModuleStateCard
|
||||
moduleKey="mcp"
|
||||
enabled={enabled}
|
||||
icon={Plug}
|
||||
title={t("settings.mcp.enableTitle")}
|
||||
description={t("settings.mcp.enableDescription")}
|
||||
anchor="mcp-enable"
|
||||
slot="settings-mcp-state-card"
|
||||
>
|
||||
<p className="text-sm">{t("settings.mcp.offKeepsTokens")}</p>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
|
||||
{t("settings.mcp.endpointLabel")}
|
||||
</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
|
||||
{endpoint}
|
||||
</code>
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
||||
{t("settings.mcp.connectDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</ModuleStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -508,39 +455,17 @@ function McpTokensCard() {
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label={t("settings.tokenRevokeAction")}
|
||||
className="min-h-11 w-full"
|
||||
title={t("settings.tokenRevoke")}
|
||||
body={t("settings.tokenRevokeDescription")}
|
||||
confirmLabel={t("settings.tokenRevokeAction")}
|
||||
onConfirm={() => void handleRevoke(tok.id)}
|
||||
slot="mcp-token-revoke"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
destructive
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -11,8 +11,6 @@ import { MedicationViewToggle } from "@/components/medications/medication-view-t
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
@@ -24,7 +22,7 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
|
||||
* from a Layout-hub child to its own standalone nav entry and gathered the
|
||||
* medication-specific preferences here. v1.18.1 (D3) made medications a
|
||||
* toggleable fail-open module, so this nav entry hides when the account
|
||||
* turns the module off (the medication data routes stay live). Hosts three
|
||||
* turns the module off (the medication data routes stay live). Hosts two
|
||||
* blocks:
|
||||
* 1. The list VIEW preference — cards vs table, written optimistically
|
||||
* through the same `PUT /api/medications/layout` the page header
|
||||
@@ -32,8 +30,9 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
|
||||
* 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv)
|
||||
* with drag + arrow reordering, flushed by an explicit Save,
|
||||
* persisted as active-ids-then-inactive-ids on the same layout row.
|
||||
* 3. Injection-site exclusions — moved here from the account profile so
|
||||
* every medication-specific preference lives on one screen.
|
||||
*
|
||||
* v1.44 — Arrangement holds order and view only; the injection-site
|
||||
* exclusions moved to the medications page under Modules.
|
||||
*
|
||||
* The list view + order both write through the same `/api/medications/layout`
|
||||
* contract the /medications page reads, so a save here repaints both views.
|
||||
@@ -49,7 +48,6 @@ interface MedicationListEntry {
|
||||
|
||||
export function MedicationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { layout, isLayoutLoading, setView } = useMedicationListLayout();
|
||||
|
||||
const { data: medications, isLoading } = useQuery({
|
||||
@@ -120,11 +118,6 @@ export function MedicationsSection() {
|
||||
<MedicationOrderEditor medications={ordered} />
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.18.0 — injection-site exclusions are a medication setting;
|
||||
they moved here from the account profile so all medication-specific
|
||||
preferences live in one place. */}
|
||||
<InjectionSitesCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { surfaceModule } from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* Server-safe ids and copy keys of the module pages under Settings > Modules
|
||||
* (`/settings/modules/<id>`). The route reads them in
|
||||
* `generateStaticParams()`, so they live apart from the client bodies in
|
||||
* `module-settings-pages.tsx`.
|
||||
*/
|
||||
export const MODULE_SETTINGS_PAGE_IDS = [
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
] as const;
|
||||
|
||||
export type ModuleSettingsPageId = (typeof MODULE_SETTINGS_PAGE_IDS)[number];
|
||||
|
||||
export function isModuleSettingsPageId(
|
||||
value: string,
|
||||
): value is ModuleSettingsPageId {
|
||||
return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** i18n keys of a page's heading, under `settings.sections.modules.pages`. */
|
||||
export function moduleSettingsPageKeys(id: ModuleSettingsPageId): {
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
} {
|
||||
return {
|
||||
titleKey: `settings.sections.modules.pages.${id}.title`,
|
||||
descriptionKey: `settings.sections.modules.pages.${id}.description`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The module a page belongs to, from the surface map. */
|
||||
export function moduleSettingsPageModule(
|
||||
id: ModuleSettingsPageId,
|
||||
): ModuleKey | undefined {
|
||||
return surfaceModule(`settings-module:${id}`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The pages a module's own settings live on: `/settings/modules/<id>`.
|
||||
*
|
||||
* Settings > Arrangement holds order and view only. What a module needs
|
||||
* beyond that (the biomarker catalog and on-device text recognition for
|
||||
* labs, the mood tags, the injection sites a medication rotation leaves out)
|
||||
* lives on the module's page under Modules, reached from the module's row in
|
||||
* Settings > Modules and from the wrench on the module's own page. Each page
|
||||
* links to the module's arrangement page and back.
|
||||
*
|
||||
* Every page is gated on its module through the surface map
|
||||
* (`settings-module:<id>`), fail-open like the rest of Settings.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowUpDown, ChevronRight, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
import { LabsModuleSettings } from "@/components/labs/labs-settings";
|
||||
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
|
||||
import { MoodSection } from "@/components/settings/mood-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleSettingsPageId } from "./module-settings-page-ids";
|
||||
|
||||
/** A card pointing at the module's order-and-view page under Arrangement. */
|
||||
function ArrangementLinkCard({ id }: { id: ModuleSettingsPageId }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SettingsCard data-slot="module-settings-arrangement-link">
|
||||
<SettingsCardHeader
|
||||
icon={ArrowUpDown}
|
||||
title={t("settings.sections.modules.pages.arrangementTitle")}
|
||||
description={t(
|
||||
"settings.sections.modules.pages.arrangementDescription",
|
||||
)}
|
||||
/>
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm" className="min-h-11">
|
||||
<Link href={`/settings/layout/${id}`}>
|
||||
{t("settings.sections.modules.pages.openArrangement")}
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicationsModulePage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<InjectionSitesCard isAuthenticated={isAuthenticated} />
|
||||
<ArrangementLinkCard id="medications" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabsModulePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LabsModuleSettings />
|
||||
<ArrangementLinkCard id="labs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoodModulePage() {
|
||||
return <MoodSection />;
|
||||
}
|
||||
|
||||
export const MODULE_SETTINGS_PAGE_BODIES: Record<
|
||||
ModuleSettingsPageId,
|
||||
() => React.JSX.Element
|
||||
> = {
|
||||
medications: MedicationsModulePage,
|
||||
mood: MoodModulePage,
|
||||
labs: LabsModulePage,
|
||||
};
|
||||
|
||||
export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) {
|
||||
const Body = MODULE_SETTINGS_PAGE_BODIES[id];
|
||||
return <Body />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way from a module's arrangement page to its own page under Modules,
|
||||
* for the modules that have one.
|
||||
*/
|
||||
export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SettingsCard data-slot="arrangement-module-settings-link">
|
||||
<SettingsCardHeader
|
||||
icon={SlidersHorizontal}
|
||||
title={t("settings.sections.modules.pages.moduleSettingsTitle")}
|
||||
description={t(
|
||||
"settings.sections.modules.pages.moduleSettingsDescription",
|
||||
)}
|
||||
/>
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm" className="min-h-11">
|
||||
<Link href={`/settings/modules/${id}`}>
|
||||
{t("settings.sections.modules.openModuleSettings")}
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A module's on/off state, shown where the module is configured, with a link
|
||||
* to the one switch in Settings > Modules.
|
||||
*
|
||||
* The Coach and the MCP connector each had a second switch on their own page
|
||||
* beside the one in Modules: two controls for one state, and a person who
|
||||
* flipped one never knew the other existed. The switch stays in Modules (the
|
||||
* row carries `id="module-<key>"`); the module's page says what the state is
|
||||
* and where to change it.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
export function ModuleStateCard({
|
||||
moduleKey,
|
||||
enabled,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
anchor,
|
||||
slot,
|
||||
children,
|
||||
}: {
|
||||
moduleKey: ModuleKey;
|
||||
enabled: boolean;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
anchor: string;
|
||||
slot: string;
|
||||
/** Body copy between the header and the link, e.g. what "off" keeps. */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SettingsCard data-slot={slot} data-enabled={enabled ? "true" : "false"}>
|
||||
<SettingsCardHeader
|
||||
anchor={anchor}
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
status={
|
||||
<Badge variant={enabled ? "secondary" : "outline"}>
|
||||
{enabled
|
||||
? t("settings.moduleState.on")
|
||||
: t("settings.moduleState.off")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm" className="min-h-11">
|
||||
<Link href={`/settings/modules#module-${moduleKey}`}>
|
||||
{t("settings.moduleState.change")}
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +92,12 @@ export function ModuleToggleRow({
|
||||
// is in flight.
|
||||
const interactive = !locked && disabledReason == null;
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
// `id="module-<key>"` is the anchor other pages link to when they show a
|
||||
// module's state and send the person here to change it.
|
||||
<div
|
||||
id={`module-${moduleKey}`}
|
||||
className="flex scroll-mt-28 items-start justify-between gap-4 py-3"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Icon
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
|
||||
@@ -40,6 +40,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { MODULE_ICONS } from "@/components/settings/module-icons";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
|
||||
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
@@ -117,6 +118,10 @@ export function ModulesSection() {
|
||||
// Delegated modules carry more than on/off at their canonical
|
||||
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
|
||||
// so keep a small deep-link to it beside the live switch.
|
||||
//
|
||||
// v1.44 — a module with its own page under Modules (labs, mood,
|
||||
// medications) links to it the same way: what the module needs
|
||||
// beyond order and view lives there, not under Arrangement.
|
||||
const manageLink =
|
||||
delegate !== undefined && def.managedAt
|
||||
? {
|
||||
@@ -125,7 +130,12 @@ export function ModulesSection() {
|
||||
section: t(def.managedAt.labelKey),
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
: isModuleSettingsPageId(key)
|
||||
? {
|
||||
href: `/settings/modules/${key}`,
|
||||
label: t("settings.sections.modules.openModuleSettings"),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
interface EnabledSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function persistChannelEnabled(
|
||||
endpoint: string,
|
||||
enabled: boolean,
|
||||
fallbackError: string,
|
||||
): Promise<void> {
|
||||
const response = await apiFetchRaw(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const json = (await response.json()) as { error?: string };
|
||||
throw new Error(json.error || fallbackError);
|
||||
}
|
||||
}
|
||||
|
||||
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
|
||||
queryClient: QueryClient,
|
||||
queryKey: QueryKey,
|
||||
enabled: boolean,
|
||||
): T | undefined {
|
||||
const previous = queryClient.getQueryData<T>(queryKey);
|
||||
queryClient.setQueryData<T>(queryKey, (current) =>
|
||||
current ? { ...current, enabled } : current,
|
||||
);
|
||||
return previous;
|
||||
}
|
||||
|
||||
export function rollbackChannelEnabled<T extends EnabledSettings>(
|
||||
queryClient: QueryClient,
|
||||
queryKey: QueryKey,
|
||||
previous: T | undefined,
|
||||
): void {
|
||||
queryClient.setQueryData(queryKey, previous);
|
||||
}
|
||||
@@ -1,78 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell, Smartphone } from "lucide-react";
|
||||
import { Bell, BellRing, ChevronRight, Smartphone } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { NotificationContentCard } from "@/components/settings/notification-content-card";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { VorsorgeReminderSwitchesCard } from "@/components/measurement-reminders/vorsorge-settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
|
||||
*
|
||||
* v1.18.0 — the single module-gated home for reminder TYPES. Earlier the
|
||||
* settings carried two near-identical entries: this Notifications screen and a
|
||||
* separate "Erinnerungen" hub that only deep-linked into the canonical editors
|
||||
* ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the
|
||||
* reminder TYPES now live centrally on this one screen.
|
||||
* The one home for every reminder kind. A reminder whose settings belong to
|
||||
* one place is set here; a reminder that is set where its subject lives (a
|
||||
* medication's dose times on the medication, the cycle reminders in the
|
||||
* per-event matrix) gets a row here that says where, so nothing has to be
|
||||
* found by luck.
|
||||
*
|
||||
* Each reminder-type card is shown ONLY when its module is enabled, read from
|
||||
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
|
||||
* Insights pills gate off). Mood → `mood`, low-stock runway → `medications`
|
||||
* (a toggleable fail-open module since D3, so a missing key reads as enabled
|
||||
* and a stale `/me` payload never blanks a card).
|
||||
* Each card is shown only while its module is on, read from the resolved
|
||||
* `useAuth().user.modules` map and failing open, so a stale `/me` payload
|
||||
* never blanks a card.
|
||||
*
|
||||
* v1.25.7 — delivery channels move to Settings → Integrationen (channels are
|
||||
* delivery providers, the same family as the connected services). This screen
|
||||
* keeps ONLY the reminder-TYPE content; `/settings/channels` 301-redirects to
|
||||
* `/settings/integrations#channels`.
|
||||
*
|
||||
* v1.18.6 — the page heading + subtitle come from the shared
|
||||
* `<SettingsSectionFrame>`. The proactive-Coach nudge card lives under
|
||||
* Settings → Coach (it is a Coach setting, not a generic notification).
|
||||
* v1.18.0 — the separate "Erinnerungen" hub is gone; `/settings/reminders`
|
||||
* redirects here. v1.25.7 — the delivery channels live under Integrations
|
||||
* (they are delivery providers); `/settings/channels` redirects there and this
|
||||
* page links to them. v1.44 — the Coach nudges came back from Settings > Coach
|
||||
* and the checkup reminder switches from the checkups arrangement page; both
|
||||
* old places link here (`/settings/coach#coach-nudge` forwards).
|
||||
*/
|
||||
export function NotificationsSection() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// The Coach gate reads the surface map like the Coach section does, after
|
||||
// hydration, so the server render and the first client paint agree.
|
||||
const mounted = useMounted();
|
||||
|
||||
// v1.18.0 — module-gated per-type visibility. Fail OPEN (`!== false`)
|
||||
// so a stale `/me` payload without the module map keeps every card visible.
|
||||
const moodEnabled = user?.modules?.mood !== false;
|
||||
const medsEnabled = user?.modules?.medications !== false;
|
||||
const anyReminder = moodEnabled || medsEnabled;
|
||||
const cycleEnabled = user?.modules?.cycle !== false;
|
||||
const coachEnabled =
|
||||
!mounted || isSurfaceVisible("settings:coach", user?.modules);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
|
||||
least one reminder-type card is visible, so a fully-disabled
|
||||
account never shows an empty heading. */}
|
||||
{anyReminder ? (
|
||||
<section className="space-y-4">
|
||||
<SectionHeading
|
||||
icon={Bell}
|
||||
title={t("settings.sections.notifications.remindersHeading")}
|
||||
/>
|
||||
{/* Group 1 — Reminders ("what you receive"). The checkup reminders are
|
||||
not a module and always show, so the group always has content. */}
|
||||
<section className="space-y-4">
|
||||
<SectionHeading
|
||||
icon={Bell}
|
||||
title={t("settings.sections.notifications.remindersHeading")}
|
||||
/>
|
||||
|
||||
{/* Mood check-in reminder — only when the mood module is enabled. */}
|
||||
{moodEnabled ? (
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
<MoodReminderCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
{moodEnabled ? (
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
<MoodReminderCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Medication low-stock runway — only when the medications module
|
||||
is enabled (toggleable, fail-open, since D3). */}
|
||||
{medsEnabled ? (
|
||||
<div id="low-stock" className="scroll-mt-28">
|
||||
<LowStockCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{medsEnabled ? (
|
||||
<div id="low-stock" className="scroll-mt-28">
|
||||
<LowStockCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{coachEnabled ? (
|
||||
<div id="coach-nudge" className="scroll-mt-28">
|
||||
<CoachNudgeCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div id="vorsorge-reminders" className="scroll-mt-28">
|
||||
<VorsorgeReminderSwitchesCard />
|
||||
</div>
|
||||
|
||||
{/* Reminders set where their subject lives. One row each, saying
|
||||
where; nothing here writes. */}
|
||||
{medsEnabled || cycleEnabled ? (
|
||||
<SettingsCard data-slot="notifications-elsewhere">
|
||||
<SettingsCardHeader
|
||||
anchor="other-reminders"
|
||||
icon={BellRing}
|
||||
title={t("settings.sections.notifications.elsewhere.title")}
|
||||
description={t(
|
||||
"settings.sections.notifications.elsewhere.description",
|
||||
)}
|
||||
/>
|
||||
<ul className="divide-border divide-y">
|
||||
{medsEnabled ? (
|
||||
<li className="space-y-1 py-3 first:pt-0">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.sections.notifications.elsewhere.doses.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"settings.sections.notifications.elsewhere.doses.description",
|
||||
)}
|
||||
</p>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link href="/medications">
|
||||
{t(
|
||||
"settings.sections.notifications.elsewhere.doses.link",
|
||||
)}
|
||||
<ChevronRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</li>
|
||||
) : null}
|
||||
{cycleEnabled ? (
|
||||
<li
|
||||
id="cycle-reminders"
|
||||
className="scroll-mt-28 space-y-1 py-3 first:pt-0"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.sections.notifications.elsewhere.cycle.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"settings.sections.notifications.elsewhere.cycle.description",
|
||||
)}
|
||||
</p>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link href="/notifications">
|
||||
{t(
|
||||
"settings.sections.notifications.elsewhere.cycle.link",
|
||||
)}
|
||||
<ChevronRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Group 2 — How notifications look on the phone. Not module-gated:
|
||||
it applies to every notification the account receives. */}
|
||||
|
||||
@@ -14,11 +14,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} from "@/components/settings/notification-channel-toggle";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -37,10 +34,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
// The enable switch is a draft field: it saves with the form's one Save.
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.settingsNtfy(),
|
||||
@@ -52,18 +48,22 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.serverUrl}|${settings.topic}`
|
||||
? `${settings.serverUrl}|${settings.topic}|${settings.enabled}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setServerUrl(settings!.serverUrl);
|
||||
setTopic(settings!.topic);
|
||||
setEnabled(settings!.enabled);
|
||||
}
|
||||
const dirty = isNtfyFormDirty({ serverUrl, topic, authToken }, settings);
|
||||
const dirty = isNtfyFormDirty(
|
||||
{ serverUrl, topic, authToken, enabled },
|
||||
settings,
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/settings/ntfy", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -93,46 +93,13 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
setSaveError(null);
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
setAuthToken("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleEnabled = useMutation<
|
||||
void,
|
||||
Error,
|
||||
boolean,
|
||||
NtfySettings | undefined
|
||||
>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
|
||||
onMutate: async (enabled) => {
|
||||
const queryKey = queryKeys.settingsNtfy();
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
return optimisticallySetChannelEnabled<NtfySettings>(
|
||||
queryClient,
|
||||
queryKey,
|
||||
enabled,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
|
||||
setSaveError(err.message);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,16 +116,16 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
|
||||
<Switch
|
||||
id="ntfy-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate(settings?.enabled ?? false);
|
||||
save.mutate();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
@@ -199,12 +166,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMsg && (saveMsgType === "error" || !dirty) && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{saveMsg}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -216,7 +180,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
disabled={save.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings > System > Preview features.
|
||||
*
|
||||
* One switch per preview feature, each off until the person turns it on, each
|
||||
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
|
||||
* preview adds something beside what is there and never replaces a page.
|
||||
*
|
||||
* The `topics` flag is stored and shown here; the topic overview pages that
|
||||
* read it arrive in a later release, and the card says so rather than
|
||||
* promising a change the switch does not make yet.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { LayoutDashboard } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DEFAULT_PREVIEW_FEATURES,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
|
||||
const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions";
|
||||
|
||||
export function PreviewSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const key = queryKeys.previewFeatures();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
|
||||
});
|
||||
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationKey: key,
|
||||
mutationFn: (topics: boolean) =>
|
||||
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
|
||||
onMutate: async (topics) => {
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<PreviewFeatures>(key);
|
||||
queryClient.setQueryData<PreviewFeatures>(key, {
|
||||
...(previous ?? DEFAULT_PREVIEW_FEATURES),
|
||||
topics,
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: (saved, topics) => {
|
||||
if (saved) queryClient.setQueryData(key, saved);
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
topics
|
||||
? t("settings.preview.topics.savedOn")
|
||||
: t("settings.preview.topics.savedOff"),
|
||||
);
|
||||
},
|
||||
onError: (failure, _topics, context) => {
|
||||
queryClient.setQueryData(key, context?.previous);
|
||||
showErrorToast(t("settings.savingError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-preview-topics">
|
||||
<SettingsCardHeader
|
||||
anchor="preview-topics"
|
||||
icon={LayoutDashboard}
|
||||
title={t("settings.preview.topics.title")}
|
||||
titleId="settings-preview-topics-title"
|
||||
description={t("settings.preview.topics.description")}
|
||||
status={
|
||||
<Switch
|
||||
data-slot="settings-preview-topics-switch"
|
||||
checked={features.topics}
|
||||
disabled={query.isLoading || toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-labelledby="settings-preview-topics-title"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{query.isError ? (
|
||||
<QueryErrorRow
|
||||
inset
|
||||
error={query.error}
|
||||
slot="settings-preview-error"
|
||||
message={t("settings.preview.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link
|
||||
href={DISCUSSIONS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("settings.preview.feedbackLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,20 @@
|
||||
* v1.23 — "Data & Privacy" dashboard (P7).
|
||||
*
|
||||
* One coherent pane that ASSEMBLES already-shipped machinery — it does not
|
||||
* rebuild any of it. Each block either embeds an existing card (active
|
||||
* sessions, security activity, trusted devices) or links to the existing
|
||||
* surface that owns the action (export incl. the passphrase option, account
|
||||
* deletion / data reset, AI privacy mode, research mode). The retention +
|
||||
* encryption facts are read from the server so the copy stays truthful, and
|
||||
* the backup↔deletion lag is disclosed honestly.
|
||||
* rebuild any of it. Each block states a fact or links to the surface that
|
||||
* owns the action (account deletion / data reset, AI privacy mode). The
|
||||
* retention + encryption facts are read from the server so the copy stays
|
||||
* truthful, and the backup↔deletion lag is disclosed honestly.
|
||||
*
|
||||
* v1.44 — the active sessions, trusted devices and security activity moved
|
||||
* to Settings > Security beside the sign-in controls, and the export card
|
||||
* left: Export & import and the Health record sit next to this page in the
|
||||
* Data group. The old anchors forward (`settings-anchor-forward.tsx`).
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Clock,
|
||||
@@ -29,9 +31,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
||||
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
||||
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
|
||||
import { WorkoutRoutesCard } from "@/components/settings/workout-routes-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -181,28 +180,6 @@ export function PrivacySection() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Export */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="your-data-export"
|
||||
icon={Download}
|
||||
title={t("settings.privacy.export.title")}
|
||||
description={t("settings.privacy.export.description")}
|
||||
/>
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/export">
|
||||
{t("settings.privacy.export.openExport")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/gesundheitsakte">
|
||||
{t("settings.privacy.export.openHealthRecord")}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Delete / reset — with the honest backup lag disclosure */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
@@ -241,11 +218,6 @@ export function PrivacySection() {
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Active sessions + trusted devices + security activity (embedded) */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,9 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// pieces into one coherent privacy pane. Sits next to `advanced` (the
|
||||
// destructive-action home) and before `about`.
|
||||
"privacy",
|
||||
// v1.44 — "Preview features": per-user opt-in to things finished enough to
|
||||
// try and not yet the default. Sits in the System group before About.
|
||||
"preview",
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { PasswordCard } from "@/components/settings/password-card";
|
||||
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
||||
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
||||
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
|
||||
import { TotpCard } from "./totp-card";
|
||||
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
@@ -79,6 +82,15 @@ export function SecuritySection() {
|
||||
`/api/auth/me/mfa` that never answers must not take it down with the
|
||||
cards that do need the status. */}
|
||||
<PasswordCard />
|
||||
|
||||
{/* v1.44 — who is signed in and what happened, beside the controls
|
||||
that decide how one signs in. These lived on the privacy page;
|
||||
`/settings/privacy#sessions` (and the other two anchors) forward
|
||||
here. Outside the status conditional for the same reason as the
|
||||
password: they read their own routes. */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -311,46 +312,21 @@ export function PasskeyListSection({
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
disabled={remove.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
aria-busy={remove.isPending || undefined}
|
||||
onClick={() => remove.mutate(pk.id)}
|
||||
>
|
||||
{remove.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label=""
|
||||
ariaLabel={t("settings.deletePasskey")}
|
||||
title={t("settings.deletePasskey")}
|
||||
body={t("settings.deletePasskeyDescription")}
|
||||
confirmLabel={t("common.delete")}
|
||||
onConfirm={() => remove.mutate(pk.id)}
|
||||
pending={remove.isPending && remove.variables === pk.id}
|
||||
disabled={remove.isPending}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,18 +13,8 @@ import {
|
||||
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 { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -308,46 +298,23 @@ export function SecurityKeysCard({
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
disabled={remove.isPending}
|
||||
aria-label={t("settings.security.keys.remove")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.security.keys.remove")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.security.keys.removeConfirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
aria-busy={remove.isPending || undefined}
|
||||
onClick={() => remove.mutate(key.id)}
|
||||
>
|
||||
{remove.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label=""
|
||||
ariaLabel={t("settings.security.keys.remove")}
|
||||
title={t("settings.security.keys.remove")}
|
||||
body={t("settings.security.keys.removeConfirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
onConfirm={() => remove.mutate(key.id)}
|
||||
pending={
|
||||
remove.isPending && remove.variables === key.id
|
||||
}
|
||||
disabled={remove.isPending}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -498,6 +498,7 @@ export function TotpCard({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
data-testid="recovery-regenerate-confirm"
|
||||
disabled={regenerate.isPending}
|
||||
aria-busy={regenerate.isPending || undefined}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Old deep links into cards that moved to another Settings page.
|
||||
*
|
||||
* A path that moved is a server redirect (`next.config.ts`), but a card that
|
||||
* moved while its page stayed cannot be: the `#anchor` never reaches the
|
||||
* server. Bookmarks, cross-links in older notifications and the docs point at
|
||||
* `/settings/privacy#sessions` and the like, so the shell forwards a known
|
||||
* anchor on a known page to the card's new home, once, on arrival.
|
||||
*
|
||||
* `SETTINGS_ANCHOR_FORWARDS` is also read by the legacy-routes guard, which
|
||||
* proves every target lands on a page that exists.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export const SETTINGS_ANCHOR_FORWARDS: Readonly<
|
||||
Record<string, Readonly<Record<string, string>>>
|
||||
> = {
|
||||
// v1.44 — sessions, trusted devices and the security activity joined the
|
||||
// other sign-in controls under Security; the duplicate export card left.
|
||||
"/settings/privacy": {
|
||||
sessions: "/settings/security#sessions",
|
||||
"trusted-devices": "/settings/security#trusted-devices",
|
||||
"security-activity": "/settings/security#security-activity",
|
||||
"your-data-export": "/settings/export",
|
||||
},
|
||||
// v1.44 — the proactive nudges are a reminder and live with the others.
|
||||
"/settings/coach": {
|
||||
"coach-nudge": "/settings/notifications#coach-nudge",
|
||||
},
|
||||
// v1.44 — Arrangement holds order and view only; each module's own
|
||||
// settings moved to its page under Modules.
|
||||
"/settings/layout/labs": {
|
||||
"labs-local-ocr": "/settings/modules/labs#labs-local-ocr",
|
||||
"labs-biomarkers": "/settings/modules/labs#labs-biomarkers",
|
||||
},
|
||||
"/settings/layout/medications": {
|
||||
"injection-sites": "/settings/modules/medications#injection-sites",
|
||||
},
|
||||
"/settings/layout/vorsorge": {
|
||||
"screening-hints": "/settings/anamnesis#screening-hints",
|
||||
"vorsorge-reminders": "/settings/notifications#vorsorge-reminders",
|
||||
},
|
||||
};
|
||||
|
||||
/** Where an anchor on a page now lives, or null when it did not move. */
|
||||
export function forwardedAnchorTarget(
|
||||
pathname: string,
|
||||
hash: string,
|
||||
): string | null {
|
||||
const anchor = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
if (!anchor) return null;
|
||||
return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null;
|
||||
}
|
||||
|
||||
export function SettingsAnchorForward() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const target = forwardedAnchorTarget(pathname, window.location.hash);
|
||||
if (target) router.replace(target);
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings navigation below `md`: one button naming where the person is, and a
|
||||
* bottom sheet listing every section under its group.
|
||||
*
|
||||
* The phone used to carry every section as a chip in one horizontal strip,
|
||||
* more than twenty of them, so most of Settings sat off screen and the groups
|
||||
* the desktop sidebar shows were reduced to thin dividers. The sheet shows the
|
||||
* same groups and order as the sidebar, one 44 px row per section, and closes
|
||||
* on choice. Both read the visible-section list the shell computes, so the
|
||||
* module and record filters apply to both alike.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface SettingsNavEntry {
|
||||
slug: string;
|
||||
title: string;
|
||||
groupLabel: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export function SettingsMobileNav({
|
||||
entries,
|
||||
activeSlug,
|
||||
}: {
|
||||
entries: readonly SettingsNavEntry[];
|
||||
activeSlug: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const active = entries.find((entry) => entry.slug === activeSlug);
|
||||
const ActiveIcon = active?.icon;
|
||||
|
||||
// Group the flat, pre-ordered list for rendering; the order of first
|
||||
// appearance is the group order.
|
||||
const groups: { label: string; entries: SettingsNavEntry[] }[] = [];
|
||||
for (const entry of entries) {
|
||||
const last = groups.at(-1);
|
||||
if (last && last.label === entry.groupLabel) last.entries.push(entry);
|
||||
else groups.push({ label: entry.groupLabel, entries: [entry] });
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="mb-4 md:hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="settings-mobile-nav-trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
className="border-border bg-card hover:bg-accent focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{ActiveIcon ? (
|
||||
<ActiveIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
{active ? (
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{active.groupLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{active?.title ?? t("settings.shell.allSections")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{t("settings.shell.allSections")}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("settings.shell.allSections")}
|
||||
>
|
||||
<div className="space-y-4 pb-2" data-slot="settings-mobile-nav-list">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label} className="space-y-1">
|
||||
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
|
||||
{group.label}
|
||||
</h2>
|
||||
<ul className="space-y-0.5">
|
||||
{group.entries.map((entry) => {
|
||||
const isActive = entry.slug === activeSlug;
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<li key={entry.slug}>
|
||||
<Link
|
||||
href={`/settings/${entry.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
{entry.title}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
/**
|
||||
* One settings section, loaded on its own.
|
||||
*
|
||||
* The section route renders exactly one of twenty-one sections, but importing
|
||||
* them statically put all twenty-one (and everything under them: the import
|
||||
* The section route renders exactly one of the sections, but importing
|
||||
* them statically put all of them (and everything under them: the import
|
||||
* panel, the integration cards, the MCP and API token tables, the score
|
||||
* editor) into the route's entry bundle, so opening "About" downloaded the
|
||||
* whole settings tree. Each section is now its own chunk. A first load still
|
||||
@@ -124,6 +124,10 @@ const LOADERS: Record<
|
||||
import("@/components/settings/privacy-section").then(
|
||||
(m) => m.PrivacySection,
|
||||
),
|
||||
preview: () =>
|
||||
import("@/components/settings/preview-section").then(
|
||||
(m) => m.PreviewSection,
|
||||
),
|
||||
};
|
||||
|
||||
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
@@ -158,6 +162,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
|
||||
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
|
||||
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
|
||||
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
* by the page itself.
|
||||
*
|
||||
* Mobile-first per `docs/ui-guidelines.md`:
|
||||
* - <md: section selector renders as a horizontal scroll strip at the top so
|
||||
* a thumb-tap can swap sections without leaving the viewport.
|
||||
* - >=md: sticky 220px sidebar + content column with `max-w-screen-xl`.
|
||||
* - <md: one button naming the current section opens a bottom sheet with
|
||||
* every section under its group (`SettingsMobileNav`).
|
||||
* - >=md: sticky 220px sidebar, grouped the same way, beside a content
|
||||
* column with `max-w-screen-xl`.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
CloudSun,
|
||||
Download,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Info,
|
||||
KeyRound,
|
||||
@@ -56,6 +58,8 @@ import {
|
||||
type SettingsSectionSlug,
|
||||
} from "./section-slugs";
|
||||
import { SettingsHubBackLink } from "./settings-hub-back-link";
|
||||
import { SettingsMobileNav } from "./settings-mobile-nav";
|
||||
import { SettingsAnchorForward } from "./settings-anchor-forward";
|
||||
|
||||
// Re-export so existing call-sites importing from this module keep working.
|
||||
// New server-side call-sites (e.g. `generateStaticParams()`) should import
|
||||
@@ -71,18 +75,24 @@ export {
|
||||
};
|
||||
|
||||
/**
|
||||
* The sidebar's 19 entries render under one
|
||||
* of these seven non-interactive group headers instead of as one flat
|
||||
* list. Presentation only: no slug, route, or redirect changes. Order
|
||||
* below is the render order of the groups themselves.
|
||||
* The nine groups every section belongs to, in render order. The sidebar
|
||||
* paints a non-interactive header above the first visible entry of each, and
|
||||
* the phone's section sheet uses the same headers.
|
||||
*
|
||||
* Account: who you are and who can get in. Tracking: what the app records.
|
||||
* Arrangement: order and view only. Notifications: every reminder. Health
|
||||
* profile: what describes you. AI and Connections: what talks to the outside.
|
||||
* Data: export, import, privacy and deletion. System: previews and about.
|
||||
*/
|
||||
export type SettingsSectionGroup =
|
||||
| "account"
|
||||
| "yourData"
|
||||
| "display"
|
||||
| "tracking"
|
||||
| "arrangement"
|
||||
| "notifications"
|
||||
| "healthProfile"
|
||||
| "ai"
|
||||
| "connectivity"
|
||||
| "connections"
|
||||
| "data"
|
||||
| "system";
|
||||
|
||||
/** i18n key for each group's header label, under `settings.shell.groups.*`. */
|
||||
@@ -91,11 +101,13 @@ export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record<
|
||||
string
|
||||
> = {
|
||||
account: "settings.shell.groups.account",
|
||||
yourData: "settings.shell.groups.yourData",
|
||||
display: "settings.shell.groups.display",
|
||||
tracking: "settings.shell.groups.tracking",
|
||||
arrangement: "settings.shell.groups.arrangement",
|
||||
notifications: "settings.shell.groups.notifications",
|
||||
healthProfile: "settings.shell.groups.healthProfile",
|
||||
ai: "settings.shell.groups.ai",
|
||||
connectivity: "settings.shell.groups.connectivity",
|
||||
connections: "settings.shell.groups.connections",
|
||||
data: "settings.shell.groups.data",
|
||||
system: "settings.shell.groups.system",
|
||||
};
|
||||
|
||||
@@ -109,159 +121,84 @@ interface SettingsSection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for the section list that the settings shell renders
|
||||
* in its sidebar + mobile chip-strip.
|
||||
* Source of truth for the section list that the settings shell renders in
|
||||
* its sidebar and in the phone's section sheet, pre-ordered by group.
|
||||
*
|
||||
* The list used to render in a single flat
|
||||
* order with no visual structure (19 entries deep, the AI cluster and the
|
||||
* programmatic-access cluster interleaved, the health-profile pair split
|
||||
* across the list). It is now clustered into the seven
|
||||
* `SettingsSectionGroup`s, each entry carrying its `group`; the list is
|
||||
* pre-ordered by group and the shell inserts a non-interactive header
|
||||
* before the first VISIBLE entry of each group. This is a presentation-only
|
||||
* reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS`
|
||||
* (`section-slugs.ts`, which also carries the page-only slugs and drives
|
||||
* `generateStaticParams()`) is untouched.
|
||||
* `SETTINGS_SECTION_SLUGS` (`section-slugs.ts`) is the route registry and
|
||||
* keeps its own order (it drives `generateStaticParams()` and the record
|
||||
* landing order); this list decides only where each entry is shown. Every
|
||||
* slug a person ever reached keeps its URL: a section that moved between
|
||||
* groups moved here, not in the route.
|
||||
*
|
||||
* "About" sits at the end of the list (System group). v1.4.33 IW7 had
|
||||
* folded it into the sidebar user-card dropdown only, which left
|
||||
* `/settings/about` an orphaned route — reachable by URL but discoverable
|
||||
* nowhere in the settings navigation. It is now both a regular shell entry
|
||||
* and a user-card dropdown item ("Über HealthLog" / "About HealthLog").
|
||||
* The card titles people cite stay as they are: "Export & import",
|
||||
* "Integrations", "API tokens", "Shared access", "Modules".
|
||||
*/
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
// ── Account ──
|
||||
// ── Account ── profile and display, sign-in and sessions, who else may open
|
||||
// this record.
|
||||
{
|
||||
slug: "account",
|
||||
titleKey: "settings.sections.account.title",
|
||||
icon: User,
|
||||
group: "account",
|
||||
},
|
||||
// v1.23 — the account-security home: second factors, recovery codes,
|
||||
// passkey management.
|
||||
// Second factors, passkeys, password, and (since v1.44) the active
|
||||
// sessions, trusted devices and security activity that used to sit on the
|
||||
// privacy page.
|
||||
{
|
||||
slug: "security",
|
||||
titleKey: "settings.sections.security.title",
|
||||
icon: ShieldCheck,
|
||||
group: "account",
|
||||
},
|
||||
// v1.36.0 — who else may open this record, and whose this account may open.
|
||||
// In the Account group beside Security: both answer "who can get at my
|
||||
// account and what have they done", and a person looking for one will look
|
||||
// where the other is.
|
||||
{
|
||||
slug: "access",
|
||||
titleKey: "settings.sections.access.title",
|
||||
icon: UsersRound,
|
||||
group: "account",
|
||||
},
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
// encryption / session / activity pieces into one coherent privacy pane.
|
||||
{
|
||||
slug: "privacy",
|
||||
titleKey: "settings.sections.privacy.title",
|
||||
icon: Lock,
|
||||
group: "account",
|
||||
},
|
||||
// ── Your data ──
|
||||
// v1.18.0 — the "Was du trackst" hub: the front door for enabling /
|
||||
// disabling the secondary tracking domains.
|
||||
// ── Tracking ── which areas are recorded; each module's own settings
|
||||
// (biomarkers, mood tags, injection sites) open from its row.
|
||||
{
|
||||
slug: "modules",
|
||||
titleKey: "settings.sections.modules.title",
|
||||
icon: Blocks,
|
||||
group: "yourData",
|
||||
group: "tracking",
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "settings.sections.integrations.title",
|
||||
icon: Link2,
|
||||
group: "yourData",
|
||||
},
|
||||
// v1.18.1 — Quellen (source weighting): which connection wins when
|
||||
// two report the same metric. v1.25.3 — Kanäle (delivery channels) folded
|
||||
// back into Notifications as an in-page group;
|
||||
// `/settings/channels` 301-redirects to `/settings/notifications#channels`.
|
||||
{
|
||||
slug: "sources",
|
||||
titleKey: "settings.sections.sources.title",
|
||||
icon: Layers,
|
||||
group: "yourData",
|
||||
},
|
||||
// v1.18.0 — the full health record (PDF + FHIR R4 + zip package).
|
||||
// v1.18.6.1 — NOT module-gated: the health record is a flagship export
|
||||
// capability; gating its only entry-point on the `doctorReport` opt-out
|
||||
// meant a stray toggle in the Modules hub made the whole section vanish
|
||||
// with no in-context way back. The `doctorReport` module still governs
|
||||
// the data layer server-side (403 `module.disabled` over Bearer).
|
||||
{
|
||||
slug: "gesundheitsakte",
|
||||
titleKey: "settings.sections.gesundheitsakte.title",
|
||||
icon: FileHeart,
|
||||
group: "yourData",
|
||||
},
|
||||
// v1.25.7 — clinician share links fold into the Gesundheitsakte section as
|
||||
// a labelled "Sharing" group; `/settings/sharing` 301-redirects there.
|
||||
{
|
||||
slug: "export",
|
||||
titleKey: "settings.sections.export.title",
|
||||
icon: Download,
|
||||
group: "yourData",
|
||||
},
|
||||
// ── Display ──
|
||||
// v1.17.1 — one nav entry replaces the four scattered Dashboard /
|
||||
// Insights / Medications / Mood "arrange" entries; those routes still
|
||||
// resolve, reached through this hub. v1.25.3 — renamed to "Appearance" /
|
||||
// "Darstellung" and widened into the index for every module's
|
||||
// view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) —
|
||||
// each module lives on its own subpage at `/settings/layout/<module>`;
|
||||
// the old per-module routes 301-redirect there.
|
||||
// ── Arrangement ── order and view, nothing else. The slug stays `layout`.
|
||||
{
|
||||
slug: "layout",
|
||||
titleKey: "settings.sections.layout.title",
|
||||
icon: Palette,
|
||||
group: "display",
|
||||
group: "arrangement",
|
||||
},
|
||||
// v1.25.7 — Vorsorge (preventive-care reminders) folded into
|
||||
// "Darstellung"; `/settings/vorsorge` 301-redirects to
|
||||
// `/settings/layout/vorsorge`.
|
||||
{
|
||||
slug: "thresholds",
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
icon: SlidersHorizontal,
|
||||
group: "display",
|
||||
},
|
||||
// v1.18.0 — "Benachrichtigungen" is the single module-gated
|
||||
// reminder-types home; the standalone "Erinnerungen" hub is gone.
|
||||
// ── Notifications ── every reminder kind, or the link to where it lives.
|
||||
{
|
||||
slug: "notifications",
|
||||
titleKey: "settings.sections.notifications.title",
|
||||
icon: Bell,
|
||||
group: "display",
|
||||
group: "notifications",
|
||||
},
|
||||
// ── Health profile ──
|
||||
// v1.35.0 — which pillars count toward the Health Score. Sits at the top
|
||||
// of the health-profile cluster: it is a statement about the person's own
|
||||
// health picture, not about how the app looks or what it records.
|
||||
{
|
||||
slug: "score",
|
||||
titleKey: "settings.sections.score.title",
|
||||
icon: Gauge,
|
||||
group: "healthProfile",
|
||||
},
|
||||
// v1.25 — Anamnese (medical history): the structured-record
|
||||
// home for allergies + family history. NOT module-gated (like Vorsorge) —
|
||||
// these are foundational health-profile records, always available.
|
||||
{
|
||||
slug: "thresholds",
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
icon: SlidersHorizontal,
|
||||
group: "healthProfile",
|
||||
},
|
||||
{
|
||||
slug: "anamnesis",
|
||||
titleKey: "settings.sections.anamnesis.title",
|
||||
icon: ClipboardList,
|
||||
group: "healthProfile",
|
||||
},
|
||||
// v1.25 — Environmental context: home location, travel overrides,
|
||||
// and the weather/daylight backfill. Module-gated on the opt-in
|
||||
// `environment` module (`settings:environment` in the surface map), so the
|
||||
// entry only appears once the user turns it on.
|
||||
// Module-gated on the opt-in `environment` module.
|
||||
{
|
||||
slug: "environment",
|
||||
titleKey: "settings.sections.environment.title",
|
||||
@@ -275,37 +212,73 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
icon: Sparkles,
|
||||
group: "ai",
|
||||
},
|
||||
// v1.18.0 — Coach: the Coach preference cards get their own entry,
|
||||
// shown only when the coach module is enabled (`settings:coach` in the
|
||||
// surface map). The AI entry above keeps
|
||||
// provider / model / BYOK configuration.
|
||||
// Module-gated on the coach module (`settings:coach`).
|
||||
{
|
||||
slug: "coach",
|
||||
titleKey: "settings.sections.coach.title",
|
||||
icon: Bot,
|
||||
group: "ai",
|
||||
},
|
||||
// ── Connectivity ──
|
||||
// ── Connections ── data coming in (integrations, which source wins) and
|
||||
// programmatic access going out (tokens, the MCP connector).
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "settings.sections.integrations.title",
|
||||
icon: Link2,
|
||||
group: "connections",
|
||||
},
|
||||
{
|
||||
slug: "sources",
|
||||
titleKey: "settings.sections.sources.title",
|
||||
icon: Layers,
|
||||
group: "connections",
|
||||
},
|
||||
{
|
||||
slug: "api",
|
||||
titleKey: "settings.sections.api.title",
|
||||
icon: KeyRound,
|
||||
group: "connectivity",
|
||||
group: "connections",
|
||||
},
|
||||
// v1.22.0 — remote MCP connector. Not module-gated: the card carries its
|
||||
// own enable toggle (mirroring `gesundheitsakte`), so the entry-point
|
||||
// stays reachable even with the opt-in module off.
|
||||
// Not module-gated: the page shows the connector's state and links to the
|
||||
// one switch in Modules.
|
||||
{
|
||||
slug: "mcp",
|
||||
titleKey: "settings.sections.mcp.title",
|
||||
icon: Plug,
|
||||
group: "connectivity",
|
||||
group: "connections",
|
||||
},
|
||||
// ── Data ── getting data out and in, what is stored, and deleting it.
|
||||
{
|
||||
slug: "export",
|
||||
titleKey: "settings.sections.export.title",
|
||||
icon: Download,
|
||||
group: "data",
|
||||
},
|
||||
// Not module-gated: the health record is a flagship export, and its only
|
||||
// entry point must not vanish behind the `doctorReport` opt-out.
|
||||
{
|
||||
slug: "gesundheitsakte",
|
||||
titleKey: "settings.sections.gesundheitsakte.title",
|
||||
icon: FileHeart,
|
||||
group: "data",
|
||||
},
|
||||
{
|
||||
slug: "privacy",
|
||||
titleKey: "settings.sections.privacy.title",
|
||||
icon: Lock,
|
||||
group: "data",
|
||||
},
|
||||
// ── System ──
|
||||
{
|
||||
slug: "advanced",
|
||||
titleKey: "settings.sections.advanced.title",
|
||||
icon: Settings2,
|
||||
group: "data",
|
||||
},
|
||||
// ── System ──
|
||||
{
|
||||
slug: "preview",
|
||||
titleKey: "settings.sections.preview.title",
|
||||
icon: FlaskConical,
|
||||
group: "system",
|
||||
},
|
||||
{
|
||||
@@ -453,40 +426,6 @@ export function SettingsShell({
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
|
||||
// v1.4.33 — keep the active chip in view inside the horizontal
|
||||
// mobile strip. On a 393 CSS px viewport the strip is wider than the
|
||||
// viewport and the rightmost chips sit off-screen; a user who tapped
|
||||
// one of those chips lands on the new route with the strip still
|
||||
// scrolled to the leftmost chip, which reads as if the navigation is
|
||||
// broken. We pin the active chip to the left edge of the strip.
|
||||
//
|
||||
// v1.7.0 — adjust only the strip's own horizontal scroll offset
|
||||
// (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`.
|
||||
// `scrollIntoView` walks every scrollable ancestor and adjusts both
|
||||
// axes, so on mobile it also nudged the whole document vertically on
|
||||
// each route change — a dizzy auto-scroll that fired on every settings
|
||||
// sub-page tap. Scrolling the strip's own `scrollLeft` confines the
|
||||
// motion to its horizontal axis and never touches the page scroll
|
||||
// position.
|
||||
const mobileStripRef = React.useRef<HTMLElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const strip = mobileStripRef.current;
|
||||
if (!strip) return;
|
||||
const active = strip.querySelector<HTMLElement>('[aria-current="page"]');
|
||||
if (!active) return;
|
||||
// Land the active chip at the strip's left edge, clamped to the
|
||||
// scrollable range. `offsetLeft` is relative to the strip's padding
|
||||
// box, which is the origin for `scrollLeft`.
|
||||
const maxScroll = strip.scrollWidth - strip.clientWidth;
|
||||
const target = Math.max(0, Math.min(active.offsetLeft, maxScroll));
|
||||
// Position the active chip instantly. A smooth behaviour here animates
|
||||
// the strip from its reset `scrollLeft: 0` to the target on every tap,
|
||||
// which reads as an unwanted "scroll from the start" sweep when swapping
|
||||
// sections. An instant jump keeps the chip in view without the sweep.
|
||||
strip.scrollTo({ left: target, behavior: "auto" });
|
||||
}, [activeSlug]);
|
||||
|
||||
// v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading`
|
||||
// (the canonical path); the slug fallback keeps the shell renderable on
|
||||
// its own (and in tests that mount it without a heading) by deriving the
|
||||
@@ -549,64 +488,18 @@ export function SettingsShell({
|
||||
<div className="mb-4 md:hidden">{headingBlock(true)}</div>
|
||||
) : null}
|
||||
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+.
|
||||
`no-scrollbar` (defined in `globals.css`) suppresses the
|
||||
painted scrollbar; the horizontal swipe + keyboard arrow
|
||||
scrolling still work. Without it the 10-section strip
|
||||
renders an always-on scrollbar at the top of every settings
|
||||
page, which makes the page feel like every section card has
|
||||
an overflow problem.
|
||||
|
||||
v1.4.33 — `snap-x snap-mandatory` lets a swipe-flick land
|
||||
on the next chip's leading edge instead of the in-between
|
||||
dead zone. The auto `scrollIntoView({inline: "center"})`
|
||||
effect above stays the canonical positioner for the active
|
||||
chip; snap is the polish layer when the user manually flicks
|
||||
through the strip without tapping. */}
|
||||
<nav
|
||||
ref={mobileStripRef}
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max items-center gap-2">
|
||||
{visibleSections.map((section, index) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
// A quiet vertical divider marks a
|
||||
// group boundary in the horizontal strip (the desktop sidebar
|
||||
// carries the full text header for the same grouping).
|
||||
const startsNewGroup =
|
||||
index > 0 && visibleSections[index - 1].group !== section.group;
|
||||
return (
|
||||
<React.Fragment key={section.slug}>
|
||||
{startsNewGroup ? (
|
||||
<li aria-hidden="true" className="shrink-0">
|
||||
<span className="bg-border block h-6 w-px" />
|
||||
</li>
|
||||
) : null}
|
||||
<li className="snap-start">
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
// v1.4.25 — chip strip is the primary mobile-settings
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<SettingsAnchorForward />
|
||||
{/* Below `md`: one button naming the current section, opening the
|
||||
grouped section sheet. Hidden on md+, where the sidebar shows. */}
|
||||
<SettingsMobileNav
|
||||
activeSlug={activeSlug}
|
||||
entries={visibleSections.map((section) => ({
|
||||
slug: section.slug,
|
||||
title: t(section.titleKey),
|
||||
groupLabel: t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group]),
|
||||
icon: section.icon,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* v1.18.6.1 — two-row grid on desktop:
|
||||
row 1 / col 2 = heading (content column only)
|
||||
|
||||
@@ -26,19 +26,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { FileText, KeyRound, Link2, List, Trash2 } 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 { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -255,38 +245,21 @@ function ShareLinksCard() {
|
||||
{/* §12 — a right-aligned row action, not a full-width
|
||||
destructive strip stacked once per link (which reads as
|
||||
an alarm wall on a list of shares). */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.revoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.sharing.revokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmButton
|
||||
label={t("settings.sharing.revoke")}
|
||||
title={t("settings.sharing.revoke")}
|
||||
body={t("settings.sharing.revokeDescription")}
|
||||
confirmLabel={t("settings.sharing.revoke")}
|
||||
onConfirm={() => revokeMutation.mutate(link.id)}
|
||||
pending={
|
||||
revokeMutation.isPending &&
|
||||
revokeMutation.variables === link.id
|
||||
}
|
||||
size="sm"
|
||||
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
destructive
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -697,6 +697,7 @@ export function SourcesSection() {
|
||||
disabled={saveMutation.isPending}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
destructive
|
||||
/>
|
||||
{dirty && priority ? (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} from "@/components/settings/notification-channel-toggle";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -39,9 +36,10 @@ export function TelegramCard({
|
||||
const queryClient = useQueryClient();
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
// The enable switch is a draft field: it saves with the form's one Save.
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.telegramSettings(),
|
||||
@@ -52,67 +50,25 @@ export function TelegramCard({
|
||||
});
|
||||
|
||||
// Sync from server using the React-recommended "previous-payload-id"
|
||||
// pattern instead of setState-in-effect. This avoids the strict
|
||||
// `react-hooks/set-state-in-effect` lint failure that the legacy file
|
||||
// disabled at module scope.
|
||||
const settingsKey = settings ? (settings.chatId ?? "") : null;
|
||||
// pattern instead of setState-in-effect. Reseeds after every save, so the
|
||||
// switch reads what the server stored.
|
||||
const settingsKey = settings
|
||||
? `${settings.chatId ?? ""}|${settings.enabled}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
if (settings!.chatId) setChatId(settings!.chatId);
|
||||
setEnabled(settings!.enabled);
|
||||
}
|
||||
const dirty = isTelegramFormDirty({ botToken, chatId }, settings);
|
||||
|
||||
const toggleEnabled = useMutation<
|
||||
void,
|
||||
Error,
|
||||
boolean,
|
||||
TelegramSettings | undefined
|
||||
>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled(
|
||||
"/api/settings/telegram",
|
||||
enabled,
|
||||
t("settings.savingError"),
|
||||
),
|
||||
onMutate: async (enabled) => {
|
||||
const queryKey = queryKeys.telegramSettings();
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
return optimisticallySetChannelEnabled<TelegramSettings>(
|
||||
queryClient,
|
||||
queryKey,
|
||||
enabled,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(t("settings.saved"));
|
||||
setMsgType("success");
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(
|
||||
queryClient,
|
||||
queryKeys.telegramSettings(),
|
||||
previous,
|
||||
);
|
||||
setMsg(err.message);
|
||||
setMsgType("error");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() });
|
||||
},
|
||||
});
|
||||
const dirty = isTelegramFormDirty({ botToken, chatId, enabled }, settings);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
setError(null);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
enabled: settings?.enabled ?? false,
|
||||
};
|
||||
const body: Record<string, unknown> = { enabled };
|
||||
if (botToken.trim()) body.botToken = botToken.trim();
|
||||
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
|
||||
|
||||
@@ -126,16 +82,16 @@ export function TelegramCard({
|
||||
}).catch(() => null);
|
||||
|
||||
if (res?.ok) {
|
||||
setMsg(t("settings.saved"));
|
||||
setMsgType("success");
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
setBotToken("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
|
||||
} else {
|
||||
const json = (await res?.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setMsg(json?.error || t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
const message = json?.error || t("settings.savingError");
|
||||
setError(message);
|
||||
showErrorToast(message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -192,9 +148,9 @@ export function TelegramCard({
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="tg-enabled"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={saving || toggleEnabled.isPending}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Label htmlFor="tg-enabled" className="cursor-pointer">
|
||||
{t("settings.enableNotifications")}
|
||||
@@ -209,14 +165,9 @@ export function TelegramCard({
|
||||
</ol>
|
||||
</SettingsInfoTile>
|
||||
|
||||
{msg && (msgType === "error" || !dirty) && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{msg}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -226,11 +177,7 @@ export function TelegramCard({
|
||||
disabled={!settings?.hasBotToken}
|
||||
unsavedChanges={dirty}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || toggleEnabled.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Button type="submit" disabled={saving} className="min-h-11">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
|
||||
@@ -179,6 +179,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
confirmLabel={t("thresholds.resetAllConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate(null)}
|
||||
destructive
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
@@ -371,6 +372,7 @@ function MetricRow({
|
||||
setOverrideMode(false);
|
||||
onReset();
|
||||
}}
|
||||
destructive
|
||||
/>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@@ -445,6 +447,7 @@ function MetricRow({
|
||||
confirmLabel={t("thresholds.resetConfirm")}
|
||||
pending={busy}
|
||||
onConfirm={onReset}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -39,30 +42,6 @@ export function TimeFormatSelect({
|
||||
const [optimistic, setOptimistic] = useState<TimeFormatPreference | null>(
|
||||
null,
|
||||
);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO";
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -84,31 +63,19 @@ export function TimeFormatSelect({
|
||||
// Repaint every formatter consumer right away; the /me refetch below
|
||||
// re-asserts the same value once it settles.
|
||||
storeTimeFormat(next);
|
||||
setMsg(t("settings.timeFormat.saved"));
|
||||
setMsgType("success");
|
||||
toastWrittenOutcome("success", t("settings.timeFormat.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error ? err.message : t("settings.timeFormat.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
showErrorToast(t("settings.timeFormat.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelect(next: TimeFormatPreference) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
@@ -126,16 +93,8 @@ export function TimeFormatSelect({
|
||||
<option value="H24">{t("settings.timeFormat.h24")}</option>
|
||||
<option value="H12">{t("settings.timeFormat.h12")}</option>
|
||||
</NativeSelect>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg ?? t("settings.timeFormat.description")}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.timeFormat.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* The profile timezone as a Display preference: it saves the moment it
|
||||
* changes, like the language, units and formats beside it.
|
||||
*
|
||||
* Until v1.44 it sat in the profile form and waited for that form's Save,
|
||||
* which made it the one display preference that did not take effect on
|
||||
* change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route
|
||||
* that owns the IANA validation). The local mirror is written on success so
|
||||
* every rendered timestamp repaints in the same tab before the `/me`
|
||||
* refetch confirms it (issue #490).
|
||||
*
|
||||
* The select shows the stored zone: a value on screen is a value that is
|
||||
* saved. The form used to pre-fill the device's zone over the untouched
|
||||
* default and save it with the next profile save; that suggestion stays,
|
||||
* as a one-tap offer under the select (`resolveInitialTimezone` decides
|
||||
* when), instead of an unsaved value dressed as the current one.
|
||||
*/
|
||||
export function TimezoneSelect({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [optimistic, setOptimistic] = useState<string | null>(null);
|
||||
|
||||
const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: string) => {
|
||||
const res = await apiFetchRaw("/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ timezone: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return next;
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
storeTimezone(next);
|
||||
toastWrittenOutcome("success", t("settings.timezoneSaved"));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setOptimistic(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
showErrorToast(t("settings.timezoneInvalid"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
function handleChange(next: string) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
// The device zone is read after hydration so both renders agree.
|
||||
const mounted = useMounted();
|
||||
const suggestion = mounted
|
||||
? resolveInitialTimezone(user?.timezone, detectBrowserTimezone())
|
||||
: value;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<TimezonePicker value={value} onChange={handleChange} />
|
||||
{suggestion !== value && optimistic === null ? (
|
||||
<InlineAction
|
||||
tone="primary"
|
||||
size="xs"
|
||||
data-slot="timezone-use-device"
|
||||
disabled={mutation.isPending || !isAuthenticated}
|
||||
onClick={() => handleChange(suggestion)}
|
||||
>
|
||||
{t("settings.timezoneUseDevice", { zone: suggestion })}
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -36,30 +39,6 @@ export function UnitPreferenceSelect({
|
||||
const { user } = useAuth();
|
||||
|
||||
const [optimistic, setOptimistic] = useState<UnitPreference | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric";
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -78,36 +57,22 @@ export function UnitPreferenceSelect({
|
||||
return next;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(t("settings.dashboard.units.saved"));
|
||||
setMsgType("success");
|
||||
toastWrittenOutcome("success", t("settings.dashboard.units.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userUnitPreference(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.dashboard.units.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
showErrorToast(t("settings.dashboard.units.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelect(next: UnitPreference) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
@@ -126,16 +91,8 @@ export function UnitPreferenceSelect({
|
||||
{t("settings.dashboard.units.imperial")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg ?? t("settings.dashboard.units.description")}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dashboard.units.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,17 +9,18 @@
|
||||
* The shell supplies the page chrome (left nav, heading, subtitle) so this
|
||||
* body is the preventive-care cards only. Preventive-care reminders are not
|
||||
* a toggleable module, so this entry carries no `moduleGate` (always shown).
|
||||
*
|
||||
* v1.44 — order and view only. The reminder switches moved to Settings >
|
||||
* Notifications, the screening hints to Health profile > Medical history
|
||||
* beside the life circumstances they come from.
|
||||
*/
|
||||
|
||||
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
|
||||
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
|
||||
|
||||
export function VorsorgeSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<VorsorgeSettings />
|
||||
{/* v1.43 (#614) — hints from the life circumstances, with their source. */}
|
||||
<ScreeningHintsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} from "@/components/settings/notification-channel-toggle";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -41,10 +38,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [headerName, setHeaderName] = useState("");
|
||||
const [headerValue, setHeaderValue] = useState("");
|
||||
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
// The enable switch is a draft field: it saves with the form's one Save.
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.settingsWebhook(),
|
||||
@@ -56,7 +52,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
|
||||
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}|${settings.enabled}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
@@ -64,14 +60,15 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
setUrl(settings!.url);
|
||||
setHeaderName(settings!.headerName);
|
||||
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
|
||||
setEnabled(settings!.enabled);
|
||||
}
|
||||
const dirty = isWebhookFormDirty(
|
||||
{ url, headerName, headerValue, format },
|
||||
{ url, headerName, headerValue, format, enabled },
|
||||
settings,
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/settings/webhook", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -102,54 +99,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
setSaveError(null);
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
setHeaderValue("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleEnabled = useMutation<
|
||||
void,
|
||||
Error,
|
||||
boolean,
|
||||
WebhookSettings | undefined
|
||||
>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled(
|
||||
"/api/settings/webhook",
|
||||
enabled,
|
||||
t("common.error"),
|
||||
),
|
||||
onMutate: async (enabled) => {
|
||||
const queryKey = queryKeys.settingsWebhook();
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
return optimisticallySetChannelEnabled<WebhookSettings>(
|
||||
queryClient,
|
||||
queryKey,
|
||||
enabled,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(
|
||||
queryClient,
|
||||
queryKeys.settingsWebhook(),
|
||||
previous,
|
||||
);
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
|
||||
setSaveError(err.message);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -166,16 +122,16 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
|
||||
<Switch
|
||||
id="webhook-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate(settings?.enabled ?? false);
|
||||
save.mutate();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
@@ -244,12 +200,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMsg && (saveMsgType === "error" || !dirty) && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{saveMsg}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -261,7 +214,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
disabled={save.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -241,3 +241,35 @@ describe("<ConfirmDialog> — the trigger-less half", () => {
|
||||
expect(carriers.map(nameOf)).toEqual(["ConfirmBody"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog with holdOpen — the request stays on screen", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const body = walk(
|
||||
ConfirmDialog({
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
onConfirm,
|
||||
pending: true,
|
||||
holdOpen: true,
|
||||
...COPY,
|
||||
}) as ReactElement,
|
||||
).find((el) => nameOf(el) === "ConfirmBody")!;
|
||||
const rendered = walk(
|
||||
(body.type as (p: unknown) => ReactElement)(body.props),
|
||||
);
|
||||
|
||||
it("keeps the dialog open on confirm and still calls back", () => {
|
||||
const action = rendered.find((el) => nameOf(el) === "AlertDialogAction")!;
|
||||
const preventDefault = vi.fn();
|
||||
(action.props as { onClick: (e: unknown) => void }).onClick({
|
||||
preventDefault,
|
||||
});
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("holds Cancel while the request runs", () => {
|
||||
const cancel = rendered.find((el) => nameOf(el) === "AlertDialogCancel")!;
|
||||
expect((cancel.props as { disabled?: boolean }).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,12 @@ interface ConfirmCopy {
|
||||
pending?: boolean;
|
||||
/** `data-slot` for tests and the design guards. */
|
||||
slot?: string;
|
||||
/**
|
||||
* `data-testid` for the trigger; the dialog's action carries
|
||||
* `<testId>-confirm`. For call sites whose tests already address the
|
||||
* control by test id.
|
||||
*/
|
||||
testId?: string;
|
||||
/** A control that belongs with the question, such as an opt-out checkbox. */
|
||||
extra?: ReactNode;
|
||||
/**
|
||||
@@ -59,6 +65,16 @@ interface ConfirmCopy {
|
||||
* data-list selection bar's confirm already does.
|
||||
*/
|
||||
destructive?: boolean;
|
||||
/**
|
||||
* The confirm keeps the dialog open: the caller closes it once the act
|
||||
* settled, and Cancel waits while `pending`. For an act whose in-flight
|
||||
* state must stay on screen (an irreversible delete), where a dismissal
|
||||
* mid-request would read as cancelled. Only meaningful on `ConfirmDialog`,
|
||||
* whose caller owns `open`.
|
||||
*/
|
||||
holdOpen?: boolean;
|
||||
/** The confirm stays disabled, e.g. until a field in `extra` is valid. */
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
function ConfirmBody({
|
||||
@@ -68,8 +84,11 @@ function ConfirmBody({
|
||||
onConfirm,
|
||||
pending = false,
|
||||
slot,
|
||||
testId,
|
||||
extra,
|
||||
destructive = false,
|
||||
holdOpen = false,
|
||||
confirmDisabled = false,
|
||||
}: ConfirmCopy) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -81,13 +100,23 @@ function ConfirmBody({
|
||||
</AlertDialogHeader>
|
||||
{extra}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={holdOpen && pending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
data-slot={slot ? `${slot}-confirm` : undefined}
|
||||
disabled={pending}
|
||||
data-testid={testId ? `${testId}-confirm` : undefined}
|
||||
disabled={pending || confirmDisabled}
|
||||
aria-busy={pending || undefined}
|
||||
onClick={onConfirm}
|
||||
onClick={
|
||||
holdOpen
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
: onConfirm
|
||||
}
|
||||
>
|
||||
{pending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
@@ -113,6 +142,7 @@ export function ConfirmButton({
|
||||
icon,
|
||||
ariaLabel,
|
||||
slot,
|
||||
testId,
|
||||
extra,
|
||||
destructive = false,
|
||||
}: ConfirmCopy & {
|
||||
@@ -136,6 +166,7 @@ export function ConfirmButton({
|
||||
className={className}
|
||||
aria-label={ariaLabel}
|
||||
data-slot={slot}
|
||||
data-testid={testId}
|
||||
disabled={disabled || pending}
|
||||
>
|
||||
{pending ? (
|
||||
@@ -156,6 +187,7 @@ export function ConfirmButton({
|
||||
onConfirm={onConfirm}
|
||||
pending={pending}
|
||||
slot={slot}
|
||||
testId={testId}
|
||||
extra={extra}
|
||||
destructive={destructive}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user