Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/lib/manager.ts |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/lib/manager.ts |
| Original SHA-256 | 72bcf546ac742953c68bdd22ee149ae1c307e6b44e6a0fc287c96f02ba3e3fc8 |
| Git blob SHA | 5b6b11b7f8dfe599636fd4120fdc21f0a8d1b29e |
| Reading-copy SHA-256 | 72bcf546ac742953c68bdd22ee149ae1c307e6b44e6a0fc287c96f02ba3e3fc8 |
Recovered source transcript
Download complete source text ↓263 lines · 8.9 KB
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { request } from './api'
// THE MANAGER VIEW.
//
// A manager running six recruiters opened this product and got the recruiter dashboard: identical screen,
// org wide, one window, no comparison, nothing actionable. Three things were missing and all three are reads
// over data already held: scope, comparison, and capacity.
export interface Comparison {
now: string
previous: string
// direction describes the NUMBER. better says whether that movement is what the firm wants, which is metric
// specific: a rising fill rate is good and a rising time to fill is not. The server decides, because a
// component deciding for itself gets it backwards on half the metrics.
direction: 'up' | 'down' | 'flat'
better?: 'yes' | 'no' | 'flat'
}
export interface RecruiterLoad {
user_id?: string
name: string
open_requisitions: number
active_applications: number
band: 'ok' | 'watch' | 'strained' | 'overloaded'
}
export interface ManagerView {
scope: 'organization' | 'team' | 'user'
scope_label: string
period_days: number
previous_end: string
placements: Comparison
submissions: Comparison
fill_rate: Comparison
time_to_fill_days: Comparison
capacity: RecruiterLoad[]
overloaded: number
}
// The published benchmarks the bands come from, stated in the interface so a manager can see WHY somebody is
// flagged rather than trusting a colour.
export const LOAD_BENCHMARK = 14
export const LOAD_STRAINED = 20
export function bandColor(b: string): string {
if (b === 'overloaded') return 'var(--color-risk)'
if (b === 'strained') return 'var(--color-warn)'
if (b === 'watch') return 'var(--color-blue-soft)'
return 'var(--color-ok)'
}
// betterColor colours a movement by whether it is WANTED, never by its direction.
export function betterColor(c: Comparison): string {
if (c.better === 'yes') return 'var(--color-ok)'
if (c.better === 'no') return 'var(--color-risk)'
return 'var(--color-muted)'
}
// THE KEY IS THE QUESTION ASKED, NEVER THE CLOCK READING THAT ANSWERS IT.
//
// This hook first took a `since` instant computed by the screen. That instant carried milliseconds and was
// recomputed on every render, so the query key differed on every render, so TanStack refetched, which
// rerendered, which produced another key. The result was an unbounded request loop: the manager view issued
// roughly one request per 15ms, never left its loading state, and burned the API rate limiter so the NEXT
// screen a recruiter opened got a 429 and rendered empty.
//
// A manager asks for "the last 30 days". That is the cache key. The instant is a detail of answering it, so
// it is computed inside queryFn, fresh at fetch time, and never observed by the key.
export function useManagerView(teamID: string, owner: string, days: string) {
return useQuery({
queryKey: ['recruitmatch', 'manager', teamID, owner, days],
queryFn: () => {
const d = new Date()
d.setDate(d.getDate() - Number(days))
const p = new URLSearchParams()
if (teamID) p.set('team_id', teamID)
if (owner) p.set('owner', owner)
p.set('since', d.toISOString())
return request<ManagerView>(`/recruiting/manager?${p.toString()}`)
},
})
}
// THE EXCEPTION QUEUE, AND THE ACTION THAT ANSWERS IT.
//
// Phase 1 was a report. These two turn it into a tool: the queue says which of a manager's people has a
// problem, and the desk move is what they do about it.
export type ExceptionKind = 'aging_order' | 'silent_desk' | 'unclaimed_redeployment'
export interface Exception {
kind: ExceptionKind
// Two severities, not five. A manager scanning a list makes a binary decision, look now or look later.
severity: 'watch' | 'act'
subject_type: string
subject_id: string
title: string
detail: string
owner_user_id?: string
owner_name?: string
days: number
}
export interface ExceptionQueue {
scope: 'organization' | 'team' | 'user'
scope_label: string
exceptions: Exception[]
counts: Partial<Record<ExceptionKind, number>>
// The server says plainly when it cut the list. The interface must repeat that rather than let a
// manager read a capped count as the real one.
truncated: boolean
total: number
}
export const EXCEPTION_LABELS: Record<ExceptionKind, string> = {
aging_order: 'Orders nobody has touched',
silent_desk: 'Desks that have gone quiet',
unclaimed_redeployment: 'Redeployments nobody picked up',
}
export function severityColor(s: string): string {
return s === 'act' ? 'var(--color-risk)' : 'var(--color-warn)'
}
export function useExceptions(teamID: string, owner: string) {
const p = new URLSearchParams()
if (teamID) p.set('team_id', teamID)
if (owner) p.set('owner', owner)
const qs = p.toString()
return useQuery({
queryKey: ['recruitmatch', 'exceptions', teamID, owner],
queryFn: () => request<ExceptionQueue>(`/recruiting/exceptions${qs ? `?${qs}` : ''}`),
})
}
export interface DeskMove {
from_user_id: string
from_name?: string
to_user_id: string
to_name?: string
clients: number
requisitions: number
applications: number
total: number
}
export const MOVABLE_KINDS = ['clients', 'requisitions', 'applications'] as const
// useMoveDesk invalidates BOTH the manager summary and the exception queue on success.
//
// Moving a desk changes capacity and it changes who owns the neglected orders, so leaving either cached
// would show a manager the world as it was before the action they just took and make them wonder whether
// it worked.
export function useMoveDesk() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: { from_user_id: string; to_user_id: string; include?: string[] }) =>
request<DeskMove>('/recruiting/desk/reassign', { method: 'POST', body: JSON.stringify(v) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['recruitmatch', 'manager'] })
qc.invalidateQueries({ queryKey: ['recruitmatch', 'exceptions'] })
},
})
}
// PHASE 4: GOALS AND THE FORECAST.
//
// Phase 1 answers "better or worse than last month". A goal is what makes it answer "is this enough",
// which is the question the business actually asks.
export const GOAL_METRICS = ['placements', 'submissions', 'fill_rate', 'time_to_fill_days'] as const
export type GoalMetric = (typeof GOAL_METRICS)[number]
export const GOAL_METRIC_LABELS: Record<GoalMetric, string> = {
placements: 'Placements',
submissions: 'Submissions',
fill_rate: 'Fill rate',
time_to_fill_days: 'Days to fill',
}
export interface Goal {
id: string
scope: 'organization' | 'team' | 'user'
subject_id?: string
subject_name?: string
metric: GoalMetric
period_start: string
period_days: number
target: string
note?: string
}
export interface StageConversion {
stage: string
reached: number
placed: number
rate: string
// measured false means the sample was too thin. The rate is zero and MUST be shown as unknown rather
// than as a forecast of nothing.
measured: boolean
}
export interface Forecast {
scope: string
scope_label: string
open_applications: number
expected_placements: string
conversions: StageConversion[]
unmeasured_stages?: string[]
sample_floor: number
}
export function useGoals(teamID: string, owner: string) {
const p = new URLSearchParams()
if (teamID) p.set('team_id', teamID)
if (owner) p.set('owner', owner)
const qs = p.toString()
return useQuery({
queryKey: ['recruitmatch', 'goals', teamID, owner],
queryFn: () => request<{ goals: Goal[]; count: number }>(`/recruiting/goals${qs ? `?${qs}` : ''}`),
})
}
export function useForecast(teamID: string, owner: string) {
const p = new URLSearchParams()
if (teamID) p.set('team_id', teamID)
if (owner) p.set('owner', owner)
const qs = p.toString()
return useQuery({
queryKey: ['recruitmatch', 'forecast', teamID, owner],
queryFn: () => request<Forecast>(`/recruiting/forecast${qs ? `?${qs}` : ''}`),
})
}
export function useSetGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: {
scope: string
subject_id?: string
metric: GoalMetric
period_start: string
period_days: number
target: string
note?: string
}) => request<Goal>('/recruiting/goals', { method: 'POST', body: JSON.stringify(v) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['recruitmatch', 'goals'] }),
})
}
// attainment is computed in the BROWSER from the figure already on screen, deliberately. The server does
// not recompute the actual for a goal, because the number a manager is judged against must be the same
// number they are looking at, and two independent computations of "placements this period" is exactly how
// two screens end up disagreeing.
export function attainment(actual: string, target: string): number | null {
const t = Number(target)
const a = Number(actual)
if (!isFinite(t) || !isFinite(a) || t <= 0) return null
return Math.round((a / t) * 100)
}