diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index b70074c..012698d 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -203,9 +203,6 @@ async def fetch_form_data( search: str | None = Query(None), processing_state: str | None = Query(None), is_duplicate: bool | None = Query(None), - # Tri-valued, like is_duplicate above: omit for no filter, true for rows that - # have the link, false for the ones missing it. Chasing the gaps is half the - # reason these exist, so `false` has to be a real filter and not "unset". has_linkedin: bool | None = Query(None), has_resume: bool | None = Query(None), city: str | None = Query(None), @@ -213,7 +210,6 @@ async def fetch_form_data( assigned: bool | None = Query(None), no_suggestions: bool | None = Query(None), offset: int = Query(0,ge=0), - # Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged. limit: int | None = Query(None,ge=1,le=500), current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 62e798a..09d52bc 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -68,6 +68,43 @@ const LIST_CACHE = { } const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } +/** + * Last 10 opened applicant details. The queue list is a light row; GET-by-id + * is the body, suggested roles, resume text. Re-opening one of these should + * not pay that trip again. Cap is LRU — the 11th open drops the oldest from + * the query cache. Mutations that write THIS row still invalidate its key. + */ +const OPENED_DETAIL_CAP = 10 +const openedDetailLru = [] + +function detailQueryKey(kind, id) { + return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id) +} + +function rememberOpenedDetail(qc, kind, id) { + if (!id) return + const sig = `${kind}:${id}` + const key = detailQueryKey(kind, id) + const next = openedDetailLru.filter((x) => x.sig !== sig) + next.push({ sig, kind, id, key }) + while (next.length > OPENED_DETAIL_CAP) { + const dropped = next.shift() + if (dropped && dropped.sig !== sig) { + qc.removeQueries({ queryKey: dropped.key }) + } + } + openedDetailLru.length = 0 + openedDetailLru.push(...next) +} + +const DETAIL_CACHE = { + staleTime: Infinity, + gcTime: INBOX_GC_MS, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} + /** Typing must not put a query key (and a skeleton) on screen per keystroke. */ const SEARCH_DEBOUNCE_MS = 300 @@ -1388,7 +1425,12 @@ export default function Inbox() { queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), enabled: Boolean(selectedId), + ...DETAIL_CACHE, }) + useEffect(() => { + if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return + rememberOpenedDetail(qc, selectedKind, selectedId) + }, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data]) // List rows paint immediately; GET-by-id is the full application. Block every // control in the detail pane until that fetch succeeds. The queue stays live. const detailLocked = Boolean(selectedId) && !detailQuery.isError && !detailQuery.isSuccess