Files
learning-lab-express/src/pages/Production.tsx
Iqbal 6ac8692d0d Read results once a minute
Every read of results asks each video schedule for its drafts, and the
dashboard read them every 10s while anything was being made, so the
network tab filled with video-drafts requests. Poll once a minute on the
dashboard and Results page, and reuse a read under a minute old when a
page opens or regains focus. Actions still refresh results at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:12:51 +07:00

320 lines
18 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { listDocuments } from '../api/engine'
import { Icon } from '../components/Icon'
import { PageHead, Skeleton, SkeletonCards } from '../components/Page'
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
import { describeRule } from '../express/parse'
import { isWorking, listResults, materialKey, RESULTS_POLL, titleOf, type Material } from '../express/results'
import { listSchedules } from '../express/schedules'
import { locale, t, type Key } from '../i18n'
import { ContentCard, Preview } from './Results'
const HUB_LABEL: Record<Cluster, Key> = {
sources: 'prod.hub.sources', made: 'prod.hub.made',
}
// Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari)
// the stage is pinned over the page instead, and Escape leaves it.
function useFullscreen() {
const ref = useRef<HTMLElement>(null)
const [pinned, setPinned] = useState(false)
const [native, setNative] = useState(false)
useEffect(() => {
const sync = () => setNative(document.fullscreenElement != null && document.fullscreenElement === ref.current)
document.addEventListener('fullscreenchange', sync)
return () => document.removeEventListener('fullscreenchange', sync)
}, [])
useEffect(() => {
if (!pinned) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPinned(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [pinned])
const toggle = () => {
if (native) void document.exitFullscreen()
else if (pinned) setPinned(false)
else if (ref.current?.requestFullscreen && document.fullscreenEnabled) ref.current.requestFullscreen().catch(() => setPinned(true))
else setPinned(true)
}
return { ref, full: native || pinned, pinned, toggle }
}
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
export function ProductionPage() {
const { user } = useUser()
// A short greeting: Clerk's first name can hold more than one word.
const name = user?.firstName?.trim().split(/\s+/)[0]
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
const results = useQuery({
queryKey: ['results'],
queryFn: listResults,
...RESULTS_POLL,
})
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const fullscreen = useFullscreen()
const materials = results.data?.materials ?? []
const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
const graph = useMemo(
() => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
[docs.data, results.data],
)
const num = (n: number, ready: boolean, unit: Key) => (ready
? <strong>{n.toLocaleString(locale())} <em>{t(unit, { n })}</em></strong>
: <Skeleton className="skel-num" />)
return (
<>
<PageHead eyebrow={t('prod.eyebrow')} description={t('prod.description')}
title={<>{name ? t('prod.welcomeName', { name }) : t('prod.welcome')} <span className="welcome-spark">✦</span></>}
actions={<>
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
<Link className="btn primary" to="/instruction"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} />
<section ref={fullscreen.ref} className={`graph-stage${fullscreen.full ? ' full' : ''}${fullscreen.pinned ? ' pinned' : ''}`} aria-label={t('prod.graphTitle')}>
<button type="button" className="graph-fullscreen" onClick={fullscreen.toggle} aria-pressed={fullscreen.full}
aria-label={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')} title={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')}>
<Icon name={fullscreen.full ? 'shrink' : 'expand'} />
</button>
{graph
? <KnowledgeGraph graph={graph} />
// The engine's own message: an unreachable engine says so, any other failure says what it was.
: <div className="graph-loading" role="status">{(docs.error ?? results.error)?.message ?? t('prod.loadingGraph')}</div>}
<aside className="graph-panel">
<div className="graph-stats">
<div><small>{t('prod.nodes')}</small>{graph ? <b>{graph.nodes.filter(n => !n.hub).length}</b> : <b>-</b>}<span>{t('prod.nodesSub')}</span></div>
<div><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
</div>
<h3>{t('prod.scheduleTitle')}</h3>
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/instruction">{t('prod.giveInstruction')}</Link></p>}
<ul className="graph-list">
{schedules.data?.slice(0, 4).map(s => (
<li key={s.agent.id}>
<time>{s.active && s.agent.next_run_at ? when(s.agent.next_run_at) : '-'}</time>
<span>{describeRule(s.rule)}</span>
<em className={s.active ? 'on' : undefined}>{t(s.active ? 'sch.active' : 'sch.paused')}</em>
</li>
))}
</ul>
<h3>{t('prod.nowTitle')}</h3>
{results.data && working.length === 0 && <p className="graph-empty">{t('prod.nothingNow')}</p>}
<ul className="graph-list now">
{working.slice(0, 6).map(m => (
<li key={`${m.kind}-${m.agentId}-${m.id}`}>
<i className="node-dot working" />
<span>{titleOf(m)}</span>
<em>{t(`res.state.${m.status as 'preparing' | 'generating'}`)}</em>
</li>
))}
</ul>
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
</aside>
</section>
<div className="metrics prod-metrics">
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data, docs.data?.length === 1 ? 'prod.unitMaterialOne' : 'prod.unitMaterial')}<small>{t('prod.metricMaterialSub')}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(materials.filter(m => m.status !== 'failed').length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<ContentActivity made={made} working={working} loading={results.isPending} />
</>
)
}
const ACTIVITY_LIMIT = 3
const newest = (list: Material[]) => [...list].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, ACTIVITY_LIMIT)
// The newest finished results and everything still being made (planned pieces included), as the same
// cards as the Results page; one opens in place.
function ContentActivity({ made, working, loading }: { made: Material[]; working: Material[]; loading: boolean }) {
const [tab, setTab] = useState<'latest' | 'working'>('latest')
const [openId, setOpenId] = useState<string | null>(null)
const shown = newest(tab === 'latest' ? made : working)
const open = [...made, ...working].find(m => materialKey(m) === openId) ?? null
return (
<section className="prod-activity">
<div className="section-heading">
<div><h3>{t('prod.activityTitle')}</h3><p>{t('prod.activityDescription')}</p></div>
<Link className="text-btn" to={`/results?status=${tab === 'latest' ? 'done' : 'working'}`}>{t('prod.activityAll')}<Icon name="arrow-right" /></Link>
</div>
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={tab === 'latest'} className={tab === 'latest' ? 'active' : undefined} onClick={() => setTab('latest')}>
<Icon name="book" />{t('prod.activityLatest')} <b>{made.length}</b>
</button>
<button role="tab" aria-selected={tab === 'working'} className={tab === 'working' ? 'active' : undefined} onClick={() => setTab('working')}>
{working.length ? <span className="live-dot" /> : <Icon name="clock" />}{t('prod.activityWorking')} <b>{working.length}</b>
</button>
</div>
{loading
? <SkeletonCards count={3} label={t('common.loading')} />
: shown.length
? <div className="activity-grid">
{shown.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}
</div>
: <p className="graph-empty activity-empty">{t(tab === 'latest' ? 'prod.activityNoneLatest' : 'prod.activityNoneWorking')}</p>}
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
</section>
)
}
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
// light travels along the links between clusters. Positions are written straight to the DOM each
// frame, so hovering or selecting (React state) never fights the animation.
const DRIFT = 5
const MAX_PARTICLES = 80
const ease = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)
function curve(ax: number, ay: number, bx: number, by: number, bend: number) {
const mx = (ax + bx) / 2, my = (ay + by) / 2
return { cx: mx - (by - ay) * bend, cy: my + (bx - ax) * bend }
}
function KnowledgeGraph({ graph }: { graph: Graph }) {
const [selected, setSelected] = useState<GraphNode | null>(null)
const [hover, setHover] = useState<string | null>(null)
const nodeEls = useRef<(SVGGElement | null)[]>([])
const linkEls = useRef<(SVGPathElement | null)[]>([])
const particleEls = useRef<(SVGCircleElement | null)[]>([])
const index = useMemo(() => new Map(graph.nodes.map((n, i) => [n.id, i])), [graph])
const pairs = useMemo(() => graph.links.map((l, i) => ({
a: index.get(l.source)!, b: index.get(l.target)!, hub: l.hub, bend: l.hub ? 0.08 * (i % 2 ? 1 : -1) : 0.18 * (i % 2 ? 1 : -1),
})), [graph, index])
const particles = useMemo(() => pairs.map((p, i) => ({ ...p, link: i })).filter(p => !p.hub).slice(0, MAX_PARTICLES), [pairs])
const degree = useMemo(() => {
const d = new Map<string, number>()
for (const l of graph.links) if (!l.hub) { d.set(l.source, (d.get(l.source) ?? 0) + 1); d.set(l.target, (d.get(l.target) ?? 0) + 1) }
return d
}, [graph])
const stars = useMemo(() => Array.from({ length: 70 }, (_, i) => {
const r = (k: number) => (Math.sin(i * 127.1 + k * 311.7) * 43758.5453) % 1
return { x: Math.abs(r(1)) * graph.width, y: Math.abs(r(2)) * graph.height, s: 0.4 + Math.abs(r(3)) * 1.1, d: Math.abs(r(4)) * 6 }
}), [graph])
useLayoutEffect(() => {
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const hubOf = graph.nodes.map(n => index.get(`hub-${n.cluster}`)!)
const phase = graph.nodes.map((_, i) => i * 2.399)
const x = new Float64Array(graph.nodes.length)
const y = new Float64Array(graph.nodes.length)
const t0 = performance.now()
let frame = 0
const draw = (now: number) => {
const t = still ? 1e9 : now - t0
graph.nodes.forEach((n, i) => {
const hub = graph.nodes[hubOf[i]]
const grow = n.hub ? ease(t / 700) : ease((t - 250 - (i % 24) * 25) / 1100)
const drift = still ? 0 : (n.hub ? 0.4 : 1) * DRIFT
const k = t / 1000
x[i] = hub.x + (n.x - hub.x) * grow + Math.sin(k * 0.6 + phase[i]) * drift
y[i] = hub.y + (n.y - hub.y) * grow + Math.cos(k * 0.5 + phase[i] * 1.3) * drift
const el = nodeEls.current[i]
if (el) {
el.setAttribute('transform', `translate(${x[i].toFixed(1)} ${y[i].toFixed(1)})`)
el.style.opacity = n.hub ? '' : String(Math.min(1, grow * 1.4))
}
})
pairs.forEach((p, i) => {
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
linkEls.current[i]?.setAttribute('d', `M${x[p.a].toFixed(1)} ${y[p.a].toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x[p.b].toFixed(1)} ${y[p.b].toFixed(1)}`)
})
particles.forEach((p, i) => {
const el = particleEls.current[i]
if (!el) return
if (still) { el.style.display = 'none'; return }
const u = ((t / 1000) * (0.18 + (i % 5) * 0.03) + i * 0.37) % 1
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
const v = 1 - u
el.setAttribute('cx', (v * v * x[p.a] + 2 * v * u * cx + u * u * x[p.b]).toFixed(1))
el.setAttribute('cy', (v * v * y[p.a] + 2 * v * u * cy + u * u * y[p.b]).toFixed(1))
el.style.opacity = String(Math.sin(u * Math.PI) * Math.min(1, t / 1600))
})
if (!still) frame = requestAnimationFrame(draw)
}
draw(t0)
if (!still) frame = requestAnimationFrame(draw)
return () => cancelAnimationFrame(frame)
}, [graph, index, pairs, particles])
const focus = hover ?? selected?.id ?? null
const neighbours = useMemo(() => {
if (!focus) return null
const set = new Set([focus])
for (const l of graph.links) {
if (l.source === focus) set.add(l.target)
if (l.target === focus) set.add(l.source)
}
return set
}, [focus, graph])
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
return (
<>
<div className="graph-note">
<i className="live-dot" />
{selected
? <><b>{selected.label}</b>{!selected.hub && <span>{t(HUB_LABEL[selected.cluster])}</span>}<button type="button" aria-label={t('res.close')} onClick={() => setSelected(null)}><Icon name="close" /></button></>
: <><b>{t('prod.graphTitle')}</b><span>{t('prod.clickNode')}</span></>}
</div>
<svg className={`graph-svg${neighbours ? ' focused' : ''}`} viewBox={`0 0 ${graph.width} ${graph.height}`} role="img"
aria-label={t('prod.graphLabel', { nodes: graph.nodes.length - CLUSTERS.length })} onClick={() => setSelected(null)}>
<defs>
<radialGradient id="graph-glow"><stop offset="0" stopColor="#2e90fa" stopOpacity=".32" /><stop offset=".5" stopColor="#6941c6" stopOpacity=".08" /><stop offset="1" stopColor="#2e90fa" stopOpacity="0" /></radialGradient>
<radialGradient id="graph-core"><stop offset="0" stopColor="#fff" /><stop offset=".55" stopColor="#fff" stopOpacity=".9" /><stop offset="1" stopColor="#fff" stopOpacity=".2" /></radialGradient>
<filter id="graph-bloom" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
</defs>
<g className="graph-stars">
{stars.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.s} style={{ animationDelay: `${s.d}s` }} />)}
</g>
<circle className="graph-glow" cx={500} cy={320} r={320} fill="url(#graph-glow)" />
<ellipse className="graph-orbit slow" cx={500} cy={320} rx={430} ry={275} />
<ellipse className="graph-orbit" cx={500} cy={320} rx={300} ry={195} />
<g className="graph-links">
{pairs.map((p, i) => (
<path key={i} ref={el => { linkEls.current[i] = el }} style={{ ['--c' as string]: `var(--g-${graph.nodes[p.a].cluster})` }}
className={`${p.hub ? 'hub' : 'cross'}${lit(p) ? ' lit' : neighbours ? ' dim' : ''}`} />
))}
</g>
<g className="graph-particles">
{particles.map((p, i) => (
<circle key={i} r={1.8} ref={el => { particleEls.current[i] = el }} className={`c-${graph.nodes[p.a].cluster}${neighbours && !lit(p) ? ' dim' : ''}`} />
))}
</g>
<g>
{graph.nodes.map((n, i) => {
const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
return (
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${n.id !== focus && neighbours?.has(n.id) ? ' near' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
onClick={e => { e.stopPropagation(); setSelected(n) }}>
<circle className="hit" r={r + 8} />
{n.hub && <><circle className="ring" r={r + 4} /><circle className="ring late" r={r + 4} /><circle className="halo" r={26} /></>}
<circle className="core" r={r} filter="url(#graph-bloom)" />
{n.hub && <circle className="dot" r={r * 0.45} />}
<text y={-r - 8}>{n.label.length > 40 ? n.label.slice(0, 38) + '…' : n.label}</text>
<title>{n.label}</title>
</g>
)
})}
</g>
</svg>
<ul className="graph-legend">
{CLUSTERS.map(c => <li key={c}><i className={`node-dot ${c}`} />{t(HUB_LABEL[c])}</li>)}
</ul>
</>
)
}