// Candidate detail — Notion-page style notes, no live discussion clutter. // Used standalone (full-screen artboard) and as a pop-up from the kanban. const { useState: useStateC, useEffect: useEffectC, useRef: useRefC } = React; function StageFlow({ currentStageId }) { const flow = STAGES.filter(s => !["backburner","rejected","not_interested","declined"].includes(s.id)); const idx = flow.findIndex(s => s.id === currentStageId); return (
{flow.map((s, i) => { const cls = i < idx ? " is-done" : i === idx ? " is-current" : ""; return ( {s.label} {i < flow.length - 1 && } ); })}
); } function SubmitterCard({ c }) { const d = c._detail || {}; const why = d.submitter_why; const relocate = d.relocation_willing; const inPerson = d.in_person_willing; if (!why && !relocate && !inPerson) return null; const labelFor = (v) => { if (!v) return null; const norm = String(v).toLowerCase(); if (norm === "yes") return "Yes"; if (norm === "no") return "No"; if (norm === "open" || norm === "maybe") return "Open to it"; return v; }; const submitterName = d.referrer || c.referrer || d.submitted_by || "External submission"; return (
From {submitterName}
{why && (
Why interested
{why}
)} {(relocate || inPerson) && (
{relocate && (
Relocating to Seattle
{labelFor(relocate)}
)} {inPerson && (
5 days in-person
{labelFor(inPerson)}
)}
)}
); } function AISummaryCard({ c }) { const jobTitle = JOBS.find(j => j.id === c.jobId)?.title; const [generating, setGenerating] = useStateC(false); const [brief, setBrief] = useStateC(null); const displayBrief = brief || c._detail?.ai_summary; const [briefError, setBriefError] = useStateC(null); const regenerate = async () => { setGenerating(true); setBriefError(null); try { const resp = await fetch(`${API_BASE_URL}/candidates/${c.id}/brief`, { method: "POST", headers: { Authorization: `Bearer ${localStorage.getItem("ats_token")}` }, }); if (!resp.ok) { setGenerating(false); setBriefError("Failed to regenerate brief."); return; } const poll = setInterval(async () => { const detail = await fetchCandidateDetail(c.id); if (detail?.ai_summary && detail.ai_summary !== displayBrief) { setBrief(detail.ai_summary); setGenerating(false); clearInterval(poll); } }, 3000); setTimeout(() => { clearInterval(poll); setGenerating(false); }, 30000); } catch { setGenerating(false); setBriefError("Failed to regenerate brief."); } }; return (
N Candidate brief
{briefError &&

{briefError}

} {generating ?

Generating candidate brief...

: displayBrief ?
: c._detail?.resume_summary ?
:

{c.first}{jobTitle ? <> — candidate for {jobTitle} : ""}.

}
); } // === Per-candidate live note + score store =========================== // Module-level reactive store. Components subscribe via useAtsCandidateStore(c.id) // and re-render when posts mutate the data. const __atsStore = { // candidateId -> { ratings: [...], notes: [...], interviews: [...] } data: new Map(), listeners: new Set(), get(cid) { if (!this.data.has(cid)) this.data.set(cid, { ratings: [], notes: [], interviews: [] }); return this.data.get(cid); }, setInterviews(cid, interviews) { this.get(cid).interviews = interviews; this._emit(); }, addRating(cid, rating) { this.get(cid).ratings.push(rating); this._emit(); }, addNote(cid, note) { this.get(cid).notes.unshift(note); // newest on top this._emit(); }, subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }, _emit() { this.listeners.forEach(fn => fn()); }, }; function useAtsCandidateStore(cid) { const [, force] = useStateC(0); useEffectC(() => __atsStore.subscribe(() => force(n => n + 1)), []); return __atsStore.get(cid); } // Combined ratings = API interviews + any posts for this candidate. function ratingsFor(cid) { return [...__atsStore.get(cid).interviews, ...__atsStore.get(cid).ratings]; } // ---- Radar chart primitives (kept for potential future use) ---- function consensusLabel(spread) { if (spread == null) return { label: "—", tone: "neutral" }; if (spread <= 0.5) return { label: "Tight", tone: "good" }; if (spread <= 1.0) return { label: "Aligned", tone: "good" }; if (spread <= 1.5) return { label: "Mixed", tone: "warn" }; return { label: "Split", tone: "bad" }; } // Lay out N axes evenly around a circle, starting at the top (12 o'clock). function axisPoints(n, cx, cy, r) { const out = []; for (let i = 0; i < n; i++) { const a = -Math.PI / 2 + (i * 2 * Math.PI) / n; out.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, angle: a }); } return out; } // Returns the polygon points string for an array of values (0..5) along axes. function polyPoints(values, axes, cx, cy, rMax) { return values.map((v, i) => { const r = (Math.max(0, Math.min(5, v)) / 5) * rMax; const a = axes[i].angle; return `${cx + Math.cos(a) * r},${cy + Math.sin(a) * r}`; }).join(" "); } // ====== Notion-style notes ====== // Each note is a page-level block: author row + clean body + optional reactions. // No chrome, no borders — the page IS the document. function NotionNote({ n, onDelete }) { const [confirmDelete, setConfirmDelete] = useStateC(false); return (
{n.author.name} {n.author.role && {n.author.role}} {n.time} {n.pinned && } {onDelete && !confirmDelete && ( )} {onDelete && confirmDelete && ( Delete?{" "} {" / "} )}
{n.body}
{n.attached && (
📎 {typeof n.attached === "string" ? ( {n.attached} ) : n.attached.url ? ( {n.attached.name} ) : ( {n.attached.name} )}
)} {n.reactions && (
{n.reactions.map((r, i) => ( ))}
)}
); } function NotesComposer({ c }) { const viewer = useViewer(); const [expanded, setExpanded] = useStateC(false); const [posted, setPosted] = useStateC(false); const [saveError, setSaveError] = useStateC(null); const [saving, setSaving] = useStateC(false); const EMPTY = { rawNotes: "", tldr: "", summary: "" }; const [vals, setVals] = useStateC(EMPTY); const [noteStage, setNoteStage] = useStateC(c.stage || "sourced"); const [attachedFile, setAttachedFile] = useStateC(null); const fileInputRef = React.useRef(null); const MAX_ATTACH = 25 * 1024 * 1024; const setField = (k) => (e) => setVals(prev => ({ ...prev, [k]: e.target.value })); // Auto-bullet: typing "- " (or "* ") at start of any line converts it to "• ". // Pressing Enter inside a bullet line continues the list; Enter on empty bullet exits. const applyBulletConvert = (k, ta, v) => { const caret = ta.selectionStart; if (caret < 2) return false; const last2 = v.slice(caret - 2, caret); if (last2 !== "- " && last2 !== "* ") return false; const lineStart = v.lastIndexOf("\n", caret - 3) + 1; const before = v.slice(lineStart, caret - 2); if (before.trim().length > 0) return false; // only at line start (allow leading whitespace) const next = v.slice(0, caret - 2) + before + "• " + v.slice(caret); ta.value = next; const pos = (lineStart + before.length) + 2; ta.setSelectionRange(pos, pos); setVals(prev => ({ ...prev, [k]: next })); return true; }; const onKey = (k) => (e) => { if (e.key === "Escape") { e.stopPropagation(); setExpanded(false); return; } if (e.key === "Tab") { const ta = e.target; const v = ta.value; const caret = ta.selectionStart; const lineStart = v.lastIndexOf("\n", caret - 1) + 1; const lineEnd = v.indexOf("\n", caret); const line = v.slice(lineStart, lineEnd === -1 ? v.length : lineEnd); const m = line.match(/^(\s*)([-•*])\s/); if (m) { e.preventDefault(); if (e.shiftKey) { const indent = m[1]; if (indent.length >= 2) { const next = v.slice(0, lineStart) + line.slice(2) + v.slice(lineEnd === -1 ? v.length : lineEnd); ta.value = next; ta.setSelectionRange(Math.max(lineStart, caret - 2), Math.max(lineStart, caret - 2)); setVals(prev => ({ ...prev, [k]: next })); } } else { const next = v.slice(0, lineStart) + " " + line + v.slice(lineEnd === -1 ? v.length : lineEnd); ta.value = next; ta.setSelectionRange(caret + 2, caret + 2); setVals(prev => ({ ...prev, [k]: next })); } return; } } if (e.key === "Enter" && !e.shiftKey) { const ta = e.target; const v = ta.value; const caret = ta.selectionStart; const lineStart = v.lastIndexOf("\n", caret - 1) + 1; const line = v.slice(lineStart, caret); const m = line.match(/^(\s*)([-•*])\s+(.*)$/); if (m) { e.preventDefault(); if (!m[3]) { const next = v.slice(0, lineStart) + v.slice(caret); ta.value = next; ta.setSelectionRange(lineStart, lineStart); setVals(prev => ({ ...prev, [k]: next })); return; } const bullet = m[2] === "*" ? "•" : m[2]; const insert = "\n" + m[1] + bullet + " "; const next = v.slice(0, caret) + insert + v.slice(caret); ta.value = next; const pos = caret + insert.length; ta.setSelectionRange(pos, pos); setVals(prev => ({ ...prev, [k]: next })); } } }; const onInput = (k) => (e) => { const ta = e.target; if (e.nativeEvent.inputType === "insertText" && e.nativeEvent.data === " ") { applyBulletConvert(k, ta, ta.value); } }; const FIELDS = [ { id: "rawNotes", icon: "brain", label: "Raw notes", hint: "Brain dump — anything worth capturing · use - for bullets", rows: 6 }, { id: "tldr", icon: "spark", label: "TLDR", hint: "One sentence: your overall impression", rows: 1 }, { id: "summary", icon: "chat", label: "Summary & justification", hint: "Expand on the TLDR — what drove your assessment", rows: 4 }, ]; if (!expanded) { return ( <> {posted && (
Note posted to timeline
)} ); } return (
{viewer.name} now Tab to indent bullets · ⌘⏎ to post
{FIELDS.map(f => (