))
)}
@@ -394,9 +425,18 @@ export default function Candidates() {
{profileFor && (
c.id === profileFor.id) ?? profileFor}
- jobTitle={jobTitleOf(profileFor)}
+ candidate={{
+ ...(candidates.find((c) => c.id === profileFor.id) ?? profileFor),
+ initials: initialsOf(profileFor.name),
+ color: avatarColor(profileFor.name),
+ stage: profileFor.stage || 'Shortlist',
+ userId: profileFor.userId || profileFor.id,
+ }}
+ atsScore={atsScore}
+ recommendation={scoreQuery.data?.band ?? null}
onClose={() => setProfileFor(null)}
+ onAdvance={advance}
+ onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx
index 546cfed..95644dc 100644
--- a/frontend/src/screens/Inbox.jsx
+++ b/frontend/src/screens/Inbox.jsx
@@ -9,7 +9,7 @@
now, which is the structural fix.
============================================================ */
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -270,34 +270,36 @@ function patchReadState(data, idSet, read) {
* a view-only user gets a 403 here and the rows snap back. The bulk bars disable
* themselves for those users; click-to-read cannot, so it still relies on rollback.
*/
+async function optimisticRead(qc, ids, read) {
+ await qc.cancelQueries({ queryKey: qk.mailbox.all() })
+ const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
+ const idSet = new Set(ids)
+
+ // Which rows actually FLIP, counted BEFORE the patch: the counts object is a
+ // bag of totals with no per-row detail to derive the delta from afterwards.
+ // A Set, because the same id appears in several cached lists at once.
+ const flipping = new Set()
+ for (const [, data] of previous) {
+ if (!Array.isArray(data)) continue
+ for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
+ }
+
+ qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
+ // The Unread tab badge and the sidebar badge share this entry. Without the
+ // delta they sit stale until the refetch lands — invisible for one row,
+ // glaring for two hundred. `previous` already covers it for rollback,
+ // because ['mailbox'] is a prefix of ['mailbox','counts'].
+ qc.setQueryData(qk.mailbox.counts(), (c) => (c
+ ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) }
+ : c))
+ return { previous }
+}
+
function useSetRead(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ ids, read }) => setReadChunked(ids, read),
- onMutate: async ({ ids, read }) => {
- await qc.cancelQueries({ queryKey: qk.mailbox.all() })
- const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
- const idSet = new Set(ids)
-
- // Which rows actually FLIP, counted BEFORE the patch: the counts object is a
- // bag of totals with no per-row detail to derive the delta from afterwards.
- // A Set, because the same id appears in several cached lists at once.
- const flipping = new Set()
- for (const [, data] of previous) {
- if (!Array.isArray(data)) continue
- for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
- }
-
- qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
- // The Unread tab badge and the sidebar badge share this entry. Without the
- // delta they sit stale until the refetch lands — invisible for one row,
- // glaring for two hundred. `previous` already covers it for rollback,
- // because ['mailbox'] is a prefix of ['mailbox','counts'].
- qc.setQueryData(qk.mailbox.counts(), (c) => (c
- ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) }
- : c))
- return { previous }
- },
+ onMutate: ({ ids, read }) => optimisticRead(qc, ids, read),
onError: (err, _vars, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
@@ -309,14 +311,19 @@ function useSetRead(toast) {
/**
* PATCH /inbox/read-all — every row matching a server-side list filter.
*
- * Deliberately NOT optimistic: the scope is a WHERE clause, so the client cannot
- * know which rows it hit until the count comes back. It reports that count rather
- * than guessing, which is also the honest answer when the view was already read.
+ * The WRITE is a WHERE clause, but the optimistic patch still runs over the ids
+ * the caller can see: this endpoint is only ever used where the visible list IS
+ * the server's whole result (see SERVER_SCOPED_TABS, and the list endpoint takes
+ * no pagination), so those ids are the scope, not a sample of it. Without the
+ * patch the rows sit unchanged for a whole round trip plus a refetch, which reads
+ * as a dead button — and the reconciling refetch on settle corrects it anyway if
+ * the scope ever did reach further.
*/
function useSetReadAll(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }),
+ onMutate: ({ ids, read }) => optimisticRead(qc, ids ?? [], read),
onSuccess: (res, { read }) => {
const n = res?.data?.updated ?? 0
toast(
@@ -326,7 +333,10 @@ function useSetReadAll(toast) {
'success',
)
},
- onError: (err) => toast(friendlyAuthError(err, 'Could not update read state.'), 'error'),
+ onError: (err, _vars, ctx) => {
+ for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
+ toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
+ },
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
@@ -405,20 +415,52 @@ function RowCheck({ checked, onToggle, label }) {
)
}
+const READ_TICK_MS = 1000
+
/**
* Select-all plus the read/unread actions for one list.
*
- * Every button names what it will hit — "Mark 12 read", or "Mark all read" with
- * the scope spelled out in its tooltip — rather than a bare "Mark read" whose
- * reach depends on selection state the user has to keep in their head. "All" is
- * always the CURRENT view, never the whole mailbox from behind a filter.
+ * The leading ✓ is not a mode indicator. It only appears on the Mark read /
+ * Mark all read button that was just clicked, then clears from both after 1s.
+ *
+ * `rows` rather than a count, because the bar needs each row's read state, and
+ * `selectedIds` narrows it to what the selected pair of buttons would touch.
*/
-function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
+function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
const { selectedIds, toggleAll, clear, allSelected } = selection
const n = selectedIds.size
- const disabled = !canEdit || busy
+ const total = rows.length
+
+ // What the visible pair of buttons acts on: the ticked rows when there is a
+ // selection, the whole view otherwise.
+ const target = n > 0 ? rows.filter((r) => selectedIds.has(r.id)) : rows
+ const unreadCount = target.reduce((acc, r) => acc + (r.unread ? 1 : 0), 0)
+ const readCount = target.length - unreadCount
+
+ const blocked = !canEdit || busy
const tip = !canEdit ? 'Requires inbox.edit' : undefined
- const allTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
+ const scopeTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
+ const nothingTo = (word) => `Nothing to mark ${word} here`
+
+ // `selected` = Mark read, `all` = Mark all read. Never both at once in the
+ // bar, but the flash still resets both so a leftover tick cannot linger
+ // after the selection-vs-all swap.
+ const [ticked, setTicked] = useState(null)
+ const tickTimer = useRef(null)
+
+ useEffect(() => () => {
+ if (tickTimer.current) clearTimeout(tickTimer.current)
+ }, [])
+
+ function flashRead(which, action) {
+ if (tickTimer.current) clearTimeout(tickTimer.current)
+ setTicked(which)
+ action()
+ tickTimer.current = setTimeout(() => {
+ setTicked(null)
+ tickTimer.current = null
+ }, READ_TICK_MS)
+ }
return (
- {n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`}
+ {n > 0
+ ? `${n} selected`
+ : `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
{n > 0 ? (
<>
>
@@ -458,19 +503,20 @@ function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit,
<>
>
)}
@@ -614,7 +660,8 @@ export default function Inbox() {
// that were never on screen. Those cases send the visible ids instead, which
// is exact and, since the list endpoint is unpaginated, complete.
if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) {
- setReadAll.mutate({ read, filter: tabFilter })
+ // `ids` drives the optimistic patch only; the write itself is the filter.
+ setReadAll.mutate({ read, filter: tabFilter, ids: list.map((i) => i.id) })
return
}
const ids = list.map((i) => i.id)
@@ -719,7 +766,7 @@ export default function Inbox() {