import { useMemo, useState } from 'react' import { AlertTriangle, ArrowRight, Globe, Info, TrendingUp } from 'lucide-react' import { ToolHeader } from '../../components/ToolHeader' import { Bar, Panel } from '../../components/ui' import type { RTimeToFill } from '../../lib/types' import { recruitBrand } from './brand' import { useAnalytics } from './analyticsQueries' const chip = (on: boolean) => `rounded-full px-3 py-1 text-xs font-semibold transition ${ on ? 'bg-recruit/25 text-recruit shadow-[inset_0_0_0_1px_var(--color-recruit)]' : 'bg-navy-900 text-faint hover:text-muted border border-line/60' }` const money = (v?: string) => { if (!v) return '' const [w, f = ''] = v.split('.') const t = f.replace(/0+$/, '') return '$' + w + '.' + (t.length <= 2 ? t.padEnd(2, '0') : t) } // The time to fill card, which is the whole point of this screen. // // Both figures are shown side by side and labelled. The naive one is what every other tool reports; the // corrected one keeps the still open orders in the calculation. When they differ, the gap IS the // survivorship bias, and showing only one of them is how that bias goes unnoticed. function TimeToFillCard({ title, hint, ttf }: { title: string; hint: string; ttf: RTimeToFill }) { const biased = ttf.median_reached && ttf.naive_median_days && ttf.median_days ? Number(ttf.median_days) - Number(ttf.naive_median_days) : null return ( {ttf.cohort === 0 ? (

No requisitions opened in this window.

) : ( <>

Median, corrected

{ttf.median_reached ? (

{Number(ttf.median_days).toFixed(1)} days

) : ( // Absent is not zero. Fewer than half the cohort has filled, so a median does not exist // yet, and inventing one would be a fabrication.

Not yet reached

)}

keeps the {ttf.still_open} still open in the calculation

Median, filled only

{ttf.naive_median_days ? Number(ttf.naive_median_days).toFixed(1) : '—'} {ttf.naive_median_days && days}

ignores everything still open

{!ttf.median_reached && ttf.naive_median_days && (

Only {ttf.filled} of {ttf.cohort} have filled, so half the cohort has not finished and there is no median yet. The {Number(ttf.naive_median_days).toFixed(1)} day figure beside it counts only the ones that finished, which is why it looks fast.

)} {biased !== null && biased > 0.05 && (

Counting only filled orders understates this by {biased.toFixed(1)} days.

)}

{ttf.cohort} opened · {ttf.filled} filled · {ttf.still_open} still open

)}
) } export function Analytics() { const [days, setDays] = useState(90) // Memoised on the window length, not recomputed per render. // // Without this, Date.now() produces a slightly different instant every render, the query key changes // with it, and React Query refetches forever: the screen sits on "Computing" while the API answers // correctly every time. The API being right is exactly why this only shows up by looking at the page. const since = useMemo(() => new Date(Date.now() - days * 864e5).toISOString(), [days]) const q = useAnalytics(since) if (q.isLoading) return Computing… if (q.isError || !q.data) { return ( Could not load analytics. {(q.error as Error)?.message} ) } const v = q.data const maxRung = v.funnel.length ? v.funnel[0].count : 0 const maxAging = Math.max(1, ...v.aging.map((a) => a.count)) return (
{v.time_zone}
{[30, 90, 180, 365].map((d) => ( ))} Cohort is every job order OPENED in the window, not every one filled.
{[ { l: 'Filled', n: v.fill_rate.filled }, { l: 'Closed', n: v.fill_rate.closed_unfilled }, { l: 'Cancelled', n: v.fill_rate.cancelled }, { l: 'Open', n: v.fill_rate.still_open }, ].map((x) => (

{x.l}

{x.n}

))}
{v.fill_rate.suppressed ? ( // The counts are always shown; only the ratio is withheld, because a rate over a handful of // orders is arithmetically correct and meaningless.

Too few resolved job orders to report a rate. The counts above are exact.

) : (

Fill rate, counting cancellations {v.fill_rate.rate_of_resolved}%

Fill rate, excluding cancellations {v.fill_rate.rate_of_decided ?? '—'}%

A cancelled order is a role the client pulled. Both denominators are shown because the definition genuinely forks and picking one silently makes two reports disagree.

)}
{v.fill_rate.still_open === 0 ? (

Nothing open.

) : (
{v.aging.map((a) => (
{a.label}
0 ? 6 : 0)}%`, background: a.min_days >= 60 ? 'var(--color-risk)' : a.min_days >= 30 ? 'var(--color-warn)' : 'var(--color-blue-soft)', }} />
{a.count}
))}

A distribution, not an average. An average age hides the tail, and the tail is the problem.

)}
{v.funnel.map((s) => (
{s.label}
0 ? Math.max((s.count / maxRung) * 100, s.count > 0 ? 6 : 0) : 0}%` }} />
{s.count} · {s.pct}%
))}
{v.conversion.length > 0 && v.funnel[0].count > 0 && (
    {v.conversion.map((c) => (
  • {c.from_label} {c.to_label} {c.rate}%
  • ))}
)} {v.sources.length === 0 ? (

No candidates in this window.

) : (
    {v.sources.map((s) => (
  • {s.source} {s.applications} candidates · {s.placed} placed {s.suppressed ? ( rate withheld ) : ( {s.rate}% )}
  • ))}
)}

