diff --git a/frontend/inbox-loading.test.mjs b/frontend/inbox-loading.test.mjs index 55e4a0b..18f276d 100644 --- a/frontend/inbox-loading.test.mjs +++ b/frontend/inbox-loading.test.mjs @@ -65,18 +65,23 @@ dom.window.localStorage.setItem('tf-auth', JSON.stringify({ })) // ---------------------------------------------------------------- fixtures +// resume_status is the pipeline's verdict on the attachment, collapsed by +// _RESUME_STATUS in backend/inbox/serializers.py. One healthy row and one whose +// PDF yielded no text, because the list is supposed to tell those apart. 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', + resume_status: 'Parsed', }, { 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', + resume_status: 'Failed', }, ] @@ -171,6 +176,19 @@ 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 +/** The queue row for one candidate, as a live element — badges and all. */ +function rowFor(root, name) { + for (const el of root.querySelectorAll('.inbox-item')) { + if ((el.querySelector('.ii-name')?.textContent || '').includes(name)) return el + } + return null +} + +const badgesOn = (el) => [...el.querySelectorAll('.badge')].map((b) => b.textContent.trim()) +/** Only the parse-state labels; the read-state and processing badges are noise here. */ +const RESUME_LABELS = ['Parsed', 'Parsing', 'Failed', 'Pending'] +const resumeBadgesOn = (el) => badgesOn(el).filter((t) => RESUME_LABELS.includes(t)) + try { const mod = await import(pathToFileURL(outFile).href) mod.boot() @@ -224,6 +242,38 @@ try { view.text().includes('Updated just now'), ) + // ---- parse state on the row --------------------------------------------- + // A CV whose PDF yielded no text is never matched to a job and never scored. + // Before this, the list rendered it identically to a healthy one and the only + // way to find out was to click it. + const failedRow = rowFor(container, 'Grace Hopper') + const parsedRow = rowFor(container, 'Ada Lovelace') + const formRow = rowFor(container, 'Katherine Johnson') + + check( + 'a CV that could not be read is labelled on the row', + failedRow && resumeBadgesOn(failedRow).includes('Failed'), + `badges=${failedRow ? JSON.stringify(badgesOn(failedRow)) : 'row not found'}`, + ) + check( + 'the label carries a plain-language tooltip', + Boolean(failedRow?.querySelector('.badge.b-red')?.getAttribute('title')), + `title=${JSON.stringify(failedRow?.querySelector('.badge.b-red')?.getAttribute('title') || '')}`, + ) + check( + 'a healthy CV gets no badge, so the label stays a signal', + parsedRow && resumeBadgesOn(parsedRow).length === 0, + `badges=${parsedRow ? JSON.stringify(badgesOn(parsedRow)) : 'row not found'}`, + ) + // Weaker than the three above by nature: mapFormRow sets no resumeStatus, so + // this passes even with the kind guard removed. It is a tripwire for the day + // a form row gains such a field, not proof that the guard is doing work. + check( + 'Sheet Form rows are never labelled — they have no attachment to parse', + formRow && resumeBadgesOn(formRow).length === 0, + `badges=${formRow ? JSON.stringify(badgesOn(formRow)) : 'row not found'}`, + ) + const firstVisitEmailCalls = emailGate.calls await view.unmount() diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 95905f2..048c971 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -330,6 +330,31 @@ const RESUME_STATUS = { failed: 'Failed', dlq: 'Failed', skipped: 'Pending', } +const RESUME_STATUS_CLASS = { Parsed: 'b-green', Failed: 'b-red' } + +/** + * What each parse state means, in words a recruiter can act on. "Failed" alone + * says nothing about what to do next — the attachment is still there to open by + * hand, and that is the point worth making. + */ +const RESUME_STATUS_TIP = { + Parsed: 'Text was read from this CV.', + Parsing: 'Still reading the text from this CV.', + Failed: 'No text could be read from this CV, so it was never matched to a job. Open the attachment to read it by hand.', + Pending: 'This CV has not been matched to a job yet.', +} + +/** + * Parse state -> badge colour. Anything not settled one way or the other — + * Parsing, Pending, or a label the backend adds later — lands on amber. + * + * Shared by the queue row and the detail pane so the same state can never paint + * two different colours on the two halves of this screen. + */ +function resumeStatusClass(status) { + return RESUME_STATUS_CLASS[status] ?? 'b-amber' +} + const SHORTLIST_JOB_WARNING = 'Choose a matching job above to add this candidate to the shortlist.' /** @@ -1475,6 +1500,22 @@ export default function Inbox() {
{i.processing} + {/* Exceptions only: a healthy row shows nothing, so the + badge stays a signal rather than decoration. + The kind check is redundant TODAY — mapFormRow sets no + resumeStatus, so the truthiness test already excludes + sheet rows. It is kept because those applicants have no + mailbox attachment to parse at all, and a future field + named resumeStatus on a form row must not be read as a + parse verdict. */} + {i.kind !== 'form' && i.resumeStatus && i.resumeStatus !== 'Parsed' && ( + + {i.resumeStatus} + + )} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {formatRole(i.applicationStatus)} )} @@ -2075,7 +2116,10 @@ function ApplicationDetail({ {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( <>{formatRole(i.applicationStatus)}{' '} )} - + {i.resumeStatus} {' '} {loading && Loading details…} diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx index baf8757..da48984 100644 --- a/frontend/src/ui/primitives.jsx +++ b/frontend/src/ui/primitives.jsx @@ -48,9 +48,15 @@ export const STATUS_CLASS = { // define an identical private copy. export const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' } -export function Badge({ children, className }) { +/** + * `title` is optional and usually unset. It exists for badges whose one-word + * label is not self-explanatory — "Failed" on an Inbox row, for instance, where + * the hover has to say that the CV text could not be read and the attachment is + * still there to open by hand. + */ +export function Badge({ children, className, title }) { const cls = className || STATUS_CLASS[children] || 'b-gray' - return {children} + return {children} } export function ScoreChip({ score }) {