From fa5c6500b579eb3497cd6b148bdf2bfb4b5e4a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 01:15:54 +0200 Subject: [PATCH] refactor(settings): split import-panel into per-source card components --- src/components/settings/import-panel.tsx | 920 ------------------ .../import-panel/apple-health-import-card.tsx | 274 ++++++ .../settings/import-panel/constants.ts | 6 + .../settings/import-panel/csv-import-card.tsx | 275 ++++++ .../import-panel/import-card-shell.tsx | 31 + .../settings/import-panel/import-examples.ts | 68 ++ .../settings/import-panel/index.tsx | 56 ++ .../import-panel/json-import-card.tsx | 235 +++++ 8 files changed, 945 insertions(+), 920 deletions(-) delete mode 100644 src/components/settings/import-panel.tsx create mode 100644 src/components/settings/import-panel/apple-health-import-card.tsx create mode 100644 src/components/settings/import-panel/constants.ts create mode 100644 src/components/settings/import-panel/csv-import-card.tsx create mode 100644 src/components/settings/import-panel/import-card-shell.tsx create mode 100644 src/components/settings/import-panel/import-examples.ts create mode 100644 src/components/settings/import-panel/index.tsx create mode 100644 src/components/settings/import-panel/json-import-card.tsx diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx deleted file mode 100644 index c42fc41b4..000000000 --- a/src/components/settings/import-panel.tsx +++ /dev/null @@ -1,920 +0,0 @@ -"use client"; - -/** - * v1.15.7 — Settings → Export & Import → Import area. - * - * Web UI for the two existing import backends (issue #281: the routes - * shipped without a surface). Two clearly-separated controls: - * - * 1. Apple Health `export.zip` — multipart POST to - * `/api/import/apple-health-export` (field `file`), then poll - * `GET /api/import/apple-health-export/[jobId]/status` on a - * `refetchInterval` while the job runs, stopping on a terminal - * state (`done` / `failed`). The upload streams server-side so a - * multi-GB archive never buffers in the browser beyond the POST. - * Idempotent on the file's SHA-256 — re-uploading the same archive - * merges rather than duplicating. - * - * 2. Generic JSON import — upload a `.json` file OR paste JSON, - * validated client-side for parseability, POSTed to `/api/import`. - * A "Download example" button mints a small valid example as a Blob - * (no static asset). Returns `{ measurements, moodEntries, skipped }` - * counts. - * - * Both controls surface rate-limit (429) and error states cleanly and - * announce progress to assistive tech via `aria-live` regions. - */ - -import { useCallback, useId, useRef, useState } from "react"; -import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - CheckCircle2, - Download, - FileJson, - FileSpreadsheet, - Loader2, - Upload, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Progress } from "@/components/ui/progress"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { Textarea } from "@/components/ui/textarea"; -import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements"; - -/** - * Upper bound for the paste textareas, mirroring the 16 MB server-side body - * ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste - * before it ever reaches the route and feeds the live character counter. - */ -const MAX_PASTE_CHARS = 16 * 1024 * 1024; - -// ─────────────────────────── Section wrapper ─────────────────────────── - -export function ImportPanel() { - const { t } = useTranslations(); - return ( -
-
-

- {t("settings.sections.export.import.heading")} -

-

- {t("settings.sections.export.import.description")} -

-
-
- - - -
-
- ); -} - -// ─────────────────────────── Card shell ─────────────────────────── - -interface ImportCardShellProps { - testId: string; - icon: React.ComponentType<{ className?: string }>; - title: string; - description: string; - children: React.ReactNode; -} - -function ImportCardShell({ - testId, - icon: Icon, - title, - description, - children, -}: ImportCardShellProps) { - return ( - -
-
-

{description}

