feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) (#935)

* feat(agent): 'Vad din agent vet' page rendering the ledger context (P2)

The human-facing surface for the openwiki ledger-context: a read-only page
that renders the exact payload the AI agent reads (Accounted://ledger/context
+ the briefing digest) as a legible profile of how this company books.

- Route app/(dashboard)/agent-knowledge (server component) calls the shared
  buildLedgerContext(supabase, companyId) directly: one payload, two
  renderers, no new API or data path.
- Sections mirror the payload 1:1: coverage/freshness strip, counterparty
  patterns (monochrome confidence bars + seen/agree evidence), supplier
  patterns, explicit rules shown as authoritative instructions distinct from
  observed patterns, account usage, VAT profile, conventions.
- Nav entry in the Analys group (icon Brain), ungated so it doubles as an
  upsell; flip requiredCapability to paywall.
- Design per .claude/rules/design.md (PageHeader, Card, Table, Badge,
  AccountNumber BAS tooltips); sv + en strings (agentKnowledge namespace).
  VAT/BAS labels stay Swedish in both locales per i18n rules.

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

* feat(agent): deep entity-resolved analysis + radial graph for the knowledge page

Reworks the 'Vad din agent vet' page from tables into a radial-hub graph
driven by a new full-history deep analysis, per founder feedback.

- fix(rpc): median_booking_lag_days now measures real posting promptness via
  committed_at, not entry_date (which the bank flow sets to the transaction
  date, giving a ~0 tautology: 151/152 on prod). migration 20260708120000.
- feat(rpc): get_ledger_deep_context (migration 20260708130000): full-history,
  deterministic, read-side. Merges counterparties by normalize_counterparty_key
  (e.g. Claude = 14 bookings across 12 name variants, weekly, 9 710 kr, always
  5420), mines booked verifikat for SEK spend (coalesce amount_sek), detects
  recurrence cadence, dominant account + share, plus supplier entities. Storno
  excluded, corrections kept; 19xx/26xx excluded from the dominant contra.
- LedgerGraph: radial SVG (company center, accounts inner ring, payees outer
  ring), hover/focus reveals variants + spend + cadence + account. Keyboard
  focusable nodes with per-node accessible names + a screen-reader data table.
- Page fetches the deep context alongside the light context; coverage strip
  gains tracked-payee / recurring / tracked-spend stats. sv + en strings.
- 14 pg tests (light + deep) green; both RPCs applied to prod + version-matched.

Reviewed by an adversarial multi-lens pass (accounting/SQL, frontend/a11y,
prod-fact verification); all four verified findings fixed (SEK currency,
storno-lag guard, keyboard a11y, spacing tokens).

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

* feat(agent): gentle mount fade-in for the radial map (reduced-motion safe)

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

* fix(agent): render the page for a rules-only company (empty-state edge case)

isEmpty ignored explicit_rules, so a company with configured mapping rules
but no posted transactions hit the 'hasn't learned anything' empty state and
lost its rules section. Rules are independent of bookings. (CodeRabbit)

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

* feat(agent): show Kompetens (skills) + Fakta (memory) on the knowledge page

The 'Vad din agent vet' page now shows the full picture of what the agent
knows: alongside the booking map, a compact read-only view of its Kompetens
(the Swedish accounting/tax knowledge atoms it ships with, grouped by
tier as chips with active/dormant state) and the Fakta it remembers (top
learned facts with kind + source), each linking to /settings/assistant for
full management. Server-rendered via a new buildAgentCompetence() that
mirrors GET /api/agent/skills + /api/agent/memory. Also renders in the
no-bookings case so a new company still sees its agent's competence.
sv + en strings.

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

* refactor(agent): restructure knowledge page - hero graph + tabbed detail

Declutters the page per feedback: the booking map is the always-visible hero,
and the supporting detail (Kompetens · Minne · Regler & profil) moves into
tabs so only one view shows at a time instead of a long card stack. Split
AgentCompetenceSections into standalone CompetenceCard + FactsCard for the
tabs; removed the top stat row on request. sv + en.

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

* feat(agent): Reconciliation Aurora rewrite of the ledger knowledge graph

Full rewrite of LedgerGraph: node area = sqrt(spend), colour = cadence,
shape = supplier/counterparty, confidence = depth-of-field; on-mount
descriptor-collapse animation with xN badge; cadence pulse veins;
deterministic seeded layout; framer-motion only (no new deps); keyboard
navigation, reduced-motion and sr-only support. Build-verified; 3-lens
adversarial review findings fixed.

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

* fix(agent): sample-size-honest confidence in the ledger knowledge graph

dominant_account_share was raw cnt/total, so a counterparty with a single
booking rendered as '100% säkerhet': fake certainty by construction (the
data_quality_master Item-C / P3 finding). New migration replaces
get_ledger_deep_context with a Laplace-smoothed share (cnt+1)/(total+2)
(1/1 -> 0.67, 3/3 -> 0.80) and exposes the raw evidence as
dominant_account_count / dominant_account_total. The detail card now shows
'Bokförd hit i k av n fall' under the confidence bar; the existing focus
buckets, stroke widths and percent labels inherit the honest value
unchanged. pg-real test updated to guard the n=1 case.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-12 21:25:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 64ea0fef02
commit 4b51af3d80
13 changed files with 1914 additions and 1 deletions
@@ -0,0 +1,128 @@
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { Pin, ArrowUpRight } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import type { AgentCompetence, AtomTier, FactKind, FactSource } from '@/lib/agent-context/agent-competence'
/**
* Read-only views of the agent's competence (domain-knowledge atoms) and top
* learned facts, for the "Vad din agent vet" overview. Each is a standalone
* Card so it can sit in its own tab. Full editable management lives in
* /settings/assistant; each links there.
*/
const TIER_ORDER: AtomTier[] = ['horizontal', 'vertical', 'modifier']
export async function CompetenceCard({ competence }: { competence: AgentCompetence }) {
const t = await getTranslations('agentKnowledge')
const { atoms } = competence
const activeAtoms = atoms.filter((a) => a.active).length
const tierLabel = (tier: AtomTier) =>
tier === 'horizontal' ? t('tier_horizontal') : tier === 'vertical' ? t('tier_vertical') : t('tier_modifier')
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('comp_title')}</CardTitle>
<CardDescription>{t('comp_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-0">
{atoms.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('comp_empty')}</p>
) : (
<>
{TIER_ORDER.map((tier) => {
const items = atoms.filter((a) => a.tier === tier)
if (items.length === 0) return null
return (
<div key={tier} className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{tierLabel(tier)}
</h3>
<div className="flex flex-wrap gap-2">
{items.map((a) => (
<Badge
key={a.id}
variant={a.active ? 'secondary' : 'outline'}
className={a.active ? '' : 'text-muted-foreground'}
title={a.description}
>
{a.title}
{!a.active && tier !== 'horizontal' && (
<span className="ml-1.5 opacity-70">· {t('badge_dormant')}</span>
)}
</Badge>
))}
</div>
</div>
)
})}
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span className="tabular-nums">{t('comp_count', { total: atoms.length, active: activeAtoms })}</span>
<ManageLink href="/settings/assistant?view=skills" label={t('comp_manage')} />
</div>
</>
)}
</CardContent>
</Card>
)
}
export async function FactsCard({ competence }: { competence: AgentCompetence }) {
const t = await getTranslations('agentKnowledge')
const { facts, factsActiveTotal } = competence
const kindLabel = (k: FactKind) =>
k === 'fact' ? t('kind_fact') : k === 'preference' ? t('kind_preference') : k === 'pattern' ? t('kind_pattern') : t('kind_correction')
const sourceLabel = (s: FactSource) =>
s === 'composer' ? t('source_composer') : s === 'user_taught' ? t('source_user_taught') : s === 'agent_learned' ? t('source_agent_learned') : t('source_derived')
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('facts_title')}</CardTitle>
<CardDescription>{t('facts_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{facts.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('facts_empty')}</p>
) : (
<>
<ul className="space-y-3">
{facts.map((f) => (
<li key={f.id} className="flex items-start gap-2">
{f.is_pinned ? (
<Pin className="mt-1 h-3.5 w-3.5 shrink-0 fill-current text-muted-foreground" aria-label={t('facts_pinned')} />
) : (
<span className="mt-1 h-3.5 w-3.5 shrink-0" aria-hidden />
)}
<div className="min-w-0 space-y-0.5">
<p className="text-sm text-foreground">{f.content}</p>
<p className="text-[11px] text-muted-foreground">
{kindLabel(f.kind)} · {sourceLabel(f.source)}
</p>
</div>
</li>
))}
</ul>
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span className="tabular-nums">
{factsActiveTotal > facts.length ? t('facts_more', { n: factsActiveTotal - facts.length }) : ''}
</span>
<ManageLink href="/settings/assistant" label={t('facts_manage')} />
</div>
</>
)}
</CardContent>
</Card>
)
}
function ManageLink({ href, label }: { href: string; label: string }) {
return (
<Link href={href} className="inline-flex items-center gap-1 text-foreground underline underline-offset-2 hover:text-muted-foreground">
{label}
<ArrowUpRight className="h-3 w-3" />
</Link>
)
}
@@ -0,0 +1,247 @@
import { getTranslations } from 'next-intl/server'
import { Brain } from 'lucide-react'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table'
import { AccountNumber } from '@/components/ui/account-number'
import { EmptyState } from '@/components/ui/empty-state'
import { formatDateLong } from '@/lib/utils'
import type { LedgerContext } from '@/lib/agent-context/ledger-context'
import type { DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
import type { AgentCompetence } from '@/lib/agent-context/agent-competence'
import { LedgerGraph } from './LedgerGraph'
import { CompetenceCard, FactsCard } from './AgentCompetenceSections'
import { KnowledgeTabs } from './KnowledgeTabs'
// Swedish VAT (moms) treatment codes stay Swedish in both locales, like BAS
// account names and momsdeklaration labels (.claude/rules/i18n.md).
const VAT_LABELS: Record<string, string> = {
standard_25: 'Moms 25%',
standard_12: 'Moms 12%',
standard_6: 'Moms 6%',
reduced_12: 'Moms 12%',
reduced_6: 'Moms 6%',
reverse_charge: 'Omvänd moms',
reverse_charge_eu: 'Omvänd moms (EU)',
reverse_charge_services: 'Omvänd moms (tjänster)',
eu_reverse_charge_services: 'Omvänd moms (EU-tjänster)',
eu_goods: 'EU-varor',
export: 'Export',
exempt: 'Momsfri',
no_vat: 'Ingen moms',
}
function vatLabel(code: string | null): string | null {
if (!code) return null
return VAT_LABELS[code] ?? code
}
export async function AgentKnowledgeView({
context,
deep,
competence,
companyName,
}: {
context: LedgerContext
deep: DeepLedgerContext
competence: AgentCompetence
companyName: string
}) {
const t = await getTranslations('agentKnowledge')
const { meta, explicit_rules, vat_profile, conventions } = context
const entities = [...deep.counterparty_entities, ...deep.supplier_entities]
const isEmpty = meta.coverage.posted_entries_window === 0 && entities.length === 0 && explicit_rules.length === 0
if (isEmpty) {
// No bookings yet, but the agent still ships with competence and may
// already remember facts: show those rather than a dead end.
return (
<>
<Card>
<CardContent className="p-0">
<EmptyState
icon={Brain}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('empty_action')}
actionHref="/transactions"
/>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<CompetenceCard competence={competence} />
<FactsCard competence={competence} />
</div>
</>
)
}
const methodLabel =
conventions.accounting_method === 'accrual'
? t('method_accrual')
: conventions.accounting_method === 'cash'
? t('method_cash')
: t('method_unknown')
const periodLabel =
vat_profile.moms_period === 'monthly'
? t('period_monthly')
: vat_profile.moms_period === 'quarterly'
? t('period_quarterly')
: vat_profile.moms_period === 'yearly'
? t('period_yearly')
: (vat_profile.moms_period ?? t('unknown'))
// "Regler & profil" tab: user-authored rules + observed VAT + conventions.
const configContent = (
<>
{explicit_rules.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('rules_title')}</CardTitle>
<CardDescription>{t('rules_description')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('col_rule')}</TableHead>
<TableHead>{t('col_match')}</TableHead>
<TableHead>{t('col_account')}</TableHead>
<TableHead>{t('col_vat')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{explicit_rules.map((r, i) => (
<TableRow key={r.rule_name + r.match + i}>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.rule_name}</span>
<Badge variant="default">{t('src_rule')}</Badge>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{r.match}</TableCell>
<TableCell>
{r.account_number
? <AccountNumber number={r.account_number} showName size="sm" />
: <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-muted-foreground">{vatLabel(r.vat_treatment) ?? '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('vat_title')}</CardTitle>
<CardDescription>{t('vat_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<Row label={t('vat_registered_label')}>
<Badge variant={vat_profile.registered ? 'success' : 'outline'}>
{vat_profile.registered ? t('yes') : t('no')}
</Badge>
</Row>
<Row label={t('vat_period_label')}>
<span className="text-sm">{periodLabel}</span>
</Row>
<Row label={t('vat_treatments_label')}>
{vat_profile.treatments_used_12m.length === 0 ? (
<span className="text-sm text-muted-foreground">{t('vat_no_treatments')}</span>
) : (
<div className="flex flex-wrap justify-end gap-2">
{vat_profile.treatments_used_12m.map((code) => (
<Badge key={code} variant="outline">{vatLabel(code)}</Badge>
))}
</div>
)}
</Row>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('conv_title')}</CardTitle>
<CardDescription>{t('conv_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<Row label={t('conv_method_label')}>
<span className="text-sm">{methodLabel}</span>
</Row>
<Row label={t('conv_series_label')}>
{conventions.voucher_series_in_use.length === 0 ? (
<span className="text-sm text-muted-foreground">-</span>
) : (
<div className="flex flex-wrap justify-end gap-2">
{conventions.voucher_series_in_use.map((s) => (
<Badge key={s} variant="secondary" className="font-mono">{s}</Badge>
))}
</div>
)}
</Row>
<Row label={t('conv_salary_label')}>
<Badge variant={conventions.salary_run_active ? 'success' : 'outline'}>
{conventions.salary_run_active ? t('yes') : t('no')}
</Badge>
</Row>
{conventions.typical_booking_lag_days !== null && (
<Row label={t('conv_lag_label')}>
<span className="text-sm tabular-nums">{t('meta_lag_value', { days: conventions.typical_booking_lag_days })}</span>
</Row>
)}
</CardContent>
</Card>
</div>
</>
)
const tabs = [
{ value: 'competence', label: t('tab_competence'), content: <CompetenceCard competence={competence} /> },
{ value: 'memory', label: t('tab_memory'), content: <FactsCard competence={competence} /> },
{ value: 'config', label: t('tab_config'), content: configContent },
]
return (
<>
{/* The cinematic hero: a self-contained dark panel with its own header */}
<LedgerGraph deep={deep} companyName={companyName} />
{/* Supporting detail, tabbed so it doesn't stack into a long scroll */}
<KnowledgeTabs tabs={tabs} />
<p className="text-right text-xs text-muted-foreground">
{t('footer_basis', { entries: meta.coverage.posted_entries_window, date: formatDateLong(meta.computed_at) })}
</p>
</>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{label}</span>
<div>{children}</div>
</div>
)
}
@@ -0,0 +1,33 @@
'use client'
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
/**
* Client wrapper that organizes the page's supporting detail into tabs so the
* booking map stays the hero and the rest doesn't stack into a long scroll.
* Content is server-rendered upstream and handed in as ReactNode slots.
*/
export function KnowledgeTabs({
tabs,
}: {
tabs: { value: string; label: string; content: ReactNode }[]
}) {
if (tabs.length === 0) return null
return (
<Tabs defaultValue={tabs[0].value} className="space-y-6">
<TabsList>
{tabs.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((t) => (
<TabsContent key={t.value} value={t.value} className="space-y-8 focus-visible:outline-none">
{t.content}
</TabsContent>
))}
</Tabs>
)
}
+847
View File
@@ -0,0 +1,847 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence, useReducedMotion, animate } from 'framer-motion'
import { RotateCw } from 'lucide-react'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useTranslations } from 'next-intl'
import { formatCurrency } from '@/lib/utils'
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
/**
* "Reconciliation Aurora" - the cinematic hero of the "Vad din agent vet" page.
*
* A self-contained dark SVG panel. The company is a luminous hub; the BAS
* accounts it books to form an inner ring of wedges (each wedge sized by the
* money that flows through it); the counterparties/suppliers live in the outer
* band, each constrained to its dominant account's angular slice so threads can
* never cross between accounts (the anti-hairball guarantee).
*
* Four orthogonal channels, so no two compete:
* - node AREA = total spend (radius = k·√kr)
* - node COLOUR = booking cadence (weekly / monthly / irregular) - the one
* semantic axis; everything else stays achromatic
* - node SHAPE = kind (supplier = filled, counterparty = open ring)
* - node FOCUS = the agent's confidence in the account mapping, rendered as
* optical depth of field (sure = crisp/forward, unsure = soft)
*
* On mount it performs the signature act: for the most-merged payees, the raw
* bank descriptors ("CLAUDE.AI", "Anthropic PBC", "claude.ai*sub") fly in as
* ghost chips and magnetically collapse into one named node with a "×N" badge -
* the agent resolving chaos into knowledge, live, in front of the customer.
*
* Deterministic (seeded jitter, fixed input order) so the demo looks identical
* on every load. Motion is CSS-driven (single-clock idle, GPU dash pulses) with
* framer only orchestrating the entrance, the merge and the hover card. Fully
* keyboard-navigable; a visually-hidden table carries the payload for readers;
* honours prefers-reduced-motion (jumps straight to the settled state).
*/
const W = 1000
const H = 1000
const CX = W / 2
const CY = H / 2
const R_HUB = 34
const R_ACCOUNT = 178
const R_PAYEE_MIN = 258
const R_PAYEE_MAX = 432
const MAX_ACCOUNTS = 9
const MAX_PER_ACCOUNT = 6
const WEDGE_GAP = 0.07 // radians of padding between account wedges
// This panel is its own dark world regardless of the app theme, so the depth of
// field, the glow and the cadence hues all read. Achromatic chrome; colour only
// ever means cadence.
const INK = '#0a0a0c'
const PAPER = '#ecebe6'
const HAIR = 'rgba(236,235,230,0.13)'
const HAIR_STRONG = 'rgba(236,235,230,0.30)'
const MUTED = 'rgba(236,235,230,0.52)'
const CAD: Record<Cadence, string> = {
weekly: '#e0895f', // terracotta - fast, recurring
monthly: '#d7a648', // ochre - monthly
irregular: '#83a98d', // sage - one-off / irregular
}
// Depth-of-field buckets: stdDeviation in viewBox units (~0.64× on screen).
const DOF = [0, 1.7, 3.4, 5.2]
type Cadence = 'weekly' | 'monthly' | 'irregular'
interface Payee {
id: string
entity: DeepEntity
accountNumber: string
x: number
y: number
r: number
cadence: Cadence
bucket: number // depth-of-field bucket index into DOF
thread: string // svg path from hub to node, bowed through the account anchor
labelRight: boolean
revealDelay: number
merge: boolean // show the ghost-descriptor collapse on mount
chips: string[]
}
interface Account {
number: string
name: string | null
x: number
y: number
midAngle: number
arc: string
revealDelay: number
}
interface Model {
accounts: Account[]
payees: Payee[]
truncated: boolean
totals: { tx: number; payees: number; accounts: number }
}
function polar(cx: number, cy: number, r: number, a: number) {
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }
}
function arcPath(cx: number, cy: number, r: number, a0: number, a1: number) {
const s = polar(cx, cy, r, a0)
const e = polar(cx, cy, r, a1)
const large = a1 - a0 > Math.PI ? 1 : 0
return `M ${s.x.toFixed(2)} ${s.y.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t
}
// Deterministic [0,1) hash of a string (xmur3 → mulberry32), so seeded jitter is
// byte-identical on every render: the live demo never reshuffles.
function rand01(seed: string): number {
let h = 1779033703 ^ seed.length
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
h = Math.imul(h ^ (h >>> 16), 2246822507)
h = Math.imul(h ^ (h >>> 13), 3266489909)
h ^= h >>> 16
return (h >>> 0) / 4294967296
}
function cadenceOf(e: DeepEntity): Cadence {
const cd = e.cadence_days
if (cd === null || e.occurrences < 3) return 'irregular'
if (cd >= 4 && cd <= 10) return 'weekly'
if (cd > 10 && cd <= 45) return 'monthly'
return 'irregular'
}
function bucketOf(share: number | null): number {
if (share === null) return 3
if (share >= 0.85) return 0
if (share >= 0.7) return 1
if (share >= 0.5) return 2
return 3
}
// Center-out slot order: biggest spender sits at the wedge's angular centre.
function centerOut(n: number): number[] {
const mid = (n - 1) / 2
return Array.from({ length: n }, (_, i) => i).sort(
(a, b) => Math.abs(a - mid) - Math.abs(b - mid),
)
}
function buildModel(deep: DeepLedgerContext): Model {
const all: DeepEntity[] = [
...(deep?.counterparty_entities ?? []),
...(deep?.supplier_entities ?? []),
].filter((e) => e.dominant_account_number)
const totalTx = all.reduce((s, e) => s + e.occurrences, 0)
const byAccount = new Map<string, DeepEntity[]>()
for (const e of all) {
const acc = e.dominant_account_number as string
const arr = byAccount.get(acc) ?? []
arr.push(e)
byAccount.set(acc, arr)
}
// Weight a wedge by the money that flows through it (fall back to volume).
const activity = (items: DeepEntity[]) => {
const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0)
return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0)
}
let groups = [...byAccount.entries()].map(([number, items]) => ({
number,
items: items.slice().sort((a, b) => b.total_amount - a.total_amount),
weight: activity(items),
}))
// Rank by weight to pick what to show, then lay out in a fixed order (account
// number) so wedges never swap places between loads.
groups.sort((a, b) => b.weight - a.weight)
const totalAccounts = groups.length
groups = groups.slice(0, MAX_ACCOUNTS)
let truncated = totalAccounts > groups.length
for (const g of groups) {
if (g.items.length > MAX_PER_ACCOUNT) {
truncated = true
g.items = g.items.slice(0, MAX_PER_ACCOUNT)
}
}
groups.sort((a, b) => a.number.localeCompare(b.number))
const shownWeight = groups.reduce((s, g) => s + g.weight, 0) || 1
const maxSpend = Math.max(...groups.flatMap((g) => g.items.map((i) => i.total_amount)), 1)
// The most name-merged payees earn the on-mount "descriptors collapse" moment.
const mergeIds = new Set(
all
.filter((e) => e.variant_count >= 3)
.sort((a, b) => b.variant_count - a.variant_count)
.slice(0, 5)
.map((e) => `${e.dominant_account_number}:${e.key}`),
)
const accounts: Account[] = []
const payees: Payee[] = []
const spans = 2 * Math.PI - WEDGE_GAP * groups.length
let angle = -Math.PI / 2 + WEDGE_GAP / 2
groups.forEach((g, gi) => {
const width = spans * (g.weight / shownWeight)
const mid = angle + width / 2
const anchor = polar(CX, CY, R_ACCOUNT, mid)
accounts.push({
number: g.number,
name: getAccountDescription(g.number)?.name ?? null,
x: anchor.x,
y: anchor.y,
midAngle: mid,
arc: arcPath(CX, CY, R_ACCOUNT, angle + width * 0.04, angle + width * 0.96),
revealDelay: 0.2 + gi * 0.06,
})
const n = g.items.length
const inner = width * 0.16
const order = centerOut(n) // order[rank] = slot for that rank (rank 0 = biggest → centre)
g.items.forEach((e, rank) => {
const slot = order[rank]
const t = n === 1 ? 0.5 : slot / (n - 1)
const pa = angle + inner + t * (width - 2 * inner)
const spendFrac = Math.sqrt(Math.max(e.total_amount, 0) / maxSpend)
const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + spendFrac * 14
const pos = polar(CX, CY, Math.min(radius, R_PAYEE_MAX + 10), pa)
const id = `${g.number}:${e.key}`
payees.push({
id,
entity: e,
accountNumber: g.number,
x: pos.x,
y: pos.y,
r: 7 + 19 * spendFrac,
cadence: cadenceOf(e),
bucket: bucketOf(e.dominant_account_share),
thread: `M ${CX} ${CY} Q ${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)} ${pos.x.toFixed(2)} ${pos.y.toFixed(2)}`,
labelRight: Math.cos(pa) >= 0,
revealDelay: 0.55 + gi * 0.05 + rank * 0.045,
merge: mergeIds.has(id),
chips: e.variants.slice(0, 6),
})
angle += 0
})
angle += width + WEDGE_GAP
})
return {
accounts,
payees,
truncated,
totals: { tx: totalTx, payees: payees.length, accounts: accounts.length },
}
}
// Weekly beats faster than monthly; irregular drifts slow. Seconds per pulse.
const PULSE_DUR: Record<Cadence, number> = { weekly: 1.15, monthly: 2.7, irregular: 4.3 }
export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; companyName: string }) {
const t = useTranslations('agentKnowledge')
const reduce = useReducedMotion() ?? false
const model = useMemo(() => buildModel(deep), [deep])
const [hover, setHover] = useState<string | null>(null)
const [runKey, setRunKey] = useState(0)
// Track which run has finished its intro rather than a bare boolean, so a
// replay (runKey++) resets to "unresolved" by derivation, without a
// setState-in-effect. Chips fly in, then collapse into their node.
const [resolvedRun, setResolvedRun] = useState(-1)
useEffect(() => {
const id = setTimeout(() => setResolvedRun(runKey), reduce ? 0 : 1300)
return () => clearTimeout(id)
}, [runKey, reduce])
const resolved = reduce || resolvedRun === runKey
const payeeById = useMemo(() => new Map(model.payees.map((p) => [p.id, p])), [model])
function payeeCaption(p: Payee): string {
const e = p.entity
const nm = getAccountDescription(e.dominant_account_number ?? '')?.name
return [
e.name,
t('cap_bookings', { n: e.occurrences }),
e.variant_count > 1 ? t('cap_variants', { n: e.variant_count }) : null,
t(`cadence_${p.cadence}`),
formatCurrency(e.total_amount),
`${e.dominant_account_number}${nm ? ` ${nm}` : ''}${
e.dominant_account_share !== null ? ` · ${Math.round(e.dominant_account_share * 100)}%` : ''
}`,
]
.filter(Boolean)
.join(' · ')
}
if (model.payees.length === 0) {
return (
<div
className="rounded-xl border p-16 text-center text-sm"
style={{ background: INK, borderColor: HAIR, color: MUTED }}
>
{t('none_cp')}
</div>
)
}
// Which ids are "lit" given the current hover (a payee lights its account and
// the reverse); everything else recedes into the depth of field.
const active = new Set<string>()
if (hover) {
active.add(hover)
if (hover.startsWith('acc:')) {
const num = hover.slice(4)
model.payees.forEach((p) => p.accountNumber === num && active.add(p.id))
} else {
const p = payeeById.get(hover)
if (p) active.add(`acc:${p.accountNumber}`)
}
}
const lit = (id: string) => !hover || active.has(id)
const hoveredPayee = hover && !hover.startsWith('acc:') ? payeeById.get(hover) ?? null : null
return (
<div
className="relative overflow-hidden rounded-xl border"
style={{
borderColor: HAIR_STRONG,
background: `radial-gradient(120% 120% at 50% 42%, #17171b 0%, ${INK} 62%)`,
}}
>
<style>{keyframes}</style>
{/* header */}
<div className="flex items-start justify-between gap-4 px-5 pt-5 md:px-7 md:pt-6">
<div>
<h2
className="font-display text-lg tracking-tight md:text-xl"
style={{ color: PAPER }}
>
{t('graph_title')}
</h2>
<p className="mt-1 max-w-md text-sm leading-relaxed" style={{ color: MUTED }}>
{t('graph_description')}
</p>
</div>
{!reduce && (
<button
type="button"
onClick={() => setRunKey((k) => k + 1)}
className="inline-flex shrink-0 items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition-colors"
style={{ borderColor: HAIR_STRONG, color: MUTED }}
>
<RotateCw className="h-3.5 w-3.5" />
{t('graph_replay')}
</button>
)}
</div>
{/* stage */}
<div className="relative mx-auto aspect-square w-full max-w-[680px]">
<svg
viewBox={`0 0 ${W} ${H}`}
className="block h-full w-full"
role="img"
aria-label={t('graph_aria', { payees: model.payees.length, accounts: model.accounts.length })}
>
<defs>
{DOF.map((sd, i) => (
<filter key={i} id={`dof-${i}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation={sd} />
</filter>
))}
<radialGradient id="aurora-hub" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={PAPER} stopOpacity="0.22" />
<stop offset="100%" stopColor={PAPER} stopOpacity="0" />
</radialGradient>
</defs>
<g key={runKey}>
{/* account wedge arcs (the spine) */}
{model.accounts.map((a) => (
<g key={`acc:${a.number}`} opacity={lit(`acc:${a.number}`) ? 1 : 0.14}>
<motion.path
d={a.arc}
fill="none"
stroke={HAIR_STRONG}
strokeWidth={1.4}
strokeLinecap="round"
initial={reduce ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.7, delay: a.revealDelay, ease: 'easeInOut' }}
/>
<AccountHit account={a} caption={accountCaption(a)} onHover={setHover} lit={lit(`acc:${a.number}`)} />
</g>
))}
{/* threads: base vein + travelling cadence pulse */}
{model.payees.map((p) => (
<g key={`thread:${p.id}`} opacity={lit(p.id) ? 1 : 0.08}>
<motion.path
d={p.thread}
fill="none"
stroke={PAPER}
strokeOpacity={0.16}
strokeWidth={0.9 + 1.1 * (p.entity.dominant_account_share ?? 0.6)}
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.6, delay: p.revealDelay, ease: 'easeOut' }}
/>
<path
d={p.thread}
fill="none"
stroke={CAD[p.cadence]}
strokeWidth={2.4}
strokeLinecap="round"
pathLength={1}
className={reduce ? undefined : 'aurora-pulse'}
style={{
strokeDasharray: '0.035 1',
opacity: resolved && lit(p.id) ? 0.9 : 0,
transition: 'opacity .6s ease',
animationDuration: `${PULSE_DUR[p.cadence]}s`,
animationDelay: `${rand01(p.id + 'd') * -PULSE_DUR[p.cadence]}s`,
}}
/>
</g>
))}
{/* payee nodes, blurry buckets first so the confident ones sit on top */}
{[...model.payees]
.sort((a, b) => b.bucket - a.bucket)
.map((p) => (
<PayeeGlyph
key={p.id}
p={p}
reduce={reduce}
resolved={resolved}
lit={lit(p.id)}
focused={hover === p.id}
caption={payeeCaption(p)}
onHover={setHover}
/>
))}
{/* company hub */}
<g>
<circle cx={CX} cy={CY} r={R_HUB * 2.6} fill="url(#aurora-hub)" />
<circle
cx={CX}
cy={CY}
r={R_HUB}
fill={PAPER}
className={reduce ? undefined : 'aurora-breathe'}
style={{ transformOrigin: `${CX}px ${CY}px` }}
/>
<text
x={CX}
y={CY}
fill={INK}
fontSize={20}
fontWeight={600}
textAnchor="middle"
dominantBaseline="central"
>
{initialsOf(companyName)}
</text>
</g>
</g>
</svg>
{/* tally: the "understood" count-up */}
<div
className="pointer-events-none absolute bottom-3 left-4 text-xs tabular-nums md:bottom-4 md:left-6"
style={{ color: MUTED }}
>
<span style={{ color: PAPER }}>
<CountUp target={model.totals.tx} run={runKey} reduce={reduce} />
</span>{' '}
{t('graph_tally', {
payees: model.totals.payees,
accounts: model.totals.accounts,
})}
</div>
{/* hover detail card, anchored to the node */}
<AnimatePresence>
{hoveredPayee && (
<DetailCard key={hoveredPayee.id} p={hoveredPayee} t={t} />
)}
</AnimatePresence>
</div>
{/* legend */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t px-5 py-3 text-xs md:px-7"
style={{ borderColor: HAIR, color: MUTED }}
>
<span className="inline-flex items-center gap-2">
<Dot fill={CAD.weekly} /> {t('cadence_weekly')}
</span>
<span className="inline-flex items-center gap-2">
<Dot fill={CAD.monthly} /> {t('cadence_monthly')}
</span>
<span className="inline-flex items-center gap-2">
<Dot fill={CAD.irregular} /> {t('cadence_irregular')}
</span>
<span className="opacity-70">{t('legend_size')}</span>
<span className="opacity-70">{t('legend_focus')}</span>
{model.truncated && <span className="italic opacity-70">{t('graph_truncated')}</span>}
</div>
{/* screen-reader alternative: the full payload as a plain list */}
<ul className="sr-only">
{model.payees.map((p) => (
<li key={`sr:${p.id}`}>{payeeCaption(p)}</li>
))}
</ul>
</div>
)
}
function accountCaption(a: Account): string {
return `${a.number}${a.name ? ` · ${a.name}` : ''}`
}
// Enlarged invisible hit target + keyboard focus for an account arc.
function AccountHit({
account,
caption,
onHover,
lit,
}: {
account: Account
caption: string
onHover: (id: string | null) => void
lit: boolean
}) {
const id = `acc:${account.number}`
const inside = polar(CX, CY, R_ACCOUNT - 20, account.midAngle)
return (
<g
tabIndex={0}
role="button"
aria-label={caption}
onMouseEnter={() => onHover(id)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(id)}
onBlur={() => onHover(null)}
className="cursor-pointer outline-none [&:focus-visible>circle]:opacity-100"
>
<title>{caption}</title>
<circle cx={account.x} cy={account.y} r={22} fill="transparent" />
<circle cx={account.x} cy={account.y} r={26} fill="none" stroke={PAPER} strokeWidth={1.25} opacity={0} />
<text
x={inside.x}
y={inside.y}
fill={lit ? PAPER : MUTED}
fontSize={13}
fontWeight={500}
textAnchor="middle"
dominantBaseline="central"
style={{ fontFamily: 'var(--font-geist-mono, ui-monospace, monospace)' }}
>
{account.number}
</text>
</g>
)
}
function PayeeGlyph({
p,
reduce,
resolved,
lit,
focused,
caption,
onHover,
}: {
p: Payee
reduce: boolean
resolved: boolean
lit: boolean
focused: boolean
caption: string
onHover: (id: string | null) => void
}) {
const colour = CAD[p.cadence]
const showChips = !reduce && p.merge && !resolved
// The depth-of-field blur only attaches once the entrance spring settles, so
// feGaussianBlur never re-rasterizes per frame while the node is moving (and
// the blur "racking in" as the node comes to rest is the intended focus pull).
const [settled, setSettled] = useState(reduce)
// Only the biggest spenders keep a resting label; the rest reveal on focus.
const bigLabel = p.r >= 14
const label = p.entity.name.length > 16 ? p.entity.name.slice(0, 15) + '…' : p.entity.name
const lx = p.labelRight ? p.r + 8 : -(p.r + 8)
return (
// Positioning lives on a plain <g> (SVG transform attribute) so it can never
// be clobbered by framer's CSS transform on the scaling child below. Hover
// "racks focus" onto a node by dropping it to the crisp filter bucket.
<g
transform={`translate(${p.x} ${p.y})`}
filter={`url(#dof-${focused || !settled ? 0 : p.bucket})`}
tabIndex={0}
role="button"
aria-label={caption}
onMouseEnter={() => onHover(p.id)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(p.id)}
onBlur={() => onHover(null)}
className="cursor-pointer outline-none"
>
<title>{caption}</title>
{/* always-full-size transparent hit target */}
<circle cx={0} cy={0} r={Math.max(p.r + 8, 16)} fill="transparent" />
{/* opacity layer: entrance fade + hover dimming. Animates opacity only, so
framer never sets a CSS transform that would fight the attribute. */}
<motion.g
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: lit ? 1 : 0.12 }}
transition={{ duration: reduce ? 0.2 : 0.4, delay: reduce ? 0 : p.merge ? 0.1 : p.revealDelay }}
>
{/* the money shot: raw bank descriptors that collapse inward. Kept OUT
of the scaling group so they show at full size during the scatter. */}
<AnimatePresence>
{showChips &&
p.chips.map((chip, i) => {
const ang = rand01(p.id + chip) * Math.PI * 2
const rad = 34 + rand01(chip + String(i)) * 52
const ox = Math.cos(ang) * rad
const oy = Math.sin(ang) * rad
return (
<motion.text
key={chip + i}
fontSize={12}
fill={MUTED}
textAnchor="middle"
dominantBaseline="central"
initial={{ opacity: 0, x: ox * 1.55, y: oy * 1.55 }}
animate={{ opacity: 0.7, x: ox, y: oy }}
exit={{ opacity: 0, x: 0, y: 0, scale: 0.4 }}
transition={{ duration: 0.5, delay: 0.1 + i * 0.05, ease: 'easeOut' }}
>
{chip.length > 18 ? chip.slice(0, 17) + '…' : chip}
</motion.text>
)
})}
</AnimatePresence>
{/* scale layer: the node grows in around its centre (fill-box) */}
<motion.g
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
onAnimationComplete={() => setSettled(true)}
transition={
reduce
? { duration: 0 }
: { type: 'spring', stiffness: 150, damping: 17, delay: p.merge ? 1.32 : p.revealDelay }
}
style={{ transformBox: 'fill-box', transformOrigin: 'center' }}
>
{/* variant echo ring: faint concentric hint that this node is a merge */}
{p.entity.variant_count > 2 && (
<circle cx={0} cy={0} r={p.r + 5} fill="none" stroke={colour} strokeWidth={0.75} strokeOpacity={0.35} />
)}
{focused && <circle cx={0} cy={0} r={p.r + 6} fill="none" stroke={PAPER} strokeWidth={1.5} />}
{/* the glyph: supplier = filled, counterparty = open */}
<circle
cx={0}
cy={0}
r={p.r}
fill={p.entity.kind === 'supplier' ? colour : INK}
fillOpacity={p.entity.kind === 'supplier' ? 0.85 : 1}
stroke={colour}
strokeWidth={2}
/>
{/* "×N" merge badge */}
{p.entity.variant_count > 1 && (resolved || reduce) && (
<g transform={`translate(${p.r * 0.72} ${-p.r * 0.72})`}>
<circle r={8.5} fill={INK} stroke={colour} strokeWidth={1} />
<text fill={PAPER} fontSize={9} fontWeight={600} textAnchor="middle" dominantBaseline="central">
{p.merge ? (
<CountUp target={p.entity.variant_count} run={resolved ? 1 : 0} reduce={reduce} prefix="×" duration={0.5} />
) : (
`×${p.entity.variant_count}`
)}
</text>
</g>
)}
</motion.g>
{bigLabel && (
<text
x={lx}
y={0}
fill={lit ? PAPER : MUTED}
fontSize={12}
dominantBaseline="central"
textAnchor={p.labelRight ? 'start' : 'end'}
>
{label}
</text>
)}
</motion.g>
</g>
)
}
function DetailCard({ p, t }: { p: Payee; t: ReturnType<typeof useTranslations> }) {
const e = p.entity
const nm = getAccountDescription(e.dominant_account_number ?? '')?.name
const share = e.dominant_account_share !== null ? Math.round(e.dominant_account_share * 100) : null
// Anchor to the node: viewBox coords → % of the square stage. Flip sides so it
// never spills off the edge.
const left = (p.x / W) * 100
const top = (p.y / H) * 100
const right = p.x < CX
return (
<motion.div
className="pointer-events-none absolute z-10 w-60 rounded-lg border p-3 backdrop-blur-sm"
style={{
left: `${left}%`,
top: `${top}%`,
transform: `translate(${right ? '14px' : 'calc(-100% - 14px)'}, -50%)`,
background: 'rgba(14,14,17,0.92)',
borderColor: HAIR_STRONG,
color: PAPER,
}}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
>
<div className="flex items-center gap-2">
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: CAD[p.cadence] }} />
<span className="truncate font-medium">{e.name}</span>
</div>
<div className="mt-2 space-y-1.5 text-xs" style={{ color: MUTED }}>
<div className="flex justify-between gap-3">
<span>{t('cap_bookings', { n: e.occurrences })}</span>
<span style={{ color: PAPER }}>{t(`cadence_${p.cadence}`)}</span>
</div>
{e.variant_count > 1 && (
<div className="flex justify-between gap-3">
<span>{t('card_variants')}</span>
<span style={{ color: PAPER }}>×{e.variant_count}</span>
</div>
)}
<div className="flex justify-between gap-3">
<span>{t('legend_size')}</span>
<span className="tabular-nums" style={{ color: PAPER }}>{formatCurrency(e.total_amount)}</span>
</div>
<div className="mt-2 border-t pt-2" style={{ borderColor: HAIR }}>
<div className="flex items-center justify-between gap-3">
<span className="font-mono" style={{ color: PAPER }}>
{e.dominant_account_number}
{nm ? ` ${nm}` : ''}
</span>
{share !== null && <span className="tabular-nums">{t('card_confidence', { share })}</span>}
</div>
{share !== null && (
<div className="mt-1.5 h-1 overflow-hidden rounded-full" style={{ background: HAIR }}>
<div className="h-full rounded-full" style={{ width: `${share}%`, background: CAD[p.cadence] }} />
</div>
)}
{e.dominant_account_count != null && e.dominant_account_total != null && (
<div className="mt-1 tabular-nums opacity-80">
{t('card_evidence', { k: e.dominant_account_count, n: e.dominant_account_total })}
</div>
)}
</div>
{e.variants.length > 1 && (
<div className="pt-1 leading-relaxed opacity-80">
{e.variants.slice(0, 4).join(' · ')}
{e.variant_count > 4 ? ' …' : ''}
</div>
)}
</div>
</motion.div>
)
}
function CountUp({
target,
run,
reduce,
prefix = '',
duration = 1,
}: {
target: number
run: number
reduce: boolean
prefix?: string
duration?: number
}) {
const [v, setV] = useState(0)
// Re-run whenever `run` bumps (mount / replay / resolve). No ref-guard: it
// would early-return on React StrictMode's second effect setup in dev and
// freeze the number at 0 (the cleanup already stops any prior animation).
useEffect(() => {
if (reduce) return
const controls = animate(0, target, {
duration,
ease: 'easeOut',
onUpdate: (x) => setV(Math.round(x)),
})
return () => controls.stop()
}, [target, run, reduce, duration])
const shown = reduce ? target : v
return <>{prefix}{shown.toLocaleString('sv-SE')}</>
}
function Dot({ fill }: { fill: string }) {
return <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: fill }} />
}
function initialsOf(name: string): string {
return (
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('') || '•'
)
}
// One clock for all continuous life: GPU-friendly CSS keyframes, frozen for
// prefers-reduced-motion users.
const keyframes = `
@keyframes aurora-pulse { to { stroke-dashoffset: -1; } }
@keyframes aurora-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.03); } }
.aurora-pulse { animation-name: aurora-pulse; animation-timing-function: linear; animation-iteration-count: infinite; }
.aurora-breathe { animation: aurora-breathe 5.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.aurora-pulse, .aurora-breathe { animation: none !important; }
}
`