import { useMemo, useState } from 'react' import { AlertTriangle, ArrowRight, Globe, Info, TrendingUp } from 'lucide-react' import { ToolHeader } from '../../components/ToolHeader' import { 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 margin {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}

)}

Hourly margin is not annualised. 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.

) }