diff --git a/components/extensions/general/ArcimMigrationTheater.tsx b/components/extensions/general/ArcimMigrationTheater.tsx new file mode 100644 index 00000000..4243de22 --- /dev/null +++ b/components/extensions/general/ArcimMigrationTheater.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import TheaterCanvas, { type TheaterCanvasHandle } from '@/components/import/TheaterCanvas' +import type { TheaterModel } from '@/lib/import/theater-model' + +/** + * The migration wizard's version of the import theater. Unlike /import + * (a fixed narration script paced against one opaque server call), the + * wizard KNOWS what the server is doing: phase 1 posts one SIE file at a + * time and phase 2 streams the orchestrator's real progress events. The + * narration is therefore simply those real step labels, printed once each + * as they arrive; the canvas reacts to the same events. Nothing here + * invents progress. + * + * The graph itself is built from the client-parsed SIE data, so drawing it + * while the server writes is honest: it shows what the data contains, the + * narration shows how far the import has come. + */ + +interface ArcimMigrationTheaterProps { + model: TheaterModel + /** The wizard's real current step label (SIE per-file, then streamed). */ + currentStep: string + /** The wizard's real progress bar value, 0-100. */ + progress: number +} + +interface LogLine { + id: number + text: string +} + +/** Keep the printed log short enough to never overflow the card. */ +const MAX_LOG_LINES = 6 + +export default function ArcimMigrationTheater({ + model, + currentStep, + progress, +}: ArcimMigrationTheaterProps) { + const canvasRef = useRef(null) + const [log, setLog] = useState([]) + const [elapsed, setElapsed] = useState(0) + + const reduced = + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + + // Visible elapsed counter: a multi-minute migration must never look frozen. + useEffect(() => { + const started = Date.now() + const id = window.setInterval( + () => setElapsed(Math.floor((Date.now() - started) / 1000)), + 1000, + ) + return () => window.clearInterval(id) + }, []) + + // Opening beat: the GL skeleton (year rings, buckets, account waves) + // builds while phase 1 writes the journal. Reduced motion renders the + // settled graph instead (via the settled prop below). + useEffect(() => { + if (reduced) return + const canvas = () => canvasRef.current + const timers: number[] = [] + const at = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms)) + at(300, () => canvas()?.spawn('ring')) + at(1200, () => { + canvas()?.spawn('bucket') + canvas()?.feed(2100) + ;[0, 1, 2, 3].forEach((w) => + timers.push(window.setTimeout(() => canvas()?.spawn('account', w), 200 + w * 420)), + ) + }) + return () => timers.forEach((id) => window.clearTimeout(id)) + }, [reduced]) + + // Real-event narration: append each distinct step label once, and let the + // canvas mark the milestone. The entity phase (bar past its 55% handoff) + // attaches counterparties: that is literally what the orchestrator is + // importing then. Reconciliation (95+) gets the pulse. + const lastStepRef = useRef('') + const idRef = useRef(0) + const cpWaveRef = useRef(0) + useEffect(() => { + if (!currentStep || currentStep === lastStepRef.current) return + lastStepRef.current = currentStep + idRef.current += 1 + const line = { id: idRef.current, text: currentStep } + setLog((prev) => [...prev, line].slice(-MAX_LOG_LINES)) + if (reduced) return + const canvas = canvasRef.current + if (!canvas) return + if (progress > 55 && cpWaveRef.current < 3) { + canvas.spawn('counterparty', cpWaveRef.current) + cpWaveRef.current += 1 + } + canvas.feed(900) + if (progress >= 95) canvas.pulse() + }, [currentStep, progress, reduced]) + + return ( + + +
+
+

Migrering pågår

+

+ Vi hämtar och importerar din bokföringsdata. Det kan ta några minuter. +

+ {/* The live region covers only the step labels: the per-second + timer below would drown them out in a screen reader. */} +
    + {log.map((line, i) => { + const active = i === log.length - 1 + return ( +
  1. +

    + {line.text} +

    +
  2. + ) + })} +
+
+ + +
+
+
+ +
+
+
+
+ ) +} diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 3254d70c..9782eb5d 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -199,6 +199,8 @@ interface MigrationResults { stepErrors?: MigrationStepError[] } import AccountMappingStep from '@/components/import/AccountMappingStep' +import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater' +import type { TheaterModel } from '@/lib/import/theater-model' import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types' import type { BASAccount } from '@/types' @@ -1967,6 +1969,9 @@ export default function ArcimMigrationWorkspace({ const [migrationProgress, setMigrationProgress] = useState(0) const [migrationResults, setMigrationResults] = useState(null) const [sieImportResults, setSieImportResults] = useState([]) + // Knowledge-graph theater for the migrating step, built from the already + // client-held parsed SIE. Null falls back to the plain progress card. + const [theaterModel, setTheaterModel] = useState(null) // Wizard progress: only user-interactive steps const userSteps = STEPS.filter(s => { @@ -2376,6 +2381,19 @@ export default function ArcimMigrationWorkspace({ setMigrationProgress(5) setError(null) + // Build the theater from the parsed SIE the client already holds. + // Best-effort: any failure just leaves the plain progress card. + if (sieData?.parsed) { + try { + const { buildTheaterModel } = await import('@/lib/import/theater-model') + setTheaterModel(buildTheaterModel(sieData.parsed)) + } catch { + setTheaterModel(null) + } + } else { + setTheaterModel(null) + } + try { // ── Phase 1: SIE import ────────────────────────────────── if (migrationOptions.importSIEData && sieData && sieData.rawContent.length > 0) { @@ -2524,6 +2542,7 @@ export default function ArcimMigrationWorkspace({ setMigrationOptions(DEFAULT_OPTIONS) setMigrationResults(null) setSieImportResults([]) + setTheaterModel(null) setError(null) // Refresh status so provider step shows updated import history fetchStatus() @@ -2627,7 +2646,15 @@ export default function ArcimMigrationWorkspace({ )} {step === 'migrating' && ( - + theaterModel ? ( + + ) : ( + + ) )} {step === 'result' && ( diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index d89ffe9e..cc48cfab 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -1164,7 +1164,12 @@ export const arcimMigrationExtension: Extension = { const envelope = await migrateFailureResponse(error, consentId).json() send({ kind: 'error', ...envelope }) } finally { - controller.close() + try { + controller.close() + } catch { + // Already closed because the reader cancelled; close() + // would otherwise reject start() as an unhandled rejection. + } } }, })