First touch, never last. Attributing a placement to the most recent contact is how every channel eventually looks like a referral.

{[ { l: 'Live', n: v.placements.live }, { l: 'Started', n: v.placements.started }, { l: 'Fell off', n: v.placements.fell_off }, ].map((x) => (

{x.l}

{x.n}

))}
{v.placements.contract_gross_per_hour && (

Live contract spread {money(v.placements.contract_gross_per_hour)}/hr

)} {v.placements.direct_hire_fees && (

Direct hire fees {money(v.placements.direct_hire_fees)}

)} {v.placements.guarantee_breaches > 0 && (

Guarantee breaches {v.placements.guarantee_breaches}

)}

This is the SPREAD, bill minus pay, before the cost of employing anybody. It is not margin and it is not profit. It is also not annualised, because doing so would assume a full time full year assignment nobody agreed to.

{v.recruiters.length === 0 ? (

No activity logged in this window.

) : (
    {v.recruiters.map((r) => (
  • {r.name} {r.logged} logged · {r.connects} connected {r.connect_rate && {r.connect_rate}%}
  • ))}
)}

Attempts and conversations are counted separately. A hundred dials with two conversations is a different day from a hundred conversations.

{/* DESK OUTCOMES sits beside recruiter EFFORT deliberately. Effort says what somebody did, outcomes say what it produced, and a desk logging two hundred calls and placing nobody reads identically to one logging forty and placing four in an effort report alone. */} {v.desk_outcomes.length === 0 ? (

Nothing was submitted or placed in this window.

) : (
    {v.desk_outcomes.map((d) => (
  • {d.name} {d.submissions} submitted · {d.placements} placed {d.placement_rate && ( {d.placement_rate}% )}
  • ))}
)}

A submission counts from the stage history, so somebody submitted and later rejected still counts. The work happened.

{/* REDEPLOYMENT is the metric that measures whether the firm keeps what it already won, and nothing in this product could compute it until the redeployment watch existed. */} {v.redeployment.ended === 0 ? (

Nobody finished an assignment in this window, so there is nothing to measure yet.

) : ( <>
{v.redeployment.rate ?? '0'}% {v.redeployment.redeployed} of {v.redeployment.ended} kept
= 50 ? 'var(--color-ok)' : 'var(--color-warn)'} />
)}

A contractor rolling off is already cleared, already known to the client and already proven on the work, which makes them the cheapest placement available. Everyone is counted once however many assignments they ended.

) }