diff --git a/backend/inbox/mailbox_sync_tasks.py b/backend/inbox/mailbox_sync_tasks.py index e4f90d9..d574df0 100644 --- a/backend/inbox/mailbox_sync_tasks.py +++ b/backend/inbox/mailbox_sync_tasks.py @@ -69,9 +69,26 @@ async def sync_mailbox(run_id:str) -> dict: service=Email(session=session) if not service.token: return await _fail(run_id,"EMAIL_API_TOKEN is not configured") + + async def on_progress(processed,expected,entries): + ingested=sum(1 for e in entries if e.get("status") in ("ingested","known")) + skipped=sum(1 for e in entries if e.get("status")=="skipped") + errors=sum(1 for e in entries if e.get("status")=="error") + await MailboxSyncRun.update_run(session,run_id,{ + "entries":list(entries), + "triage":{ + "expected":expected, + "processed":processed, + "ingested":ingested, + "skipped":skipped, + "errors":errors, + "total":expected, + }, + }) + try: summary=await service.run_mailbox_sync_page( - top=top,skip=skip,test_on=test_on, + top=top,skip=skip,test_on=test_on,on_progress=on_progress, ) except Exception as e: logger.exception("mailbox sync failed for run %s",run_id) diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 956da03..c569990 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -356,13 +356,18 @@ class Email: raise HTTPException(status_code=404,detail="No sync runs yet") return serialize_mailbox_sync_run(row) - async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True): + async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True,on_progress=None): """Pull one Outlook page, triage, ingest, enqueue matching. Returns summary. Shared by the legacy synchronous /email/fetch and the background Taskiq worker. + `on_progress(processed, expected, entries)` is optional; the mailbox_sync + worker uses it so the Sync button can poll a live percentage. """ data=await self.service_email(top,skip) value=data.get("value") or [] + expected=len(value) + if on_progress: + await on_progress(0,expected,[]) decisions=await self.triage_round([item.get("id") for item in value]) entries=[] for item in value: @@ -398,6 +403,8 @@ class Email: "triage_status":"error", }) self.triage_errors.append(str(message_id)) + if on_progress: + await on_progress(len(entries),expected,entries) if self.pending_match_ids: await self.enqueue_matching(list(self.pending_match_ids),force=False) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index e15ce4e..9657137 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -15,6 +15,7 @@ import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import OpenResumeButton from '../ui/OpenResumeButton' import PageHeader from '../ui/PageHeader' +import SyncButton from '../ui/SyncButton' import { Tabs } from '../ui/Tabs' import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable' import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives' @@ -38,6 +39,25 @@ const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates'] /** Inbox GET `top` / sheet GET `limit` both cap at 500. */ const PAGE_SIZE_MAX = 500 +const SYNC_RUN_KEY = 'mailbox_sync_run_id' + +function readStoredSyncRunId() { + try { + return localStorage.getItem(SYNC_RUN_KEY) || null + } catch { + return null + } +} + +function storeSyncRunId(id) { + try { + if (id) localStorage.setItem(SYNC_RUN_KEY, id) + else localStorage.removeItem(SYNC_RUN_KEY) + } catch { + /* private mode / quota — polling still works in-session */ + } +} + /** Inbox channel: Outlook email queue vs imported Google Form rows. */ const CHANNELS = [ { key: 'email', label: 'Email', icon: 'mail' }, @@ -959,6 +979,63 @@ export default function Inbox() { markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind }) } + const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId()) + const syncToastShown = useRef(null) + + const sync = useMutation({ + mutationFn: () => inboxApi.startMailboxSync(), + onSuccess: (res) => { + const id = res?.data?.id + if (id) { + storeSyncRunId(id) + setSyncRunId(id) + } + toast('Mailbox sync started — safe to leave this page', 'info') + }, + onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'), + }) + + const syncRun = useQuery({ + queryKey: qk.mailbox.sync(syncRunId), + queryFn: async () => { + const res = await inboxApi.getMailboxSync(syncRunId) + return res?.data ?? null + }, + enabled: Boolean(syncRunId), + refetchInterval: (q) => { + const status = q.state.data?.status + return status === 'queued' || status === 'running' ? 800 : false + }, + }) + + useEffect(() => { + const run = syncRun.data + if (!run?.id) return undefined + if (run.status === 'completed' && syncToastShown.current !== run.id) { + syncToastShown.current = run.id + const t = run.triage + toast( + t + ? `Mailbox synced — ${t.ingested ?? 0} imported, ${t.skipped ?? 0} filtered out` + : 'Mailbox synced', + 'success', + ) + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + const timer = setTimeout(() => { + storeSyncRunId(null) + setSyncRunId(null) + }, 1800) + return () => clearTimeout(timer) + } + if (run.status === 'failed' && syncToastShown.current !== run.id) { + syncToastShown.current = run.id + toast(run.error || 'Sync failed', 'error') + storeSyncRunId(null) + setSyncRunId(null) + } + return undefined + }, [qc, syncRun.data, toast]) + return (
Google Sheets · Form data )} + {!isForms && ( + sync.mutate()} + /> + )} diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 405a4b2..dc27a52 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1109,6 +1109,31 @@ canvas { width: 100%; max-width: 100%; display: block; } .integration-status .pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: currentColor; animation: pulse 1.8s infinite; } @keyframes pulse { 0% { transform: scale(1); opacity: .7; } 100% { transform: scale(3); opacity: 0; } } +/* Mailbox Sync — same metrics as .btn so it lines up with Upload CVs. + No fill animation; percentage is text from the worker. */ +.sync-btn { + min-width: 7.5rem; +} +.sync-btn-spinner { + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--text) 25%, transparent); + border-top-color: var(--text); + animation: sync-spin .8s linear infinite; + flex: none; +} +@keyframes sync-spin { to { transform: rotate(360deg); } } +.sync-btn-pct { + font-variant-numeric: tabular-nums; + min-width: 2.6ch; + text-align: left; +} +.sync-btn.is-failed { border-color: var(--danger); } +@media (prefers-reduced-motion: reduce) { + .sync-btn-spinner { animation: none; } +} + .email-preview { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 18px; white-space: pre-wrap; font-size: 13.5px; line-height: 1.7; color: var(--text-2); } .attach-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev); } .attach-icn { width: 42px; height: 42px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); display: grid; place-items: center; } @@ -1483,7 +1508,8 @@ canvas { width: 100%; max-width: 100%; display: block; } .page-title { font-size: var(--fs-2xl); } .page-head { gap: 12px; margin-bottom: 18px; } .page-head-actions { width: 100%; } - .page-head-actions .btn { flex: 1 1 auto; justify-content: center; } + .page-head-actions .btn, + .page-head-actions .sync-btn { flex: 1 1 auto; justify-content: center; } /* The action cluster was eating 232px of a 375px bar, leaving search ~59px — too narrow to read or tap. Drop the secondary affordances diff --git a/frontend/src/ui/SyncButton.jsx b/frontend/src/ui/SyncButton.jsx new file mode 100644 index 0000000..5c81835 --- /dev/null +++ b/frontend/src/ui/SyncButton.jsx @@ -0,0 +1,52 @@ +/* ============================================================ + SyncButton.jsx — mailbox Sync control. + + Idle is a plain pill. After click, the label follows the worker's + triage.processed / triage.expected. 100% only when the run completes. + ============================================================ */ + +import { Icon } from './primitives' + +export function mailboxSyncPercent(run) { + if (!run) return null + if (run.status === 'completed') return 100 + if (run.status === 'failed') return null + const expected = Number(run.triage?.expected) + const processed = Number(run.triage?.processed) + const nExpected = Number.isFinite(expected) && expected > 0 ? expected : 0 + const nProcessed = Number.isFinite(processed) + ? processed + : (Array.isArray(run.entries) ? run.entries.length : 0) + if (nExpected <= 0) return null + return Math.min(99, Math.round((nProcessed / nExpected) * 100)) +} + +export default function SyncButton({ run, pending, disabled, onClick }) { + const status = run?.status + const busy = pending || status === 'queued' || status === 'running' + const done = status === 'completed' + const failed = status === 'failed' + const pct = mailboxSyncPercent(run) + const label = failed + ? 'Sync failed' + : done + ? 'Synced' + : busy + ? 'Syncing' + : 'Sync' + + return ( + + ) +}