diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 8212a09..c745239 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -122,6 +122,32 @@ def list_user_jobs(current_user: User = Depends(get_current_user)): return jobs +@router.delete("/jobs/{job_id}", status_code=204) +def forget_job(job_id: str, current_user: User = Depends(get_current_user)): + """Take one job off this person's list. + + A job is a record of work, not the work itself: removing it stops nothing + that is running and deletes nothing it produced. It exists because the list + is a list of what happened, and one somebody has read and dealt with is + clutter in front of the ones they have not. + + Only from your own list — the id alone is not authority to touch anybody + else's, and the keys behind it are shared. + """ + import redis as redis_lib + from app.config import settings + + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + key = f"extraction:user_jobs:{current_user.id}" + if not r.lrem(key, 0, job_id): + raise HTTPException(404, "That job is not in your list") + # The steps and status go with it. They are keyed by job id and nothing + # else points at them once the list entry is gone, so leaving them would + # simply be rubbish with an hour to live. + for suffix in ("status", "steps", "error", "job_title", "batch_id", "quiz_id", "article"): + r.delete(f"extraction:{suffix}:{job_id}") + + @router.get("/job/{job_id}") def get_extraction_job(job_id: str, current_user: User = Depends(require_moderator)): """Poll extraction job progress. Returns the steps, the status, and — when it diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 5a6a36e..023a08c 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -93,76 +93,6 @@ function FeedbackBadge() { } -function JobsBadge({ jobs }) { - const [open, setOpen] = useState(false) - const wrap = useRef(null) - const allJobs = jobs - const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending') - - // Anywhere outside closes it, and so does Escape. It only closed when a job - // in it was clicked, so a panel opened to check on something then sat over - // the page until you found the badge again. - useEffect(() => { - if (!open) return undefined - const away = event => { if (!wrap.current?.contains(event.target)) setOpen(false) } - const onKey = event => { if (event.key === 'Escape') setOpen(false) } - document.addEventListener('mousedown', away) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', away) - document.removeEventListener('keydown', onKey) - } - }, [open]) - - if (allJobs.length === 0) return null - - const running = activeJobs.length - return ( -
- - {open && ( -
-
- Extractions -
- {allJobs.slice(0, 5).map(job => ( -
-
- {job.title} - - {job.status === 'running' ? `${job.steps_count} steps` : job.status} - -
-
{job.last_step || 'Waiting…'}
- {job.status === 'completed' && job.quiz_id && ( - setOpen(false)}>Open Quiz → - )} -
- ))} - {allJobs.length === 0 &&
No recent extractions
} -
- )} -
- ) -} - /** * You, rather than the material: dashboard, account, settings, sign out. * @@ -231,7 +161,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { const [peek, setPeek] = useState(false) // Registered by the quiz player while it is on screen without a rail. const sessionDrawer = useSessionDrawer() - const [jobs, setJobs] = useState([]) const location = useLocation() const inSession = useInSession() const isModerator = user?.role === 'admin' || user?.role === 'moderator' @@ -249,24 +178,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { .catch(() => setCanManageQuestions(false)) }, [user, isModerator]) - // Single job-polling instance for the whole navbar - useEffect(() => { - if (!user) return - let interval - const load = async () => { - try { - const res = await api.get('/quizzes/jobs') - const jobList = res.data || [] - setJobs(jobList) - const running = jobList.filter(j => j.status === 'running' || j.status === 'pending') - clearInterval(interval) - interval = setInterval(load, running.length > 0 ? 3000 : 30000) - } catch { } - } - load() - interval = setInterval(load, 30000) - return () => clearInterval(interval) - }, [user]) + // No job badge here any more. Extraction is workbench business — a handful + // of people ever start one — and it sat in the header of every page, for + // everybody, polling every thirty seconds for a number most of them had no + // use for. It lives on the Extraction jobs page, where the logs are. // Where you go to study. Home is the signed-out landing page, so it is not @@ -333,7 +248,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { {user ? (
-
diff --git a/frontend/src/pages/FlashcardStudyPage.css b/frontend/src/pages/FlashcardStudyPage.css index f210b42..9d334a0 100644 --- a/frontend/src/pages/FlashcardStudyPage.css +++ b/frontend/src/pages/FlashcardStudyPage.css @@ -38,7 +38,8 @@ body:has(.fc-study) .site-footer { display: none; } /* The deck's own bar: what you are studying and how far in. Fixed, like the player's — the counter leaving the screen exactly when a long card makes you want it is the thing this prevents. */ -.fc-head { flex: none; padding: 14px 0 10px; border-bottom: 1px solid var(--border); } +.fc-head { flex: none; padding: 10px 0; border-bottom: 1px solid var(--border); } +.fc-head .articles-back { display: inline-block; margin-bottom: 6px; } .fc-head-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; @@ -72,7 +73,11 @@ body:has(.fc-study) .site-footer { display: none; } padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); } -.fc-foot-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; } +/* Full-size, not the small variant. These four are the whole interaction — + read, judge, move on — and they were the smallest controls on a screen with + nothing else competing for the space. */ +.fc-foot-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; } +.fc-foot-nav .btn { min-width: 132px; padding: 12px 22px; font-size: .95rem; font-weight: 600; } .fc-keys { flex: none; margin: 0; font-size: .74rem; color: var(--text-subtle); } .fc-known { background: #22c55e; border-color: #22c55e; color: #fff; } diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx index 7ec9b48..1845fb1 100644 --- a/frontend/src/pages/FlashcardStudyPage.jsx +++ b/frontend/src/pages/FlashcardStudyPage.jsx @@ -154,6 +154,10 @@ export default function FlashcardStudyPage() { counter that scrolls away with the content is a counter that leaves exactly when a long card makes you want it. */}
+ {/* The way out, above the title, like every other page — not a small + grey button in a bar whose other controls are about the card in + front of you. */} + ← Cards

{deck.title}

@@ -239,7 +243,6 @@ export default function FlashcardStudyPage() { No Exit session — a deck has no block to hand in, and the controls that change the run are at the top where they belong. */}
- ← All decks
{flipped ? ( diff --git a/frontend/src/pages/JobsPage.jsx b/frontend/src/pages/JobsPage.jsx index 3c92408..a894ee3 100644 --- a/frontend/src/pages/JobsPage.jsx +++ b/frontend/src/pages/JobsPage.jsx @@ -5,9 +5,10 @@ import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' import BackLink from '../components/BackLink' -function JobDetail({ job }) { +function JobDetail({ job, onForget }) { const [steps, setSteps] = useState([]) const [expanded, setExpanded] = useState(false) + const [forgetting, setForgetting] = useState(false) const [quizData, setQuizData] = useState(null) const { dialogProps, openAlert } = useDialog() const pollRef = useRef(null) @@ -80,8 +81,22 @@ function JobDetail({ job }) { }}>Cancel )} + {/* Off the list, not undone. A job is a record of work: forgetting + one stops nothing that is running and deletes nothing it made — + it clears a line somebody has already read and dealt with, so the + ones they have not are not buried under it. */} + {job.status !== 'running' && ( + forgetting ? ( + <> + + + + ) : ( + + ) + )}
@@ -154,22 +169,34 @@ export default function JobsPage() { .finally(() => setLoading(false)) }, []) + const forget = async (jobId) => { + try { + await api.delete(`/quizzes/jobs/${jobId}`) + setJobs(rows => rows.filter(row => row.job_id !== jobId)) + } catch { /* the row stays, which is the honest signal */ } + } + if (loading) return
return (
Tools
-

Extraction Jobs

+

Jobs

- History of quiz extractions — including skipped questions and chunk details. + Everything the machine has been asked to do for you — extractions, AI + drafts, card generation. Open the log to see what it did, step by + step, and why it stopped if it stopped. Jobs age out of this list on + their own after a day.

{jobs.length === 0 ? (
No extractions yet.
) : ( - jobs.map(job => ) + jobs.map(job => ( + + )) )}
) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index b35b34c..635aacc 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -253,7 +253,7 @@ function ToolsSection() { how somebody ends up in Settings wondering how to get back. */ { to: '/access', icon: '🔑', label: 'Access', desc: 'Who may edit what' }, { to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted questions' }, - { to: '/jobs', icon: '📋', label: 'Extraction jobs', desc: 'Extraction history' }, + { to: '/jobs', icon: '📋', label: 'Jobs', desc: 'Extractions, drafts and card runs, with their logs' }, ].map(item => ( diff --git a/frontend/src/pages/ToolsPage.css b/frontend/src/pages/ToolsPage.css index 07e5cba..c15affe 100644 --- a/frontend/src/pages/ToolsPage.css +++ b/frontend/src/pages/ToolsPage.css @@ -58,3 +58,11 @@ shows, so it brings its own layout; this only gives it room to breathe and somewhere to scroll when the list is long. */ .tools-labs { margin-top: 14px; max-height: 60vh; overflow-y: auto; } + +/* Where the jobs this page starts end up. Beside the heading, because it is a + destination rather than part of the trail back out. */ +.tools-jobs-link { + float: right; margin-top: -34px; + font-size: .85rem; font-weight: 600; color: var(--primary); text-decoration: none; +} +.tools-jobs-link:hover { text-decoration: underline; } diff --git a/frontend/src/pages/ToolsPage.jsx b/frontend/src/pages/ToolsPage.jsx index aea8ec1..871abd6 100644 --- a/frontend/src/pages/ToolsPage.jsx +++ b/frontend/src/pages/ToolsPage.jsx @@ -58,6 +58,10 @@ export default function ToolsPage() { looks like a different application. */} Questions

Tools

+ {/* Where a job started here ends up. It used to be a badge in the site + header, on every page, for everybody — polling every thirty seconds + for a number almost nobody had a use for. */} + Jobs and logs →

A document goes in, a model proposes questions, you read them, and the ones worth keeping move into the bank. Nothing is in the bank until