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(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 ( <> {t('sch.newInstruction')}} /> {mixed && (
)}

{t('sch.cardTitle')}

{schedules.isPending && } {schedules.isError && {schedules.error.message}} {all?.length === 0 && {t('sch.emptyCopy')}} {mixed && shown?.length === 0 && {t('sch.emptyMineCopy')}} {!!shown?.length &&
{shown.map(s => )}
}
) } 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 (
{describeRule(rule)} {owners &&
{createdBy(schedule, owners)}
}
{production ? : t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}
{editing && (
{ e.preventDefault(); retopic.mutate() }}> setTopic(e.target.value)} />
)} {error && {error.message}}
{active ? t('sch.active') : t('sch.paused')}
{!editing && } {!production && active && } {production && active && !producing && }
{production && !producing && } {producing && production && setProducing(false)} />} {open && }
) } // Content schedules report this week's progress from the engine's production period. Each row // asks for its own, so it is read once a minute; starting a production refreshes it at once. const useProduction = (agentId: number) => useQuery({ queryKey: ['production', agentId], queryFn: () => productionStatus(agentId), refetchInterval: 60_000, staleTime: 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 {t('sch.planningOff')} } 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 (
{!finished &&
{plan.status === 'failed' ? ( {t('prod.failed', { code: plan.error_code ?? '–' })} ) : (
    {steps.map((label, i) => (
  1. {label}
  2. ))}
)} {!!total && plan.status !== 'planning' &&
} {(failed > 0 || (shortfall > 0 && plan.status !== 'planning')) && ( {[failed ? t('prod.someFailed', { n: failed }) : '', shortfall ? t('prod.shortfall', { n: shortfall }) : ''].filter(Boolean).join(' ')} )} {(ready > 0 || plan.status === 'running') && {t('prod.viewResults')} }
) } 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
return (
{running ? (

{t('sch.alreadyProducing')}

) : remaining === 0 ? (

{t('sch.weekFull')}

) : quote.data ? (

{t('sch.produceQuote', { n: quote.data.target, credits: quote.data.planning_quote.credits ?? 0, budget: agent.content_goal?.weekly_budget ?? 0 })}

) : ( )} {error && {productionError(error.message)}}
{!running && remaining > 0 && (quote.data ? : )}
) } 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
if (runs.isError) return
{runs.error.message}
if (!runs.data.length) return
{t('sch.neverRan')}
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 (
    {shown.map(r => (
  • {fmt(r.created_at)} {r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')} {runStatus(r.status)} {r.budget_spent != null && {t('common.credits', { n: r.budget_spent })}} {r.safe_error_code && {runError(r.safe_error_code)}} {r.source_outcome === 'no_new_sources' && {t('sch.noNewSources')}} {r.id === blockedId && }
  • ))}
) } 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
if (readiness.isError) return
{readiness.error.message}
if (!readiness.data.issues.length) return
{t('sch.blockedNone')}
return (
{t('sch.blockedBy')}
    {readiness.data.issues.map(issue => (
  • {issue.title} · {issueReason(issue.reason)} {t('sch.fixInStudio')}
  • ))}
) }