refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix

The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.

`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.

`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 23:34:08 +02:00
1 parent c97c4c3be3
commit ef21967d12
4 files changed
+287 -602

No files matched your search

+12 -602
View File
@@ -1,15 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { queryKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
@@ -17,38 +13,7 @@ import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
MoreHorizontal,
Loader2,
Plus,
Pill,
Upload,
Copy,
RefreshCw,
RotateCcw,
} from "lucide-react";
import { Loader2, Pill, Plus } from "lucide-react";
interface Schedule {
id: string;
@@ -102,11 +67,6 @@ export default function MedicationsPage() {
const shouldOpenFromUrl = searchParams?.get("new") === "1";
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
const [editingMed, setEditingMed] = useState<Medication | null>(null);
const [importMedId, setImportMedId] = useState<string | null>(null);
const [apiMed, setApiMed] = useState<{
id: string;
name: string;
} | null>(null);
useEffect(() => {
if (shouldOpenFromUrl) {
@@ -286,14 +246,15 @@ export default function MedicationsPage() {
</div>
)}
{/* API Endpoint Dialog */}
<ApiEndpointDialog medication={apiMed} onClose={() => setApiMed(null)} />
{/* Import Dialog */}
<IntakeImportDialog
medicationId={importMedId}
onClose={() => setImportMedId(null)}
/>
{/* v1.5.5 — the per-card kebab triggers used to open inline
IntakeImportDialog / ApiEndpointDialog mounts here, but both
state slots were only ever set from inside the dialogs
themselves (never from a list-card prop) so the inline
mounts were dead code. v1.5.5 retires both on the list page:
the import dialog lives in the detail-page intake-history
header (see `src/components/medications/intake-import-dialog.tsx`),
the API endpoint moves to the detail-page Settings → Externe
Integration sub-row. */}
{/* v1.5.4 — modal-wizard mount. The same component drives both
create (no initial) and edit (hydrates from the medication's
@@ -341,554 +302,3 @@ function medicationToPayload(med: Medication): MedicationPayload {
};
}
function IntakeImportDialog({
medicationId,
onClose,
}: {
medicationId: string | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const [jsonText, setJsonText] = useState("");
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [resultType, setResultType] = useState<"success" | "error" | null>(
null,
);
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const [fileInputKey, setFileInputKey] = useState(0);
const queryClient = useQueryClient();
function resetImportForm() {
setJsonText("");
setResult(null);
setResultType(null);
setSelectedFileName(null);
setFileInputKey((prev) => prev + 1);
}
function handleClose() {
resetImportForm();
onClose();
}
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setResult(null);
setResultType(null);
try {
const content = await file.text();
JSON.parse(content);
setJsonText(content);
setSelectedFileName(file.name);
setResult(t("medications.importFileLoaded", { name: file.name }));
setResultType("success");
} catch {
setResult(t("medications.importInvalidJson"));
setResultType("error");
}
}
async function handleImport() {
if (!medicationId || !jsonText.trim()) return;
setImporting(true);
setResult(null);
setResultType(null);
try {
let data = JSON.parse(jsonText.trim());
// Support both array and object-with-array
if (!Array.isArray(data)) {
const arrKey = Object.keys(data).find((k) => Array.isArray(data[k]));
if (arrKey) data = data[arrKey];
else throw new Error(t("medications.importNoArray"));
}
const res = await fetch(
`/api/medications/${medicationId}/intake/import`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
},
);
const json = await res.json();
if (res.ok) {
const d = json.data;
setResult(
t("medications.importResult", { imported: d.imported }) +
(d.skippedDuplicates > 0
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
: "") +
(d.skippedInvalid > 0
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
: ""),
);
setResultType("success");
void invalidateKeys(queryClient, medicationDependentKeys);
} else {
setResult(json.error || t("medications.importFailed"));
setResultType("error");
}
} catch (err) {
setResult(
err instanceof SyntaxError
? t("medications.importInvalidFormat")
: (err as Error).message || t("medications.importFailed"),
);
setResultType("error");
} finally {
setImporting(false);
}
}
return (
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("medications.importDescription")}
</p>
<div className="space-y-1.5">
<Label htmlFor="intake-import-file" className="text-xs font-medium">
{t("medications.importUploadFile")}
</Label>
<input
key={fileInputKey}
id="intake-import-file"
type="file"
accept="application/json,.json"
onChange={handleFileSelect}
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
/>
{selectedFileName && (
<p className="text-muted-foreground text-xs">
{t("medications.importSelected", { name: selectedFileName })}
</p>
)}
</div>
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
{`[
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
]`}
</pre>
<Textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder={t("medications.importPaste")}
rows={8}
// JSON paste — disable sentence-case and spell-check so the
// primitive defaults don't munge structured input.
autoCapitalize="none"
spellCheck={false}
className="font-mono"
/>
{result && (
<p
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{result}
</p>
)}
<div className="flex items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
disabled={importing}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetImportForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={importing}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
>
{/*
v1.4.33 IW9 — icon swap (not append) on `importing`
to keep the button width stable through the
in-flight request. Pre-v1.4.33 both icons painted
during loading and the button grew by ~24 px, which
showed up as a CLS hit on the medications import
dialog.
*/}
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{t("common.import")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ApiEndpointDialog({
medication,
onClose,
}: {
medication: { id: string; name: string } | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
const {
data: status,
isFetching: loadingStatus,
refetch: refetchStatus,
error: statusError,
} = useQuery<ApiEndpointStatus>({
queryKey: apiEndpointKey,
queryFn: async () => {
const res = await fetch(
`/api/medications/${medication!.id}/api-endpoint`,
);
const json = await res.json();
if (!res.ok) {
throw new Error(json.error || t("medications.statusLoadFailed"));
}
return {
enabled: json.data.enabled === true,
activeTokenCount: json.data.activeTokenCount ?? 0,
};
},
enabled: !!medication,
staleTime: 0,
gcTime: 0,
});
const enabled = status?.enabled ?? false;
const activeTokenCount = status?.activeTokenCount ?? 0;
const [token, setToken] = useState<string | null>(null);
const [toggling, setToggling] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [exampleType, setExampleType] = useState<ExampleType>("curl");
const displayMsg =
msg ?? (statusError instanceof Error ? statusError.message : null);
function handleClose() {
queryClient.removeQueries({ queryKey: apiEndpointKey });
setToken(null);
setMsg(null);
setCopied(null);
setExampleType("curl");
onClose();
}
async function toggleEndpoint(nextEnabled: boolean) {
if (!medication) return;
setToggling(true);
setToken(null);
setMsg(null);
try {
const res = await fetch(
`/api/medications/${medication.id}/api-endpoint`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled }),
},
);
const json = await res.json();
if (!res.ok) {
setMsg(json.error || t("medications.changeFailed"));
return;
}
const nextStatus: ApiEndpointStatus = {
enabled: json.data.enabled === true,
activeTokenCount:
typeof json.data.activeTokenCount === "number"
? json.data.activeTokenCount
: !json.data.enabled
? 0
: (status?.activeTokenCount ?? 0),
};
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
if (json.data.token) {
setToken(json.data.token);
}
setMsg(
nextEnabled
? t("medications.apiEndpointActivated")
: t("medications.apiEndpointDeactivated"),
);
} catch {
setMsg(t("medications.changeFailed"));
} finally {
setToggling(false);
}
}
async function copyText(text: string, key: string) {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
}
const baseUrl =
typeof window !== "undefined" ? window.location.origin : "https://...";
const endpoint = `${baseUrl}/api/ingest/medication`;
const payload = `{"medicationName":"${medication?.name ?? ""}","idempotencyKey":"einnahme-202602191230"}`;
const curlCmd = `curl -X POST ${endpoint} \\
-H "Authorization: Bearer ${token ?? "DEIN_TOKEN"}" \\
-H "Content-Type: application/json" \\
-d '${payload}'`;
const wgetCmd = `wget --method=POST "${endpoint}" \\
--header="Authorization: Bearer ${token ?? "DEIN_TOKEN"}" \\
--header="Content-Type: application/json" \\
--body-data='${payload}' \\
-O -`;
const fetchCmd = `await fetch("${endpoint}", {
method: "POST",
headers: {
"Authorization": "Bearer ${token ?? "DEIN_TOKEN"}",
"Content-Type": "application/json"
},
body: JSON.stringify({
medicationName: "${medication?.name ?? ""}",
idempotencyKey: "einnahme-" + Date.now()
})
});`;
const powershellCmd = `Invoke-RestMethod -Method Post -Uri "${endpoint}" \\
-Headers @{ Authorization = "Bearer ${token ?? "DEIN_TOKEN"}" } \\
-ContentType "application/json" \\
-Body '${payload}'`;
const exampleMap: Record<ExampleType, { label: string; value: string }> = {
curl: { label: "cURL", value: curlCmd },
wget: { label: "wget", value: wgetCmd },
fetch: { label: "JavaScript fetch", value: fetchCmd },
powershell: { label: "PowerShell", value: powershellCmd },
};
const selectedExample = exampleMap[exampleType];
return (
<Dialog open={!!medication} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{t("medications.apiEndpointTitle", {
name: medication?.name ?? "",
})}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("medications.apiEndpointDescription")}
</p>
<div className="bg-muted/40 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">
{t("medications.apiEndpointActive")}
</p>
<p className="text-muted-foreground text-xs">
{enabled
? t("medications.apiTokenCount", {
count: activeTokenCount,
})
: t("common.disabled")}
</p>
</div>
<Switch
checked={enabled}
disabled={loadingStatus || toggling}
onCheckedChange={(checked) => {
void toggleEndpoint(checked);
}}
/>
</div>
</div>
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-medium">Endpoint</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded px-3 py-2 font-mono text-xs break-all">
POST {endpoint}
</code>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => copyText(endpoint, "url")}
aria-label={t("common.copy")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "url" && (
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
)}
</div>
{/* Token */}
<div className="space-y-1.5">
<Label className="text-xs font-medium">
{t("medications.apiToken")}
</Label>
{token ? (
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded px-3 py-2 font-mono text-xs break-all">
{token}
</code>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => copyText(token, "token")}
aria-label={t("common.copy")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<p className="text-muted-foreground text-xs">
{enabled
? t("medications.apiTokenActiveHint")
: t("medications.apiTokenActivateHint")}
</p>
)}
{token && (
<p className="text-muted-foreground text-xs">
{t("medications.apiTokenOnceHint")}
</p>
)}
{copied === "token" && (
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
)}
</div>
{/* request examples */}
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium">
{t("medications.requestExample")}
</Label>
<Select
value={exampleType}
onValueChange={(value) => setExampleType(value as ExampleType)}
>
<SelectTrigger size="sm" className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="curl">cURL</SelectItem>
<SelectItem value="wget">wget</SelectItem>
<SelectItem value="fetch">JavaScript fetch</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative">
<pre className="bg-muted rounded-lg p-3 font-mono text-xs break-all whitespace-pre-wrap">
{selectedExample.value}
</pre>
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-7 w-7"
onClick={() => copyText(selectedExample.value, "example")}
aria-label={t("common.copy")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
{copied === "example" && (
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
)}
</div>
{displayMsg && (
<p
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
>
{displayMsg}
</p>
)}
<div className="flex items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
disabled={loadingStatus || toggling}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
void refetchStatus();
}}
disabled={loadingStatus || toggling}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t("medications.retryLoad")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleClose}>{t("common.close")}</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,251 @@
"use client";
/**
* v1.5.5 D-3 §10 invariant 19 — `IntakeImportDialog` lifted out of
* `src/app/medications/page.tsx` so the detail-page intake-history
* header can mount it as the only CSV-import affordance. The list
* page previously owned the only mount + the per-card kebab triggered
* `setImportMedId(id)`. v1.5.5 retires the per-card trigger; the
* detail page is the only surface that opens the dialog, and the
* intake-history preview owns the trigger so the import lives next to
* the table it changes.
*
* The contract is preserved verbatim from the v1.5.4 list-page copy
* so the integration test surface stays familiar: open via
* `medicationId` going non-null, close via `onClose`. The dialog
* resets every transient piece of state on close so the next open
* never picks up the previous session's text.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface IntakeImportDialogProps {
medicationId: string | null;
onClose: () => void;
}
export function IntakeImportDialog({
medicationId,
onClose,
}: IntakeImportDialogProps) {
const { t } = useTranslations();
const [jsonText, setJsonText] = useState("");
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [resultType, setResultType] = useState<"success" | "error" | null>(
null,
);
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const [fileInputKey, setFileInputKey] = useState(0);
const queryClient = useQueryClient();
function resetImportForm() {
setJsonText("");
setResult(null);
setResultType(null);
setSelectedFileName(null);
setFileInputKey((prev) => prev + 1);
}
function handleClose() {
resetImportForm();
onClose();
}
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setResult(null);
setResultType(null);
try {
const content = await file.text();
JSON.parse(content);
setJsonText(content);
setSelectedFileName(file.name);
setResult(t("medications.importFileLoaded", { name: file.name }));
setResultType("success");
} catch {
setResult(t("medications.importInvalidJson"));
setResultType("error");
}
}
async function handleImport() {
if (!medicationId || !jsonText.trim()) return;
setImporting(true);
setResult(null);
setResultType(null);
try {
let data = JSON.parse(jsonText.trim());
// Support both array and object-with-array
if (!Array.isArray(data)) {
const arrKey = Object.keys(data).find((k) => Array.isArray(data[k]));
if (arrKey) data = data[arrKey];
else throw new Error(t("medications.importNoArray"));
}
const res = await fetch(
`/api/medications/${medicationId}/intake/import`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
},
);
const json = await res.json();
if (res.ok) {
const d = json.data;
setResult(
t("medications.importResult", { imported: d.imported }) +
(d.skippedDuplicates > 0
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
: "") +
(d.skippedInvalid > 0
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
: ""),
);
setResultType("success");
void invalidateKeys(queryClient, medicationDependentKeys);
} else {
setResult(json.error || t("medications.importFailed"));
setResultType("error");
}
} catch (err) {
setResult(
err instanceof SyntaxError
? t("medications.importInvalidFormat")
: (err as Error).message || t("medications.importFailed"),
);
setResultType("error");
} finally {
setImporting(false);
}
}
return (
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("medications.importDescription")}
</p>
<div className="space-y-1.5">
<Label htmlFor="intake-import-file" className="text-xs font-medium">
{t("medications.importUploadFile")}
</Label>
<input
key={fileInputKey}
id="intake-import-file"
type="file"
accept="application/json,.json"
onChange={handleFileSelect}
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
/>
{selectedFileName && (
<p className="text-muted-foreground text-xs">
{t("medications.importSelected", { name: selectedFileName })}
</p>
)}
</div>
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
{`[
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
]`}
</pre>
<Textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder={t("medications.importPaste")}
rows={8}
autoCapitalize="none"
spellCheck={false}
className="font-mono"
/>
{result && (
<p
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
role="status"
aria-live="polite"
>
{result}
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
disabled={importing}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetImportForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={importing}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
className="min-h-11 sm:min-h-9"
>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{t("common.import")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+10
View File
@@ -223,6 +223,16 @@ describe("dependent-key bundles", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
});
// v1.5.5 D-3 §10 invariant 20 — the per-medication inline compliance
// chart cache key (`["compliance-chart-inline", medicationId]`) needs
// its prefix in the bundle so every detail-page mutation evicts the
// tile in one tick. Hierarchical prefix-match catches every per-id
// slot beneath it.
it("medicationDependentKeys bundles the compliance-chart-inline prefix (v1.5.5)", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["compliance-chart-inline"]));
});
});
/**
+14
View File
@@ -353,6 +353,19 @@ export const moodDependentKeys = [
* `["dashboard-medication-compliance"]` lands in the bundle so an
* intake POST refreshes the chart immediately rather than waiting for
* `staleTime` (audit L4).
*
* v1.5.5 D-3 §10 invariant 20 (was C-E2-1 / H-cluster-G) — the
* per-medication inline compliance chart used to mount under
* `queryKeys.medicationComplianceChart(medicationId)` which expands to
* `["compliance-chart-inline", id]`. The prefix `["compliance-chart-inline"]`
* lands in the bundle so every detail-page mutation (today's-dose,
* Pausieren, end, purge, edit) evicts the inline compliance tile in
* one tick. The TanStack hierarchical-prefix semantics catch every
* per-medication slot under that prefix.
*
* `queryKeys.medicationDetail(id)` rides under the
* `["medications"]` prefix already so a single medication invalidation
* also evicts its detail-page read.
*/
export const medicationDependentKeys = [
queryKeys.medications(),
@@ -361,6 +374,7 @@ export const medicationDependentKeys = [
queryKeys.insightsTargets(),
queryKeys.gamificationAchievements(),
["dashboard-medication-compliance"] as const,
["compliance-chart-inline"] as const,
];
/**