Files
accounted/components/import/ImportTheater.tsx
T
Jakob Wennberg 170b2722d6 feat(import): the Din historia reveal after SIE import (#1473)
* feat(import): the Din historia reveal after a successful SIE import

Fourth slice of the activation concept, stacked on the theater (#1471).
When the theater ran, the result step's success header becomes the
reveal: the settled constellation beside the personalized story
({years} år av historia, verifikat/konton/motparter, the balance
tie-out) and the bank bridge ("Historiken är på plats. Det som saknas
är nuet: banken.") deep-linking into the bank connect flow.

Honesty guards from the adversarial pass: the reveal requires actually
imported entries (an opening-balances-only run keeps the plain header),
the balance claim is suppressed when unbalanced vouchers were skipped,
and sandbox hides the bank bridge (live connections are stripped from
/import there). Failures and theater-less successes render the exact
previous header + stats grid.

The canvas is extracted to a shared TheaterCanvas (build mode driven by
the narration timeline via an imperative handle; settled mode for the
reveal: everything born, camera home, breathing only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(import): tame the constellation labels against real-world data

Real BAS names are paragraph-length and real files cluster in one class
region, which piled fifteen full labels into mush (founder screenshot,
Arcim Technology import). Labels now truncate hard (24/16 chars), only
the five heaviest accounts and counterparties carry labels, spread and
radius widen within a bucket, and a greedy per-frame collision pass
skips any label that would overlap one already drawn (importance order:
hub, buckets, heaviest first). The reveal also drops its meta line:
less text, the story is the headline + stats + bridge.

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>
2026-08-09 20:05:00 +02:00

159 lines
5.5 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import TheaterCanvas, { type TheaterCanvasHandle } from '@/components/import/TheaterCanvas'
import type { ImportPreview } from '@/lib/import/types'
import type { TheaterModel } from '@/lib/import/theater-model'
/**
* The import theater: while the SIE import commits server-side (one opaque
* call, up to ~5 minutes), the client-parsed file drives a knowledge graph
* that draws itself, with narration lines pacing alongside. The final line
* holds with the elapsed counter until the server answers, so the animation
* never pretends to know more than the import does. Reduced motion renders
* the settled graph and all narration instantly.
*/
interface ImportTheaterProps {
model: TheaterModel
preview: ImportPreview
/** Elapsed seconds since execute started (owned by ImportReviewStep). */
elapsed: number
}
export default function ImportTheater({ model, preview, elapsed }: ImportTheaterProps) {
const t = useTranslations('import')
const canvasRef = useRef<TheaterCanvasHandle | null>(null)
const [lineCount, setLineCount] = useState(0)
const [voucherTick, setVoucherTick] = useState(0)
const reduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
const lines: { title: string; sub: string | null; ok?: boolean; warn?: boolean }[] = [
{
title: t('theater_reading', { company: model.companyName || preview.companyName || '' }),
sub: t('theater_years', { count: Math.max(model.years.length, 1) }),
ok: true,
},
{
title: t('theater_vouchers'),
sub: `${voucherTick.toLocaleString('sv-SE')} ${t('theater_vouchers_unit')}`,
},
{
title: t('theater_accounts'),
sub: t('theater_accounts_sub', {
mapped: preview.mappingStatus.mapped,
total: preview.mappingStatus.total,
}),
},
...(model.totalCounterparties > 0
? [{
title: t('theater_counterparties'),
sub: t('theater_counterparties_sub', { count: model.totalCounterparties }),
}]
: []),
{
title: t('theater_balance'),
sub: preview.trialBalance.isBalanced ? t('theater_balance_ok') : t('theater_balance_warn'),
ok: preview.trialBalance.isBalanced,
warn: !preview.trialBalance.isBalanced,
},
]
const holdingVisible = lineCount > lines.length
// Narration pacing + graph spawn hooks: the narration and the constellation
// always agree on what has "happened".
useEffect(() => {
const canvas = () => canvasRef.current
if (reduced) {
setLineCount(lines.length + 1)
setVoucherTick(preview.voucherCount)
return
}
const timers: number[] = []
const at = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
const hasCps = model.totalCounterparties > 0
at(300, () => {
setLineCount(1)
canvas()?.spawn('ring')
})
at(1500, () => {
setLineCount(2)
canvas()?.spawn('bucket')
canvas()?.feed(2100)
;[0, 1, 2, 3].forEach((w) =>
timers.push(window.setTimeout(() => canvas()?.spawn('account', w), 200 + w * 420))
)
const t0 = performance.now()
const tick = () => {
const p = Math.min(1, (performance.now() - t0) / 1600)
setVoucherTick(Math.round(preview.voucherCount * p))
if (p < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
at(3400, () => setLineCount(3))
if (hasCps) {
at(4600, () => {
setLineCount(4)
;[0, 1, 2].forEach((w) =>
timers.push(window.setTimeout(() => canvas()?.spawn('counterparty', w), 120 + w * 420))
)
})
}
at(hasCps ? 6000 : 4600, () => {
setLineCount(hasCps ? 5 : 4)
canvas()?.pulse()
})
at(hasCps ? 7100 : 5700, () => setLineCount(lines.length + 1))
return () => timers.forEach((id) => window.clearTimeout(id))
// The theater runs once per mount for one (model, preview) pair.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
<div role="status" aria-live="polite">
<ol className="space-y-0">
{lines.map((line, i) => {
const visible = lineCount > i
const active = lineCount === i + 1 && !holdingVisible
return (
<li
key={line.title}
className={`border-b border-border/60 py-2.5 transition-opacity duration-500 last:border-b-0 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
>
<p className="text-sm">{line.title}</p>
<p
className={`mt-0.5 min-h-4 text-xs tabular-nums ${
visible && line.ok && !active
? 'text-success'
: visible && line.warn
? 'text-warning'
: 'text-muted-foreground'
}`}
>
{visible ? line.sub : null}
</p>
</li>
)
})}
</ol>
{holdingVisible && (
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
{t('theater_writing')} {elapsed}s
</p>
)}
</div>
<div className="relative min-h-[340px] md:min-h-[420px]">
<TheaterCanvas ref={canvasRef} model={model} />
</div>
</div>
)
}