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:
@@ -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 }])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user