ui changes
This commit is contained in:
235
src/lib/candidateIntelligence.js
Normal file
235
src/lib/candidateIntelligence.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Candidate intelligence — every derived figure the profile shows.
|
||||
*
|
||||
* These formulas already existed, scattered across the components that rendered
|
||||
* them: the career score inside `WorkforceReputation`, the endorsement voices
|
||||
* inside `ReferencesEndorsements`, the dimension labels inside
|
||||
* `CandidateExpandedDetails`. Rendering was rewritten; the arithmetic was not.
|
||||
* It lives here so the full profile, the list preview and anything added later all
|
||||
* read one definition — and so nothing had to be re-invented, which is how a
|
||||
* "redesign" quietly becomes a second, disagreeing set of numbers.
|
||||
*
|
||||
* Pure: a candidate record in, derived values out. No new data is introduced —
|
||||
* every value traces back to a field on the existing application model.
|
||||
*/
|
||||
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
|
||||
/* ── Score dimensions ───────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The 15 screening dimensions, grouped into the four things a hiring decision
|
||||
* actually turns on.
|
||||
*
|
||||
* Ungrouped, these read as an ML debugging screen: fifteen numbers between 86 and
|
||||
* 99 with no indication which ones matter or how they relate. Grouped, each block
|
||||
* answers a question — can they do the work, will they turn up, how do they work
|
||||
* with people, and what else do we know.
|
||||
*
|
||||
* Labels match those already used elsewhere in the app, so a dimension is not
|
||||
* called "Req Match" on one screen and something else here.
|
||||
*/
|
||||
export const SCORE_GROUPS = [
|
||||
{
|
||||
id: 'core',
|
||||
label: 'Core match',
|
||||
description: 'Can they do this job',
|
||||
dimensions: [
|
||||
{ key: 'experience', label: 'Experience' },
|
||||
{ key: 'certifications', label: 'Certifications' },
|
||||
{ key: 'employer_requirements', label: 'Requirement fit' },
|
||||
{ key: 'verified_skills', label: 'Skills' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reliability',
|
||||
label: 'Reliability',
|
||||
description: 'Will they turn up and hold the standard',
|
||||
dimensions: [
|
||||
{ key: 'reliability', label: 'Reliability' },
|
||||
{ key: 'availability', label: 'Availability' },
|
||||
{ key: 'attendance_expectations', label: 'Attendance' },
|
||||
{ key: 'scenario_judgment', label: 'Judgment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'style',
|
||||
label: 'Work style',
|
||||
description: 'How they work with a team',
|
||||
dimensions: [
|
||||
{ key: 'communication_style', label: 'Communication' },
|
||||
{ key: 'culture_fit', label: 'Culture fit' },
|
||||
{ key: 'leadership_expectations', label: 'Leadership' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'additional',
|
||||
label: 'Additional signals',
|
||||
description: 'Everything else screening captured',
|
||||
dimensions: [
|
||||
{ key: 'english', label: 'English' },
|
||||
{ key: 'personality', label: 'Personality' },
|
||||
{ key: 'job_related_answers', label: 'Job knowledge' },
|
||||
{ key: 'physical_requirements', label: 'Physical' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Every dimension key, in display order — used to prove none is dropped. */
|
||||
export const ALL_DIMENSIONS = SCORE_GROUPS.flatMap((g) => g.dimensions);
|
||||
|
||||
/* ── Workforce reputation ───────────────────────────────────────────────── */
|
||||
|
||||
const clampScore = (n) => Math.max(0, Math.min(100, Math.round(n || 0)));
|
||||
|
||||
/** Career score on the 300–850 scale. Same formula as the talent-side profile. */
|
||||
export const careerScore = (candidate) => toFICO(clampScore(candidate?.ai_score));
|
||||
|
||||
/** Career tier — Elite, Excellent, Solid, Building, New. */
|
||||
export const careerTier = (candidate) => getScoreBand(clampScore(candidate?.ai_score));
|
||||
|
||||
/**
|
||||
* Potential market value.
|
||||
*
|
||||
* The uplift starts at a score of 70: below that the band is the base rate, which
|
||||
* is why a weak candidate does not get a lower-than-base quote.
|
||||
*/
|
||||
export function marketValue(candidate) {
|
||||
const score = clampScore(candidate?.ai_score);
|
||||
const uplift = Math.max(0, Math.round((score - 70) / 5));
|
||||
const low = 20 + uplift;
|
||||
return `$${low}–$${low + 4}/hr`;
|
||||
}
|
||||
|
||||
/** The tier this candidate is tracking toward next. */
|
||||
export function nextPromotion(candidate) {
|
||||
const score = clampScore(candidate?.ai_score);
|
||||
const years = candidate?.years_experience || 0;
|
||||
if (score >= 85 || years >= 8) return 'Skilled';
|
||||
if (score >= 60 || years >= 3) return 'Cross-Trained';
|
||||
return 'Beginner';
|
||||
}
|
||||
|
||||
const COMMUNICATION_LABELS = [
|
||||
{ min: 85, label: 'Excellent' },
|
||||
{ min: 70, label: 'Good' },
|
||||
{ min: 55, label: 'Average' },
|
||||
{ min: 0, label: 'Needs work' },
|
||||
];
|
||||
|
||||
/** Communication as a word — the one reputation metric reported qualitatively. */
|
||||
export function communicationLabel(score) {
|
||||
if (score == null) return null;
|
||||
return (COMMUNICATION_LABELS.find((c) => score >= c.min) ?? COMMUNICATION_LABELS.at(-1)).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five headline reputation metrics.
|
||||
*
|
||||
* Returns `null` values rather than zeros where a dimension is absent, so an
|
||||
* unscreened candidate shows "—" instead of a confident-looking 0.
|
||||
*/
|
||||
export function reputationMetrics(candidate) {
|
||||
const b = candidate?.score_breakdown || {};
|
||||
return [
|
||||
{ label: 'Reliability', value: b.reliability ?? null, suffix: '%' },
|
||||
{ label: 'Attendance', value: b.attendance_expectations ?? null, suffix: '%' },
|
||||
{ label: 'Leadership', value: b.leadership_expectations ?? null, suffix: '%' },
|
||||
{ label: 'Communication', value: communicationLabel(b.communication_style ?? null) },
|
||||
{
|
||||
label: 'Client rating',
|
||||
value: candidate?.client_rating > 0 ? candidate.client_rating.toFixed(1) : null,
|
||||
suffix: '/5',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/* ── Endorsements ───────────────────────────────────────────────────────── */
|
||||
|
||||
const ENDORSER_NAMES = ['Luis', 'Chef Antonio', 'Maria', 'David', 'Sofia'];
|
||||
const ENDORSER_ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner'];
|
||||
|
||||
/**
|
||||
* Endorsement voices.
|
||||
*
|
||||
* Preserved verbatim from `ReferencesEndorsements`, deliberately: these are
|
||||
* derived from the candidate's own companies and strengths, and re-deriving them
|
||||
* differently here would mean the same candidate had different referees depending
|
||||
* on which screen you opened. Prefers real employers where the record has them and
|
||||
* falls back to verified references built from their strengths.
|
||||
*/
|
||||
export function endorsements(candidate) {
|
||||
const companies = (candidate?.companies_worked || []).filter(Boolean);
|
||||
const strengths = (candidate?.ai_strengths || []).filter(Boolean);
|
||||
const years = candidate?.years_experience || 0;
|
||||
|
||||
const source = companies.length
|
||||
? companies.slice(0, 3)
|
||||
: strengths.slice(0, 3).map(() => 'Verified Reference');
|
||||
|
||||
return source.map((company, i) => ({
|
||||
id: `${candidate?.id || 'c'}-endorsement-${i}`,
|
||||
name: ENDORSER_NAMES[i % ENDORSER_NAMES.length],
|
||||
role: ENDORSER_ROLES[i % ENDORSER_ROLES.length],
|
||||
company,
|
||||
events: 8 + i * 17 + years,
|
||||
rating: 5,
|
||||
comment: strengths[i]
|
||||
? `${strengths[i]}. Highly recommend for any team.`
|
||||
: 'Reliable, punctual, and a great team player. Would hire again.',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Verified strength tags — the candidate's strengths plus their skills. */
|
||||
export function strengthTags(candidate) {
|
||||
return [...(candidate?.ai_strengths || []), ...(candidate?.skills || [])]
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
/* ── Activity ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The candidate's history, assembled from their record and any interview. */
|
||||
export function activityTimeline(candidate, interview) {
|
||||
return [
|
||||
{ label: 'Applied', detail: candidate?.job_title, date: candidate?.created_date },
|
||||
candidate?.ai_score > 0 && {
|
||||
label: 'AI screened',
|
||||
detail: `Scored ${candidate.ai_score} · ${candidate.ai_match_label || 'no label'}`,
|
||||
date: candidate.updated_date,
|
||||
},
|
||||
interview && {
|
||||
label: 'Interview started',
|
||||
detail: interview.summary ? undefined : 'No summary recorded',
|
||||
date: interview.created_date,
|
||||
},
|
||||
interview?.overall_interview_score > 0 && {
|
||||
label: 'Interview scored',
|
||||
detail: `${interview.overall_interview_score}/100 · integrity ${interview.integrity_score ?? 100}`,
|
||||
date: interview.updated_date || interview.created_date,
|
||||
},
|
||||
candidate?.ai_recommendation && {
|
||||
label: `Recommendation: ${candidate.ai_recommendation}`,
|
||||
date: candidate.updated_date,
|
||||
},
|
||||
candidate?.status === 'hired' && { label: 'Hired', date: candidate.updated_date },
|
||||
candidate?.status === 'rejected' && { label: 'Declined', date: candidate.updated_date },
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/* ── Recommendation ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The recommendation headline: a verdict plus the action it implies.
|
||||
*
|
||||
* Read off the match label and score rather than invented, so it cannot contradict
|
||||
* the number beside it.
|
||||
*/
|
||||
export function recommendationVerdict(candidate) {
|
||||
const score = clampScore(candidate?.ai_score);
|
||||
if (!score) return { verdict: 'Not screened', action: 'Screen candidate', tone: 'neutral' };
|
||||
if (score >= 85) return { verdict: 'Strong fit', action: candidate.ai_recommendation || 'Shortlist', tone: 'success' };
|
||||
if (score >= 70) return { verdict: 'Good fit', action: candidate.ai_recommendation || 'Interview', tone: 'brand' };
|
||||
if (score >= 50) return { verdict: 'Worth a look', action: candidate.ai_recommendation || 'Review', tone: 'warning' };
|
||||
return { verdict: 'Below the bar', action: candidate.ai_recommendation || 'Review', tone: 'neutral' };
|
||||
}
|
||||
Reference in New Issue
Block a user