-
{children}
-
- ); -} - -// ─────────────────────────── Apple Health import ─────────────────────────── - -/** Terminal states the status poll stops on. */ -const TERMINAL_STATES: readonly string[] = ["done", "failed"]; - -interface JobStatus { - jobId: string; - status: string; - progress: { - currentPhase?: string; - recordsRead?: number; - rowsUpserted?: number; - percent?: number | null; - } | null; - result: { - totals?: { recordsRead?: number; rowsUpserted?: number }; - clinical?: { skipped?: number }; - } | null; - failureReason: string | null; -} - -function AppleHealthImportCard() { - const { t } = useTranslations(); - const fileInputRef = useRef(null); - const [jobId, setJobId] = useState(null); - const [uploading, setUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const [dragActive, setDragActive] = useState(false); - const dropDescId = useId(); - - const statusQuery = useQuery({ - queryKey: queryKeys.importJobStatus(jobId ?? "none"), - enabled: jobId !== null, - refetchInterval: (query) => { - const data = query.state.data as JobStatus | undefined; - if (data && TERMINAL_STATES.includes(data.status)) return false; - return 2000; - }, - queryFn: async (): Promise => { - return apiGet( - `/api/import/apple-health-export/${jobId}/status`, - { credentials: "include" }, - ); - }, - }); - - const upload = useCallback( - async (file: File) => { - setUploadError(null); - setUploading(true); - setJobId(null); - try { - const form = new FormData(); - form.append("file", file); - const res = await apiFetchRaw("/api/import/apple-health-export", { - method: "POST", - credentials: "include", - body: form, - }); - if (res.status === 429) { - setUploadError( - t("settings.sections.export.import.appleHealth.rateLimited"), - ); - return; - } - if (res.status === 413) { - setUploadError( - t("settings.sections.export.import.appleHealth.tooLarge"), - ); - return; - } - if (!res.ok) { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - return; - } - const body = (await res.json()).data as { - jobId: string; - status: string; - }; - if (!body?.jobId) { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - return; - } - setJobId(body.jobId); - } catch { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - } finally { - setUploading(false); - } - }, - [t], - ); - - function onFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (file) void upload(file); - // Reset so picking the same file twice re-fires the change event. - e.target.value = ""; - } - - function onDrop(e: React.DragEvent) { - e.preventDefault(); - setDragActive(false); - const file = e.dataTransfer.files?.[0]; - if (file) void upload(file); - } - - const status = statusQuery.data ?? null; - const isRunning = status !== null && !TERMINAL_STATES.includes(status.status); - const isDone = status?.status === "done"; - const isFailed = status?.status === "failed"; - const busy = uploading || isRunning; - const percent = status?.progress?.percent ?? null; - - return ( - - {/* Keyboard-operable drop area. The hidden file input does the - actual file selection; the div forwards Enter/Space to it. */} -
!busy && fileInputRef.current?.click()} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !busy) { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - if (!busy) setDragActive(true); - }} - onDragLeave={() => setDragActive(false)} - onDrop={(e) => !busy && onDrop(e)} - className={cn( - "border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none", - dragActive && "border-primary bg-primary/5", - busy && "pointer-events-none opacity-60", - )} - > -
- - -

- {t("settings.sections.export.import.appleHealth.idempotencyNote")} -

- - {/* Live progress / outcome — announced to assistive tech. */} -
- {uploading && ( -

- - {t("settings.sections.export.import.appleHealth.uploading")} -

- )} - {isRunning && ( -
-

- - {t( - `settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`, - )} -

- {typeof percent === "number" && } - {typeof status?.progress?.rowsUpserted === "number" && ( -

- {t("settings.sections.export.import.appleHealth.rowsImported", { - count: status.progress.rowsUpserted, - })} -

- )} -
- )} - {isDone && ( -
-
- )} - {(isFailed || uploadError) && ( -

-

- )} -
- -
- -
-
- ); -} - -// ─────────────────────────── Generic JSON import ─────────────────────────── - -/** - * Small valid example payload, minted as a downloadable Blob by the - * "Download example" button and used by the docs. Exported so a test can - * assert it stays a valid import body — the button and the route schema - * must never drift. - */ -export const EXAMPLE_IMPORT = { - measurements: [ - { - type: "WEIGHT", - value: 80.5, - unit: "kg", - measuredAt: "2026-05-01T08:00:00.000Z", - source: "manual", - notes: "morning", - }, - { - type: "BLOOD_PRESSURE_SYS", - value: 120, - unit: "mmHg", - measuredAt: "2026-05-01T08:05:00.000Z", - }, - { - type: "BLOOD_PRESSURE_DIA", - value: 80, - unit: "mmHg", - measuredAt: "2026-05-01T08:05:00.000Z", - }, - ], - moodEntries: [ - { - date: "2026-05-01", - mood: "GUT", - score: 4, - tags: "work,exercise", - }, - ], -}; - -interface JsonImportResult { - measurements: number; - moodEntries: number; - skipped: number; -} - -/** - * Client-side parse guard for the JSON-import textarea. Returns the - * parsed value when the text is valid JSON, otherwise a failure marker — - * we never POST an unparseable body. Exported for unit testing so the - * guard is covered without a browser. - */ -export function parseImportJson( - text: string, -): { ok: true; value: unknown } | { ok: false } { - try { - return { ok: true, value: JSON.parse(text) }; - } catch { - return { ok: false }; - } -} - -function JsonImportCard() { - const { t } = useTranslations(); - const fileInputRef = useRef(null); - const [text, setText] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - const textareaId = useId(); - - async function onFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - setError(null); - setResult(null); - try { - const content = await file.text(); - setText(content); - } catch { - setError(t("settings.sections.export.import.json.readFailed")); - } - } - - function downloadExample() { - const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "healthlog-import-example.json"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } - - async function handleImport() { - setError(null); - setResult(null); - const parsed = parseImportJson(text); - if (!parsed.ok) { - setError(t("settings.sections.export.import.json.invalidJson")); - return; - } - const payload = parsed.value; - setBusy(true); - try { - const res = await apiFetchRaw("/api/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - }); - if (res.status === 429) { - setError(t("settings.sections.export.import.json.rateLimited")); - return; - } - if (res.status === 422) { - const body = await res.json().catch(() => null); - setError( - body?.error ?? - t("settings.sections.export.import.json.invalidPayload"), - ); - return; - } - if (!res.ok) { - setError(t("settings.sections.export.import.json.failed")); - return; - } - const data = (await res.json()).data as JsonImportResult; - setResult({ - measurements: data?.measurements ?? 0, - moodEntries: data?.moodEntries ?? 0, - skipped: data?.skipped ?? 0, - }); - } catch { - setError(t("settings.sections.export.import.json.failed")); - } finally { - setBusy(false); - } - } - - return ( - -
- -