From dca184289e8360912521b5ebcdde168b8029d89f Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 17 Sep 2026 22:52:51 +0530 Subject: [PATCH] feat(hiring): final-selection queue, honest seat counts, and human interviews Authored in a parallel session alongside the TypeScript migration; committed separately so the two never share a commit. No TypeScript migration file is included here. Candidates becomes the queue of hiring decisions waiting on a person, rather than a second Talent Pool listing every application the org ever took. Final selection is DERIVED - there is no `final_selection` value in the `application_status` enum and none is added. The fact it reads is the existence of an interview row, a NOT NULL foreign key, rather than `job_applications.interview_id`, which the schema keeps as an unconstrained soft reference precisely so it may dangle. `status = 'interview'` is set both when an interview is arranged and when one is completed, so status alone cannot tell a queue of people who have been interviewed from a queue of people merely booked in. Seats on a position are counted from the employment records instead of a stored column. A `filled` counter would be a second source of truth, and the day it disagreed with `staff` nothing could say which was lying. Someone who has left frees their seat, and over-hiring floors at zero rather than going negative. `DEMO_FILL` is gone. `hiringRecords.js` padded the hires list with five invented people so Hired History read as a history rather than as three rows; the padding reached Analytics too, where "total hires" counted eight against a database holding three. Hires now come only from `staff`. Both paths that file an application on somebody's behalf now carry `worker_profile_id`, the link back to the talent-pool record. The column is nullable, so omitting it saved cleanly and failed silently: the application belonged to an email address rather than to a person, and the hire it became could not be traced back to the profile it came from. `HiredChronology` used to `return null` with no hires, taking the `chronology` node identity out of the DOM with it - so on an honest empty dataset the section could not be addressed by Owliver or the layout editor at all. It now renders an empty state inside the section it keeps. Nine new checks cover the above; `npm test` reports 1684/1691. SIX SSR PARITY CHECKS FAIL ON PURPOSE, and `scripts/__baseline__/README.md` documents each with verified tag counts. Five are the `DEMO_FILL` removal: the Hired History and Analytics baselines were captured while the padding was in effect and, because they render with queries disabled, the padding is all they contain. The sixth is this change to what Candidates says. Do not regenerate those baselines to clear them - two of the checks exist to prove the UI node tree migration added exactly two `
`s, and that proof needs the baseline to be pre-migration markup. Recapturing now would write post-migration markup into a file named `pre-migration` and the check would compare the current render against itself forever. The debt is held until the migration work lands, when both files are recaptured together. The seventh failure, the stale backend seed fixture, predates all of this. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8 --- scripts/__baseline__/README.md | 98 +++++ scripts/skill-check.mjs | 348 ++++++++++++++++++ src/App.jsx | 7 + src/components/ai-assistant/KrowAssistant.jsx | 3 - src/components/krow/AddToPositionModal.jsx | 144 ++++++++ .../krow/ScheduleInterviewModal.jsx | 291 +++++++++++++-- src/lib/hiringRecords.js | 122 ++++-- src/lib/humanInterviews.js | 102 +++++ src/lib/krowHooks.js | 179 +++++++++ src/pages/PositionDetail.jsx | 12 +- src/pages/admin/Analytics.jsx | 4 +- src/pages/admin/Candidates.jsx | 62 +++- src/pages/admin/HiredHistory.jsx | 4 +- src/pages/admin/TalentProfile.jsx | 216 +++++++++++ src/pages/admin/candidates/nodes.jsx | 46 ++- src/pages/admin/hired-history/nodes.jsx | 44 ++- src/pages/admin/talent-pool/nodes.jsx | 36 +- 17 files changed, 1624 insertions(+), 94 deletions(-) create mode 100644 src/components/krow/AddToPositionModal.jsx create mode 100644 src/lib/humanInterviews.js create mode 100644 src/pages/admin/TalentProfile.jsx diff --git a/scripts/__baseline__/README.md b/scripts/__baseline__/README.md index 0f261e0..28ddb66 100644 --- a/scripts/__baseline__/README.md +++ b/scripts/__baseline__/README.md @@ -5,6 +5,60 @@ agent layer existed. `skill-check.mjs` asserts against it on every run. Regenerating it is a deliberate act, and the reason belongs here. +## OUTSTANDING — the HTML baselines contain data that no longer exists + +**Six checks fail on purpose. Do not regenerate these baselines to clear them.** + + Hired History paints the same styled elements, in the same order + Hired History shows the same words + Hired History added only identity wrappers + analytics: paints the same styled elements, in the same order + analytics: shows the same words + candidates: shows the same words + +The first five are the `DEMO_FILL` removal, described immediately below. The +sixth is the final-selection queue and has its own dated entry further down. + +`hiringRecords.js` used to pad the hires list with five invented people +(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The +padding applied to Analytics too, so "total hires" counted eight where the +database held three. It has been removed: hires now come only from `staff`. + +`hired-history.pre-migration.html` and `analytics.pre-migration.html` were +captured **while the padding was in effect** — and because these baselines render +with queries disabled, the padding is *all* they contain. The Hired History +baseline is 31,453 characters holding all five invented names; the genuine empty +state is 11,947 and holds none. + +**Verified before leaving them failing**, so the drift is known rather than +assumed. Tag counts, baseline → now: + + 0 five table rows of people who were never hired + 13 +
39 +

7 + 8 + +Every difference is content that was fabricated. No styling, ordering or +structural rule changed. + +**Why they are not regenerated yet.** Two of them — `added only identity +wrappers` and `carries node identity in the DOM` — exist to prove the UI node +tree migration added exactly two `

