fix talent pool ui

This commit is contained in:
2026-08-11 17:09:53 +05:30
parent 7427359967
commit 8a256a4de7
12 changed files with 473 additions and 256 deletions

View File

@@ -1,6 +1,6 @@
import {
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
} from 'lucide-react';
import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
@@ -1099,6 +1099,136 @@ const candidatePipelineSummary = (f) => {
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Hiring trend — direction of travel, not another chart.
*
* The page already draws the line; what it cannot say is whether the line is
* going up. So this compares the last four weeks against the four before them
* and reports the change, using the same created/updated dates the chart buckets.
*/
const hiringTrend = (f) => {
if (!f.total) return answer('No applications yet, so there is no trend to read.');
const inWindow = (value, from, to) => {
const t = new Date(value).getTime();
return t >= f.today.getTime() - from * DAY_MS && t < f.today.getTime() - to * DAY_MS;
};
const applied = (from, to) => f.applications.filter((a) => inWindow(a.created_date, from, to)).length;
/* Screening is dated by the update that carried the score, exactly as the
Hiring trend chart buckets it. */
const screened = (from, to) => f.scored.filter((a) => inWindow(a.updated_date, from, to)).length;
const recent = applied(28, 0);
const previous = applied(56, 28);
const change = previous ? Math.round(((recent - previous) / previous) * 100) : null;
const weeks = [3, 2, 1, 0].map((w) => ({
week: w === 0 ? 'This week' : `${w} week${w === 1 ? '' : 's'} ago`,
applied: applied((w + 1) * 7, w * 7),
screened: screened((w + 1) * 7, w * 7),
}));
return doc(
heading('Hiring trend', 'Last 4 weeks against the 4 before'),
kpis([
{ label: 'Applications', value: recent, sub: `${previous} in the prior period` },
{
label: 'Change',
value: change === null ? '—' : `${change > 0 ? '+' : ''}${change}%`,
tone: change === null ? undefined : change >= 0 ? 'success' : 'warning',
},
{ label: 'Screened', value: screened(28, 0), sub: `${f.standardizedPct}% coverage` },
{ label: 'Hires', value: f.hired.length, sub: f.timeToHire ? `${f.timeToHire}d to close` : 'none closed' },
]),
heading('Week by week'),
table(
[
{ key: 'week', label: 'Week' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'screened', label: 'Screened', align: 'right' },
],
weeks
),
insights([
change !== null && {
tone: change >= 0 ? 'success' : 'warning',
title: change >= 0
? `Applications up ${change}% on the previous four weeks`
: `Applications down ${Math.abs(change)}% on the previous four weeks`,
body: change >= 0
? 'Supply is growing, so the constraint moves to screening throughput rather than reach.'
: 'Fewer people are arriving. Distribution and pay band are the two things that move this, not screening.',
},
f.unscreened.length && {
tone: 'warning',
title: `${plural(f.unscreened.length, 'application')} still unscored`,
body: 'The screened line lags the applied line by exactly this much — the gap is a queue, not a rejection rate.',
},
]),
note('Windows are counted from today, so this is independent of the 7d/30d/90d control on the chart.')
);
};
/**
* Department performance — which role category is actually converting.
*
* The page's department section ranks by volume. Volume is demand, not
* performance: the category worth copying is the one turning applicants into
* qualified candidates, which is a rate.
*/
const departmentPerformance = (f) => {
if (!f.byRole.length) return answer('No open positions, so there are no departments to compare.');
const rows = Object.values(f.byRole.reduce((acc, r) => {
acc[r.category] ||= { category: r.category, roles: 0, applied: 0, qualified: 0, hired: 0 };
acc[r.category].roles += 1;
acc[r.category].applied += r.applied;
acc[r.category].qualified += r.qualified;
acc[r.category].hired += r.hired;
return acc;
}, {})).map((d) => ({ ...d, rate: d.applied ? Math.round((d.qualified / d.applied) * 100) : 0 }))
.sort((a, b) => b.rate - a.rate || b.applied - a.applied);
const best = rows.find((d) => d.applied > 0);
const starved = rows.filter((d) => d.applied === 0);
return doc(
heading('Department performance', `${plural(rows.length, 'category')}`),
table(
[
{ key: 'category', label: 'Category' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
{ key: 'rate', label: 'Qualified', align: 'right' },
],
rows.map((d) => ({
category: d.category,
applied: d.applied ? d.applied : { value: 0, tone: 'risk' },
qualified: d.qualified,
hired: d.hired || '—',
rate: { value: `${d.rate}%`, tone: d.rate >= 50 ? 'success' : d.rate > 0 ? 'warning' : 'risk' },
}))
),
insights([
best && {
tone: 'success',
title: `${best.category} is performing best`,
body: `${best.qualified} of ${best.applied} applicants clear 70 — ${best.rate}%${best.hired ? `, and ${plural(best.hired, 'hire')} closed` : ''}. Whatever sourcing this category uses is the one worth copying.`,
},
starved.length && {
tone: 'risk',
title: `${plural(starved.length, 'category')} attracting nobody`,
body: `${starved.map((d) => d.category).join(', ')}. A conversion rate cannot be read off zero applicants.`,
},
])
);
};
/* ── Free-text responders ───────────────────────────────────────────────── */
/**
@@ -1151,6 +1281,16 @@ export const respondAdminCandidates = (question, f) => {
return recruitmentInsights(f);
};
export const respondAdminAnalytics = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'trend', 'over time', 'growing', 'slow', 'week', 'month')) return hiringTrend(f);
if (has(q, 'department', 'category', 'team', 'best perform')) return departmentPerformance(f);
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion', 'stuck', 'lose')) return pipelineHealth(f);
if (has(q, 'position', 'role', 'converting', 'strongest')) return positionStrength(f);
if (has(q, 'attention', 'urgent', 'wrong', 'fix', 'risk')) return attentionRequired(f);
return hiringOperations(f);
};
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
@@ -1196,6 +1336,15 @@ export const ADMIN_CANDIDATE_CAPABILITIES = [
{ id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
];
export const ADMIN_ANALYTICS_CAPABILITIES = [
{ id: 'hiring-trend', label: 'Hiring Trend', icon: TrendingUp, run: hiringTrend },
{ id: 'department-performance', label: 'Department Performance', icon: Layers, run: departmentPerformance },
{ id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth },
{ id: 'position-conversion', label: 'Position Conversion', icon: Target, run: positionStrength },
{ id: 'hiring-operations', label: 'Hiring Performance', icon: Activity, run: hiringOperations },
{ id: 'attention-required', label: 'Needs Attention', icon: BellRing, run: attentionRequired },
];
export const ACTIVITY_CAPABILITIES = [
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
{ id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },

View File

@@ -3,10 +3,10 @@ import {
respondAnalytics, respondCandidates, respondOverview,
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CANDIDATE_LIST_CAPABILITIES,
CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
respondActivity, respondAdminCandidates, respondCandidateList, respondControlCenter,
respondPositions,
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondPositions,
} from './capabilities/admin';
/**
@@ -66,6 +66,14 @@ export const ASSISTANT_CONTEXTS = {
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
/* Analytics reads the same records as the Control Center, but as performance
over time rather than as a state to act on — hence its own capabilities. */
'admin.analytics': {
id: 'admin.analytics',
page: 'Analytics',
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
respond: respondAdminAnalytics,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',

View File

@@ -122,6 +122,17 @@ const GREETINGS = {
return `All ${f.total} candidates are scored, credentialed and comparable.`;
},
'admin.analytics': (f) => {
if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
if (f.bottleneck && f.bottleneck.lost > 0) {
return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
}
if (f.standardizedPct < 80) {
return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
}
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
},
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
const patterns = f.activitySignals.flags.length;
@@ -155,6 +166,7 @@ const TITLES = {
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
@@ -172,6 +184,8 @@ const DESCRIPTIONS = {
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
'admin.candidates': (f) =>
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
'admin.analytics': (f) =>
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
};
@@ -218,6 +232,11 @@ const PLACEHOLDERS = {
'Ask Owliver about screening gaps…',
'Ask Owliver where the risk is…',
],
'admin.analytics': () => [
'Ask Owliver about hiring performance…',
'Ask Owliver about the hiring trend…',
'Ask Owliver where the bottleneck is…',
],
'admin.activity': () => [
'Ask Owliver about recent activity…',
'Ask Owliver about unusual activity…',
@@ -333,6 +352,19 @@ const PROMPTS = {
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
],
'admin.analytics': (f) => [
{ label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
{ label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
f.bottleneck && f.bottleneck.lost > 0
? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
: { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
{ label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
{ label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
f.unscreened.length
? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
],
'admin.activity': (f) => [
f.activitySignals.flags.length
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }

View File

@@ -17,6 +17,7 @@ const PLACEMENT = {
'/admin/positions': 'admin.positions',
'/admin/candidates': 'admin.candidatesList',
'/admin/candidates-analysis': 'admin.candidates',
'/admin/analytics': 'admin.analytics',
'/admin/activity': 'admin.activity',
},
};
@@ -26,8 +27,8 @@ const PLACEMENT = {
* documented rather than implied by omission.
*
* Admin: the reporting and reference pages, plus login. The working surfaces —
* Control Center, Positions, Candidates, Candidates Analysis and
* Activity — all carry the panel.
* Control Center, Positions, Candidates, Candidates Analysis, Analytics
* and Activity — all carry the panel.
* Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow.
@@ -39,8 +40,7 @@ export const EXCLUDED_ROUTES = [
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
// Admin pages that are a report or a form rather than a workflow.
'/admin/hired', '/admin/talent-pool', '/admin/analytics',
'/admin/profile', '/admin/login',
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
// The full candidate profile is one person's record, not a page-level question.
'/admin/candidates/:id',
];