"use client"; import { useEffect, useMemo, useState } from "react"; import { Undo2 } from "lucide-react"; import { useJobs } from "@/components/jobs/job-store"; import type { InboxJob } from "@/lib/career-ops"; import type { AtsSource } from "@/lib/explore"; import { ATS_SOURCES } from "@/lib/explore"; import { daysSince, seniorityFromTitle, sourceFromUrl, SENIORITY_ORDER, type Seniority } from "@/lib/inbox"; import { FacetChips } from "./facet-chips"; import { TriageRow, type RowScore } from "./triage-row"; import { ShortlistTray, type ShortItem } from "./shortlist-tray"; import { cn } from "@/lib/cn"; const SHORTLIST_KEY = "career-ops:shortlist"; const HIDDEN_KEY = "career-ops:hidden"; const CONFIG_KEY = "career-ops:config"; const BATCH = 20; // The inbox as a TRIAGE surface: Abundance β†’ Triage β†’ Shortlist β†’ Opt-in Score. // Default is a small fresh batch (never the full wall); free facets + Save/Skip narrow // it; only "Score shortlist" spends tokens. πŸ”΄ The shell is agnostic to what makes a // role relevant β€” order is freshness with a single documented plug point. export function InboxTriage({ inbox }: { inbox: InboxJob[] }) { const { jobs, startJob } = useJobs(); // facets const [within, setWithin] = useState(null); const [sources, setSources] = useState>(() => new Set()); const [seniorities, setSeniorities] = useState>(() => new Set()); const [locQ, setLocQ] = useState(""); const [kw, setKw] = useState(""); const [showAll, setShowAll] = useState(false); // persisted triage state + ephemeral selection/undo const [shortlist, setShortlist] = useState([]); const [hidden, setHidden] = useState([]); const [selected, setSelected] = useState>(() => new Set()); const [undo, setUndo] = useState<{ label: string; fn: () => void } | null>(null); const [hasCli, setHasCli] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { try { const s = localStorage.getItem(SHORTLIST_KEY); if (s) setShortlist(JSON.parse(s)); const h = localStorage.getItem(HIDDEN_KEY); if (h) setHidden(JSON.parse(h)); const c = localStorage.getItem(CONFIG_KEY); setHasCli(!!(c && JSON.parse(c).cliId)); } catch { /* ignore */ } setLoaded(true); }, []); useEffect(() => { if (loaded) try { localStorage.setItem(SHORTLIST_KEY, JSON.stringify(shortlist)); } catch { /* quota */ } }, [shortlist, loaded]); useEffect(() => { if (loaded) try { localStorage.setItem(HIDDEN_KEY, JSON.stringify(hidden)); } catch { /* quota */ } }, [hidden, loaded]); // auto-dismiss the undo toast useEffect(() => { if (!undo) return; const t = setTimeout(() => setUndo(null), 5000); return () => clearTimeout(t); }, [undo]); // stable "now" for freshness (per mount) const now = useMemo(() => Date.now(), []); // Dedupe by URL β€” pipeline.md can list the same posting twice; it's one job, so it // triages once (and Save/Skip/score, all keyed by URL, act on it coherently). const enriched = useMemo(() => { const seen = new Set(); const out: { job: InboxJob; source: AtsSource | null; seniority: Seniority | null; age: number | null }[] = []; for (const job of inbox) { if (seen.has(job.url)) continue; seen.add(job.url); out.push({ job, source: sourceFromUrl(job.url), seniority: seniorityFromTitle(job.role), age: daysSince(job.postedAt, now) }); } return out; }, [inbox, now]); // EVALUADA lookup: the latest evaluate worker per posting URL (running β†’ badge). const scoreByUrl = useMemo(() => { const best = new Map(); for (const j of jobs) { if (!j.input || j.kind !== "evaluate") continue; const ex = best.get(j.input); if (!ex || j.startedAt > ex.startedAt) best.set(j.input, j); } const m = new Map(); for (const [url, j] of best) { m.set(url, { score: j.result?.score ?? null, tone: j.result?.tone ?? "muted", jobId: j.id, running: j.status === "running" }); } return m; }, [jobs]); // facet options β€” only surface what's actually present in the (non-hidden) data const availSources = useMemo(() => { const set = new Set(); for (const e of enriched) if (e.source && !hidden.includes(e.job.url)) set.add(e.source); return ATS_SOURCES.filter((s) => set.has(s)); }, [enriched, hidden]); const availSeniorities = useMemo(() => { const set = new Set(); for (const e of enriched) if (e.seniority && !hidden.includes(e.job.url)) set.add(e.seniority); return SENIORITY_ORDER.filter((s) => set.has(s)); }, [enriched, hidden]); const filtered = useMemo( () => enriched.filter((e) => { if (hidden.includes(e.job.url)) return false; if (within != null && (e.age == null || e.age > within)) return false; if (sources.size && (!e.source || !sources.has(e.source))) return false; if (seniorities.size && (!e.seniority || !seniorities.has(e.seniority))) return false; if (locQ.trim() && !(e.job.location || "").toLowerCase().includes(locQ.trim().toLowerCase())) return false; if (kw.trim() && !`${e.job.company} ${e.job.role}`.toLowerCase().includes(kw.trim().toLowerCase())) return false; return true; }), [enriched, hidden, within, sources, seniorities, locQ, kw], ); // πŸ”΄ SINGLE ORDER PLUG POINT β€” freshness only (newest first_seen first; unknown last). // A smarter ranker replaces ONLY this comparator; facets/triage/shortlist/score never // touch relevance. This is the whole firewall in one line. const ordered = useMemo(() => [...filtered].sort((a, b) => (a.age ?? Infinity) - (b.age ?? Infinity)), [filtered]); const anyFacet = within != null || sources.size > 0 || seniorities.size > 0 || locQ.trim() !== "" || kw.trim() !== ""; const capped = !showAll && !anyFacet; const visible = capped ? ordered.slice(0, BATCH) : ordered; const hiddenCount = hidden.length; const isShortlisted = (url: string) => shortlist.some((s) => s.url === url); const save = (job: InboxJob) => { if (isShortlisted(job.url)) return; setShortlist((s) => [...s, { url: job.url, company: job.company, role: job.role }]); }; const skip = (job: InboxJob) => { setHidden((h) => (h.includes(job.url) ? h : [...h, job.url])); setUndo({ label: `Skipped ${job.company}`, fn: () => setHidden((h) => h.filter((u) => u !== job.url)) }); }; const toggleSelect = (url: string) => setSelected((s) => { const n = new Set(s); if (n.has(url)) n.delete(url); else n.add(url); return n; }); const saveSelected = () => { const add = enriched .filter((e) => selected.has(e.job.url) && !isShortlisted(e.job.url)) .map((e) => ({ url: e.job.url, company: e.job.company, role: e.job.role })); if (add.length) setShortlist((s) => [...s, ...add]); setSelected(new Set()); }; const estimate = useMemo(() => { const samples = jobs.filter((j) => j.kind === "evaluate" && j.status === "done" && j.cost?.tokens).map((j) => j.cost!); if (!samples.length || shortlist.length === 0) return {}; const avgT = samples.reduce((a, c) => a + c.tokens, 0) / samples.length; const usds = samples.filter((s) => s.usd != null).map((s) => s.usd!); const avgUsd = usds.length ? usds.reduce((a, c) => a + c, 0) / usds.length : undefined; return { tokens: Math.round(avgT * shortlist.length), usd: avgUsd != null ? +(avgUsd * shortlist.length).toFixed(2) : undefined }; }, [jobs, shortlist.length]); const scoreShortlist = () => { const batchId = `shortlist-${Date.now()}`; for (const it of shortlist) { startJob({ title: `Score Β· ${it.company}`, subtitle: it.role, kind: "evaluate", input: it.url, page: "/pipeline", batchId }); } setShortlist([]); // sent β€” the rows flip to Scoring… β†’ badge via scoreByUrl }; // The parent (PipelineView) renders the rich empty-inbox card; here we always // have β‰₯1 raw posting. if (inbox.length === 0) return null; return (
0 && "pb-28 sm:pb-24")}> setSources((set) => { const n = new Set(set); n.has(s) ? n.delete(s) : n.add(s); return n; })} seniorities={seniorities} toggleSeniority={(s) => setSeniorities((set) => { const n = new Set(set); n.has(s) ? n.delete(s) : n.add(s); return n; })} locQ={locQ} setLocQ={setLocQ} kw={kw} setKw={setKw} availSources={availSources} availSeniorities={availSeniorities} resultCount={filtered.length} totalCount={enriched.length - hiddenCount} anyActive={anyFacet} onClear={() => { setWithin(null); setSources(new Set()); setSeniorities(new Set()); setLocQ(""); setKw(""); }} /> {/* batch header: fresh slice by default, or the full filtered set */}

{capped ? "Fresh β€” worth a look" : anyFacet ? `${filtered.length} match${filtered.length === 1 ? "" : "es"}` : "All roles"}

{hiddenCount > 0 && ( )}
{/* multi-select action bar */} {selected.size > 0 && (
{selected.size} selected
)} {visible.length > 0 ? (
    {visible.map((e) => ( toggleSelect(e.job.url)} onSave={() => save(e.job)} onSkip={() => skip(e.job)} /> ))}
) : (

No matches

Loosen the filters to see more of your inbox.

)} {/* "See all N" β€” only when the fresh batch is capping a larger list */} {capped && ordered.length > BATCH && ( )} {/* empty-shortlist guidance (only once there's nothing saved) */} {shortlist.length === 0 && (

Save roles worth a look, then score them together β€” one token spend.

)} {/* undo toast (sits above the tray) */} {undo && (
0 ? "bottom-24 sm:bottom-24" : "bottom-6")}>
{undo.label}
)} setShortlist((s) => s.filter((x) => x.url !== url))} onClear={() => setShortlist([])} onScore={scoreShortlist} />
); }