Files
learning-lab-express/src/pages/Results.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

449 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Link, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AudioPlayer } from '../components/AudioPlayer'
import { Icon, KIND_ICON, type IconName } from '../components/Icon'
import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../components/Page'
import { toast } from '../components/Toast'
import { describeRule, kindLabel, type ContentKind } from '../express/parse'
import {
decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf,
titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus,
} from '../express/results'
import { MASTERY, scormPackage } from '../express/scorm'
import { getLang, locale, t, type Key } from '../i18n'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
// Setup fields of roleplay and simulator content, in display order.
const FIELDS = ['description', 'topic', 'objective', 'learning_objectives', 'user_role', 'ai_character', 'difficulty', 'context'] as const
// 'done' is everything no longer being made except failures, as the Production page counts it; the
// Production page links here with ?status=done or ?status=working.
const STATUS_FILTERS = ['all', 'done', 'review', 'approved', 'working', 'failed'] as const
type StatusFilter = typeof STATUS_FILTERS[number]
const STATUS_GROUP: Record<MaterialStatus, Exclude<StatusFilter, 'all' | 'done'>> = {
preparing: 'working', generating: 'working', ready: 'review', accepted: 'approved', failed: 'failed', rejected: 'failed',
}
const matchesStatus = (m: Material, status: StatusFilter) =>
status === 'all' || (status === 'done' ? !isWorking(m) && m.status !== 'failed' : STATUS_GROUP[m.status] === status)
const fmt = (iso: string) => new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
export function ResultsPage() {
const results = useQuery({
queryKey: ['results'],
queryFn: listResults,
...RESULTS_POLL,
})
const [tab, setTab] = useState<'content' | 'research'>('content')
const [query, setQuery] = useState('')
const [kind, setKind] = useState<ContentKind | 'all'>('all')
const [params] = useSearchParams()
const [status, setStatus] = useState<StatusFilter>(() => STATUS_FILTERS.find(s => s === params.get('status')) ?? 'all')
const [openId, setOpenId] = useState<string | null>(null)
const materials = results.data?.materials ?? []
const sources = results.data?.sources ?? []
const kinds = useMemo(() => [...new Set(materials.map(m => m.kind))], [materials])
const needle = query.trim().toLowerCase()
const shownMaterials = materials.filter(m => (kind === 'all' || m.kind === kind) && matchesStatus(m, status)
&& (!needle || titleOf(m).toLowerCase().includes(needle)))
const shownSources = sources.filter(s => !needle || s.title.toLowerCase().includes(needle) || s.url?.toLowerCase().includes(needle))
const open = materials.find(m => materialKey(m) === openId) ?? null
const pending = sources.filter(s => s.status === 'pending').length
return (
<>
<PageHead title={t('res.title')} description={t('res.description')} />
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={tab === 'content'} className={tab === 'content' ? 'active' : undefined} onClick={() => setTab('content')}>
{t('res.tabContent')} <b>{materials.length}</b>
</button>
<button role="tab" aria-selected={tab === 'research'} className={tab === 'research' ? 'active' : undefined} onClick={() => setTab('research')}>
{t('res.tabResearch')} <b>{pending ? t('res.pendingCount', { n: pending }) : sources.length}</b>
</button>
</div>
<section className="card">
<div className="toolbar">
<h3>{tab === 'content' ? t('res.allContent') : t('res.foundSources')} <span className="badge neutral">{tab === 'content' ? shownMaterials.length : shownSources.length}</span></h3>
<div className="toolbar-controls">
<div className="search"><Icon name="search" />
<input aria-label={t('res.search')} placeholder={t('res.search')} value={query} onChange={e => setQuery(e.target.value)} />
</div>
{tab === 'content' && (
<>
<select aria-label={t('res.filterKind')} value={kind} onChange={e => setKind(e.target.value as ContentKind | 'all')}>
<option value="all">{t('res.allKinds')}</option>
{kinds.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
</select>
<select aria-label={t('res.filterStatus')} value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
{STATUS_FILTERS.map(s => <option key={s} value={s}>{t(`res.status.${s}` as Key)}</option>)}
</select>
</>
)}
</div>
</div>
{results.isPending && (tab === 'content' ? <SkeletonCards label={t('common.loading')} /> : <SkeletonRows label={t('common.loading')} />)}
{results.isError && !results.data && <Empty icon="info" title={t('res.loadFailed')}>{results.error.message}</Empty>}
{results.data && tab === 'content' && (
shownMaterials.length
? <div className="content-grid">{shownMaterials.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}</div>
: <NoResults filtered={materials.length > 0} icon="layers" />
)}
{results.data && tab === 'research' && (
shownSources.length
? <div className="research-list">{shownSources.map(s => <SourceRow key={`${s.agentId}-${s.id}`} source={s} />)}</div>
: <NoResults filtered={sources.length > 0} icon="search" />
)}
<div className="table-note">{tab === 'content' ? t('res.noteContent') : t('res.noteResearch')}</div>
</section>
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
</>
)
}
function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) {
if (filtered) return <Empty icon="search" title={t('res.noMatch')}>{t('res.noMatchCopy')}</Empty>
return (
<div className="empty">
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{t('res.empty')}</h3>
<p>{t('res.emptyCopy')}</p>
<Link className="btn light" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>
</div>
)
}
function StatusBadge({ status }: { status: MaterialStatus }) {
const group = STATUS_GROUP[status]
const tone = group === 'approved' ? 'ready' : group === 'failed' ? 'failed' : 'draft'
return <span className={`status ${tone}`}><i className="dot"></i>{t(`res.state.${status}` as Key)}</span>
}
export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
const working = isWorking(m)
return (
<article className="content-card">
<div className={`content-cover kind-${m.kind}`}>
<Icon name={KIND_ICON[m.kind]} /><span>{kindLabel(m.kind)}</span>
<small>{m.rule.topic ? m.rule.topic.toUpperCase() : 'EXPRESS'}</small>
</div>
<div className="content-card-body">
<div className="content-card-top"><span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span><StatusBadge status={m.status} /></div>
<button className="content-title" disabled={working} onClick={onOpen}>{titleOf(m)}</button>
<p>{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}</p>
<div className="content-card-foot">
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
{working
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
: m.retry && <div className="content-card-actions"><RetryButton material={m} className="text-btn" /></div>}
</div>
</div>
</article>
)
}
// A failed plan item is produced again with a fresh quote; the result then shows as preparing.
function RetryButton({ material: m, className }: { material: Material; className: string }) {
const queryClient = useQueryClient()
const retry = useMutation({
mutationFn: () => retryMaterial(m),
onSuccess: () => {
toast(t('res.retried'))
queryClient.invalidateQueries({ queryKey: ['results'] })
},
onError: e => toast(e.message, { error: true }),
})
return (
<button className={className} disabled={retry.isPending} onClick={() => retry.mutate()}>
{retry.isPending ? t('res.retrying') : t('res.retry')}
</button>
)
}
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.
const content = useQuery({ queryKey: ['material', materialKey(m), m.parts.map(p => p.revision).join('.')], queryFn: () => materialContent(m) })
const decide = useMutation({
mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision),
onSuccess: (_, decision) => {
toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected'))
queryClient.invalidateQueries({ queryKey: ['results'] })
},
})
// No close() on cleanup: its async "close" event would call onClose, and StrictMode's
// mount → cleanup → mount in dev would then shut the dialog right after it opened.
// Unmounting removes the dialog element, which ends the modal anyway.
useEffect(() => {
if (ref.current && !ref.current.open) ref.current.showModal()
}, [])
return (
<dialog ref={ref} className="preview" aria-labelledby="preview-title" onClose={onClose}
onClick={e => e.target === ref.current && ref.current.close()}>
<div className="modal-head">
<div><h2 id="preview-title">{titleOf(m)}</h2><p>{describeRule(m.rule)}</p></div>
<button className="icon-btn" aria-label={t('res.close')} onClick={() => ref.current?.close()}><Icon name="close" /></button>
</div>
<div className="modal-body">
{m.status === 'ready' && <div className="preview-notice"><Icon name="info" /><span>{t('res.reviewNotice')}</span></div>}
{m.kind === 'audio' && <div className="preview-notice concept"><Icon name="info" /><span>{t('res.audioNotice')}</span></div>}
<div className="preview-layout">
<div>
{content.isPending && <SkeletonLines count={6} label={t('common.loading')} />}
{content.isError && <Empty icon="info" title={t('res.loadFailed')}>{content.error.message}</Empty>}
{content.isSuccess && (content.data.content ? <Body material={m} content={content.data.content} /> : <Empty icon="info" title={t('res.noContent')}>{t('res.noContentCopy')}</Empty>)}
</div>
<aside className="preview-side">
<h3>{t('res.info')}</h3>
<span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span>
<div className="detail-label">{t('res.statusLabel')}</div><StatusBadge status={m.status} />
<div className="detail-label">{t('res.schedule')}</div><b>{describeRule(m.rule)}</b>
<div className="detail-label">{t('res.created')}</div><b>{fmt(m.createdAt)}</b>
{m.video?.credits != null && m.video.credits > 0 && <><div className="detail-label">{t('res.creditsUsed')}</div><b>{t('common.credits', { n: m.video.credits })}</b></>}
<div className="detail-label">{t('res.sources')}</div>
{m.sources.length ? m.sources.map(s => <div key={s} className="preview-source"><Icon name="file" />{s}</div>) : <b>–</b>}
</aside>
</div>
</div>
<div className="modal-foot">
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
{m.retry && <RetryButton material={m} className="btn primary" />}
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
{m.status === 'ready' && (
<>
<button className="btn danger" disabled={decide.isPending} onClick={() => decide.mutate('reject')}>{t('res.reject')}</button>
<button className="btn primary" disabled={decide.isPending} onClick={() => decide.mutate('accept')}><Icon name="check" />{t('res.accept')}</button>
</>
)}
</div>
</dialog>
)
}
// What a learner sees in a SCORM package: the lesson without the answer key (the quiz is interactive there).
function lessonHtml(m: Material, content: MaterialContent): string {
if (m.kind === 'quiz') return ''
if (m.kind === 'summary' || m.kind === 'audio' || m.kind === 'scorm') {
return renderToStaticMarkup(<article className="prose"><Paragraphs text={summaryText(m, content)} /></article>)
}
return renderToStaticMarkup(<Body material={m} content={content} />)
}
function scorm(m: Material, content: MaterialContent) {
const blob = scormPackage({
title: textOf(content.title) || titleOf(m),
lessonHtml: lessonHtml(m, content),
questions: m.kind === 'quiz' || m.kind === 'scorm' ? questionsOf(content) : [],
lang: getLang(),
labels: {
quiz: t('res.quiz'), submit: t('scorm.submit'), retry: t('scorm.retry'), complete: t('scorm.complete'),
completed: t('scorm.completed'), score: t('scorm.score'), passed: t('scorm.passed', { n: MASTERY }),
failed: t('scorm.failed', { n: MASTERY }), answerAll: t('scorm.answerAll'), noLms: t('scorm.noLms'),
},
})
download(fileName(m, 'zip'), blob)
toast(t('res.scormReady'))
}
function DownloadMenu({ material: m, content, outputId }: { material: Material; content: MaterialContent; outputId: number | null }) {
const ref = useRef<HTMLDetailsElement>(null)
const pptx = useMutation({
mutationFn: () => exportPptx(outputId!),
onSuccess: blob => download(fileName(m, 'pptx'), blob),
onError: e => toast(e.message, { error: true }),
})
const close = () => ref.current?.removeAttribute('open')
if (m.kind === 'video') {
return m.video?.url
? <a className="btn" href={m.video.url} download={fileName(m, 'mp4')}><Icon name="download" />{t('res.downloadVideo')}</a>
: null
}
const scormItem = (
<button role="menuitem" onClick={() => { close(); scorm(m, content) }}>
<b>SCORM 1.2 (.zip)</b><small>{t(m.kind === 'scorm' || m.kind === 'quiz' ? 'res.scormQuizHint' : 'res.scormHint')}</small>
</button>
)
const pdf = () => {
const title = textOf(content.title) || titleOf(m)
const meta = [describeRule(m.rule), fmt(m.createdAt)].join(' · ')
const html = renderToStaticMarkup(
<>
<header><small>Learning Lab Express</small><h1>{title}</h1><p>{meta}</p></header>
{/* The audio player is for the screen; on paper the script is the lesson. */}
{m.kind === 'audio' ? <article className="prose"><Paragraphs text={summaryText(m, content)} /></article> : <Body material={m} content={content} />}
{!!m.sources.length && <footer>{t('res.sources')}: {m.sources.join(', ')}</footer>}
</>,
)
printPdf(title, html)
}
return (
<details className="download-menu" ref={ref}>
<summary className="btn"><Icon name="download" />{pptx.isPending ? t('res.preparingFile') : t('res.download')}</summary>
<div role="menu">
{m.kind === 'scorm' && scormItem}
{m.kind === 'audio' && (
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'txt'), new Blob([summaryText(m, content)], { type: 'text/plain;charset=utf-8' })) }}>
<b>{t('res.scriptTxt')}</b><small>{t('res.scriptHint')}</small>
</button>
)}
<button role="menuitem" onClick={() => { close(); pdf() }}><b>PDF</b><small>{t('res.pdfHint')}</small></button>
{m.kind === 'slide_deck' && outputId && (
<button role="menuitem" disabled={pptx.isPending} onClick={() => { close(); pptx.mutate() }}><b>PowerPoint (.pptx)</b><small>{t('res.pptxHint')}</small></button>
)}
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'md'), toMarkdown(m, content)) }}><b>Markdown (.md)</b><small>{t('res.mdHint')}</small></button>
{m.kind !== 'scorm' && scormItem}
</div>
</details>
)
}
function Paragraphs({ text }: { text: string }) {
// Generated text is light Markdown: headings, bullets and paragraphs are enough to read it.
const blocks: ReactNode[] = []
let bullets: string[] = []
const flush = () => { if (bullets.length) blocks.push(<ul key={blocks.length}>{bullets.map((b, i) => <li key={i}>{b}</li>)}</ul>); bullets = [] }
for (const line of text.split('\n').map(l => l.trim())) {
if (/^[-*•]\s+/.test(line)) { bullets.push(line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')); continue }
flush()
if (!line) continue
const heading = line.match(/^#{1,6}\s+(.*)/)
blocks.push(heading ? <h2 key={blocks.length}>{heading[1]}</h2> : <p key={blocks.length}>{line.replace(/\*\*/g, '')}</p>)
}
flush()
return <>{blocks}</>
}
// Generated summaries usually open with their own title as a heading; it is already the h1.
function summaryText(m: Material, content: MaterialContent) {
const title = textOf(content.title) || titleOf(m)
return textOf(content.text).replace(/^\s*#{1,6}\s+(.+)\n/, (line, heading: string) =>
heading.trim().toLowerCase() === title.trim().toLowerCase() ? '' : line)
}
function QuizList({ content }: { content: MaterialContent }) {
return (
<ol className="quiz-list">
{questionsOf(content).map((q, i) => (
<li key={i}><b>{q.question}</b>
{!!q.options.length && <ul>{q.options.map((o, j) => <li key={j} className={j === q.correct ? 'correct' : undefined}>{o}</li>)}</ul>}
{q.answer && q.correct < 0 && <p><b>{t('res.answer')}:</b> {q.answer}</p>}
{q.explanation && <p className="muted">{q.explanation}</p>}
</li>
))}
</ol>
)
}
function Body({ material: m, content }: { material: Material; content: MaterialContent }) {
const title = textOf(content.title) || titleOf(m)
if (m.kind === 'video') {
if (m.video?.url) return <article className="prose"><h1>{title}</h1><video className="source-player" controls preload="metadata" src={m.video.url} /></article>
if (m.status === 'failed') return <Empty icon="video" title={t('res.videoFailed')}>{t('res.videoFailedCopy')}</Empty>
return <Empty icon="video" title={t('res.videoNotReady')}>{t('res.videoProgress', { n: m.video?.progress ?? 0 })}</Empty>
}
if (m.kind === 'summary') {
return <article className="prose"><h1>{title}</h1><Paragraphs text={summaryText(m, content)} /></article>
}
if (m.kind === 'audio') {
const text = summaryText(m, content)
return (
<>
<AudioPlayer title={title} text={text} />
<details className="script-details" style={{ marginTop: 20 }}><summary>{t('res.fullScript')}</summary>
<article className="prose"><Paragraphs text={text} /></article>
</details>
</>
)
}
if (m.kind === 'scorm') {
const questions = questionsOf(content).length
return (
<article className="prose scorm-preview">
<div className="scorm-preview-head"><Icon name="scorm" /><div><b>{t('res.scormModule')}</b><small>{t('res.scormMeta', { n: questions, mastery: MASTERY })}</small></div></div>
<h1>{title}</h1>
<Paragraphs text={summaryText(m, content)} />
<h2>{t('res.quiz')}</h2>
{questions ? <QuizList content={content} /> : <p className="muted">{t('res.quizPending')}</p>}
</article>
)
}
if (m.kind === 'quiz') {
return <article className="prose"><h1>{title}</h1><QuizList content={content} /></article>
}
if (m.kind === 'flashcards') {
return (
<article className="prose"><h1>{title}</h1>
<div className="flashcards">
{(listOf(content.cards) as Record<string, unknown>[]).map((c, i) => (
<div key={i} className="flashcard"><b>{textOf(c.front)}</b><span>{textOf(c.back)}</span></div>
))}
</div>
</article>
)
}
if (m.kind === 'slide_deck') {
return (
<article className="prose"><h1>{title}</h1>
{!!content.description && <p>{textOf(content.description)}</p>}
{slidesOf(content).map((s, i) => (
<section key={i} className="slide-preview"><small>{t('res.slide', { n: i + 1 })}</small><h2>{s.title}</h2>
{!!s.bullets.length && <ul>{s.bullets.map((b, j) => <li key={j}>{b}</li>)}</ul>}
</section>
))}
</article>
)
}
// Roleplay and simulator: show their setup fields; the interactive versions live in Learning Studio.
return (
<article className="prose"><h1>{title}</h1>
{FIELDS.map(k => [k, content[k]] as const).filter(([, v]) => v != null && v !== '').map(([k, v]) => (
<div key={k}><h2>{t(`res.field.${k}`)}</h2>
{Array.isArray(v) ? <ul>{v.map((x, i) => <li key={i}>{textOf(x)}</li>)}</ul> : <Paragraphs text={textOf(v)} />}
</div>
))}
</article>
)
}
function SourceRow({ source: s }: { source: FoundSource }) {
const queryClient = useQueryClient()
const review = useMutation({
mutationFn: (decision: 'accept' | 'reject') => reviewSource(s, decision),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['results'] })
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const tone = s.status === 'accepted' ? 'ready' : s.status === 'pending' ? 'draft' : 'failed'
const label = ['pending', 'accepted', 'rejected'].includes(s.status) ? t(`res.source.${s.status}` as Key) : s.status
return (
<article className="research-row">
<span><Icon name="search" /></span>
<div>
<span className={`status ${tone}`}><i className="dot"></i>{label}</span>
<h3>{s.url ? <a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> : s.title}</h3>
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/upload">{t('res.inBrain')}</Link></>}</p>
{s.preview && <p className="source-preview">{s.preview}</p>}
{review.error && <span className="error">{review.error.message}</span>}
</div>
{s.status === 'pending' && (
<div className="research-actions">
<button className="btn small danger" disabled={review.isPending} onClick={() => review.mutate('reject')}>{t('res.reject')}</button>
<button className="btn small primary" disabled={review.isPending} onClick={() => review.mutate('accept')}>{t('res.addToBrain')}</button>
</div>
)}
</article>
)
}