From 3d91459b4261fd3eeeb256cdadd6d6da6373fd4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:21:49 +0000 Subject: [PATCH] Dashboard: real metrics, "needs your attention" panel, next-step actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real numbers instead of placeholders: - OCR confidence card showed a hard-coded 99.4%; Tesseract's mean word confidence is now stored per document (ocr_text.mean_confidence, computed from the same image_to_data pass) and averaged, or "—" before any OCR - Archive card said "PDF/A-1b"; it now counts real archive packages and how many are PDF/A-2b vs standard PDF - GET /api/projects includes title, has_archive, archive_format and mean_confidence so the dashboard needs no extra requests What needs me next: - The large hero banner is replaced by a one-line status ("3 documents waiting on you") and the main actions; export moves into an Export menu - New panel: "Processing now" shows background OCR jobs with live progress and Stop (polled via useActiveJobs); "Up next" lists documents waiting on the user, oldest first, each with its next action - Every card and table row has an always-visible next-step button (Run OCR, Review text, Add metadata, Create archive, View archive); a running document shows its job progress instead - Tabs, counts and labels all derive from one helper (utils/workflow.js), so "metadata saved but not archived" is no longer shown as Archived - Upload page accepts ?project=: "Run OCR" continues that document (it used to open an empty upload form), and a running job reattaches to its live progress - Empty state explains the three steps instead of showing zeroed metrics Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jze3nDgZGqUXGceiu5RCAf --- backend/app/db.py | 3 + backend/app/routes/batch.py | 3 +- backend/app/routes/projects.py | 18 +- backend/app/services/ocr.py | 32 ++-- backend/app/services/ocr_tasks.py | 11 +- backend/tests/test_workflow.py | 8 + src/components/NeedsAttention.jsx | 124 ++++++++++++++ src/hooks/useActiveJobs.js | 48 ++++++ src/pages/Dashboard.css | 259 +++++++++++++++++++++++++++- src/pages/Dashboard.jsx | 269 ++++++++++++++++++------------ src/pages/UploadOCR.jsx | 45 ++++- src/utils/workflow.js | 28 ++++ 12 files changed, 721 insertions(+), 127 deletions(-) create mode 100644 src/components/NeedsAttention.jsx create mode 100644 src/hooks/useActiveJobs.js create mode 100644 src/utils/workflow.js diff --git a/backend/app/db.py b/backend/app/db.py index 2849280..536eee5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -71,6 +71,7 @@ def transaction(database_path=None): original_text TEXT, cleaned_text TEXT, confidence_data TEXT, + mean_confidence REAL, FOREIGN KEY (project_id) REFERENCES projects (id) ); @@ -155,6 +156,8 @@ def init_db(database_path=None): columns = {row['name'] for row in conn.execute('PRAGMA table_info(ocr_text)')} if 'confidence_data' not in columns: conn.execute('ALTER TABLE ocr_text ADD COLUMN confidence_data TEXT') + if 'mean_confidence' not in columns: + conn.execute('ALTER TABLE ocr_text ADD COLUMN mean_confidence REAL') file_columns = {row['name'] for row in conn.execute('PRAGMA table_info(files)')} if 'archive_format' not in file_columns: conn.execute('ALTER TABLE files ADD COLUMN archive_format TEXT') diff --git a/backend/app/routes/batch.py b/backend/app/routes/batch.py index e6f7707..bcb9629 100644 --- a/backend/app/routes/batch.py +++ b/backend/app/routes/batch.py @@ -81,7 +81,8 @@ def ocr_callback(project_id): if result.get('unsupported'): return False, result['text'] with transaction() as conn: - conn.execute('UPDATE ocr_text SET original_text = ? WHERE project_id = ?', (result['text'], project_id)) + conn.execute('UPDATE ocr_text SET original_text = ?, mean_confidence = ? WHERE project_id = ?', + (result['text'], result.get('mean_confidence'), project_id)) if result.get('ocr_pdf_path'): conn.execute('UPDATE files SET ocr_path = ? WHERE project_id = ?', (result['ocr_pdf_path'], project_id)) diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py index a022da3..9912c2a 100644 --- a/backend/app/routes/projects.py +++ b/backend/app/routes/projects.py @@ -28,9 +28,23 @@ def _first_existing(*paths): @bp.get('') def list_projects(): + # Includes what the dashboard needs to suggest each document's next step. with transaction() as conn: - rows = conn.execute('SELECT * FROM projects ORDER BY created_at DESC').fetchall() - return jsonify({'projects': [dict(r) for r in rows]}) + rows = conn.execute(''' + SELECT p.*, m.title, + f.final_path IS NOT NULL AS has_archive, f.archive_format, + o.mean_confidence + FROM projects p + LEFT JOIN metadata m ON m.project_id = p.id + LEFT JOIN files f ON f.project_id = p.id + LEFT JOIN ocr_text o ON o.project_id = p.id + ORDER BY p.created_at DESC''').fetchall() + projects = [] + for row in rows: + project = dict(row) + project['has_archive'] = bool(project['has_archive']) + projects.append(project) + return jsonify({'projects': projects}) @bp.post('') diff --git a/backend/app/services/ocr.py b/backend/app/services/ocr.py index 79842e9..833925f 100644 --- a/backend/app/services/ocr.py +++ b/backend/app/services/ocr.py @@ -41,12 +41,14 @@ def ocr_image(image, lang='eng'): """ OCR one image with a single Tesseract pass. - Returns (text, low_confidence_words). Text is rebuilt from word boxes with - line and paragraph breaks, which matches image_to_string output, so the - confidence data comes for free instead of costing a second OCR run. + Returns (text, low_confidence_words, mean_confidence, word_count). Text is + rebuilt from word boxes with line and paragraph breaks, which matches + image_to_string output, so the confidence data comes for free instead of + costing a second OCR run. """ data = pytesseract.image_to_data(image, lang=lang, output_type=pytesseract.Output.DICT) lines, order, low_conf = {}, [], [] + conf_total, conf_count = 0.0, 0 for i, word in enumerate(data['text']): if data['level'][i] != 5: continue @@ -59,6 +61,9 @@ def ocr_image(image, lang='eng'): order.append(key) lines[key].append(word) conf = float(data['conf'][i]) + if conf >= 0: + conf_total += conf + conf_count += 1 if 0 <= conf < LOW_CONFIDENCE_THRESHOLD: low_conf.append({'word': word, 'conf': conf}) @@ -75,7 +80,8 @@ def ocr_image(image, lang='eng'): retry = pytesseract.image_to_string(image, lang=lang, config=HANDWRITTEN_CONFIG) if len(retry.strip()) > len(text.strip()): text = retry - return text, low_conf + mean = conf_total / conf_count if conf_count else None + return text, low_conf, mean, conf_count def _searchable_pdf_path(filepath): @@ -90,6 +96,7 @@ def _extract_pdf_text_layer(doc): def _ocr_scanned_pdf(doc, filepath, lang, progress): """Render each page, OCR it and assemble a searchable (sandwich) PDF.""" texts, confidence = [], [] + conf_total, conf_count = 0.0, 0 merged = pymupdf.open() has_pdf_pages = False total = len(doc) @@ -104,7 +111,10 @@ def _ocr_scanned_pdf(doc, filepath, lang, progress): has_pdf_pages = True except Exception as e: current_app.logger.warning('Searchable PDF page %s failed: %s', page_idx + 1, e) - page_text, page_conf = ocr_image(img, lang) + page_text, page_conf, page_mean, page_words = ocr_image(img, lang) + if page_mean is not None: + conf_total += page_mean * page_words + conf_count += page_words for item in page_conf: item['page'] = page_idx + 1 confidence.extend(page_conf) @@ -116,7 +126,8 @@ def _ocr_scanned_pdf(doc, filepath, lang, progress): ocr_pdf_path = _searchable_pdf_path(filepath) merged.save(ocr_pdf_path, garbage=3, deflate=True) merged.close() - return '\n\n'.join(texts), confidence, ocr_pdf_path + mean = conf_total / conf_count if conf_count else None + return '\n\n'.join(texts), confidence, ocr_pdf_path, mean def _extract_pdf(filepath, lang, progress): @@ -144,14 +155,14 @@ def _extract_pdf(filepath, lang, progress): if not tesseract_available(): return {'text': text or TESSERACT_MISSING_MESSAGE, 'file_type': 'PDF (OCR unavailable)'} try: - ocr_text, confidence, ocr_pdf_path = _ocr_scanned_pdf(doc, filepath, lang, progress) + ocr_text, confidence, ocr_pdf_path, mean_conf = _ocr_scanned_pdf(doc, filepath, lang, progress) except Exception as e: current_app.logger.exception('Scanned PDF OCR failed') return {'text': text or f'No text found in PDF. OCR failed: {e}', 'file_type': 'PDF (OCR Failed)'} if ocr_text.strip(): return {'text': ocr_text, 'file_type': 'PDF (Scanned/OCR)', - 'confidence': confidence, 'ocr_pdf_path': ocr_pdf_path} + 'confidence': confidence, 'ocr_pdf_path': ocr_pdf_path, 'mean_confidence': mean_conf} return {'text': text or 'No text found in PDF (OCR produced no results).', 'file_type': 'PDF (Empty)', 'ocr_pdf_path': ocr_pdf_path} @@ -172,7 +183,7 @@ def _extract_image(filepath, lang, engine, progress): try: with Image.open(filepath) as image: image.load() - text, confidence = ocr_image(image, lang) + text, confidence, mean_conf, _words = ocr_image(image, lang) ocr_pdf_path = None try: pdf_bytes = pytesseract.image_to_pdf_or_hocr(image, extension='pdf', lang=lang) @@ -184,7 +195,8 @@ def _extract_image(filepath, lang, engine, progress): ocr_pdf_path = None if not text.strip(): text = 'No text detected in image. Please ensure the image contains readable text.' - return {'text': text, 'file_type': 'image', 'confidence': confidence, 'ocr_pdf_path': ocr_pdf_path} + return {'text': text, 'file_type': 'image', 'confidence': confidence, 'ocr_pdf_path': ocr_pdf_path, + 'mean_confidence': mean_conf} except Exception as e: message = f'Error extracting text from image: {e}' if 'tessdata' in str(e) or 'traineddata' in str(e): diff --git a/backend/app/services/ocr_tasks.py b/backend/app/services/ocr_tasks.py index 02ba6c1..e8ebae7 100644 --- a/backend/app/services/ocr_tasks.py +++ b/backend/app/services/ocr_tasks.py @@ -43,10 +43,12 @@ def _load_project_file(project_id): return project -def _save_ocr_result(project_id, text, confidence=None, ocr_pdf_path=None): +def _save_ocr_result(project_id, text, confidence=None, ocr_pdf_path=None, mean_confidence=None): + """mean_confidence is Tesseract's average word confidence (0-100), None when not measured.""" with transaction() as conn: - conn.execute('UPDATE ocr_text SET original_text = ?, confidence_data = ? WHERE project_id = ?', - (text, json.dumps(confidence) if confidence else None, project_id)) + conn.execute('UPDATE ocr_text SET original_text = ?, confidence_data = ?, mean_confidence = ? ' + 'WHERE project_id = ?', + (text, json.dumps(confidence) if confidence else None, mean_confidence, project_id)) if ocr_pdf_path: conn.execute('UPDATE files SET ocr_path = ? WHERE project_id = ?', (ocr_pdf_path, project_id)) set_status(conn, project_id, 'cleanup') @@ -60,7 +62,8 @@ def run_ocr(project_id, params, progress=no_progress): result = extract_document_text(project['filepath'], lang, engine, progress) progress(1, 1, 'Saving results') - _save_ocr_result(project_id, result['text'], result.get('confidence'), result.get('ocr_pdf_path')) + _save_ocr_result(project_id, result['text'], result.get('confidence'), result.get('ocr_pdf_path'), + result.get('mean_confidence')) return { 'success': True, 'pages': 1, diff --git a/backend/tests/test_workflow.py b/backend/tests/test_workflow.py index e03f9bb..f864df4 100644 --- a/backend/tests/test_workflow.py +++ b/backend/tests/test_workflow.py @@ -26,6 +26,10 @@ def test_image_full_workflow(client, app, tmp_path): assert data['files']['ocr_path'] and os.path.exists(data['files']['ocr_path']) # Searchable immediately after OCR, with the snippet taken from the content. + listed = client.get('/api/projects').get_json()['projects'][0] + assert 80 <= listed['mean_confidence'] <= 100 # real Tesseract word confidence + assert listed['has_archive'] is False and listed['archive_format'] is None + hits = client.get('/api/search?q=Library').get_json() assert hits['count'] == 1 and '' in hits['results'][0]['snippet'] @@ -53,6 +57,10 @@ def test_image_full_workflow(client, app, tmp_path): with fitz.open(final) as doc: assert doc.metadata['title'] == 'Annual Report' + listed = client.get('/api/projects').get_json()['projects'][0] + assert listed['has_archive'] is True and listed['archive_format'] == 'PDF/A-2b' + assert listed['title'] == 'Annual Report' + served = client.get(f'/api/projects/{pid}/file?type=pdf') assert served.status_code == 200 and served.data.startswith(b'%PDF') diff --git a/src/components/NeedsAttention.jsx b/src/components/NeedsAttention.jsx new file mode 100644 index 0000000..c42d629 --- /dev/null +++ b/src/components/NeedsAttention.jsx @@ -0,0 +1,124 @@ +import { useNavigate } from 'react-router-dom' +import { ArrowRight, CheckCircle2, Loader2, Square } from 'lucide-react' +import { displayTitle, nextStep } from '../utils/workflow' + +const UP_NEXT_LIMIT = 5 + +const JOB_LABELS = { + ocr: 'OCR', + advanced_ocr: 'Advanced OCR', + handwritten_to_pdf: 'Handwriting to PDF', +} + +/** + * "What needs me next": documents being processed right now, then the + * documents waiting on the user, oldest first. + */ +const NeedsAttention = ({ projects, jobs, onStopJob, onShowAll }) => { + const navigate = useNavigate() + const byId = new Map(projects.map(p => [p.id, p])) + const busy = new Set(jobs.map(job => job.project_id)) + + const waiting = projects + .filter(p => !busy.has(p.id) && nextStep(p).needsAction) + .sort((a, b) => new Date(a.updated_at) - new Date(b.updated_at)) + + if (jobs.length === 0 && waiting.length === 0) { + return ( +
+ + All caught up — every document is archived. +
+ ) + } + + return ( +
+ {jobs.length > 0 && ( +
+

Processing now

+
    + {jobs.map(job => { + const project = byId.get(job.project_id) + const pct = Math.round(job.progress || 0) + return ( +
  • +
  • + ) + })} +
+
+ )} + + {waiting.length > 0 && ( +
+

+ Up next {waiting.length} +

+
    + {waiting.slice(0, UP_NEXT_LIMIT).map(project => { + const step = nextStep(project) + return ( +
  • +
  • + ) + })} +
+ {waiting.length > UP_NEXT_LIMIT && ( + + )} +
+ )} +
+ ) +} + +export default NeedsAttention diff --git a/src/hooks/useActiveJobs.js b/src/hooks/useActiveJobs.js new file mode 100644 index 0000000..c5ac0af --- /dev/null +++ b/src/hooks/useActiveJobs.js @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react' +import axios from 'axios' +import { API_URL } from '../config' + +const FAST_POLL_MS = 1500 // while something is running +const IDLE_POLL_MS = 8000 // picks up jobs started from another page or window + +/** + * Background jobs that are queued or running, kept fresh by polling. + * onFinished() is called when a job leaves the active list, so callers can + * reload the documents it changed. + */ +export function useActiveJobs(onFinished) { + const [jobs, setJobs] = useState([]) + const onFinishedRef = useRef(onFinished) + onFinishedRef.current = onFinished + + useEffect(() => { + let cancelled = false + let timer + let previous = new Set() + + const poll = async () => { + let active = previous.size > 0 + try { + const { data } = await axios.get(`${API_URL}/jobs`, { params: { active: true } }) + if (cancelled) return + const ids = new Set(data.jobs.map(job => job.id)) + const finished = [...previous].some(id => !ids.has(id)) + previous = ids + active = ids.size > 0 + setJobs(data.jobs) + if (finished) onFinishedRef.current?.() + } catch { + // Backend unavailable: keep the last known list and retry. + } + if (!cancelled) timer = setTimeout(poll, active ? FAST_POLL_MS : IDLE_POLL_MS) + } + + poll() + return () => { + cancelled = true + clearTimeout(timer) + } + }, []) + + return jobs +} diff --git a/src/pages/Dashboard.css b/src/pages/Dashboard.css index 80b068c..917fee8 100644 --- a/src/pages/Dashboard.css +++ b/src/pages/Dashboard.css @@ -1110,4 +1110,261 @@ .dash-cards-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} +/* ============================================ + Compact header (replaces the large hero banner) + ============================================ */ +.dash-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + flex-wrap: wrap; +} + +.dash-header-text h1 { + font-size: 1.5rem; + font-weight: 700; + color: #ffffff; + margin: 0 0 2px 0; + letter-spacing: -0.02em; +} + +.dash-header-text .dash-summary { + margin: 0; + font-size: 1.15rem; + font-weight: 600; + color: #ffffff; +} + +.dash-header-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.btn-dash-secondary.btn-sm { + padding: 6px 12px; + font-size: 0.82rem; +} + +.btn-dash-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Export dropdown built on
, so it works without extra JS */ +.dash-menu { + position: relative; +} + +.dash-menu > summary { + list-style: none; +} + +.dash-menu > summary::-webkit-details-marker { + display: none; +} + +.dash-menu-list { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 30; + min-width: 250px; + display: flex; + flex-direction: column; + padding: 6px; + background: #1b1e24; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-md); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); +} + +.dash-menu-list a { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: #e2e8f0; + text-decoration: none; + font-size: 0.88rem; +} + +.dash-menu-list a span { + font-size: 0.78rem; + color: var(--color-text-tertiary); +} + +.dash-menu-list a:hover, +.dash-menu-list a:focus-visible { + background: rgba(59, 130, 246, 0.15); + outline: none; +} + +/* ============================================ + "Needs your attention" panel + ============================================ */ +.attention-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: var(--space-lg); + background: rgba(25, 27, 31, 0.85); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-lg); +} + +.attention-panel.attention-clear { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + color: var(--color-text-secondary); + font-size: 0.92rem; +} + +.attention-heading { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 var(--space-sm) 0; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-tertiary); +} + +.attention-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.attention-row { + display: flex; + align-items: center; + gap: var(--space-md); + padding: 10px 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.attention-row:first-child { + border-top: none; +} + +.attention-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.attention-title { + font-size: 0.92rem; + font-weight: 600; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.attention-hint { + font-size: 0.8rem; + color: var(--color-text-tertiary); +} + +.attention-progress { + height: 5px; + margin-top: 4px; + background: rgba(148, 163, 184, 0.18); + border-radius: 9999px; + overflow: hidden; +} + +.attention-progress-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #60a5fa); + border-radius: inherit; + transition: width 0.4s ease; +} + +.attention-pct { + min-width: 3ch; + font-size: 0.82rem; + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); + text-align: right; +} + +.attention-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + background: #60a5fa; +} + +.attention-dot.step-cleanup { background: #f59e0b; } +.attention-dot.step-metadata { background: #06b6d4; } +.attention-dot.step-archive { background: #10b981; } + +.link-like { + background: none; + border: none; + padding: 0; + cursor: pointer; + font: inherit; +} + +.attention-title.link-like:hover { + text-decoration: underline; +} + +.attention-more { + margin-top: var(--space-sm); + font-size: 0.84rem; + font-weight: 600; + color: #60a5fa; +} + +/* Always-visible next step on each document card */ +.card-next-step { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: var(--space-sm); + display: flex; + align-items: center; + min-height: 34px; +} + +.card-job-status { + font-size: 0.82rem; + color: #93c5fd; +} + +/* Empty state steps */ +.empty-hub-steps { + margin: 0 auto var(--space-md); + padding-left: 1.4em; + max-width: 520px; + text-align: left; + color: var(--color-text-secondary); + line-height: 1.7; +} + +@media (max-width: 720px) { + .attention-panel { + grid-template-columns: 1fr; + } + .dash-header-actions { + width: 100%; + } +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 9f244e9..728aa77 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -17,14 +17,18 @@ import { X, Layers, Sparkles, - ShieldCheck, FolderOpen, ArrowRight, SlidersHorizontal, BookOpen, - Download + Download, + ChevronDown } from 'lucide-react' import Modal from '../components/Modal' +import NeedsAttention from '../components/NeedsAttention' +import { useActiveJobs } from '../hooks/useActiveJobs' +import { cancelJob } from '../api/jobs' +import { displayTitle, nextStep } from '../utils/workflow' import { useToast } from '../context/ToastContext' import { API_URL } from '../config' @@ -71,7 +75,10 @@ const Dashboard = () => { const closePreview = () => setPreviewProject(null) - const getStatusInfo = (status) => { + const getStatusInfo = (status, hasArchive = true) => { + if (status === 'archived' && !hasArchive) { + return { label: 'Ready to Archive', color: 'info', icon: SlidersHorizontal, step: '4/5' } + } const statusMap = { upload: { label: 'Uploaded', color: 'primary', icon: FileText, step: '1/5' }, ocr: { label: 'OCR Processing', color: 'primary', icon: Clock, step: '2/5' }, @@ -82,35 +89,29 @@ const Dashboard = () => { return statusMap[status] || statusMap.upload } - const getProgressPercentage = (status) => { - const progressMap = { - upload: 20, - ocr: 40, - cleanup: 60, - metadata: 80, - archived: 100 + + // Background OCR jobs; reload documents when one finishes. + const jobs = useActiveJobs(fetchProjects) + const jobByProject = useMemo(() => new Map(jobs.map(job => [job.project_id, job])), [jobs]) + + // A document with a running job opens its progress view; otherwise its next step. + const handleProjectClick = (project) => navigate( + jobByProject.has(project.id) ? `/upload?project=${project.id}` : nextStep(project).path + ) + + const handleStopJob = async (job) => { + try { + await cancelJob(job.id) + addToast('Stopping after the current page…', 'info') + } catch { + addToast('Could not stop processing. Please try again.', 'error') } - return progressMap[status] || 0 } - const handleProjectClick = (project) => { - switch (project.status) { - case 'upload': - case 'ocr': - navigate('/upload') - break - case 'cleanup': - navigate(`/cleanup/${project.id}`) - break - case 'metadata': - navigate(`/metadata/${project.id}`) - break - case 'archived': - navigate(`/archive/${project.id}`) - break - default: - navigate('/upload') - } + const showWaiting = () => { + setStatusFilter('waiting') + setCurrentPage(1) + document.querySelector('.dashboard-funnel-bar')?.scrollIntoView({ behavior: 'smooth' }) } const handleDelete = (e, projectId) => { @@ -134,38 +135,53 @@ const Dashboard = () => { // Dynamic Counts for Funnel const counts = useMemo(() => { - const c = { all: projects.length, inProgress: 0, cleanup: 0, metadata: 0, archived: 0 } - projects.forEach(p => { - if (p.status === 'upload' || p.status === 'ocr') c.inProgress++ - else if (p.status === 'cleanup') c.cleanup++ - else if (p.status === 'metadata') c.metadata++ - else if (p.status === 'archived') c.archived++ - }) + // Keyed by workflow step so tabs, cards and the attention panel agree. + const c = { all: projects.length, ocr: 0, cleanup: 0, metadata: 0, archive: 0, done: 0 } + projects.forEach(p => { c[nextStep(p).key]++ }) return c }, [projects]) + // Real figures for the summary cards (no placeholders). + const stats = useMemo(() => { + const archives = projects.filter(p => p.has_archive) + const measured = projects.filter(p => typeof p.mean_confidence === 'number') + const avgConfidence = measured.length + ? measured.reduce((sum, p) => sum + p.mean_confidence, 0) / measured.length + : null + const waitingProjects = projects.filter(p => nextStep(p).needsAction && !jobByProject.has(p.id)) + const waitingBy = { ocr: 0, cleanup: 0, metadata: 0, archive: 0 } + waitingProjects.forEach(p => { waitingBy[nextStep(p).key]++ }) + const waiting = waitingProjects.length + return { + archives: archives.length, + pdfa: archives.filter(p => p.archive_format === 'PDF/A-2b').length, + avgConfidence, + measured: measured.length, + waiting, + waitingBy, + } + }, [projects, jobByProject]) + // Filter, Sort, and Pagination Logic const filteredProjects = useMemo(() => { return projects .filter(project => { - const matchesSearch = project.filename.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesSearch = displayTitle(project).toLowerCase().includes(searchQuery.toLowerCase()) + || project.filename.toLowerCase().includes(searchQuery.toLowerCase()) if (!matchesSearch) return false if (statusFilter === 'all') return true - if (statusFilter === 'inProgress') return project.status === 'upload' || project.status === 'ocr' - if (statusFilter === 'cleanup') return project.status === 'cleanup' - if (statusFilter === 'metadata') return project.status === 'metadata' - if (statusFilter === 'archived') return project.status === 'archived' - return true + if (statusFilter === 'waiting') return nextStep(project).needsAction && !jobByProject.has(project.id) + return nextStep(project).key === statusFilter }) .sort((a, b) => { if (sortBy === 'newest') return new Date(b.created_at) - new Date(a.created_at) if (sortBy === 'oldest') return new Date(a.created_at) - new Date(b.created_at) - if (sortBy === 'progress') return getProgressPercentage(b.status) - getProgressPercentage(a.status) + if (sortBy === 'progress') return nextStep(b).progress - nextStep(a).progress if (sortBy === 'name') return a.filename.localeCompare(b.filename) return 0 }) - }, [projects, searchQuery, statusFilter, sortBy]) + }, [projects, searchQuery, statusFilter, sortBy, jobByProject]) const totalPages = Math.ceil(filteredProjects.length / itemsPerPage) || 1 const currentProjects = filteredProjects.slice( @@ -200,18 +216,18 @@ const Dashboard = () => { )} - {/* Top Welcome & Health Banner */} -
-
-
- - 100% Local Sovereignty • Air-Gapped Engine Active -
-

Archival Digitization Command Center

-

Ingest physical records, run neural OCR, generate Dublin Core metadata, and preserve historical documents locally.

+ {/* Compact header: what this is and the main actions */} +
+
+

+ {projects.length === 0 + ? 'Start by uploading a scanned document.' + : stats.waiting > 0 + ? `${stats.waiting} document${stats.waiting === 1 ? '' : 's'} waiting on you` + : 'Everything is archived.'} +

- -
+
- - - Export Catalogue - - - XML - +
+ + + Export + + + +
+ {projects.length > 0 && ( + + )} + {/* Real-time Metric Cards Deck */} + {projects.length > 0 && ( + <>
@@ -259,23 +286,27 @@ const Dashboard = () => {
- Active Digitization Queue + Waiting On You
-
{counts.inProgress + counts.cleanup + counts.metadata}
+
{stats.waiting}
- {counts.cleanup} cleanup • {counts.metadata} metadata + {stats.waitingBy.ocr} OCR • {stats.waitingBy.cleanup} review • {stats.waitingBy.metadata} metadata • {stats.waitingBy.archive} to archive
- Archived & Published + Archive Packages
-
{counts.archived}
+
{stats.archives}
- PDF/A-1b & Dublin Core ready + + {stats.archives === 0 + ? 'None created yet' + : `${stats.pdfa} PDF/A-2b · ${stats.archives - stats.pdfa} standard PDF`} +
@@ -284,9 +315,13 @@ const Dashboard = () => { OCR Confidence Average
-
99.4%
+
{stats.avgConfidence === null ? '—' : `${stats.avgConfidence.toFixed(1)}%`}
- Dual-pass neural accuracy + + {stats.measured === 0 + ? 'Shown after the first OCR run' + : `Tesseract word confidence, ${stats.measured} document${stats.measured === 1 ? '' : 's'}`} +
@@ -302,17 +337,17 @@ const Dashboard = () => { {counts.all} +
@@ -387,14 +429,21 @@ const Dashboard = () => {
+ + )} + {/* Main Content Area: Empty State or Grid / Table */} {projects.length === 0 ? (
-

No Records Ingested Yet

-

Your sovereign local archive is ready. Drag and drop scanned manuscripts, historical charters, or PDFs to start digitizing.

+

Digitize your first document

+
    +
  1. Upload a scan or PDF. Text is extracted on this computer.
  2. +
  3. Review the recognised text and fix any errors.
  4. +
  5. Describe and archive: add title, author and year, then create a PDF/A archive package.
  6. +
+ )}
) @@ -514,9 +571,9 @@ const Dashboard = () => { {currentProjects.map((project) => { - const statusInfo = getStatusInfo(project.status) + const statusInfo = getStatusInfo(project.status, project.has_archive) const StatusIcon = statusInfo.icon - const progress = getProgressPercentage(project.status) + const progress = nextStep(project).progress return ( {
- {project.filename} + {displayTitle(project)} ID: #{String(project.id || '')}
@@ -593,7 +650,7 @@ const Dashboard = () => { handleProjectClick(project) }} > - Open + {jobByProject.has(project.id) ? 'View progress' : nextStep(project).label} diff --git a/src/pages/UploadOCR.jsx b/src/pages/UploadOCR.jsx index c674133..67f303c 100644 --- a/src/pages/UploadOCR.jsx +++ b/src/pages/UploadOCR.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useProject } from '../context/ProjectContext' import { useToast } from '../context/ToastContext' import { @@ -18,11 +18,12 @@ import { } from 'lucide-react' import AdvancedOCRResults from '../components/AdvancedOCRResults' import { API_URL } from '../config' -import { cancelJob, JobCancelledError } from '../api/jobs' +import { cancelJob, findActiveJob, waitForJob, JobCancelledError } from '../api/jobs' const UploadOCR = () => { const navigate = useNavigate() - const { createProject, runOCR, runAdvancedOCR, convertHandwrittenToPDF, deleteProject, setError } = useProject() + const { createProject, getProject, runOCR, runAdvancedOCR, convertHandwrittenToPDF, deleteProject, setError } = useProject() + const [searchParams] = useSearchParams() const { addToast } = useToast() const [file, setFile] = useState(null) @@ -111,6 +112,44 @@ const UploadOCR = () => { const jobOptions = { onStart: setJob, onProgress: setJob } + // Opened from the dashboard (/upload?project=): continue that document, + // and if OCR is already running for it, show its live progress. + const resumeProjectId = searchParams.get('project') + useEffect(() => { + if (!resumeProjectId) return undefined + const controller = new AbortController() + ;(async () => { + let project + try { + project = await getProject(resumeProjectId) + } catch { + addToast('That document could not be found.', 'error') + return + } + if (controller.signal.aborted) return + setCurrentProject(project) + const active = await findActiveJob(project.id).catch(() => null) + if (!active || controller.signal.aborted) return + setProcessing(true) + setJob(active) + try { + const result = await waitForJob(active.id, { onProgress: setJob, signal: controller.signal }) + setOcrResult(result) + addToast('Neural OCR extraction complete!', 'success') + getProject(project.id).catch(() => {}) + } catch (err) { + if (err.name === 'AbortError') return + addToast(err instanceof JobCancelledError ? 'OCR cancelled.' : err.message, err instanceof JobCancelledError ? 'info' : 'error') + } + if (!controller.signal.aborted) { + setProcessing(false) + setJob(null) + setCancelling(false) + } + })() + return () => controller.abort() + }, [resumeProjectId]) + const finishJob = () => { setProcessing(false) setJob(null) diff --git a/src/utils/workflow.js b/src/utils/workflow.js new file mode 100644 index 0000000..053d400 --- /dev/null +++ b/src/utils/workflow.js @@ -0,0 +1,28 @@ +/** + * Where a document is in the digitization workflow and what the user should + * do next. Shared by the dashboard cards, table and "Needs attention" panel. + * + * Note: saving metadata sets status 'archived' before the archive package is + * generated, so `has_archive` (from GET /api/projects) decides the last step. + */ +export function nextStep(project) { + const id = project.id + switch (project.status) { + case 'upload': + case 'ocr': + return { key: 'ocr', label: 'Run OCR', hint: 'Uploaded, text not extracted yet', path: `/upload?project=${id}`, needsAction: true, progress: 20 } + case 'cleanup': + return { key: 'cleanup', label: 'Review text', hint: 'OCR done, check the text', path: `/cleanup/${id}`, needsAction: true, progress: 50 } + case 'metadata': + return { key: 'metadata', label: 'Add metadata', hint: 'Text reviewed, describe the document', path: `/metadata/${id}`, needsAction: true, progress: 70 } + case 'archived': + if (!project.has_archive) { + return { key: 'archive', label: 'Create archive', hint: 'Metadata saved, package not built yet', path: `/archive/${id}`, needsAction: true, progress: 90 } + } + return { key: 'done', label: 'View archive', hint: project.archive_format || 'Archived', path: `/archive/${id}`, needsAction: false, progress: 100 } + default: + return { key: 'ocr', label: 'Run OCR', hint: 'Uploaded', path: `/upload?project=${id}`, needsAction: true, progress: 20 } + } +} + +export const displayTitle = (project) => project.title || project.filename