Files
learning-lab-express/src/pages/Schedule.tsx
Iqbal bcd747721d Show who created each schedule and start with your own
Org admins see every Express schedule in the organization, so a shared
organization mixed everyone's schedules together. The Schedule page now
shows "Created by …" on each one and, when others' schedules are listed,
switches between Mine and Everyone in the organization.

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

305 lines
17 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 { 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, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type Owners, type RunSummary, type Schedule,
} from '../express/schedules'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
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', 'paused_configuration', 'paused_budget'])
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
const RUN_ERRORS = new Set([
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
])
const ISSUE_REASONS = new Set([
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
])
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
// Which schedules the page starts with; remembered per device.
type Scope = 'mine' | 'all'
const SCOPE_KEY = 'express.scheduleScope'
const savedScope = (): Scope => {
try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'mine' } catch { return 'mine' }
}
export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const owners = useQuery({ queryKey: ['scheduleOwners'], queryFn: scheduleOwners, staleTime: 5 * 60_000 })
const [scope, setScope] = useState<Scope>(savedScope)
const choose = (next: Scope) => {
setScope(next)
try { localStorage.setItem(SCOPE_KEY, next) } catch { /* keep it for this visit only */ }
}
const all = schedules.data
const mine = all && owners.data ? all.filter(s => isMine(s, owners.data)) : undefined
// Members only ever see their own, so the switch appears only when someone else's schedules are listed.
const mixed = !!all && !!mine && mine.length < all.length
const shown = mixed && scope === 'mine' ? mine : all
return (
<>
<PageHead title={t('sch.title')} description={t('sch.description')}
actions={<Link className="btn primary" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
{mixed && (
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={scope === 'mine'} className={scope === 'mine' ? 'active' : undefined} onClick={() => choose('mine')}>
{t('sch.mine')} <b>{mine.length}</b>
</button>
<button role="tab" aria-selected={scope === 'all'} className={scope === 'all' ? 'active' : undefined} onClick={() => choose('all')}>
{t('sch.all')} <b>{all.length}</b>
</button>
</div>
)}
<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>}
{all?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
{mixed && shown?.length === 0 && <Empty icon="calendar" title={t('sch.emptyMine')}>{t('sch.emptyMineCopy')}</Empty>}
{!!shown?.length && <div className="result-list">{shown.map(s => <ScheduleRow key={s.agent.id} schedule={s} owners={owners.data} />)}</div>}
</div>
</>
)
}
function createdBy(schedule: Schedule, owners: Owners) {
if (isMine(schedule, owners)) return t('sch.byYou')
const name = schedule.agent.created_by_user_id != null ? owners.names.get(schedule.agent.created_by_user_id) : undefined
return name ? t('sch.by', { name }) : t('sch.byUnknown')
}
function ScheduleRow({ schedule, owners }: { schedule: Schedule; owners?: Owners }) {
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(schedule)), 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>
{owners && <div className="schedule-owner"><small><Icon name="user" />{createdBy(schedule, owners)}</small></div>}
<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">{productionError(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>
const shown = runs.data.slice(0, 8)
// Readiness is today's state, so it is shown once, under the newest run it stopped.
const blockedId = shown.find(isNotReady)?.id
return (
<ul className="run-history">
{shown.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" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
{r.source_outcome === 'no_new_sources' && <span>{t('sch.noNewSources')}</span>}
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
</li>
))}
</ul>
)
}
function ReadinessIssues({ agentId }: { agentId: number }) {
const readiness = useQuery({
queryKey: ['readiness', agentId],
queryFn: () => knowledgeReadiness(agentId),
// Processing and building finish on their own; keep the list current until they do.
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
})
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
return (
<div className="run-issues">
<strong>{t('sch.blockedBy')}</strong>
<ul>
{readiness.data.issues.map(issue => (
<li key={`${issue.kind}-${issue.resource_id}`}>
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
</li>
))}
</ul>
</div>
)
}