`s and nothing else. That proof needs the +baseline to be *pre*-migration markup. Recapturing now would write *post*- +migration markup into a file named `pre-migration`, and the check would compare +the current render against itself and pass forever without proving anything. + +So the debt is held until the migration work lands, at which point both files +are recaptured together and this section is replaced by a dated entry saying so. + +**One related fix was made rather than deferred.** `HiredChronology` used to +`return null` with no hires, which took the `chronology` node identity out of the +DOM with it — so with an honest empty dataset the section could not be addressed +by Owliver or the layout editor at all, and the page said nothing where it most +needed to. It now renders an empty state inside the section, which the section +keeps. `carries node identity in the DOM` passes again because of it. + ## 2026-08-27 — the seed gained the three statuses nothing exercised `application_status` has seven values. The fixture produced four: `applied`, @@ -63,3 +117,47 @@ with it. It now names the page's topics in a sentence. Every intent `kind` was unchanged. No routing moved, no skill matching changed. That is why this regeneration was safe: the diff was read first, and it was one cosmetic change on a path that only runs when no agent is configured at all. + +## 2026-09-11 — Candidates became the final-selection queue + +`candidates: shows the same words` now fails, and it is the only check this +change breaks. + +The page used to list every application the org had ever taken — all seven +statuses at once — which made it a second Talent Pool rather than the queue of +hiring decisions waiting on a human. It now opens on final selection: interview +completed, not hired, not rejected. That is a change to what the page *says*, so +a check asserting the page says exactly what it said before was always going to +fail. There is no version of this work that leaves those words alone. + +**What drifted, verified before leaving it failing.** The rendered delta is the +empty state and nothing else: + + before "…0 of 0 candidates No candidates match your filters" + now "…0 of 0 candidates Nobody is awaiting a decision + Candidates arrive here once their interview is completed, + and leave once they are hired or declined." + +Title, subtitle and toolbar meta are byte-identical. The old copy was not merely +different, it was untrue: with no filters applied there is nothing to clear, and +"no candidates match your filters" describes a filter that was never set. + +Tag and class counts, baseline → now: + + class="…" 26 -> 27 one inserted: the description paragraph + +One insertion, nothing changed and nothing dropped — which is why +`candidates: paints the same styled elements, in the same order` and +`candidates: carries node identity in the DOM` both still pass. Those two are +the migration proof; only the words moved. + +The new stage filter options cost nothing here. `FilterSelect` is a Radix +`Select`, so its options live in a portal that is closed in static markup, and +`SelectValue` renders empty on the server — the baseline contains neither the +old option labels nor the new ones. + +**Why it is not regenerated.** The same reason as the five above: +`candidates.pre-migration.html` is load-bearing for the UI node tree proof, and +recapturing it now would write post-migration markup into a file named +`pre-migration`. It is held until the migration work lands and all of these are +recaptured together. diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 53eef2a..dae668a 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -5959,6 +5959,354 @@ record('an assigned candidate counts as hired', records.HIRED_STATUSES.includes('assigned') && records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired')); +/* ── Final selection, and seats ────────────────────────────────────────── + * + * Final selection is DERIVED — there is no `final_selection` value in the + * `application_status` enum and none is added. The fact it reads is the + * existence of an interview row, which is a NOT NULL foreign key, rather than + * `job_applications.interview_id`, which the schema keeps as an unconstrained + * soft reference precisely so it may dangle. + * + * That distinction is the whole point: `status = 'interview'` is set both when + * an interview is arranged and when one is completed, so status alone cannot + * tell a queue of people who have been interviewed from a queue of people who + * have merely been booked in. + */ +const fsApps = [ + { id: 'a-applied', status: 'applied' }, + { id: 'a-booked', status: 'interview' }, // arranged, never sat + { id: 'a-done', status: 'interview' }, // sat it + { id: 'a-hired', status: 'hired' }, + { id: 'a-assigned', status: 'assigned' }, + { id: 'a-rejected', status: 'rejected' }, +]; +const fsInterviews = [ + { id: 'i1', application_id: 'a-done' }, + { id: 'i2', application_id: 'a-hired' }, + { id: 'i3', application_id: 'a-rejected' }, +]; +const queue = records.finalSelection(fsApps, fsInterviews).map((a) => a.id); + +record('final selection holds only the interviewed and undecided', + queue.length === 1 && queue[0] === 'a-done', queue.join(', ') || 'empty'); +record('...so an interview merely arranged does not qualify', + !records.isFinalSelection(fsApps[1], fsInterviews), + 'status interview with no interview row'); +record('...and a decided application never reappears in the queue', + ['a-hired', 'a-assigned', 'a-rejected'] + .every((id) => !queue.includes(id)), + 'hired, assigned and rejected all excluded'); +record('...while the interview record, not interview_id, is what is read', + !records.isFinalSelection({ id: 'x', status: 'interview', interview_id: 'dangling' }, []), + 'a soft reference alone proves nothing'); + +/* Seats are counted from the employment records. A `filled` column would be a + second source of truth, and nothing could say which one was right. */ +const seatPosting = { id: 'p1', headcount: 2 }; +const seatStaff = [ + { id: 's1', job_posting_id: 'p1', status: 'active' }, + { id: 's2', job_posting_id: 'p1', status: 'onboarding' }, + { id: 's3', job_posting_id: 'p1', status: 'inactive' }, // left; seat reopens + { id: 's4', job_posting_id: 'p2', status: 'active' }, // another position +]; +record('seats filled are counted from the staff records', + records.filledFor('p1', seatStaff) === 2, `${records.filledFor('p1', seatStaff)} of 2`); +record('...someone who has left frees their seat', + records.remainingFor({ id: 'p1', headcount: 3 }, seatStaff) === 1, + 'inactive staff do not hold a seat'); +record('...a full position reports no remaining seats, never a negative number', + records.isFullyStaffed(seatPosting, seatStaff) + && records.remainingFor({ id: 'p1', headcount: 1 }, seatStaff) === 0, + 'over-hiring floors at zero'); + +/* ── The Candidates page IS the final-selection queue ───────────────────── + * + * The derivation above is only worth having if the page that decides hires + * actually opens on it. Candidates used to list every application the org had + * ever taken — all seven statuses at once — which made it a second Talent Pool + * rather than a queue of decisions waiting. + * + * Two rules these assertions hold in place, both of them safety rules rather + * than presentation ones: + * + * The evidence is the interview ROW. `interview_id` is a soft reference the + * schema deliberately leaves unconstrained, and it dangles in real data. + * + * The score never gates. `ai_score`, `verdict` and `hire_recommendation` + * assist a recruiter; a human makes the selection. A candidate who scored + * zero and sat the interview is still a decision somebody owes them. + */ +record('a candidate who scored zero still reaches the queue', + records.isFinalSelection({ id: 'a-zero', status: 'interview', ai_score: 0 }, + [{ id: 'i9', application_id: 'a-zero' }]), + 'screening is not a gate on the decision'); + +{ + /* Comments are stripped first: these assert what the page DOES, and a + comment explaining the bug that was fixed must not read as the bug. */ + const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const candidatesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/Candidates.jsx'), 'utf8')); + const nodesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/candidates/nodes.jsx'), 'utf8')); + /* Read from source rather than imported: the page pulls in the interview + modal, which touches `window` at module scope, and standing up a DOM to + read one string constant would be a worse test than reading the string. */ + const defaultStage = candidatesSrc.match(/export const DEFAULT_STAGE = '([a-z_]+)'/)?.[1]; + + record('the candidate page opens on the final-selection queue', + defaultStage === 'final' && /useState\(DEFAULT_STAGE\)/.test(candidatesSrc), + String(defaultStage)); + + /* The interview rows were fetched and then thrown away — `useInterviews();` + on its own line, its result unbound — which is why the queue could not be + derived on the one page that needed it. */ + record('the candidate page keeps the interview rows it fetches', + /(const|let)\s*\{[^}]*\}\s*=\s*useInterviews\(\)/.test(candidatesSrc), + 'useInterviews() is bound, not discarded'); + + /* The filter compared `a.stage`. Applications carry `status`; no application + has ever had a `stage` property, so every stage selection emptied the + page. */ + record('the stage filter reads a field applications actually carry', + !/\ba\.stage\b/.test(candidatesSrc) && /matchesStage\(/.test(candidatesSrc), + 'status, not an undefined stage'); + + /* Narrowing the default view is only safe if nothing becomes unreachable. + `shortlisted` and `assigned` were absent from these options, so the rows + holding them could not be found by any selection. */ + const offered = [...nodesSrc.matchAll(/\{ value: '([a-z_]+)', label: '[^']+' \}/g)] + .map((m) => m[1]); + const unreachable = records.APPLICATION_STATUSES.filter((st) => !offered.includes(st)); + record('every application status is still reachable from the stage filter', + unreachable.length === 0, unreachable.join(', ') || 'all seven offered'); + + record('...and the queue itself is offered alongside them', + offered.includes('final') && offered.includes('all'), + 'final selection and all stages'); +} + +/* ── Human interviews leave a trace, and only completion counts ─────────── + * + * A human interview used to persist nothing at all: the modal collected a + * type, a date, a time and notes, announced "Interview scheduled!", and dropped + * every one of them. So the final-selection queue could only ever hold people + * an AI had interviewed, and anyone seen by a person was invisible to it. + * + * What changed is where each fact lives, and the split is the safety property: + * + * booking → a `user_activity` row. Advances nobody. + * completion→ an `ai_interviews` row. The evidence, unchanged since Step 6. + * + * `isFinalSelection` is not touched by any of this and is not expected to be. + * These assertions exist to prove that a booking cannot masquerade as an + * interview, which is the one way this work could put a candidate in front of a + * hiring decision that nobody has actually met. + */ +const human = await server.ssrLoadModule('/src/lib/humanInterviews.js'); + +{ + const app = { id: 'a-human', status: 'interview', job_posting_id: 'p1', applicant_name: 'A' }; + const at = (n) => new Date(Date.UTC(2026, 0, n)).toISOString(); + + const bookedEvent = { + event_type: human.SCHEDULED, application_id: 'a-human', created_date: at(1), + metadata: { interview_type: 'video', scheduled_date: '2026-01-09', scheduled_time: '10:00', notes: 'prep' }, + }; + const completedRow = { id: 'i-human', application_id: 'a-human' }; + + /* Booked, and nothing else. */ + record('a booked human interview is recorded', + human.humanInterviewState(app, [bookedEvent], []) === 'scheduled'); + record('...and the booking keeps what was entered', + (() => { + const d = human.scheduleDetails(human.scheduledInterview(app, [bookedEvent])); + return d.type === 'video' && d.date === '2026-01-09' && d.time === '10:00' && d.notes === 'prep'; + })(), 'type, date, time and notes survive'); + record('...but booking alone is NOT final selection', + !records.isFinalSelection(app, []), + 'no interview row, no decision to make'); + + /* Held. */ + record('a completed human interview is recorded', + human.humanInterviewState(app, [bookedEvent], [completedRow]) === 'completed'); + record('...and a completed human interview DOES reach final selection', + records.isFinalSelection(app, [completedRow])); + record('...even with no score, because a human interview measures none', + records.isFinalSelection({ ...app, ai_score: 0 }, [completedRow]), + 'absence of an AI score is not absence of an interview'); + + /* Not held. Both outcomes are events, and neither is an interview. */ + for (const [label, type] of [['cancelled', human.CANCELLED], ['not attended', human.NO_SHOW]]) { + const events = [bookedEvent, { event_type: type, application_id: 'a-human', created_date: at(2) }]; + record(`an interview ${label} leaves no completion record`, + human.humanInterviewState(app, events, []) === (type === human.CANCELLED ? 'cancelled' : 'no_show')); + record(`...so ${label} never reaches final selection`, + !records.isFinalSelection(app, [])); + } + + /* A decided application is out regardless of how it was interviewed. */ + for (const status of ['hired', 'assigned', 'rejected']) { + record(`a ${status} application leaves the queue even with a human interview`, + !records.isFinalSelection({ ...app, status }, [completedRow])); + } + + /* The Step 6 guard, restated against a human booking: a booking may well set + interview_id one day, and it must still prove nothing on its own. */ + record('a dangling interview_id still does not qualify a human interview', + !records.isFinalSelection({ ...app, interview_id: 'nope' }, []), + 'the row is the evidence, not the reference'); + + /* An event claiming completion with no row behind it. The log is not the + record, and treating it as one would reintroduce exactly the soft-reference + problem interview_id already demonstrates. */ + record('a completion EVENT without an interview row is not completion', + human.humanInterviewState(app, + [{ event_type: human.COMPLETED, application_id: 'a-human', created_date: at(3) }], []) === 'none', + 'user_activity is a log, not the evidence'); +} + +/* ── Nothing about AI is invented for a human interview ─────────────────── + * + * The completed-interview record lives in `ai_interviews`, which is currently + * reused as the durable completion record for both flows. Reusing the table is + * a deliberate decision; filling in its AI columns with plausible numbers no + * machine produced would not be. A fabricated score or verdict would be read by + * analytics, by the candidate profile and by a recruiter as a measurement. + * + * `overall_interview_score` matters twice over: the server copies it onto the + * application only when the request mentions it, so sending a 0 would overwrite + * a real screening score with one this interview never took. + */ +{ + const hooksSrc = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); + const complete = hooksSrc.slice( + hooksSrc.indexOf('export function useCompleteHumanInterview'), + hooksSrc.indexOf('export function useRecordInterviewNotHeld') + ); + const schedule = hooksSrc.slice( + hooksSrc.indexOf('export function useScheduleHumanInterview'), + hooksSrc.indexOf('export function useCompleteHumanInterview') + ); + + record('the human completion path exists at all', complete.length > 0 && schedule.length > 0); + + const fabricated = ['overall_interview_score', 'integrity_score', 'ai_flags', 'category_scores', + 'hire_recommendation', 'messages', 'ai_score'] + .filter((f) => new RegExp(`^\\s*${f}\\s*:`, 'm').test(complete)); + record('a human interview fabricates no AI data', + fabricated.length === 0, fabricated.join(', ') || 'no AI column is written'); + + record('...and the interviewer\'s own assessment is only sent when given', + /if \(assessment\) record\.verdict = assessment;/.test(complete), + 'verdict is human-entered or left to the schema'); + + record('...while the facts it does write are the ones it knows', + /application_id: application\.id/.test(complete) + && /job_posting_id: application\.job_posting_id/.test(complete)); + + /* The rule the whole step turns on. */ + record('scheduling never writes an interview record', + !/AIInterview|useCreateInterview|createInterview/.test(schedule), + 'a booking touches user_activity only'); + + record('...and completion goes through the existing interview client', + /createInterview\.mutateAsync/.test(complete), + 'no second interview API'); + + /* Not held writes an event and nothing else — no row, and no invented + application status to go with it. */ + const notHeld = hooksSrc.slice(hooksSrc.indexOf('export function useRecordInterviewNotHeld')); + record('a cancelled or unattended interview writes no record and no new status', + !/AIInterview|createInterview/.test(notHeld) && !/JobApplication\.update/.test(notHeld), + 'an event, and nothing else'); +} + +/* The AI flow is untouched: it still writes its own record at completion, and + still does it through the same client this now shares. */ +{ + const aiSrc = readFileSync(join(ROOT, 'src/components/krow/AIInterviewModal.jsx'), 'utf8'); + record('the AI interview still records itself on completion', + /const finishInterview = useCallback/.test(aiSrc) + && /createInterview\.mutateAsync\(\{/.test(aiSrc) + && /messages: finalMessages/.test(aiSrc), + 'unchanged by the human path'); +} + +/* The modal is no longer theatre. */ +{ + const modalSrc = readFileSync(join(ROOT, 'src/components/krow/ScheduleInterviewModal.jsx'), 'utf8'); + record('scheduling an interview persists it', + /useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc)); + record('...the props its six call sites pass are unchanged', + /\{ open, onClose, application, position \}/.test(modalSrc), + 'open, onClose, application — position optional'); + record('...and a failed save is reported rather than celebrated', + /setError\(/.test(modalSrc) && !/setScheduled\(true\)/.test(modalSrc), + 'the old version always claimed success'); +} + +/* Every path that files an application on somebody's behalf must carry the + link back to the talent-pool record. The column is nullable, so omitting it + saves cleanly and fails silently: the application belongs to an email address + instead of to a person, and the hire it becomes cannot be traced back to the + profile it came from. Both call sites are asserted because they were written + at different times and only one of them is on the position page. */ +const positionDetailSource = readFileSync(join(ROOT, 'src/pages/PositionDetail.jsx'), 'utf8'); +const hooksSource = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); +record('admitting talent to a position links the application to the profile', + /worker_profile_id: profile\.id/.test(positionDetailSource), + 'PositionDetail.admitTalent'); +record('...and so does assigning a worker straight from the pool', + /worker_profile_id: worker\.profile\?\.id/.test(hooksSource), + 'useAssignWorkers filing an application'); + +/* ── One person, many positions ────────────────────────────────────────── + * + * The two profile routes answer different questions and must stay separate. + * `/admin/talent/:id` is the PERSON — a `worker_profiles` row, and every + * position they are up for. `/admin/candidates/:id` is that person FOR ONE + * POSITION — a `job_applications` row, with its score, interview and decision. + * + * One screen doing both is how a product ends up with a candidate record per + * vacancy, which is exactly what `worker_profiles` exists to prevent. + */ +record('the person and the application have separate routes', + routed.has('/admin/talent/:id') && routed.has('/admin/candidates/:id'), + [...routed].filter((r) => r.includes('talent') || r.includes('candidates')).join(', ')); + +/* Which applications are this person's. The foreign key is the answer; email is + the fallback for rows written before the key was being set, and it has to be + there or every application already in the database detaches from its person. */ +const whoProfile = { id: 'wp-1', email: 'Maria@Example.com' }; +const whoApps = [ + { id: 'by-key', worker_profile_id: 'wp-1', email: 'nothing@else.com' }, + { id: 'by-email', worker_profile_id: null, email: 'maria@example.com' }, + { id: 'by-email-case', email: 'MARIA@EXAMPLE.COM' }, + { id: 'somebody-else', worker_profile_id: 'wp-2', email: 'other@example.com' }, +]; +const whoIds = records.applicationsForProfile(whoApps, whoProfile).map((a) => a.id); +record('an application finds its person by foreign key', + whoIds.includes('by-key'), 'worker_profile_id'); +record('...and falls back to email, case-insensitively, for older rows', + whoIds.includes('by-email') && whoIds.includes('by-email-case'), + 'citext identity, as worker_profiles already asserts'); +record('...without collecting somebody else', + !whoIds.includes('somebody-else') && whoIds.length === 3, whoIds.join(', ')); +record('...and asks for nobody when there is no profile', + records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything'); + +/* The reported symptom: a talent pool you could read and not use. */ +const talentPoolSource = readFileSync(join(ROOT, 'src/pages/admin/talent-pool/nodes.jsx'), 'utf8'); +record('a talent pool row opens that person', + /onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource), + 'DataTable wires the handler only when it is given one'); +record('...and no action pretends to open something it does not', + !/toast\.info\(`Opening/.test(talentPoolSource), + 'the "view profile" toast is gone'); +record('...and a person can be put in front of a vacancy from the pool', + /setConsidering\(p\)/.test(talentPoolSource) + && /AddToPositionModal/.test(talentPoolSource), + 'the step that was missing between pool and position'); + /* "Screened and waiting on a decision" includes the shortlisted. This counted `ai_screened` alone, which read as correct only while nothing was ever shortlisted — the first shortlisted candidate dropped out of the count in diff --git a/src/App.jsx b/src/App.jsx index 98cba13..fef4ad2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -38,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile'; import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis'; import AdminHiredHistory from '@/pages/admin/HiredHistory'; import AdminTalentPool from '@/pages/admin/TalentPool'; +import AdminTalentProfile from '@/pages/admin/TalentProfile'; import AdminAnalytics from '@/pages/admin/Analytics'; import AdminActivity from '@/pages/admin/Activity'; import AdminProfile from '@/pages/admin/Profile'; @@ -107,6 +108,12 @@ const AuthenticatedApp = () => { } /> } /> } /> + {/* The person, keyed by worker profile — distinct from + `candidates/:id`, which is one person FOR ONE POSITION. Two + routes because they answer different questions; one screen + would push the product back towards a candidate record per + vacancy, which is the thing `worker_profiles` exists to stop. */} + } /> } /> } /> } /> diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index eba1960..e54f137 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -1080,9 +1080,6 @@ export default function KrowAssistant({ > {composer} -

- Owliver reads this page's data. Check anything you act on. -

); diff --git a/src/components/krow/AddToPositionModal.jsx b/src/components/krow/AddToPositionModal.jsx new file mode 100644 index 0000000..0af75c0 --- /dev/null +++ b/src/components/krow/AddToPositionModal.jsx @@ -0,0 +1,144 @@ +import React, { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds'; +import { useCreateApplication, useJobPostings } from '@/lib/krowHooks'; +import { remainingFor } from '@/lib/hiringRecords'; + +/** + * Put a person from the talent pool in front of a vacancy. + * + * This is the step the product was missing. The talent pool held people, the + * positions held vacancies, and nothing in the interface joined the two — so a + * candidate could only enter a pipeline from the position's own suggestion + * panel, and the Candidates page filled up with applications nobody could trace + * back to a person. + * + * What it writes is one `job_applications` row, which is the join: + * `worker_profile_id` names the person, `job_posting_id` names the vacancy. The + * master record is never copied — the same profile can hold an application + * against every position it is considered for. + * + * Filing the same person against the same position twice is refused by the + * database (`UNIQUE (job_posting_id, email)`), so positions they are already up + * for are shown as such rather than offered and then rejected. + */ +export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }) { + const navigate = useNavigate(); + const { data: postings = [], isLoading } = useJobPostings(); + const createApplication = useCreateApplication(); + const [busyId, setBusyId] = useState(null); + + /* A vacancy you can still be considered for: open, and not one this person is + already in the running for. Drafts and closed roles are not offers. */ + const open_ = useMemo( + () => postings.filter((p) => p.status === 'active' || p.status === 'paused'), + [postings] + ); + + const appliedTo = useMemo(() => { + const byPosting = new Map(); + for (const a of existing) byPosting.set(a.job_posting_id, a); + return byPosting; + }, [existing]); + + const add = async (posting) => { + if (!profile) return; + setBusyId(posting.id); + try { + const application = await createApplication.mutateAsync({ + /* The link back to the person. Without it the row saves anyway — the + column is nullable — and the hire it may become cannot be traced to + the profile it came from. */ + worker_profile_id: profile.id, + applicant_name: profile.full_name, + email: profile.email || '', + phone: profile.phone || '', + years_experience: profile.experience_years || 0, + skills: profile.skills || [], + availability: profile.availability || [], + certifications: profile.certifications || [], + companies_worked: (profile.experience || []).map((e) => e.company).filter(Boolean), + selfie_url: profile.selfie_url || '', + professional_summary: profile.career_goals || '', + cover_letter: '', + job_posting_id: posting.id, + job_title: posting.title, + status: 'applied', + ai_score: profile.krow_score || 0, + }); + toast.success(`${profile.full_name} is now up for ${posting.title}`); + onOpenChange?.(false); + if (application?.id) navigate(`/admin/candidates/${application.id}`); + } catch (error) { + /* The duplicate guard lives in the database, so this is also the path a + race takes — two operators adding the same person at once. Saying what + happened beats a generic failure. */ + const message = String(error?.message || ''); + toast.error(/conflict|exists|duplicate/i.test(message) + ? `${profile.full_name} is already up for ${posting.title}` + : message || 'That position could not be opened for this candidate'); + } finally { + setBusyId(null); + } + }; + + return ( + + {isLoading &&
} + + {!isLoading && open_.length === 0 && ( + { onOpenChange?.(false); navigate('/admin/positions'); }}>Go to Positions} + /> + )} + + {!isLoading && open_.length > 0 && ( +
    + {open_.map((p) => { + const already = appliedTo.get(p.id); + const remaining = staff.length ? remainingFor(p, staff) : null; + return ( +
  • +
    +

    {p.title}

    +

    + {[p.company, p.location].filter(Boolean).join(' · ') || '—'} + {remaining !== null && ` · ${remaining} of ${p.headcount ?? 1} open`} +

    +
    + + {already ? ( +
    + Already considered + +
    + ) : ( + + )} +
  • + ); + })} +
+ )} + + ); +} diff --git a/src/components/krow/ScheduleInterviewModal.jsx b/src/components/krow/ScheduleInterviewModal.jsx index 1340670..9a64120 100644 --- a/src/components/krow/ScheduleInterviewModal.jsx +++ b/src/components/krow/ScheduleInterviewModal.jsx @@ -1,50 +1,284 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; -import { Calendar, Clock, Video, Phone, User } from 'lucide-react'; +import { Calendar, CalendarX, Check, Clock, Phone, User, UserX, Video } from 'lucide-react'; +import { + useCompleteHumanInterview, useInterviews, useRecordInterviewNotHeld, + useScheduleHumanInterview, useUserActivity, +} from '@/lib/krowHooks'; +import { + CANCELLED, NO_SHOW, humanInterviewState, scheduleDetails, scheduledInterview, +} from '@/lib/humanInterviews'; + +/** + * Booking a human interview, and recording what came of it. + * + * This modal used to be a decoration. It collected a type, a date, a time and + * some notes, said "Interview scheduled!", and threw all four away — no + * request, no record, and nobody notified despite the message saying so. A + * candidate interviewed by a person therefore left no trace anywhere, which is + * why the final-selection queue could only ever contain AI interviews. + * + * It now covers the whole of a human interview's life, in the order it happens: + * + * nothing booked → the booking form, as before + * booked → what happened: held, not attended, or called off + * held → nothing left to do; the record exists + * + * THE ONE RULE THIS FILE EXISTS TO KEEP + * + * Booking an interview and completing one are different writes with different + * consequences. The booking is a `user_activity` row and advances nobody. Only + * completion writes the `ai_interviews` record that `isFinalSelection` reads, + * and completion is an explicit act by whoever ran the interview. So a + * candidate cannot reach a hiring decision by being put in a calendar. + * + * The props are unchanged — `open`, `onClose`, `application` — because six + * places mount this: admin Candidates (directly and through its node tree), + * Candidate Profile, Position Detail, the older Candidates page, and + * CandidateCard's own fallback. `position` is optional and only sharpens the + * activity record when a caller has it. + */ + +const TYPES = [ + { value: 'video', label: 'Video', icon: Video }, + { value: 'phone', label: 'Phone', icon: Phone }, + { value: 'inperson', label: 'In Person', icon: User }, +]; + +const TYPE_LABEL = Object.fromEntries(TYPES.map((t) => [t.value, t.label])); + +/* The interviewer's own reading, in the three words the column already holds. + Recorded because a person chose it, and never acted on automatically — the + hire control is elsewhere and stays a deliberate click. */ +const ASSESSMENTS = [ + { value: 'hire', label: 'Would hire' }, + { value: 'maybe', label: 'Unsure' }, + { value: 'no', label: 'Would not hire' }, +]; + +const field = 'w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none'; + +export default function ScheduleInterviewModal({ open, onClose, application, position }) { + const { data: activity = [] } = useUserActivity(); + const { data: interviews = [] } = useInterviews(); + + const schedule = useScheduleHumanInterview(); + const complete = useCompleteHumanInterview(); + const notHeld = useRecordInterviewNotHeld(); -export default function ScheduleInterviewModal({ open, onClose, application }) { const [type, setType] = useState('video'); const [date, setDate] = useState(''); const [time, setTime] = useState(''); const [notes, setNotes] = useState(''); - const [scheduled, setScheduled] = useState(false); - const handleSchedule = () => { - setScheduled(true); - setTimeout(() => { - setScheduled(false); - onClose(); - }, 1500); + const [recording, setRecording] = useState(null); // 'completed' | NO_SHOW | CANCELLED + const [assessment, setAssessment] = useState(''); + const [outcomeNotes, setOutcomeNotes] = useState(''); + + const [done, setDone] = useState(''); + const [error, setError] = useState(''); + + const state = useMemo( + () => humanInterviewState(application, activity, interviews), + [application, activity, interviews] + ); + const booking = useMemo( + () => scheduledInterview(application, activity), + [application, activity] + ); + const booked = booking ? scheduleDetails(booking) : null; + + const busy = schedule.isPending || complete.isPending || notHeld.isPending; + + const finish = (message) => { + setDone(message); + setTimeout(() => { setDone(''); onClose(); }, 1500); }; + const run = async (work, message) => { + setError(''); + try { + await work(); + finish(message); + } catch (e) { + /* The booking is a real write now, so a failure is a real failure and + says so, rather than the old unconditional "Interview scheduled!". */ + setError(e?.message || 'That could not be saved. Please try again.'); + } + }; + + const submitSchedule = () => run( + () => schedule.mutateAsync({ application, position, type, date, time, notes }), + 'Interview scheduled' + ); + + const submitOutcome = () => { + if (recording === 'completed') { + return run( + () => complete.mutateAsync({ application, position, assessment, notes: outcomeNotes }), + 'Interview recorded' + ); + } + return run( + () => notHeld.mutateAsync({ application, position, outcome: recording, notes: outcomeNotes }), + recording === NO_SHOW ? 'Marked as not attended' : 'Interview cancelled' + ); + }; + + const title = state === 'scheduled' ? 'Interview' : 'Schedule Interview'; + return ( !v && onClose()}> - Schedule Interview + {title} - {scheduled ? ( + {done ? (
- +
-

Interview scheduled!

-

{application?.applicant_name} will be notified.

+

{done}

+

{application?.applicant_name}

+
+ ) : state === 'completed' ? ( + /* The interview record exists, so there is nothing to book and + nothing to record. This is also the state that puts the candidate + in the final-selection queue. */ +
+
+ +
+

Interview already recorded

+

+ {application?.applicant_name} is awaiting a hiring decision. +

+ + + +
+ ) : state === 'scheduled' && !recording ? ( +
+
+

+ {TYPE_LABEL[booked?.type] || 'Video'} interview booked +

+

+ {booked?.date} at {booked?.time} +

+ {booked?.notes && ( +

{booked.notes}

+ )} +
+ +
+

What happened?

+

+ Only a completed interview puts this candidate up for a hiring decision. +

+
+ + + +
+
+ + {error &&

{error}

} + + + + +
+ ) : recording ? ( +
+ {recording === 'completed' ? ( +
+ +

+ Your own reading, entered by you. It is recorded alongside the interview + and never decides anything — hiring stays a separate, deliberate action. +

+
+ {ASSESSMENTS.map((a) => ( + + ))} +
+
+ ) : ( +

+ {recording === NO_SHOW + ? 'Recorded as not attended. No interview record is created, so this candidate does not go forward for a decision.' + : 'Recorded as cancelled. No interview record is created, so this candidate does not go forward for a decision.'} +

+ )} + +
+ +