* feat(import): stream real Arcim migration progress as NDJSON (#1485) * feat(import): stream real Arcim migration progress as NDJSON The /migrate route ran the orchestrator to completion and answered with one JSON blob, so the wizard faked its progress bar: a hardcoded 55% anchor and a static step label for a phase that can take minutes. The orchestrator has had a real onProgress channel (eight emit points with Swedish step labels and anchors) since it was written; the route just never passed it. Now a request with Accept: application/x-ndjson gets a streamed response: one line per orchestrator progress event, then a terminal done line with the results or an error line carrying the same structured envelope the JSON path returns (the 200 status is already committed once the stream opens). Callers without the header keep the original single-JSON contract, so pre-deploy tabs and the existing error-mapping tests are untouched. The wizard opts in, drives MigratingStep from the real labels and anchors (mapped onto the 55-100 slice of the wizard bar), and treats a dropped connection as unconfirmed rather than failed, since the migration keeps running server-side and a blind retry could double-import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the opt-in NDJSON streaming decision for /migrate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat(import): mount the knowledge-graph theater on the Arcim migrating step The migration wizard already holds the fully parsed SIE client-side (SIEData.parsed from /sie-data), so the same TheaterCanvas that carries the /import flow can build the company's knowledge graph while the migration runs: no server change, and the plain progress card stays as the fallback whenever no parsed SIE exists (e.g. providers without SIE). Unlike /import's fixed narration script, the wizard knows exactly what the server is doing: phase 1 posts one SIE file at a time and phase 2 streams the orchestrator's real progress events. ArcimMigrationTheater therefore narrates by printing those real step labels once each as they arrive, and keys the canvas to the same milestones: the GL skeleton (rings, buckets, accounts) builds during the journal writes, counterparty waves attach while customers and suppliers import, and reconciliation pulses. Real progress bar and elapsed counter stay visible throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): review fixes on the migration theater and stream close CodeRabbit on #1486: (1) the aria-live region wrapped the per-second elapsed counter, so a screen reader re-announced the timer every second and drowned out the real step labels; the live region now covers only the narration list and the timer row is aria-hidden (the Progress bar exposes its own ARIA value). (2) controller.close() in the stream's finally block throws if the reader already cancelled, escaping start() as an unhandled rejection; now guarded like send(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
'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<TheaterCanvasHandle | null>(null)
|
|
const [log, setLog] = useState<LogLine[]>([])
|
|
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 (
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
|
<div>
|
|
<p className="text-sm font-medium">Migrering pågår</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Vi hämtar och importerar din bokföringsdata. Det kan ta några minuter.
|
|
</p>
|
|
{/* The live region covers only the step labels: the per-second
|
|
timer below would drown them out in a screen reader. */}
|
|
<ol className="mt-4 space-y-0" role="status" aria-live="polite">
|
|
{log.map((line, i) => {
|
|
const active = i === log.length - 1
|
|
return (
|
|
<li
|
|
key={line.id}
|
|
className="border-b border-border/60 py-2.5 last:border-b-0"
|
|
>
|
|
<p className={`text-sm ${active ? '' : 'text-muted-foreground'}`}>
|
|
{line.text}
|
|
</p>
|
|
</li>
|
|
)
|
|
})}
|
|
</ol>
|
|
<div className="mt-4 space-y-1">
|
|
<div
|
|
className="flex justify-between text-xs text-muted-foreground tabular-nums"
|
|
aria-hidden="true"
|
|
>
|
|
<span>{elapsed}s</span>
|
|
<span>{progress}%</span>
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</div>
|
|
<div className="relative min-h-[340px] md:min-h-[420px]">
|
|
<TheaterCanvas ref={canvasRef} model={model} settled={reduced} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|