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:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

194
src/pages/Instruction.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page'
import {
dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type ResearchRule, type Rule,
} from '../express/parse'
import { t, type Key } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
// How the kinds that are not a plain Compose output are made, shown under the rule.
const KIND_NOTE: Partial<Record<ContentKind, Key>> = { video: 'ins.noteVideo', audio: 'ins.noteAudio', scorm: 'ins.noteScorm' }
type Outcome = { rule: Rule; ok: boolean; message?: string }
export function InstructionPage() {
const queryClient = useQueryClient()
const [text, setText] = useState('')
const [rules, setRules] = useState<Rule[] | null>(null)
const [unknown, setUnknown] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null)
function compose() {
const result = parseInstruction(text)
setRules(result.rules)
setUnknown(result.unknown)
setOutcomes(null)
}
// A count or frequency typed in by hand replaces the monthly request it was derived from.
const update = (i: number, patch: Partial<ContentRule> | Partial<ResearchRule>) =>
setRules(rs => rs!.map((r, j) => (j === i
? ({ ...r, ...patch, ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}) } as Rule)
: r)))
const remove = (i: number) => setRules(rs => rs!.filter((_, j) => j !== i))
const ready = rules?.filter(canSchedule) ?? []
async function approve() {
setSaving(true)
const done: Outcome[] = []
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
for (const rule of ready) {
try {
await createSchedule(rule)
done.push({ rule, ok: true })
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
setOutcomes([...done])
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
if (done.every(o => o.ok)) { setRules(null); setText('') }
// Only the failed rules stay, so approving again does not create the others twice.
else setRules(rs => rs!.filter(r => !done.some(o => o.ok && o.rule === r)))
}
return (
<>
<PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="card">
<div className="card-head"><h3>{t('ins.cardTitle')}</h3><Icon name="spark" /></div>
<div style={{ padding: '20px 22px 8px' }}>
<label htmlFor="instruction">{t('ins.label')}</label>
<textarea id="instruction" rows={3} value={text} placeholder={t('ins.placeholder')}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) compose() }} />
<div className="ai-compose-footer">
<small>{t('ins.hint')}</small>
<button className="btn primary" disabled={!text.trim()} onClick={compose}><Icon name="calendar" />{t('ins.compose')}</button>
</div>
</div>
<div className="prompt-chips" style={{ paddingTop: 12 }}>
{EXAMPLES.map(key => <button key={key} onClick={() => { setText(t(key)); setRules(null); setOutcomes(null) }}>{t(key)}</button>)}
</div>
</div>
{rules && (
<div className="card" style={{ marginTop: 22 }}>
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{rules.length === 0 && (
<div className="empty"><h3>{t('ins.notUnderstood')}</h3><p>{t('ins.notUnderstoodCopy')}</p></div>
)}
<div className="rule-list">
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)}
</div>
{unknown.length > 0 && (
<div className="notice-row" style={{ margin: '0 22px 18px' }}>
<Icon name="info" /><span>{t('ins.skipped', { parts: unknown.map(u => `“${u}”`).join(', ') })}</span>
</div>
)}
{ready.length > 0 && (
<div className="rule-approve">
<small>{t('ins.approveNote')}</small>
<button className="btn primary" disabled={saving} onClick={approve}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
{outcomes && (
<ul className="rule-outcomes">
{outcomes.map((o, i) => (
<li key={i}>
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
<span>{describeRule(o.rule)}</span>
{o.message && <span className="error">{o.message}</span>}
</li>
))}
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
</ul>
)}
</div>
)}
</>
)
}
function RuleCard({ rule, onChange, onRemove }: {
rule: Rule
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
onRemove: () => void
}) {
const video = rule.type === 'content' && rule.kind === 'video'
const badCadence = video && videoCadence(rule) === null
const tooMany = rule.type === 'content' && !video && weeklyTarget(rule) > 50
// A video's credit limit follows today's price of one video.
const quote = useQuery({ queryKey: ['video-quote'], queryFn: videoQuote, enabled: video, staleTime: 5 * 60_000 })
const budget = canSchedule(rule) && (!video || quote.data) ? budgetFor(rule, quote.data?.max_credits) : null
return (
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div className="rule-body">
<b>{describeRule(rule)}</b>
<div className="rule-fields">
{rule.type === 'content' ? (
<>
<select aria-label={t('ins.kind')} value={rule.kind} onChange={e => onChange({ kind: e.target.value as ContentKind })}>
{KINDS.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
</select>
<input aria-label={t('ins.count')} type="number" min={1} max={50} value={rule.count}
onChange={e => onChange({ count: Math.max(1, Number(e.target.value) || 1) })} />
<select aria-label={t('ins.frequency')} value={rule.per} onChange={e => onChange({ per: e.target.value as ContentRule['per'] })}>
<option value="day">{t('ins.perDay')}</option>
<option value="week">{t('ins.perWeek')}</option>
</select>
</>
) : (
<>
<select aria-label={t('ins.researchFrequency')} value={rule.cadence} onChange={e => onChange({ cadence: e.target.value as ResearchRule['cadence'] })}>
<option value="daily">{t('ins.everyDay')}</option>
<option value="weekly">{t('ins.everyWeek')}</option>
</select>
{rule.cadence === 'weekly' && (
<select aria-label={t('ins.weekday')} value={rule.weekday} onChange={e => onChange({ weekday: Number(e.target.value) })}>
{[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{dayName(d)}</option>)}
</select>
)}
</>
)}
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t('ins.topicPlaceholder')}
onChange={e => onChange({ topic: e.target.value })} />
</div>
{rule.type === 'research' && (
<label className="rule-check">
<input type="checkbox" checked={rule.autoApprove} onChange={e => onChange({ autoApprove: e.target.checked })} />
{t('ins.autoApprove')}
</label>
)}
{rule.type === 'content' && KIND_NOTE[rule.kind] && <small className="rule-note">{t(KIND_NOTE[rule.kind]!, { seconds: VIDEO.seconds, scenes: VIDEO.scenes })}</small>}
{badCadence && <small className="error">{t('ins.videoCadence')} {t('ins.notCreated')}</small>}
{video && quote.isError && <small className="error">{t('ins.videoQuoteFailed', { message: quote.error.message })}</small>}
{tooMany && <small className="error">{t('ins.tooMany')}</small>}
{rule.type === 'content' && rule.monthly && <small className="rule-note">{monthNote(rule)}</small>}
{budget && (
<small className="rule-note">
{t('ins.budget', {
first: budget.weekly ? t('ins.budgetWeekly', { n: budget.weekly }) : t(video ? 'ins.budgetVideo' : 'ins.budgetRun', { n: budget.run }),
monthly: budget.monthly,
})}
</small>
)}
</div>
<button className="icon-btn" aria-label={t('ins.removeRule')} onClick={onRemove}><Icon name="trash" /></button>
</div>
)
}