Files
HealthLog/src/components/settings/advanced-section.tsx
T
Marc-André Bombeck 024da9fe96 fix(ui): granular sleep sources, viewport-safe tour, one aligned settings language
A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
2026-06-10 21:19:05 +02:00

502 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpenCheck, Loader2, 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 { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
type ResearchModeStatus,
researchModeGateState,
} from "@/lib/medications/research-mode-types";
/**
* Settings → Advanced.
*
* v1.4.16 phase B7: every export path (CSV, JSON, doctor-report PDF)
* moved out into the dedicated `<ExportSection>` under `/settings/export`.
* What stays here is the irreversible danger-zone — the "wipe all my
* data" surface that should never live next to a single-click export
* button.
*
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
* Research Mode and the data-reset are both opt-in / version-gated
* controls; they share a semantic shelf even though they touch
* different parts of the user record. Toggle ON opens the
* acknowledgment dialog (the user reads + confirms before the server
* stamps the version). Toggle OFF fires DELETE directly. A separate
* amber banner above the toggle catches the version-mismatch case
* (server bumped the disclaimer while the user was still
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
* dialog.
*/
export function AdvancedSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-advanced-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="sr-only"
>
{t("settings.sections.advanced.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.advanced.description")}
</p>
</header>
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</section>
);
}
/**
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
* the toggle's caption out of the JSX. The four-way ladder (loading /
* enabled-open / enabled-stale / disabled) is dense to read inline,
* and the surface stays open to a fifth state ("never queried") when
* we wire the offline shell in v1.4.26.
*/
function researchModeStatusLabel(
status: ResearchModeStatus | null | undefined,
isLoading: boolean,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (isLoading) return t("common.loading");
const gate = researchModeGateState(status);
if (gate === "off") return t("settings.researchMode.disabledStatus");
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
return t("settings.researchMode.acknowledgedOn", {
date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—",
});
}
function ResearchModeCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [toggleBusy, setToggleBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
queryKey: queryKeys.researchMode(),
queryFn: async () => {
const res = await fetch("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
},
staleTime: 60 * 1000,
});
const gateState = researchModeGateState(status);
const showRePrompt = gateState === "stale";
// The Switch's `checked` mirrors the server flag exactly — even when
// the version is stale we keep the toggle "on" so the user sees that
// their previous choice is preserved; the banner above the toggle
// explains why the chart isn't painting.
const switchChecked = !!status?.enabled;
async function handleToggle(next: boolean) {
if (toggleBusy) return;
setErrorMessage(null);
if (next) {
// Toggle ON → open the acknowledgment dialog. The dialog owns
// the POST; on success it invalidates the `research-mode` query
// and the toggle reflects the new state.
setDialogOpen(true);
return;
}
// Toggle OFF → fire DELETE directly.
setToggleBusy(true);
try {
const res = await fetch("/api/auth/me/research-mode", {
method: "DELETE",
});
if (!res.ok) {
setErrorMessage(t("settings.researchMode.disableError"));
return;
}
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
} catch {
setErrorMessage(t("settings.researchMode.disableError"));
} finally {
setToggleBusy(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("settings.researchMode.subtitle")}
</p>
{showRePrompt && (
<div
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.rePromptBody")}
</p>
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => setDialogOpen(true)}
data-slot="settings-research-mode-reprompt-cta"
>
{t("settings.researchMode.rePromptCta")}
</Button>
</div>
)}
<div className="mt-4 flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
</p>
<p
className="text-muted-foreground text-xs"
data-slot="settings-research-mode-status"
>
{researchModeStatusLabel(status, isLoading, t)}
</p>
</div>
<Switch
checked={switchChecked}
disabled={isLoading || toggleBusy}
onCheckedChange={(next) => {
void handleToggle(next);
}}
aria-label={t("settings.researchMode.toggleLabel")}
data-slot="settings-research-mode-toggle"
/>
</div>
{errorMessage && (
<p
role="alert"
className="text-destructive mt-3 text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
</p>
)}
<ResearchModeAcknowledgmentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
/>
</div>
);
}
function DataResetCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleting, setDeleting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
async function handleDeleteAllData() {
if (deleting) return;
setDeleting(true);
setMsg(null);
setMsgType(null);
try {
const res = await fetch("/api/settings/data", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE" }),
});
const json = await res.json();
if (!res.ok) {
setMsg(json.error || t("settings.dangerZoneDeleteFailed"));
setMsgType("error");
return;
}
await queryClient.invalidateQueries();
setMsg(t("settings.dangerZoneSuccess"));
setMsgType("success");
setConfirmOpen(false);
} catch {
setMsg(t("settings.dangerZoneDeleteFailed"));
setMsgType("error");
} finally {
setDeleting(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-data-reset-card"
>
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
neutralised the title colour so the danger-zone shaping is
GitHub-style (red CTA only) rather than red-on-red-on-red.
The protective gate (confirmation dialog) is unchanged; this
is purely a visual-tone fix per the v1.4.43 audit. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.dangerZoneDescription")}
</p>
</div>
<AlertDialog
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.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="w-full shrink-0 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>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}
/**
* v1.4.43 QoL (M3) — separate destructive action for the full account
* delete. Pre-fix, the only "danger zone" CTA wiped the user's health
* data but left the user row, passkeys, audit log, and sessions
* intact — half of what a user reading "Danger Zone" expects per
* GDPR Article 17. The route `DELETE /api/settings/account` already
* cascades User + passkeys + audit log + sessions (and is rate-limited
* by the API handler); this card is its UI front door.
*
* Visually the card mirrors the data-reset shaping (neutral title,
* red CTA only) but the dialog copy is explicit: this deletes the
* account, not just the data, and the user will be signed out
* immediately.
*/
function AccountDeleteCard() {
const { t } = useTranslations();
const [deleting, setDeleting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
async function handleDeleteAccount() {
if (deleting) return;
setDeleting(true);
setMsg(null);
setMsgType(null);
try {
const res = await fetch("/api/settings/account", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
});
if (!res.ok) {
try {
const json = await res.json();
setMsg(json.error || t("settings.deleteAccountFailed"));
} catch {
setMsg(t("settings.deleteAccountFailed"));
}
setMsgType("error");
setDeleting(false);
return;
}
setMsg(t("settings.deleteAccountSuccess"));
setMsgType("success");
// The route destroyed every session before deleting the row;
// give the toast a beat to paint, then bounce to the login page.
// Leave `deleting` set so the confirm button keeps its pending
// state through the redirect — the row is gone, nothing to undo.
setTimeout(() => {
window.location.href = "/auth/login";
}, 1_500);
} catch {
setMsg(t("settings.deleteAccountFailed"));
setMsgType("error");
setDeleting(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-account-delete-card"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
</div>
<AlertDialog
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.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="w-full shrink-0 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>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}