mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
497 lines
17 KiB
TypeScript
497 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 { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import {
|
|
type ResearchModeStatus,
|
|
researchModeGateState,
|
|
} from "@/lib/medications/research-mode-types";
|
|
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
|
|
|
/**
|
|
* 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 apiFetchRaw("/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 apiFetchRaw("/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-4 sm:p-6"
|
|
data-slot="settings-research-mode-card"
|
|
>
|
|
<SettingsCardHeader
|
|
icon={BookOpenCheck}
|
|
title={t("settings.researchMode.sectionTitle")}
|
|
description={t("settings.researchMode.subtitle")}
|
|
/>
|
|
|
|
{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 apiFetchRaw("/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-4 sm: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 apiFetchRaw("/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-4 sm: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>
|
|
);
|
|
}
|