mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
502 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|