Turn Production into a welcome dashboard with content activity

Greet the user by name, drop the development-phase strip, move three metric
cards (material in the Brain, content made, uploaded to the LMS) below the
knowledge graph, and add a content activity section with the latest and
in-progress results as Results cards that open their preview in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-27 23:22:57 +07:00
parent 3b56c27210
commit 6f3a5234b4
7 changed files with 125 additions and 56 deletions

View File

@@ -125,7 +125,8 @@ export function InstructionPage() {
}
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
// A new schedule may start producing right away; the dashboard shows it under content in progress.
await Promise.all([queryClient.invalidateQueries({ queryKey: ['schedules'] }), queryClient.invalidateQueries({ queryKey: ['results'] })])
const ok = done.filter(o => o.ok).length
const report = inLang(replyLang, () => (ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })))
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])

View File

@@ -1,14 +1,16 @@
import { 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 } from '../components/Page'
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, titleOf } from '../express/results'
import { isWorking, listResults, materialKey, 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', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
@@ -19,21 +21,23 @@ const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'shor
// 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,
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
// Polled more often than on Results so a run that just started shows up under content in progress.
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 20_000),
})
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const materials = results.data?.materials ?? []
const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
const approved = materials.filter(m => m.status === 'accepted')
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
const lms = approved.filter(m => m.kind === 'scorm')
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
const graph = useMemo(
() => (docs.data && results.data && schedules.data
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
@@ -41,21 +45,18 @@ export function ProductionPage() {
[docs.data, results.data, schedules.data],
)
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
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 title={t('prod.title')} description={t('prod.description')} />
<div className="metrics prod-metrics">
<div className="metric">
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
</div>
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<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="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} />
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
{graph
? <KnowledgeGraph graph={graph} />
@@ -91,10 +92,54 @@ export function ProductionPage() {
{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">{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.

View File

@@ -116,7 +116,7 @@ function StatusBadge({ status }: { status: MaterialStatus }) {
return <span className={`status ${tone}`}><i className="dot"></i>{t(`res.state.${status}` as Key)}</span>
}
function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
const working = isWorking(m)
return (
<article className="content-card">
@@ -157,7 +157,7 @@ function RetryButton({ material: m, className }: { material: Material; className
)
}
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
export function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
const queryClient = useQueryClient()
// Every part's revision, so a SCORM module refetches when only its quiz changes.