Add a Production dashboard with an animated knowledge graph

The sidebar now groups Production under Dashboard and the four existing
pages under Settings, and Production is the landing page. It shows content
in progress, material, content made, approved and LMS-ready counts, plus a
knowledge graph linking Brain documents, results, found sources and
schedules, with the schedule and what is being made right now beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-27 15:57:10 +07:00
parent 6d4850f4ce
commit ddbdbc7d44
8 changed files with 608 additions and 16 deletions

38
src/express/graph.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import type { Source } from '../api/engine'
import { buildGraph, HEIGHT, WIDTH } from './graph'
import type { FoundSource, Material } from './results'
import type { Schedule } from './schedules'
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source
const material = (id: number, status: Material['status'], sources: string[]) =>
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
describe('buildGraph', () => {
const data = {
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')],
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])],
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource],
schedules: [schedule],
}
const graph = buildGraph(data, c => c)
it('puts results in progress in their own cluster', () => {
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made')
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working')
})
it('links results to their sources and schedule, and found sources to their document', () => {
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2']))
})
it('lays out the same data the same way, inside the canvas', () => {
expect(buildGraph(data, c => c).nodes).toEqual(graph.nodes)
for (const n of graph.nodes) {
expect(n.x).toBeGreaterThanOrEqual(0); expect(n.x).toBeLessThanOrEqual(WIDTH)
expect(n.y).toBeGreaterThanOrEqual(0); expect(n.y).toBeLessThanOrEqual(HEIGHT)
}
})
})

133
src/express/graph.ts Normal file
View File

@@ -0,0 +1,133 @@
// The Production knowledge graph: every Brain document, result, found source and schedule as a node,
// grouped around one hub per cluster and linked where the engine says they are related (a result's
// sources, the schedule that made it, the document a found source became). Laid out once with a
// small seeded force simulation, so the same data always draws the same picture.
import type { Source } from '../api/engine'
import { isWorking, type FoundSource, type Material } from './results'
import type { Schedule } from './schedules'
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
export interface GraphNode {
id: string
cluster: Cluster
label: string
hub: boolean
x: number
y: number
}
export interface GraphLink { source: string; target: string; hub: boolean }
export interface Graph { nodes: GraphNode[]; links: GraphLink[]; width: number; height: number }
export const WIDTH = 1000
export const HEIGHT = 640
// Where each cluster settles, around the middle of the canvas.
const ANCHOR: Record<Cluster, [number, number]> = {
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230],
}
const norm = (s: string) => s.trim().toLowerCase()
export function buildGraph(
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
hubLabel: (c: Cluster) => string,
): Graph {
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
const links: GraphLink[] = []
const add = (id: string, cluster: Cluster, label: string) => {
nodes.push({ id, cluster, label, hub: false, x: 0, y: 0 })
links.push({ source: id, target: `hub-${cluster}`, hub: true })
}
const link = (source: string, target: string) => links.push({ source, target, hub: false })
// Results name their sources by title, so documents are found by title or file name.
const docByName = new Map<string, string>()
for (const d of docs) {
add(`doc-${d.docId}`, 'sources', d.title)
docByName.set(norm(d.title), `doc-${d.docId}`)
docByName.set(norm(d.originalName), `doc-${d.docId}`)
}
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name)
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
for (const m of materials) {
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind)
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
for (const title of new Set(m.sources)) {
const doc = docByName.get(norm(title))
if (doc) link(id, doc)
}
}
for (const s of sources) {
const id = `found-${s.agentId}-${s.id}`
add(id, 'research', s.title)
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
}
layout(nodes, links)
return { nodes, links, width: WIDTH, height: HEIGHT }
}
// Deterministic per node, so a refetch with the same data does not reshuffle the picture.
function seeded(id: string) {
let h = 2166136261
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)
return () => {
h = Math.imul(h ^ (h >>> 15), 2246822507)
h = Math.imul(h ^ (h >>> 13), 3266489909)
return ((h ^= h >>> 16) >>> 0) / 4294967296
}
}
function layout(nodes: GraphNode[], links: GraphLink[], iterations = 260) {
const index = new Map(nodes.map((n, i) => [n.id, i]))
const vx = new Float64Array(nodes.length)
const vy = new Float64Array(nodes.length)
for (const n of nodes) {
const rand = seeded(n.id)
const [ax, ay] = ANCHOR[n.cluster]
n.x = ax + (n.hub ? 0 : (rand() - 0.5) * 120)
n.y = ay + (n.hub ? 0 : (rand() - 0.5) * 120)
}
const pairs = links.map(l => [index.get(l.source)!, index.get(l.target)!, l.hub] as const).filter(([a, b]) => a != null && b != null)
for (let it = 0; it < iterations; it++) {
const alpha = 1 - it / iterations
// Repulsion keeps nodes from piling up; only near neighbours matter.
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x
const dy = nodes[j].y - nodes[i].y
const d2 = dx * dx + dy * dy + 0.01
if (d2 > 140 * 140) continue
const f = 260 / d2
vx[i] -= dx * f; vy[i] -= dy * f
vx[j] += dx * f; vy[j] += dy * f
}
}
// Springs: short to the cluster hub, longer across clusters.
for (const [a, b, hub] of pairs) {
const dx = nodes[b].x - nodes[a].x
const dy = nodes[b].y - nodes[a].y
const d = Math.sqrt(dx * dx + dy * dy) || 1
const f = ((d - (hub ? 46 : 110)) / d) * (hub ? 0.06 : 0.015)
vx[a] += dx * f; vy[a] += dy * f
vx[b] -= dx * f; vy[b] -= dy * f
}
nodes.forEach((n, i) => {
const [ax, ay] = ANCHOR[n.cluster]
const pull = n.hub ? 0.12 : 0.012
vx[i] += (ax - n.x) * pull
vy[i] += (ay - n.y) * pull
n.x = Math.min(WIDTH - 20, Math.max(20, n.x + vx[i] * alpha))
n.y = Math.min(HEIGHT - 20, Math.max(20, n.y + vy[i] * alpha))
vx[i] *= 0.6; vy[i] *= 0.6
})
}
}