From db0652ab24bb0c8e16a3610a12e09cbae59125b4 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 3 Sep 2026 16:39:55 +0500 Subject: [PATCH] Inbox: serve the queue from cache instead of re-skeletoning every visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the Recruitment Inbox showed skeleton placeholders every time, and on the combined All channel it showed them stacked ABOVE rows that had already arrived — the channel ORs its two sources' pending flags together, so the email rows sat under placeholders while the sheet fetch finished. The queue is a local table, not a live feed. Rows only appear when the Sync worker writes them, and that path already invalidates qk.mailbox.all(). So refetching on every visit bought nothing and cost a full-width skeleton each time — worse on the All channel, which fetches BOTH sources unpaged and so re-downloaded thousands of rows to render ten. - LIST_CACHE / COUNT_CACHE: 10 min staleTime, 1 h gcTime, keepPreviousData. A revisit inside the window paints rows immediately and issues no request. - Placeholders now mean "nothing to show yet": gated on the list being empty. A refetch over live rows is a 2px bar and the word "Updating…", with the rows readable and clickable throughout. - "Updated 4 min ago" + a Refresh button under the search box, because a cached list that never admits its age reads as a broken list. Refresh re-reads the DB; Sync pulls new mail from Outlook. Both tooltips now say which is which — two circular arrows were indistinguishable. - Search debounced to 300ms. Each keystroke used to mint a query key, a request and a skeleton, so the list strobed while you typed. - resume_text dropped from the list row mapping. serialize_application ships the whole extracted CV on every row and the queue renders none of it; caching a thousand of them for an hour is not a trade worth making. The detail query fetches it for the one row actually open. Trade-off: a sync run started elsewhere is now invisible here for up to ten minutes. The age label and the Refresh button are the deliberate mitigation. inbox-loading.test.mjs pins all of it, driving hand-gated fetches so the frames a fast API flashes past become assertable: both sources pending, one source home, and a second visit served from cache. Reverting the placeholder rule reproduces the original defect as "skeleton=true rows=2". Wired into npm run verify; needed a mountRoute export on the smoke harness, since renderRoute tears the tree down before those intermediate frames can be read. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/inbox-loading.test.mjs | 255 +++++++++++++++++++++++++++++++ frontend/package.json | 3 +- frontend/src/__smoke__/entry.jsx | 34 ++++- frontend/src/screens/Inbox.jsx | 172 +++++++++++++++++++-- frontend/src/styles/styles.css | 56 +++++++ frontend/src/ui/SyncButton.jsx | 12 +- 6 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 frontend/inbox-loading.test.mjs diff --git a/frontend/inbox-loading.test.mjs b/frontend/inbox-loading.test.mjs new file mode 100644 index 0000000..55e4a0b --- /dev/null +++ b/frontend/inbox-loading.test.mjs @@ -0,0 +1,255 @@ +/** + * Inbox loading-state test — what the queue SHOWS while its data is in flight. + * + * node inbox-loading.test.mjs + * + * The complaint this pins down: opening the Recruitment Inbox showed skeleton + * placeholders every single time, even on a revisit, and on the combined "All" + * channel it showed them stacked ABOVE rows that had already arrived. The queue + * is a local table that only grows when the Sync worker writes to it, so a + * revisit has no business blanking itself. + * + * Every fetch here is gated by hand, so the assertions land on the exact frames + * a fast local API would flash past: both sources pending, one source home, and + * a second visit served from cache. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import esbuild from 'esbuild' +import { JSDOM } from 'jsdom' + +// ---------------------------------------------------------------- environment +const dom = new JSDOM('
', { + url: 'http://localhost:5173/', + pretendToBeVisual: true, +}) + +globalThis.window = dom.window +globalThis.document = dom.window.document +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +globalThis.HTMLElement = dom.window.HTMLElement +globalThis.Element = dom.window.Element +globalThis.Node = dom.window.Node +globalThis.getComputedStyle = dom.window.getComputedStyle +globalThis.localStorage = dom.window.localStorage +globalThis.DOMParser = dom.window.DOMParser +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) +globalThis.cancelAnimationFrame = clearTimeout +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +class RO { observe() {} unobserve() {} disconnect() {} } +class MO { observe() {} disconnect() {} takeRecords() { return [] } } +globalThis.ResizeObserver = RO +globalThis.MutationObserver = MO +dom.window.ResizeObserver = RO +dom.window.MutationObserver = MO +dom.window.matchMedia = () => ({ + matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, +}) +dom.window.HTMLCanvasElement.prototype.getContext = () => + new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) }) + +const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', + 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent', + 'requisitions'] +const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] +const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) + +dom.window.localStorage.setItem('tf-auth', JSON.stringify({ + access_token: 'test', refresh_token: 'test', expires_in: 1800, + expires_at: Date.now() + 1800_000, + data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions }, +})) + +// ---------------------------------------------------------------- fixtures +const EMAIL_ROWS = [ + { + id: '11111111-1111-1111-1111-111111111111', + name: 'Ada Lovelace', email: 'ada@example.com', + position: 'Backend Engineer', source: 'careers-rozee@example.com', + received: '2026-09-02T10:00:00Z', unread: true, processing: 'Unread', + }, + { + id: '22222222-2222-2222-2222-222222222222', + name: 'Grace Hopper', email: 'grace@example.com', + position: 'Platform Engineer', source: 'careers-rozee@example.com', + received: '2026-09-01T10:00:00Z', unread: false, processing: 'Read', + }, +] + +const FORM_ROWS = [ + { + id: '33333333-3333-3333-3333-333333333333', + name: 'Katherine Johnson', candidate_email: 'kj@example.com', + position_applied_for: 'Data Analyst', source_of_application: 'LinkedIn', + entry_date: '2026-09-03T00:00:00Z', entry_time: '09:15', processing_state: 'unread', + }, +] + +/** + * One gate per source. `open()` releases every request parked on it, and every + * request that arrives afterwards resolves straight away — which is what makes + * "email home, sheet still travelling" an assertable frame rather than a race. + */ +function gate(payload) { + const waiting = [] + let open = false + return { + calls: 0, + payload, + take() { + this.calls += 1 + if (open) return Promise.resolve(this.payload) + return new Promise((resolve) => waiting.push(() => resolve(this.payload))) + }, + release() { + open = true + waiting.splice(0).forEach((fn) => fn()) + }, + close() { open = false }, + } +} + +const emailGate = gate({ data: EMAIL_ROWS, total: EMAIL_ROWS.length, status_code: 200 }) +const formGate = gate({ data: FORM_ROWS, total: FORM_ROWS.length, status_code: 200 }) + +const COUNTS = { all: 3, unread: 1, processed: 0, rejected: 0, duplicates: 0 } + +globalThis.fetch = async (input) => { + const url = String(input?.url ?? input) + let body + if (url.includes('/inbox/all-applications/count')) body = { total: EMAIL_ROWS.length, status_code: 200 } + else if (url.includes('/inbox/all-applications')) body = await emailGate.take() + else if (url.includes('/inbox/counts')) body = { data: COUNTS, status_code: 200 } + else if (url.includes('/sheet/form-data/count')) body = { total: FORM_ROWS.length, status_code: 200 } + else if (url.includes('/sheet/form-data/counts')) body = { data: { all: 1 }, status_code: 200 } + else if (url.includes('/sheet/form-data/fetch')) body = await formGate.take() + else body = { data: [], status_code: 200 } + return { + ok: true, status: 200, statusText: 'OK', + text: async () => JSON.stringify(body), + } +} + +// ---------------------------------------------------------------- bundle +const outDir = mkdtempSync(join(tmpdir(), 'tf-inbox-')) +const outFile = join(outDir, 'entry.mjs') + +await esbuild.build({ + entryPoints: ['src/__smoke__/entry.jsx'], + outfile: outFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + loader: { '.js': 'jsx', '.jsx': 'jsx' }, + logLevel: 'error', + define: { + 'process.env.NODE_ENV': '"development"', + 'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }), + }, +}) + +// ---------------------------------------------------------------- run +let failed = 0 +function check(name, condition, detail) { + if (condition) { + console.log(`ok ${name}`) + if (detail) console.log(` ${detail}`) + } else { + console.log(`FAIL ${name}`) + if (detail) console.log(` ${detail}`) + failed++ + } +} + +const hasSkeleton = (html) => html.includes('skeleton-row') +const hasRefreshBar = (html) => html.includes('inbox-refresh-bar') +const rowCount = (html) => (html.match(/class="inbox-item/g) || []).length + +try { + const mod = await import(pathToFileURL(outFile).href) + mod.boot() + + const container = dom.window.document.createElement('div') + dom.window.document.body.appendChild(container) + + // ---- frame 1: cold open, neither source home ---------------------------- + const view = await mod.mountRoute('/inbox', container) + check( + 'cold open with nothing cached shows placeholders', + hasSkeleton(view.html()) && rowCount(view.html()) === 0, + `skeleton=${hasSkeleton(view.html())} rows=${rowCount(view.html())}`, + ) + + // ---- frame 2: email home, sheet still travelling ------------------------ + // THE REGRESSION: this frame used to render six placeholders on top of two + // real rows, because the All channel ORs the two pending flags together. + emailGate.release() + await view.settle(60) + const partial = view.html() + check( + 'THE REGRESSION: rows that arrived are never buried under placeholders', + !hasSkeleton(partial) && rowCount(partial) === EMAIL_ROWS.length, + `skeleton=${hasSkeleton(partial)} rows=${rowCount(partial)}`, + ) + check( + 'the half still in flight is named, not mimed', + view.text().includes('Still loading Sheet Form applications'), + ) + check( + 'a refetch over live rows is a hairline bar', + hasRefreshBar(partial), + ) + + // ---- frame 3: both home ------------------------------------------------- + formGate.release() + await view.settle(60) + const settled = view.html() + check( + 'both sources merge into one list', + rowCount(settled) === EMAIL_ROWS.length + FORM_ROWS.length, + `rows=${rowCount(settled)}`, + ) + check( + 'no placeholders and no progress bar once the queue is settled', + !hasSkeleton(settled) && !hasRefreshBar(settled), + ) + check( + 'the list says how old it is', + view.text().includes('Updated just now'), + ) + + const firstVisitEmailCalls = emailGate.calls + await view.unmount() + + // ---- frame 4: leave, come back ----------------------------------------- + // The headline fix. Same query keys, inside staleTime: React Query serves + // the cache, so the first painted frame already has rows and no request goes + // out. This is the assertion that fails if LIST_CACHE is ever removed. + const second = dom.window.document.createElement('div') + dom.window.document.body.appendChild(second) + const revisit = await mod.mountRoute('/inbox', second) + const firstFrame = revisit.html() + check( + 'THE FIX: re-opening the Inbox paints rows immediately, no placeholders', + !hasSkeleton(firstFrame) && rowCount(firstFrame) === EMAIL_ROWS.length + FORM_ROWS.length, + `skeleton=${hasSkeleton(firstFrame)} rows=${rowCount(firstFrame)}`, + ) + await revisit.settle(60) + check( + 're-opening inside staleTime issues no new list request', + emailGate.calls === firstVisitEmailCalls, + `calls before=${firstVisitEmailCalls} after=${emailGate.calls}`, + ) + await revisit.unmount() +} finally { + rmSync(outDir, { recursive: true, force: true }) +} + +console.log(failed ? `\n${failed} inbox loading check(s) FAILED` : '\nAll inbox loading checks passed') +process.exit(failed ? 1 : 0) diff --git a/frontend/package.json b/frontend/package.json index 1ab5aea..c3763f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,9 @@ "smoke": "node smoke.test.mjs", "test:token": "node token.test.mjs", "test:theme": "node theme.test.mjs", + "test:inbox": "node inbox-loading.test.mjs", "test:mobile": "node mobile.test.mjs", - "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs" + "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node inbox-loading.test.mjs" }, "dependencies": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index c06942c..aa7018f 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -78,8 +78,32 @@ export function boot() { initializeCache(queryClient) } -/** Mount one route, wait for effects to settle, return its rendered text. */ -export async function renderRoute(path, container) { +/** + * Like renderRoute, but hands the mount BACK instead of tearing it down. + * + * renderRoute answers "did this route render at all". The Inbox loading test + * asks a different question — what is on screen between one fetch resolving + * and the next — so it needs to step time itself and read the DOM at each + * step. `settle` must come from here, not the test file, because act() has to + * be the bundle's React, not a second copy. + */ +export async function mountRoute(path, container) { + const tree = routeTree(path) + const root = createRoot(container) + await act(async () => { root.render(tree) }) + const settle = async (ms = 20) => { + await act(async () => { await new Promise((r) => setTimeout(r, ms)) }) + } + await settle() + return { + settle, + html: () => container.innerHTML, + text: () => container.textContent || '', + unmount: async () => { await act(async () => { root.unmount() }) }, + } +} + +function routeTree(path) { const h = React.createElement const isAuth = path.startsWith('/auth/') const def = TABLE.find((r) => `/${r.path}` === path) @@ -93,7 +117,7 @@ export async function renderRoute(path, container) { h(Route, { path, element: h(Screen) }), ) - const tree = h( + return h( QueryClientProvider, { client: queryClient }, h(ThemeProvider, null, h(ToastProvider, null, @@ -105,7 +129,11 @@ export async function renderRoute(path, container) { ), ), ) +} +/** Mount one route, wait for effects to settle, return its rendered text. */ +export async function renderRoute(path, container) { + const tree = routeTree(path) const root = createRoot(container) try { await act(async () => { root.render(tree) }) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 988596f..95905f2 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' @@ -42,6 +42,46 @@ const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates'] /** Inbox GET `top` / sheet GET `limit` both cap at 500. */ const PAGE_SIZE_MAX = 500 +/** + * Cache policy for the queue. + * + * This list is a local table, not a live feed. Applications only appear when + * the Sync worker writes them, and that path already invalidates + * qk.mailbox.all() the moment a run completes — so refetching on every visit + * bought nothing and cost a full-width skeleton each time. The All channel + * makes that worse: it fetches BOTH sources unpaged, so the "loading" state a + * recruiter saw on re-entry was thousands of rows being re-downloaded to + * render the same ten. + * + * staleTime therefore covers a normal working stretch, and keepPreviousData + * means a tab switch, a page turn or a keystroke re-renders the rows already + * on screen instead of blanking them. Nothing here can hide new mail: Sync + * invalidates, and every write mutation on this screen already does too. + */ +const INBOX_STALE_MS = 10 * 60_000 +const INBOX_GC_MS = 60 * 60_000 +const LIST_CACHE = { + staleTime: INBOX_STALE_MS, + gcTime: INBOX_GC_MS, + placeholderData: keepPreviousData, +} +const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } + +/** Typing must not put a query key (and a skeleton) on screen per keystroke. */ +const SEARCH_DEBOUNCE_MS = 300 + +/** "Updated 4 min ago" under the search box — the honest label for a cached list. */ +function agoLabel(ts) { + if (!ts) return null + const secs = Math.max(0, Math.round((Date.now() - ts) / 1000)) + if (secs < 45) return 'just now' + const mins = Math.round(secs / 60) + if (mins < 60) return `${mins} min ago` + const hours = Math.round(mins / 60) + if (hours < 24) return `${hours} hr ago` + return `${Math.round(hours / 24)} d ago` +} + const SYNC_RUN_KEY = 'mailbox_sync_run_id' function readStoredSyncRunId() { @@ -379,7 +419,11 @@ async function fetchApplications(params) { filePath: row.file_path || '', linkedinSlug: row.linkedin_slug || '', linkedinUrl: row.linkedin_url || '', - resumeText: row.resume_text || '', + // resume_text is deliberately DROPPED here. serialize_application ships + // the whole extracted CV on every row, and the queue renders none of + // it — but the cache would then hold a thousand full resumes for the + // hour that LIST_CACHE keeps a page alive. The detail query fetches it + // for the one row actually open, which is the only place it is read. atsScore: row.ats_score, phone: row.phone, experience: row.experience, @@ -722,6 +766,47 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, ) } +/** + * "Updated 4 min ago" + Refresh, under the search box. + * + * A cached list that never says how old it is reads as a broken list. This is + * the honest label, and the button next to it re-reads the DB — deliberately + * NOT the same act as Sync, which pulls new mail from Outlook. The tooltips + * say which is which because the two were previously indistinguishable. + * + * Owns its own interval so the minute count stays current without re-rendering + * the queue: under "Show all" that parent render is a thousand rows. + */ +function QueueFreshness({ at, refreshing, onRefresh }) { + const [, tick] = useState(0) + useEffect(() => { + if (!at) return undefined + const t = setInterval(() => tick((n) => n + 1), 30_000) + return () => clearInterval(t) + }, [at]) + + return ( +
+ + {refreshing + ? 'Updating…' + : at + ? `Updated ${agoLabel(at)}` + : 'Loading…'} + + +
+ ) +} + export default function Inbox() { const { toast } = useToast() const navigate = useNavigate() @@ -741,6 +826,15 @@ export default function Inbox() { const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) + // The box updates on every keystroke; the QUERY KEY only settles when typing + // pauses. Untyped, each character produced a fresh key, an in-flight request + // and a skeleton — the list strobed while you searched. + const [search, setSearch] = useState('') + useEffect(() => { + const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(t) + }, [q]) + const isForms = channel === 'forms' // Combined channel: both sources fetched UNPAGED (each endpoint reads a // missing top/limit as no LIMIT), merged by date, and paged client-side — @@ -756,8 +850,8 @@ export default function Inbox() { // endpoint reads a missing top as unpaged. top: pageSize === 'all' || isAllChannel ? undefined : pageSize, skip: isAllChannel ? 0 : skip, - ...(q.trim() ? { search: q.trim() } : {}), - }), [tabFilter, skip, pageSize, q, isAllChannel]) + ...(search ? { search } : {}), + }), [tabFilter, skip, pageSize, search, isAllChannel]) const formParams = useMemo(() => ({ // All channel spans every sheet tab, not just the selected one. @@ -765,13 +859,14 @@ export default function Inbox() { offset: isAllChannel ? 0 : skip, limit: pageSize === 'all' || isAllChannel ? undefined : pageSize, ...formTabFilter, - ...(q.trim() ? { search: q.trim() } : {}), - }), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel]) + ...(search ? { search } : {}), + }), [formSheet, skip, pageSize, search, formTabFilter, isAllChannel]) const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications(listParams), queryFn: () => fetchApplications(listParams), enabled: !isForms, + ...LIST_CACHE, }) const formSheetsQuery = useQuery({ @@ -788,12 +883,14 @@ export default function Inbox() { queryKey: qk.mailbox.formData(formParams), queryFn: () => fetchFormApplications(formParams), enabled: isForms || isAllChannel, + ...LIST_CACHE, }) const countsQuery = useQuery({ queryKey: qk.mailbox.counts(), queryFn: fetchInboxCounts, enabled: !isForms, + ...COUNT_CACHE, }) const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined) @@ -804,6 +901,7 @@ export default function Inbox() { return res?.data ?? {} }, enabled: isForms || isAllChannel, + ...COUNT_CACHE, }) const emailTotalQuery = useQuery({ @@ -858,6 +956,14 @@ export default function Inbox() { // one healthy source still renders; error only when both are down isError: applicationsQuery.isError && formQuery.isError, isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess, + // Either source still on the wire keeps the quiet refresh bar up. + isFetching: applicationsQuery.isFetching || formQuery.isFetching, + // The OLDER of the two: the merged list is only as fresh as its + // staler half, and claiming otherwise would be a lie on the label. + dataUpdatedAt: Math.min( + applicationsQuery.dataUpdatedAt || Infinity, + formQuery.dataUpdatedAt || Infinity, + ), error: applicationsQuery.error ?? formQuery.error, data: { rows: mergedRows ?? [], total: mergedRows?.length ?? 0 }, } @@ -890,7 +996,7 @@ export default function Inbox() { const countsReady = isAllChannel ? countsQuery.isSuccess && formCountsQuery.isSuccess : (isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess) - const total = q.trim() + const total = search ? (activeQuery.data?.total ?? 0) : (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0))) // 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing @@ -934,18 +1040,41 @@ export default function Inbox() { * What "Mark all" means here, in words, for the button tooltip. Kept next to * setReadEverything so the label and the scope cannot drift apart. */ - const scopeLabel = q.trim() + const scopeLabel = search ? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search` : tab === 'All Applications' ? 'every application' : `the ${tab} tab` + /** + * Loading state, in priority order: rows already on screen ALWAYS win. + * + * The old rule was `activeQuery.isPending && `, which on the + * All channel put six placeholders ABOVE the email rows that had already + * arrived while the sheet fetch finished — a list that looked broken while + * it worked. Placeholders now mean "there is genuinely nothing to show yet"; + * a refresh over existing rows is a hairline bar and one word of text. + */ + const hasRows = list.length > 0 + const showSkeleton = !hasRows && (activeQuery.isPending || activeQuery.isFetching) + const refreshing = hasRows && Boolean(activeQuery.isFetching) + const updatedAt = Number.isFinite(activeQuery.dataUpdatedAt) && activeQuery.dataUpdatedAt > 0 + ? activeQuery.dataUpdatedAt + : null + + /** Re-read what is already stored here. Sync is the one that fetches mail. */ + const refreshQueue = useCallback(() => { + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + }, [qc]) + function switchChannel(next) { if (next === channel) return setChannel(next) setSkip(0) setSelectedId(null) setQ('') + setSearch('') // clear the committed term too, or the new channel's first + // fetch carries the old channel's search for 300ms // Unread is email-only; leave it behind when opening Sheet Forms or All. if (next !== 'email' && tab === 'Unread') setTab('All Applications') selection.clear() @@ -967,7 +1096,7 @@ export default function Inbox() { if (SERVER_SCOPED_TABS.has(tab)) { setReadAll.mutate({ read, - filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) }, + filter: { ...tabFilter, ...(search ? { search } : {}) }, // sheet rows carry no mailbox read state — email rows only ids: list.filter((i) => i.kind !== 'form').map((i) => i.id), }) @@ -1257,9 +1386,13 @@ export default function Inbox() { placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'} /> + -
- {activeQuery.isPending && ( +
+ {/* Hairline, not a skeleton: the rows below stay readable and + clickable while the refetch lands. */} + {refreshing &&
} + {showSkeleton && ( )} {/* The big error state only when there is truly nothing to @@ -1269,6 +1402,18 @@ export default function Inbox() { {friendlyAuthError(activeQuery.error, 'Request failed')} )} + {/* All channel, one source home and one still travelling: say + which. The merge re-sorts by date when the second lands, so + the list is about to reshuffle and that should not be a + surprise. */} + {isAllChannel && hasRows + && (Boolean(applicationsQuery.isFetching) !== Boolean(formQuery.isFetching)) && ( +
+ {applicationsQuery.isFetching + ? 'Still loading email applications…' + : 'Still loading Sheet Form applications…'} +
+ )} {/* All channel with one source down: keep the healthy list, note the gap in one line instead of a full error state. */} {isAllChannel && applicationsQuery.isError !== formQuery.isError @@ -1287,7 +1432,10 @@ export default function Inbox() { : 'Sheet Form applications couldn’t load right now — showing Email only.'}
)} - {activeQuery.isSuccess && list.length === 0 ? ( + {/* `!showSkeleton` matters now that keepPreviousData holds the + query in `success` while a new key loads: without it a cold + tab flashed "Nothing here" over a request still in flight. */} + {activeQuery.isSuccess && list.length === 0 && !showSkeleton ? ( {isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'} diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 49e13be..e363438 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1137,6 +1137,62 @@ canvas { width: 100%; max-width: 100%; display: block; } font-size: 12px; white-space: nowrap; } + +/* ---- Quiet refresh ------------------------------------------------------- + The queue is cached between visits (see LIST_CACHE in screens/Inbox.jsx), + so a revisit repaints the rows instantly and any refetch happens underneath + them. These three elements are that refetch's entire vocabulary: an age + label so a cached list never pretends to be live, a hairline bar while a + request is out, and one line naming the slower half of the All channel. */ +.inbox-freshness { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; + min-width: 0; +} +.inbox-freshness .cell-sub { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.inbox-freshness .btn-sm { + padding: 3px 8px; + font-size: 12px; + flex-shrink: 0; +} +.inbox-list-body { position: relative; } +/* Sticky so it stays visible when the refetch starts from a scrolled queue. */ +.inbox-refresh-bar { + position: sticky; + top: 0; + z-index: 2; + height: 2px; + overflow: hidden; + background: var(--bg-sunken); +} +.inbox-refresh-bar::after { + content: ''; + position: absolute; + inset: 0; + width: 38%; + background: var(--primary); + animation: inboxRefreshSlide 1.1s ease-in-out infinite; +} +@keyframes inboxRefreshSlide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(365%); } +} +@media (prefers-reduced-motion: reduce) { + .inbox-refresh-bar::after { animation: none; width: 100%; opacity: .5; } +} +.inbox-partial-note { + padding: 7px 16px; + border-bottom: 1px solid var(--border); + font-size: var(--fs-xs); + color: var(--text-3); +} /* `--chip` is the source's own brand colour, set inline. It tints the background and fills the dot, while the label stays on theme text so 11px copy keeps its contrast in both modes. */ diff --git a/frontend/src/ui/SyncButton.jsx b/frontend/src/ui/SyncButton.jsx index 5c81835..a386a0e 100644 --- a/frontend/src/ui/SyncButton.jsx +++ b/frontend/src/ui/SyncButton.jsx @@ -35,13 +35,23 @@ export default function SyncButton({ run, pending, disabled, onClick }) { ? 'Syncing' : 'Sync' + // Sync and the queue's own Refresh button are different acts: this one goes + // out to Outlook and writes new rows, the other re-reads what is already + // stored. Say so — two circular arrows on one screen otherwise read as the + // same button. + const tip = disabled && !busy + ? 'Requires inbox.edit' + : busy + ? undefined + : 'Pull new mail from Outlook into this inbox' + return (