Inbox: serve the queue from cache instead of re-skeletoning every visit
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) <noreply@anthropic.com>
pull/73/head^2
parent
e7752b94e6
commit
db0652ab24
|
|
@ -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('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="inbox-freshness">
|
||||
<span className="cell-sub">
|
||||
{refreshing
|
||||
? 'Updating…'
|
||||
: at
|
||||
? `Updated ${agoLabel(at)}`
|
||||
: 'Loading…'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
title="Re-read the applications already saved here. Use Sync to pull new mail from Outlook."
|
||||
>
|
||||
<Icon name="refresh" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 && <SkeletonRows/>`, 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…'}
|
||||
/>
|
||||
</div>
|
||||
<QueueFreshness at={updatedAt} refreshing={refreshing} onRefresh={refreshQueue} />
|
||||
</div>
|
||||
<div>
|
||||
{activeQuery.isPending && (
|
||||
<div className="inbox-list-body">
|
||||
{/* Hairline, not a skeleton: the rows below stay readable and
|
||||
clickable while the refetch lands. */}
|
||||
{refreshing && <div className="inbox-refresh-bar" role="status" aria-label="Updating applications" />}
|
||||
{showSkeleton && (
|
||||
<SkeletonRows rows={6} />
|
||||
)}
|
||||
{/* The big error state only when there is truly nothing to
|
||||
|
|
@ -1269,6 +1402,18 @@ export default function Inbox() {
|
|||
{friendlyAuthError(activeQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{/* 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)) && (
|
||||
<div className="inbox-partial-note">
|
||||
{applicationsQuery.isFetching
|
||||
? 'Still loading email applications…'
|
||||
: 'Still loading Sheet Form applications…'}
|
||||
</div>
|
||||
)}
|
||||
{/* 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.'}
|
||||
</div>
|
||||
)}
|
||||
{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 ? (
|
||||
<EmptyState icon="inbox" title="Nothing here">
|
||||
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
|
||||
</EmptyState>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary sync-btn${done ? ' done' : ''}${failed ? ' is-failed' : ''}`}
|
||||
disabled={busy || disabled}
|
||||
onClick={onClick}
|
||||
title={disabled && !busy ? 'Requires inbox.edit' : undefined}
|
||||
title={tip}
|
||||
aria-label={pct != null ? `${label} ${pct}%` : label}
|
||||
>
|
||||
{busy ? <span className="sync-btn-spinner" aria-hidden="true" /> : <Icon name="refresh" />}
|
||||
|
|
|
|||
Loading…
Reference in New Issue