387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
/**
|
|
* Dashboard fact sheet.
|
|
*
|
|
* Every assistant answer is derived from here, using the same formulas the
|
|
* dashboard components use — so the assistant can never state a figure that
|
|
* contradicts the card or chart sitting next to it.
|
|
*
|
|
* This module is pure: dashboard data in, derived facts out. No UI, no
|
|
* transport, no dependency on any AI product.
|
|
*/
|
|
|
|
import { isUnlocked } from '@/lib/provingGround';
|
|
import { getScoreBand } from '@/lib/talentHome';
|
|
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
|
|
|
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
|
|
|
export const plural = (n, word, irregular?) =>
|
|
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
|
|
|
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
|
|
|
const avg = (values) =>
|
|
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
|
|
|
const DAY_MS = 1000 * 60 * 60 * 24;
|
|
|
|
/**
|
|
* Events an auditor looks at first: they change who is employed or what is being
|
|
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
|
|
* uses it, and re-exported here because the fact sheet, the Activity page's
|
|
* severity column and the assistant's security answers all import it from this
|
|
* module and must keep agreeing on what "privileged" means.
|
|
*/
|
|
export { PRIVILEGED_EVENTS };
|
|
|
|
/** `today` is injected rather than read from the clock so answers are stable. */
|
|
export function buildFacts({
|
|
applications = [],
|
|
postings = [],
|
|
interviews = [],
|
|
staff = [],
|
|
profiles = [],
|
|
activity = [],
|
|
courses = [],
|
|
profile = null,
|
|
user = null,
|
|
today = new Date(),
|
|
}) {
|
|
const scored = applications.filter((a) => a.ai_score > 0);
|
|
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
|
|
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
|
|
|
|
const unscreened = byStatus('applied');
|
|
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
|
|
const interviewing = byStatus('interview');
|
|
const hired = byStatus('hired');
|
|
|
|
const openPositions = postings.filter((p) => p.status === 'active');
|
|
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
|
|
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
|
|
|
|
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
|
const timeToHire = hired.length
|
|
? Math.round(avg(hired.map((a) =>
|
|
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
|
|
: 0;
|
|
|
|
/* Open roles attracting nobody. */
|
|
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
|
|
|
|
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
|
|
const singleCandidateRoles = openPositions
|
|
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
|
|
.filter((e) => e.viable.length === 1);
|
|
|
|
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
|
|
/* Screened and waiting on a decision — which includes the shortlisted, who
|
|
are waiting on exactly that. Restricting this to `ai_screened` read as
|
|
correct only while nothing was ever shortlisted: the first shortlisted
|
|
candidate would have dropped silently out of the count. positionInsights
|
|
draws the same set with ['ai_screened', 'shortlisted']. */
|
|
const stalled = ranked.filter(
|
|
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
|
|
);
|
|
|
|
const funnel = [
|
|
{ key: 'applied', label: 'Applied', count: applications.length },
|
|
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
|
|
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
|
|
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
|
|
{ key: 'hired', label: 'Hired', count: hired.length },
|
|
];
|
|
|
|
const transitions = funnel.slice(1).map((stage, i) => ({
|
|
from: funnel[i].label,
|
|
to: stage.label,
|
|
/* The same two stages by KEY. `from`/`to` are English labels and several
|
|
callers render them directly; the keys are what a translated surface
|
|
needs, because a label cannot be looked up in a catalogue — asking for
|
|
`stage.Interviewed` returns that string verbatim, which is how a raw key
|
|
reached the screen. Both are carried rather than one replaced, so no
|
|
existing reader changes behaviour. */
|
|
fromKey: funnel[i].key,
|
|
toKey: stage.key,
|
|
rate: pct(stage.count, funnel[i].count),
|
|
lost: Math.max(0, funnel[i].count - stage.count),
|
|
}));
|
|
|
|
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
|
|
|
|
const byRole = openPositions.map((p) => {
|
|
const apps = applicationsFor(p.id);
|
|
const roleScored = apps.filter((a) => a.ai_score > 0);
|
|
return {
|
|
posting: p,
|
|
title: p.title,
|
|
category: p.role_category || 'Uncategorized',
|
|
applied: apps.length,
|
|
screened: apps.filter((a) => a.status !== 'applied').length,
|
|
hired: apps.filter((a) => a.status === 'hired').length,
|
|
avgScore: avg(roleScored.map((a) => a.ai_score)),
|
|
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
|
|
};
|
|
});
|
|
|
|
const since = (days) => {
|
|
const cutoff = today.getTime() - days * DAY_MS;
|
|
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
|
|
};
|
|
|
|
const eventCounts = activity.reduce((acc, e) => {
|
|
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
/**
|
|
* Out-of-pattern activity.
|
|
*
|
|
* Computed by `lib/activitySignals.js` rather than here, because it now has
|
|
* a second reader: the `activity.signals` data source resolves it for a card
|
|
* or an answer, and `dataResolver.js` cannot import from this directory
|
|
* without a library reaching up into the component tree.
|
|
*
|
|
* Moved, not changed. "Two unusual patterns" has to mean the same two
|
|
* everywhere it is said, and one function is the only way to guarantee that.
|
|
*/
|
|
const signals = activitySignals(activity, today);
|
|
|
|
/* ── KROW Forge ──────────────────────────────────────────────────────────
|
|
Derived with the same gate (`isUnlocked`) and the same completion record the
|
|
Forge page renders from, so the panel and the page cannot disagree about
|
|
what is ready, what is blocked, or why. `profile` is the signed-in worker's
|
|
record when one is already in cache; without it the counts are the library's
|
|
rather than the person's, which is still true — just less personal. */
|
|
const forgeCourses = courses.filter((c) => c.status === 'active');
|
|
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
|
|
const forgeGate = (course) => isUnlocked(course, profile || {});
|
|
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
|
|
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
|
|
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
|
|
const forge = {
|
|
courses: forgeCourses,
|
|
verified: profile?.earned_badges || [],
|
|
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
|
|
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
|
|
ready: forgeReady,
|
|
locked: forgeLocked,
|
|
/* The page features the first unfinished challenge whether or not it is
|
|
unlocked, so the panel names the same one — a chip that recommends a
|
|
different challenge than the one on screen reads as a second opinion. */
|
|
next: forgeUnfinished[0] || null,
|
|
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
|
|
/* Every blocked challenge with the reasons that block it, in page order. */
|
|
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
|
|
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
|
|
level,
|
|
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
|
})).filter((d) => d.count),
|
|
|
|
/* ── The same library, read by the administrator who owns it ──────────
|
|
Everything above answers "what can I prove"; everything below answers
|
|
"what have we built, and how is the workforce doing with it". Both are
|
|
derived from the same records, so the panel cannot disagree with the page
|
|
whichever question is asked. Publication state is read with the same
|
|
mapping the Forge page uses: `active` is Published. */
|
|
library: courses,
|
|
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
|
|
drafts: courses.filter((c) => c.status === 'draft'),
|
|
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
|
|
withChallenge: courses.filter((c) => c.challenge?.type),
|
|
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
|
|
byCategory: [...courses.reduce((map, c) => {
|
|
const key = c.category || 'Uncategorised';
|
|
map.set(key, (map.get(key) || 0) + 1);
|
|
return map;
|
|
}, new Map())]
|
|
.map(([category, count]) => ({ category, count }))
|
|
.sort((a, b) => b.count - a.count),
|
|
/* Verifications the workforce actually holds, counted off worker profiles
|
|
rather than off the library — a badge is something a person has. */
|
|
workforceVerified: profiles.reduce((sum, p) => {
|
|
const ids = new Set(courses.map((c) => c.id));
|
|
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
|
|
}, 0),
|
|
usage: courses.map((c) => ({
|
|
course: c,
|
|
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
|
|
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
|
|
})),
|
|
};
|
|
|
|
/* ── Talent pool ─────────────────────────────────────────────────────────
|
|
The same score bands the Talent Pool page labels profiles with. */
|
|
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
|
const talent = {
|
|
ranked: rankedProfiles,
|
|
top: rankedProfiles.slice(0, 3),
|
|
available: profiles.filter((p) => (p.availability || []).length > 0),
|
|
unverified: profiles.filter(
|
|
(p) => !(p.earned_badges || []).length
|
|
&& !(p.capabilities || []).some((c) => c.status === 'verified')
|
|
),
|
|
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
|
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
|
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
|
|
const { label } = getScoreBand(p.krow_score || 0);
|
|
acc[label] ||= { label, count: 0 };
|
|
acc[label].count += 1;
|
|
return acc;
|
|
}, {})).sort((a, b) => b.count - a.count),
|
|
};
|
|
|
|
/* ── Hired history ───────────────────────────────────────────────────────
|
|
Staff joined to the application and posting they came from — the same join
|
|
the Hired History page performs, so department, speed and score match. */
|
|
const hires = staff.map((s) => {
|
|
const app = applications.find((a) => a.id === s.application_id);
|
|
const posting = postings.find((p) => p.id === s.job_posting_id);
|
|
return {
|
|
...s,
|
|
department: posting?.role_category || '—',
|
|
timeToHire: app
|
|
? Math.max(1, Math.round(
|
|
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
|
|
))
|
|
: null,
|
|
score: s.ai_score || app?.ai_score || 0,
|
|
};
|
|
});
|
|
|
|
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
|
|
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
|
|
}>>((acc, h) => {
|
|
const name = h[key] || 'Unspecified';
|
|
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
|
acc[name].count += 1;
|
|
if (h.score) acc[name].scores.push(h.score);
|
|
if (h.timeToHire) acc[name].days.push(h.timeToHire);
|
|
if (h.client_rating) acc[name].ratings.push(h.client_rating);
|
|
return acc;
|
|
}, {})).map((e) => ({
|
|
name: e.name,
|
|
count: e.count,
|
|
avgScore: avg(e.scores),
|
|
avgDays: avg(e.days),
|
|
avgRating: e.ratings.length
|
|
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
|
: null,
|
|
})).sort((a, b) => b.count - a.count);
|
|
|
|
const hiring = {
|
|
hires,
|
|
byDepartment: groupHires('department'),
|
|
byRoleTitle: groupHires('role'),
|
|
unrated: hires.filter((h) => !h.client_rating),
|
|
rated: hires.filter((h) => h.client_rating),
|
|
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
|
|
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
|
|
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
|
|
onboarding: hires.filter((h) => h.status === 'onboarding'),
|
|
};
|
|
|
|
return {
|
|
today,
|
|
|
|
/* The signed-in account — identity and preferences, for the pages that are
|
|
about the operator rather than the workforce. */
|
|
user,
|
|
|
|
/* Applications */
|
|
applications,
|
|
total: applications.length,
|
|
scored,
|
|
ranked,
|
|
top: ranked.slice(0, 3),
|
|
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
|
|
unscreened,
|
|
screened,
|
|
interviewing,
|
|
hired,
|
|
stalled,
|
|
avgScore: avg(scored.map((a) => a.ai_score)),
|
|
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
|
|
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
|
|
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
|
|
|
|
/**
|
|
* Candidates a human should look at more closely — either because there is
|
|
* no score to judge them on, or because the record has a gap the score does
|
|
* not reflect. Deliberately not "weak candidates": every one of these is
|
|
* under-assessed rather than assessed and found wanting.
|
|
*/
|
|
needsDeeperReview: applications.filter((a) => {
|
|
if (!(a.ai_score > 0)) return true;
|
|
const flaggedInterview = interviews.some(
|
|
(i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
|
|
);
|
|
return flaggedInterview
|
|
|| !(a.certifications || []).length
|
|
|| !a.years_experience;
|
|
}),
|
|
|
|
/* Positions */
|
|
postings,
|
|
openPositions,
|
|
starvedPositions,
|
|
singleCandidateRoles,
|
|
byRole,
|
|
applicationsFor,
|
|
|
|
/* Interviews */
|
|
interviews,
|
|
completedInterviews,
|
|
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
|
|
interviewCompletion: pct(completedInterviews.length, interviews.length),
|
|
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
|
|
|
|
/* Hires */
|
|
staff,
|
|
unratedStaff: staff.filter((s) => !s.client_rating),
|
|
timeToHire,
|
|
hireRate: pct(hired.length, applications.length),
|
|
|
|
/* Funnel */
|
|
funnel,
|
|
transitions,
|
|
bottleneck,
|
|
|
|
/* Talent pool */
|
|
profiles,
|
|
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
|
|
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
|
|
|
|
/* Activity */
|
|
activity,
|
|
activity7d: since(7),
|
|
activity24h: since(1),
|
|
eventCounts,
|
|
activitySignals: signals,
|
|
|
|
/* Signed-in worker, and the pages built on these records */
|
|
profile,
|
|
courses,
|
|
forge,
|
|
talent,
|
|
hiring,
|
|
|
|
/* Economics — mirrors ImpactMetrics */
|
|
costSaved: scored.length * 45 + interviews.length * 80,
|
|
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
|
|
standardizedPct: pct(scored.length, applications.length),
|
|
};
|
|
}
|
|
|
|
/* ── Answer formatting ──────────────────────────────────────────────────── */
|
|
|
|
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
|
|
|
|
export const numbered = (items) =>
|
|
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
|
|
|
|
export const heading = (text) => `**${text}**`;
|
|
|
|
/** Joins answer blocks, dropping empties so templates can use conditionals. */
|
|
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');
|