mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The settings / medications / admin / hooks tree still declared
bare-literal `queryKey: [ … ]` arrays at ~70 call sites. Centralise
them through `queryKeys.<entry>()` so mutation-invalidation prefixes
stay symmetrical and a future rename only touches one location.
New factory entries cover per-medication compliance / titration /
cadence / glp1-details / drug-level-chart / intake-list, the
Withings status read, the paginated admin audit-log filter, and the
workouts-recent list opts. Existing entries cover everything else
(passkeys, tokens, settings, telegram, integrations, research-mode,
admin status / users / backups / feedback / host-metrics / app-logs /
ai-quality / assistant-flags, coach-prefs, feature-flags, etc.).
Lockstep updates:
- `eslint-plugins/healthlog/queryKey-factory.js` extends
`GUARDED_DIRECTORIES` to cover `src/components/{settings,
medications,admin}` + `src/hooks`, plus the three guarded files
under `src/app/medications/**` and `src/app/targets/page.tsx`.
- `src/lib/__tests__/query-keys.test.ts` mirrors the guard's
`guardedRoots` and pins the shape of every new factory entry.
Tests stay green: 4 736 unit; the factory walker reports zero
bare-literal `queryKey:` in any newly-guarded surface.
270 lines
9.6 KiB
TypeScript
270 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog.
|
|
*
|
|
* Opt-in gate in front of the GLP-1 drug-level chart. The chart paints
|
|
* a unit-less Bateman PK curve over the user's logged intakes (research
|
|
* §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU
|
|
* MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4),
|
|
* the chart never paints until the user has explicitly read and
|
|
* acknowledged the disclaimer below. The acknowledgment record is
|
|
* server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy
|
|
* changes the user re-acknowledges before the chart unhides.
|
|
*
|
|
* The dialog is rendered controlled by the Settings toggle and the
|
|
* "re-prompt" banner (commit 3 of this phase). It accepts:
|
|
* - `open` / `onOpenChange` for full external control
|
|
* - `currentDisclaimerVersion` — the server's authoritative version
|
|
* string. Acknowledgment posts THIS value, not the imported
|
|
* constant — server redeploys that bump the constant must be
|
|
* reflected without a client reload (W19c-Backend phase report).
|
|
* - `onAcknowledged()` — fires after a successful 200; the caller
|
|
* re-fetches the research-mode state and renders the toggle in
|
|
* the new state.
|
|
*
|
|
* Copy direction (Marc-Voice, English, no AI/phase/wave mentions):
|
|
* - "What this is" — single-paragraph framing: the chart is an
|
|
* estimate from EMA-published population PK.
|
|
* - "What this isn't" — explicit boundary: not a measurement, not
|
|
* personalised, not medical advice.
|
|
* - "Why it's an estimate" — population PK + IIV; per-patient
|
|
* variation is large.
|
|
* - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim.
|
|
* - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099).
|
|
*
|
|
* Toast surface uses sonner (the project convention) so the user gets
|
|
* a transient confirmation on either success or rejection.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
|
|
|
|
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
|
|
export interface ResearchModeAcknowledgmentDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (next: boolean) => void;
|
|
/**
|
|
* The version string from `GET /api/auth/me/research-mode`. The
|
|
* server compares it byte-for-byte against the current constant; a
|
|
* stale value forces a 400 from the API which we surface as an
|
|
* error toast and keep the dialog open. Pass `null` while the
|
|
* GET is in flight; the Acknowledge CTA stays disabled.
|
|
*/
|
|
currentDisclaimerVersion: string | null;
|
|
/**
|
|
* Fires after the POST returns 200. Caller invalidates the
|
|
* `research-mode` query so the toggle reflects the new state.
|
|
*/
|
|
onAcknowledged?: () => void;
|
|
}
|
|
|
|
export function ResearchModeAcknowledgmentDialog({
|
|
open,
|
|
onOpenChange,
|
|
currentDisclaimerVersion,
|
|
onAcknowledged,
|
|
}: ResearchModeAcknowledgmentDialogProps) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (version: string) => {
|
|
const res = await fetch("/api/auth/me/research-mode", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ acknowledged: true, version }),
|
|
});
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const code =
|
|
typeof json?.error === "string"
|
|
? json.error
|
|
: `http-${res.status}`;
|
|
throw new Error(code);
|
|
}
|
|
return json?.data ?? null;
|
|
},
|
|
onSuccess: () => {
|
|
setErrorMessage(null);
|
|
toast.success(t("medications.researchMode.dialog.successToast"));
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
|
onAcknowledged?.();
|
|
onOpenChange(false);
|
|
},
|
|
onError: (err: unknown) => {
|
|
// The 400 stale-version path is the only one we surface
|
|
// explicitly — every other failure mode (401, 429, 500, network)
|
|
// collapses to a generic error so the dialog stays open but the
|
|
// user knows the acknowledgment didn't persist.
|
|
const code = err instanceof Error ? err.message : "unknown";
|
|
const isStale = code === "research-mode.version.stale";
|
|
const message = isStale
|
|
? t("medications.researchMode.dialog.staleVersionToast")
|
|
: t("medications.researchMode.dialog.errorToast");
|
|
setErrorMessage(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
function handleAcknowledge() {
|
|
if (!currentDisclaimerVersion) return;
|
|
mutation.mutate(currentDisclaimerVersion);
|
|
}
|
|
|
|
function handleCancel() {
|
|
setErrorMessage(null);
|
|
onOpenChange(false);
|
|
}
|
|
|
|
return (
|
|
<ResponsiveSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<BookOpenCheck className="text-dracula-purple h-5 w-5 shrink-0" />
|
|
{t("medications.researchMode.dialog.title")}
|
|
</span>
|
|
}
|
|
description={t("medications.researchMode.dialog.intro")}
|
|
className="sm:max-w-xl"
|
|
footer={
|
|
<div className="flex w-full gap-2 sm:justify-end sm:gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={mutation.isPending}
|
|
data-slot="research-mode-cancel"
|
|
>
|
|
{t("medications.researchMode.dialog.cancelCta")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleAcknowledge}
|
|
disabled={mutation.isPending || !currentDisclaimerVersion}
|
|
data-slot="research-mode-acknowledge"
|
|
>
|
|
{mutation.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
) : null}
|
|
{t("medications.researchMode.dialog.acknowledgeCta")}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div
|
|
data-slot="research-mode-acknowledgment-dialog"
|
|
className="space-y-4 text-sm"
|
|
>
|
|
<section
|
|
aria-labelledby="research-mode-what-it-is"
|
|
data-slot="research-mode-what-it-is"
|
|
>
|
|
<h3
|
|
id="research-mode-what-it-is"
|
|
className="text-foreground font-medium"
|
|
>
|
|
{t("medications.researchMode.dialog.whatItIsHeader")}
|
|
</h3>
|
|
<p className="text-muted-foreground mt-1">
|
|
{t("medications.researchMode.dialog.whatItIs")}
|
|
</p>
|
|
</section>
|
|
|
|
<section
|
|
aria-labelledby="research-mode-what-it-isnt"
|
|
data-slot="research-mode-what-it-isnt"
|
|
>
|
|
<h3
|
|
id="research-mode-what-it-isnt"
|
|
className="text-foreground font-medium"
|
|
>
|
|
{t("medications.researchMode.dialog.whatItIsntHeader")}
|
|
</h3>
|
|
<p className="text-muted-foreground mt-1">
|
|
{t("medications.researchMode.dialog.whatItIsnt")}
|
|
</p>
|
|
</section>
|
|
|
|
<section
|
|
aria-labelledby="research-mode-why-estimate"
|
|
data-slot="research-mode-why-estimate"
|
|
>
|
|
<h3
|
|
id="research-mode-why-estimate"
|
|
className="text-foreground font-medium"
|
|
>
|
|
{t("medications.researchMode.dialog.whyEstimateHeader")}
|
|
</h3>
|
|
<p className="text-muted-foreground mt-1">
|
|
{t("medications.researchMode.dialog.whyEstimate")}
|
|
</p>
|
|
</section>
|
|
|
|
<section
|
|
aria-labelledby="research-mode-mdr"
|
|
data-slot="research-mode-mdr-boundary"
|
|
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
|
|
>
|
|
<h3
|
|
id="research-mode-mdr"
|
|
className="text-foreground flex items-center gap-2 font-medium"
|
|
>
|
|
<AlertTriangle
|
|
className="text-warning h-4 w-4 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
|
|
</h3>
|
|
<p className="text-foreground/85 mt-1">
|
|
{t("medications.researchMode.dialog.mdrBoundary")}
|
|
</p>
|
|
</section>
|
|
|
|
<section
|
|
aria-labelledby="research-mode-citations"
|
|
data-slot="research-mode-citations"
|
|
>
|
|
<h3
|
|
id="research-mode-citations"
|
|
className="text-foreground font-medium"
|
|
>
|
|
{t("medications.researchMode.dialog.citationsHeader")}
|
|
</h3>
|
|
<p className="text-muted-foreground mt-1">
|
|
{t("medications.researchMode.dialog.citations")}
|
|
</p>
|
|
</section>
|
|
|
|
{currentDisclaimerVersion && (
|
|
<p
|
|
className="text-muted-foreground text-xs italic"
|
|
data-slot="research-mode-version"
|
|
>
|
|
{t("medications.researchMode.dialog.versionLine", {
|
|
version: currentDisclaimerVersion,
|
|
})}
|
|
</p>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<p
|
|
role="alert"
|
|
className="text-destructive text-sm"
|
|
data-slot="research-mode-error"
|
|
>
|
|
{errorMessage}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</ResponsiveSheet>
|
|
);
|
|
}
|