fix(import): migration preview and theater read all fiscal years, not just the newest (#1614)

The /sie-data route parsed only the newest fiscal year's SIE file for the
import preview and the returned SIEData.parsed. Mid-year provider exports
have few or zero vouchers in the newest year, so the first real Fortnox
migration (3 fiscal years, 4153 vouchers) previewed "0 verifikationer"
and drew an almost-empty migration theater while the import itself
landed all 4153 vouchers from the older files.

- New mergeParsedSIEFiles (lib/import/sie-merge.ts): pure, browser-clean
  whole-dataset merge (accounts union first-wins, vouchers concatenated,
  fiscal years union oldest-first re-indexed newest=0, balances and
  issues concatenated, dimensions deduped), with unit tests.
- /sie-data parses each file exactly once, builds the preview from the
  merged parse and returns parsed: merged; response shape unchanged.
  Validation stays newest-file-only so no previously accepted dataset
  is newly rejected.
- /preview drops latestOnly and computes sieStats from the merged parse:
  the connect step's "Hittade X konton och Y verifikationer" line
  renders from THESE stats, so this is where the founder-visible count
  was lying.
- The migration theater spreads its account waves across ~10s and
  births an additional wave on each real step label during the SIE
  phase (progress <= 55), through a shared rate-limited gate, so the
  canvas keeps performing over a multi-minute run. Narration labels and
  progress remain the wizard's real values; reduced motion unchanged.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-14 12:17:11 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent ea85f4c084
commit b556475b01
5 changed files with 418 additions and 33 deletions
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, 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'
@@ -59,9 +59,33 @@ export default function ArcimMigrationTheater({
return () => window.clearInterval(id)
}, [])
// Account waves (TheaterCanvas assigns account nodes wave = i % 4) are
// rationed through one gate shared by the timer schedule and the
// step-label reactions below. The floor between births matters: the
// wizard emits three step labels within the first second ("Startar",
// "Importerar bokföringsdata", "fil 1 av N"), and ungated spawns would
// collapse the whole build back into the opening two seconds: the exact
// "nothing left to perform" failure this pacing exists to fix.
const accWaveRef = useRef(0)
const nextWaveAtRef = useRef(0)
const spawnNextAccountWave = useCallback(() => {
const canvas = canvasRef.current
if (!canvas || accWaveRef.current >= 4) return
const now = Date.now()
if (now < nextWaveAtRef.current) return
canvas.spawn('account', accWaveRef.current)
accWaveRef.current += 1
nextWaveAtRef.current = now + 1800
}, [])
// 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).
// builds while phase 1 writes the journal. The account waves are spread
// across ~10s instead of the first two: a multi-year migration runs for
// minutes and the canvas must still be performing when file 2 starts.
// Real step labels can pull waves forward through the shared gate; these
// timers are the ceiling that guarantees a full build by ~10s even if
// phase 1 hangs on one file. Reduced motion renders the settled graph
// instead (via the settled prop below).
useEffect(() => {
if (reduced) return
const canvas = () => canvasRef.current
@@ -71,12 +95,13 @@ export default function ArcimMigrationTheater({
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)),
)
})
// Floor slightly below the first timer so its own spawn never loses to
// setTimeout jitter.
nextWaveAtRef.current = Date.now() + 2300
;[2400, 5000, 7600, 10200].forEach((ms) => at(ms, spawnNextAccountWave))
return () => timers.forEach((id) => window.clearTimeout(id))
}, [reduced])
}, [reduced, spawnNextAccountWave])
// Real-event narration: append each distinct step label once, and let the
// canvas mark the milestone. The entity phase (bar past its 55% handoff)
@@ -94,13 +119,19 @@ export default function ArcimMigrationTheater({
if (reduced) return
const canvas = canvasRef.current
if (!canvas) return
// SIE phase (bar at or below its 55% handoff): each real step, one per
// posted file, also births the next account wave through the rationing
// gate, so a long phase 1 keeps building the graph between the timer
// beats. Only canvas pacing reacts here: labels and progress stay the
// wizard's real values.
if (progress <= 55) spawnNextAccountWave()
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])
}, [currentStep, progress, reduced, spawnNextAccountWave])
return (
<Card>