Initial commit: Learning Lab Express
Includes fixes found while reviewing the app: - Results: open each result by a type+agent+id key, so a video and a material draft with the same id no longer open each other's popup - AuthGate: clear the query cache during render, before pages build their queries, and remount pages on organization switch (pages no longer stay on skeletons for up to a minute after sign-in or refresh) - Instruction: keep only failed rules after a partial approve, so retrying does not create the successful schedules twice; drop the monthly note once count or frequency is edited - Parser/schedules: never schedule a count below one - Audio: ignore the end event of a cancelled utterance when changing speed - Downloads: revoke the object URL later so Safari/Firefox can read it - Results: refetch SCORM content when any part's revision changes; show a failed video as failed instead of "still rendering" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
222
src/pages/Schedule.tsx
Normal file
222
src/pages/Schedule.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { Empty, PageHead, SkeletonLines, SkeletonRows } from '../components/Page'
|
||||
import { toast } from '../components/Toast'
|
||||
import { describeRule } from '../express/parse'
|
||||
import { locale, t, type Key } from '../i18n'
|
||||
import {
|
||||
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
|
||||
} from '../express/schedules'
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso
|
||||
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||
: '–')
|
||||
|
||||
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
|
||||
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
|
||||
|
||||
export function SchedulePage() {
|
||||
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('sch.title')} description={t('sch.description')}
|
||||
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
|
||||
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
|
||||
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
|
||||
{schedules.data?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
|
||||
{!!schedules.data?.length && <div className="result-list">{schedules.data.map(s => <ScheduleRow key={s.agent.id} schedule={s} />)}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
const { agent, rule, active } = schedule
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [topic, setTopic] = useState(rule.topic)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [producing, setProducing] = useState(false)
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['schedules'] })
|
||||
const video = rule.type === 'content' && rule.kind === 'video'
|
||||
// Content kinds run as weekly production plans; research and video as one run per slot.
|
||||
const production = !isRunSchedule(rule)
|
||||
|
||||
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(agent.id)), onSuccess: refresh })
|
||||
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
|
||||
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
|
||||
const runNow = useMutation({
|
||||
mutationFn: () => startRun(agent.id),
|
||||
onSuccess: () => {
|
||||
toast(t(video ? 'sch.videoStarted' : 'sch.runStarted'))
|
||||
setOpen(true)
|
||||
queryClient.invalidateQueries({ queryKey: ['runs', agent.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
},
|
||||
})
|
||||
const error = toggle.error ?? retopic.error ?? archive.error ?? runNow.error
|
||||
const busy = toggle.isPending || retopic.isPending || archive.isPending || runNow.isPending
|
||||
|
||||
return (
|
||||
<div className="schedule-row">
|
||||
<div className="result-row">
|
||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<b>{describeRule(rule)}</b>
|
||||
<div><small>{production
|
||||
? <ProductionLine agentId={agent.id} active={active} />
|
||||
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
|
||||
{editing && (
|
||||
<form className="topic-edit" onSubmit={e => { e.preventDefault(); retopic.mutate() }}>
|
||||
<input aria-label={t('sch.newTopic')} value={topic} placeholder={t('ins.topicPlaceholder')} autoFocus onChange={e => setTopic(e.target.value)} />
|
||||
<button className="btn small primary" disabled={busy}>{t('common.save')}</button>
|
||||
<button type="button" className="btn small" onClick={() => { setEditing(false); setTopic(rule.topic) }}>{t('common.cancel')}</button>
|
||||
</form>
|
||||
)}
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
</div>
|
||||
<span className={`status ${active ? 'ready' : 'draft'}`}><i className="dot"></i>{active ? t('sch.active') : t('sch.paused')}</span>
|
||||
</div>
|
||||
<div className="schedule-actions">
|
||||
{!editing && <button className="btn small" disabled={busy} onClick={() => setEditing(true)}>{t('sch.changeTopic')}</button>}
|
||||
<button className="btn small" disabled={busy} onClick={() => toggle.mutate()}>{active ? t('sch.pause') : t('sch.resume')}</button>
|
||||
{!production && active && <button className="btn small" disabled={busy} onClick={() => runNow.mutate()}>{t(video ? 'sch.videoNow' : 'sch.runNow')}</button>}
|
||||
{production && active && !producing && <button className="btn small" disabled={busy} onClick={() => setProducing(true)}>{t('sch.produceNow')}</button>}
|
||||
<button className="btn small" onClick={() => setOpen(o => !o)}>{open ? t('sch.hideHistory') : t('sch.history')}</button>
|
||||
<button className="btn small danger" disabled={busy}
|
||||
onClick={() => confirm(t('sch.deleteConfirm', { name: describeRule(rule) })) && archive.mutate()}>{t('sch.delete')}</button>
|
||||
</div>
|
||||
{production && !producing && <ProductionProgress agentId={agent.id} />}
|
||||
{producing && production && <ProducePanel schedule={schedule} onClose={() => setProducing(false)} />}
|
||||
{open && <RunHistory agentId={agent.id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Content schedules report this week's progress from the engine's production period.
|
||||
const useProduction = (agentId: number) => useQuery({
|
||||
queryKey: ['production', agentId],
|
||||
queryFn: () => productionStatus(agentId),
|
||||
refetchInterval: q => (q.state.data?.plans.some(p => PLAN_ACTIVE.has(p.status)) ? 5000 : 60_000),
|
||||
})
|
||||
|
||||
function ProductionLine({ agentId, active }: { agentId: number; active: boolean }) {
|
||||
const status = useProduction(agentId)
|
||||
const s = status.data
|
||||
if (!s) return <>{status.isError ? status.error.message : t('sch.loadingProgress')}</>
|
||||
if (!s.planning_available) {
|
||||
return <span className="error">{t('sch.planningOff')}</span>
|
||||
}
|
||||
if (!active) return <>{t('sch.paused')}</>
|
||||
const p = s.period
|
||||
return <>{p ? t('sch.thisWeek', { ready: p.ready, target: p.target, failed: p.failed ? t('sch.thisWeekFailed', { n: p.failed }) : '' }) : t('sch.waitingFirst')}</>
|
||||
}
|
||||
|
||||
// Starts this week's production early: quote the planning step, show its price, then confirm it.
|
||||
// The latest production of this schedule, step by step: topics → creating → done.
|
||||
function ProductionProgress({ agentId }: { agentId: number }) {
|
||||
const plan = useProduction(agentId).data?.plans[0]
|
||||
if (!plan || plan.status === 'quoted' || plan.status === 'cancelled') return null
|
||||
const { total, ready, failed, shortfall } = planProgress(plan)
|
||||
const step = plan.status === 'planning' ? 0 : plan.status === 'running' || plan.status === 'paused' ? 2 : plan.status === 'completed' ? 3 : 1
|
||||
const steps = [t('prod.stepTopics'), t('prod.stepStart'), t('prod.stepCreate', { ready, total }), t('prod.stepDone')]
|
||||
const finished = plan.status === 'completed' || plan.status === 'failed'
|
||||
|
||||
return (
|
||||
<div className={`production-progress${finished ? ' finished' : ''}`} role="status" aria-live="polite">
|
||||
<div className="production-head">
|
||||
{!finished && <span className="spinner" aria-hidden="true" />}
|
||||
<b>{finished ? t('prod.lastTitle') : t('prod.runningTitle', { n: plan.target })}</b>
|
||||
{plan.status !== 'planning' && total > 0 && <span className="production-count">{t('prod.count', { ready, total })}</span>}
|
||||
</div>
|
||||
{plan.status === 'failed' ? (
|
||||
<span className="error">{t('prod.failed', { code: plan.error_code ?? '–' })}</span>
|
||||
) : (
|
||||
<ol className="production-steps">
|
||||
{steps.map((label, i) => (
|
||||
<li key={i} className={i < step ? 'done' : i === step ? (finished ? 'done' : 'current') : undefined}>{label}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{!!total && plan.status !== 'planning' && <div className="production-bar"><i style={{ width: `${Math.round(((ready + failed) / total) * 100)}%` }} /></div>}
|
||||
{(failed > 0 || (shortfall > 0 && plan.status !== 'planning')) && (
|
||||
<small>{[failed ? t('prod.someFailed', { n: failed }) : '', shortfall ? t('prod.shortfall', { n: shortfall }) : ''].filter(Boolean).join(' ')}</small>
|
||||
)}
|
||||
{(ready > 0 || plan.status === 'running') && <Link className="text-btn" to="/results">{t('prod.viewResults')} <Icon name="arrow-right" /></Link>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedule; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const status = useProduction(agent.id)
|
||||
const period = status.data?.period
|
||||
const remaining = period ? Math.max(0, period.target - period.claimed) : agent.content_goal?.weekly_target ?? 0
|
||||
const [count, setCount] = useState(rule.type === 'content' ? rule.count : 1)
|
||||
const n = Math.min(Math.max(1, count), Math.max(1, remaining))
|
||||
const quote = useMutation({ mutationFn: () => quoteProduction(agent.id, n) })
|
||||
const start = useMutation({
|
||||
mutationFn: (plan: ContentPlan) => confirmProduction(agent.id, plan),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['production', agent.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
const error = quote.error ?? start.error
|
||||
const running = status.data?.plans.some(p => PLAN_ACTIVE.has(p.status))
|
||||
|
||||
if (status.isPending) return <div className="produce-panel"><SkeletonLines label={t('sch.loadingProgress')} /></div>
|
||||
return (
|
||||
<div className="produce-panel">
|
||||
{running ? (
|
||||
<p>{t('sch.alreadyProducing')}</p>
|
||||
) : remaining === 0 ? (
|
||||
<p>{t('sch.weekFull')}</p>
|
||||
) : quote.data ? (
|
||||
<p>{t('sch.produceQuote', { n: quote.data.target, credits: quote.data.planning_quote.credits ?? 0, budget: agent.content_goal?.weekly_budget ?? 0 })}</p>
|
||||
) : (
|
||||
<label>{t('sch.produceHowMany', { max: remaining })}
|
||||
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
|
||||
</label>
|
||||
)}
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
<div className="produce-actions">
|
||||
{!running && remaining > 0 && (quote.data
|
||||
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
|
||||
: <button className="btn small primary" disabled={quote.isPending} onClick={() => quote.mutate()}>{quote.isPending ? t('sch.checkingPrice') : t('sch.checkPrice')}</button>)}
|
||||
<button className="btn small" onClick={onClose}>{running || remaining === 0 ? t('res.close') : t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunHistory({ agentId }: { agentId: number }) {
|
||||
const runs = useQuery({
|
||||
queryKey: ['runs', agentId],
|
||||
queryFn: () => listRuns(agentId),
|
||||
refetchInterval: q => (q.state.data?.some(r => ['queued', 'planning', 'running'].includes(r.status)) ? 3000 : false),
|
||||
})
|
||||
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
|
||||
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
|
||||
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
|
||||
return (
|
||||
<ul className="run-history">
|
||||
{runs.data.slice(0, 8).map(r => (
|
||||
<li key={r.id}>
|
||||
<span>{fmt(r.created_at)}</span>
|
||||
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
|
||||
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
|
||||
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
|
||||
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user