400 lines
16 KiB
JavaScript
400 lines
16 KiB
JavaScript
/**
|
|
* The skill graph: the one definition of how training becomes a verified level,
|
|
* and how a verified level becomes eligibility for a position.
|
|
*
|
|
* KROW has three things that used to know nothing about each other — Forge
|
|
* (training), worker profiles (what people can do) and positions (what the work
|
|
* needs). This module is the connection, and it is deliberately the only place
|
|
* the rules live:
|
|
*
|
|
* SKILL → LEVEL → TRAINING → COMPLETION → VERIFIED SKILL → MATCH
|
|
*
|
|
* Two decisions shape everything below.
|
|
*
|
|
* **Levels are derived, never stored.** A worker's level is computed from the
|
|
* training they have actually completed, so it cannot drift out of step with the
|
|
* evidence behind it, and completing a module updates the level everywhere at
|
|
* once without a single write. The exception is an explicit admin override,
|
|
* which is stored — because a manual verification is a claim by a person, and a
|
|
* claim has to be recorded somewhere to exist at all.
|
|
*
|
|
* **The match score is arithmetic, not judgement.** Every point is traceable to
|
|
* one requirement, one weight and one comparison of levels, so the reason for a
|
|
* score can always be shown next to it. Nothing here calls a model.
|
|
*
|
|
* Everything reads the existing entities — Course, WorkerProfile, JobPosting —
|
|
* through fields added to them, so swapping the demo store for a real API means
|
|
* changing nothing in this file.
|
|
*/
|
|
|
|
/* ── Levels ────────────────────────────────────────────────────────────── */
|
|
|
|
export const LEVELS = ['beginner', 'intermediate', 'advanced', 'expert'];
|
|
|
|
export const LEVEL_LABEL = {
|
|
beginner: 'Beginner',
|
|
intermediate: 'Intermediate',
|
|
advanced: 'Advanced',
|
|
expert: 'Expert',
|
|
};
|
|
|
|
/** Position in the ladder, or -1 for "no level held". */
|
|
export const levelIndex = (level) => LEVELS.indexOf(level);
|
|
|
|
export const levelLabel = (level) => LEVEL_LABEL[level] || 'Not started';
|
|
|
|
/**
|
|
* The skill catalogue.
|
|
*
|
|
* The ladders are different lengths on purpose. Food Safety and Customer Service
|
|
* top out at Advanced because there is no fourth thing to be good at — inventing
|
|
* an Expert tier for them would make "Expert" mean less everywhere else.
|
|
*/
|
|
export const SKILLS = [
|
|
{ id: 'server', name: 'Server', category: 'Service', levels: LEVELS },
|
|
{ id: 'bartending', name: 'Bartending', category: 'Bar', levels: LEVELS },
|
|
{ id: 'leadership', name: 'Leadership', category: 'Leadership', levels: LEVELS },
|
|
{ id: 'customer_service', name: 'Customer Service', category: 'Service', levels: LEVELS.slice(0, 3) },
|
|
{ id: 'food_safety', name: 'Food Safety', category: 'Kitchen', levels: LEVELS.slice(0, 3) },
|
|
];
|
|
|
|
export const skillById = (id) => SKILLS.find((s) => s.id === id) || null;
|
|
|
|
export const skillName = (id) => skillById(id)?.name || id;
|
|
|
|
/** The level above `level` in this skill's own ladder, or null at the top. */
|
|
export function nextLevelOf(skillId, level) {
|
|
const ladder = skillById(skillId)?.levels || LEVELS;
|
|
const at = level ? ladder.indexOf(level) : -1;
|
|
return ladder[at + 1] || null;
|
|
}
|
|
|
|
/* ── Training modules ──────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* A training module is a Course record carrying four extra fields:
|
|
*
|
|
* skill_id the skill it builds
|
|
* target_level the level it contributes to
|
|
* required_level the level a worker must already hold to start it
|
|
* completion_criteria what must be done
|
|
* verification_criteria what Owliver scores the evidence against
|
|
*
|
|
* Courses without `skill_id` are library material that no level depends on. They
|
|
* still appear in Forge; they just never move anyone's level.
|
|
*/
|
|
export const isModule = (course) => Boolean(course?.skill_id && course?.target_level);
|
|
|
|
export const modulesForSkill = (courses = [], skillId) =>
|
|
courses.filter((c) => c.skill_id === skillId);
|
|
|
|
/** Modules for one skill, grouped into its ladder — `{ beginner: [...], … }`. */
|
|
export function modulesByLevel(courses = [], skillId) {
|
|
const ladder = skillById(skillId)?.levels || LEVELS;
|
|
const grouped = Object.fromEntries(ladder.map((l) => [l, []]));
|
|
for (const course of modulesForSkill(courses, skillId)) {
|
|
if (grouped[course.target_level]) grouped[course.target_level].push(course);
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
/** What a module unlocks, as one line: "Server — Advanced". */
|
|
export const unlocksLabel = (course) =>
|
|
isModule(course) ? `${skillName(course.skill_id)} — ${levelLabel(course.target_level)}` : null;
|
|
|
|
/* ── What a worker has completed ───────────────────────────────────────── */
|
|
|
|
/**
|
|
* Completion is read off the profile the platform already writes.
|
|
*
|
|
* A course reaches `completed_courses` in exactly one way: the worker submitted
|
|
* evidence and Owliver passed it (`useSubmitChallenge`). So "completed" and
|
|
* "verified" are the same set here, and there is no second flag that could
|
|
* disagree with the evidence.
|
|
*/
|
|
export const completedCourseIds = (profile) =>
|
|
new Set((profile?.completed_courses || []).map((c) => c.course_id));
|
|
|
|
/* ── Level derivation ──────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* One skill, as this worker holds it.
|
|
*
|
|
* The progression rule, in one line: **a level is earned when every module at
|
|
* that level is verified and the level below it is earned.** That is what stops
|
|
* a worker who happens to pass one advanced module from being labelled Advanced
|
|
* while the ground under it is missing — the label has to mean the whole ladder.
|
|
*
|
|
* An admin override replaces the derived level and says so, so a manual
|
|
* verification is visible as a manual verification rather than passing itself
|
|
* off as earned training.
|
|
*/
|
|
export function skillStateFor(skillId, courses, profile) {
|
|
const skill = skillById(skillId);
|
|
const ladder = skill?.levels || LEVELS;
|
|
const done = completedCourseIds(profile);
|
|
const grouped = modulesByLevel(courses, skillId);
|
|
|
|
const levels = ladder.map((level) => {
|
|
const modules = grouped[level] || [];
|
|
const completed = modules.filter((m) => done.has(m.id));
|
|
return {
|
|
level,
|
|
label: levelLabel(level),
|
|
modules,
|
|
completed,
|
|
/* An empty level cannot be "complete" — a ladder rung with no training
|
|
behind it is an authoring gap, not an achievement. */
|
|
complete: modules.length > 0 && completed.length === modules.length,
|
|
progress: modules.length ? Math.round((completed.length / modules.length) * 100) : 0,
|
|
};
|
|
});
|
|
|
|
/* Earned levels stop at the first gap: the ladder is climbed, not sampled. */
|
|
let earnedIndex = -1;
|
|
for (let i = 0; i < levels.length; i += 1) {
|
|
if (!levels[i].complete) break;
|
|
earnedIndex = i;
|
|
levels[i].earned = true;
|
|
}
|
|
|
|
const override = (profile?.skill_overrides || {})[skillId] || null;
|
|
const overrideIndex = override ? ladder.indexOf(override.level) : -1;
|
|
const verifiedIndex = Math.max(earnedIndex, overrideIndex);
|
|
const verifiedLevel = verifiedIndex >= 0 ? ladder[verifiedIndex] : null;
|
|
|
|
const nextLevel = ladder[verifiedIndex + 1] || null;
|
|
const nextRung = nextLevel ? levels[verifiedIndex + 1] : null;
|
|
const nextRequirement = nextRung?.modules.find((m) => !done.has(m.id)) || null;
|
|
|
|
const totalCompleted = levels.reduce((n, l) => n + l.completed.length, 0);
|
|
const totalModules = levels.reduce((n, l) => n + l.modules.length, 0);
|
|
|
|
return {
|
|
skillId,
|
|
name: skill?.name || skillId,
|
|
category: skill?.category || '',
|
|
ladder,
|
|
levels,
|
|
verifiedLevel,
|
|
verifiedLabel: verifiedLevel ? levelLabel(verifiedLevel) : 'Not started',
|
|
verifiedByOverride: overrideIndex > earnedIndex ? override : null,
|
|
override,
|
|
nextLevel,
|
|
nextRequirement,
|
|
/* Progress towards the *next* level, which is the number a worker is
|
|
actually working against. Total-ladder progress is reported separately. */
|
|
progressToNext: nextRung ? nextRung.progress : 100,
|
|
completedInNext: nextRung?.completed.length || 0,
|
|
requiredForNext: nextRung?.modules.length || 0,
|
|
totalCompleted,
|
|
totalModules,
|
|
status: verifiedLevel
|
|
? (nextRung && nextRung.completed.length ? 'advancing' : 'verified')
|
|
: (nextRung && nextRung.completed.length ? 'in_progress' : 'not_started'),
|
|
/* "Next level ready" means the training is done and only the level label is
|
|
waiting — which never happens under the rule above, so it reads the rung
|
|
below: every module done except one. */
|
|
nearlyThere: Boolean(nextRung && nextRung.modules.length && nextRung.modules.length - nextRung.completed.length === 1),
|
|
};
|
|
}
|
|
|
|
/** Every skill in the catalogue, as this worker holds it. */
|
|
export const skillStates = (courses, profile) =>
|
|
SKILLS.map((s) => skillStateFor(s.id, courses, profile));
|
|
|
|
/** `{ server: 'advanced', food_safety: 'beginner' }` — the shape matching reads. */
|
|
export function verifiedLevels(courses, profile) {
|
|
const held = {};
|
|
for (const state of skillStates(courses, profile)) {
|
|
if (state.verifiedLevel) held[state.skillId] = state.verifiedLevel;
|
|
}
|
|
return held;
|
|
}
|
|
|
|
/**
|
|
* The profile as it would be with one more module completed.
|
|
*
|
|
* Used to answer "what would this training do for me" without writing anything.
|
|
* Same derivation runs over it, so the projected level and the real one can
|
|
* never be computed two different ways.
|
|
*/
|
|
export const withCourseCompleted = (profile, course) => ({
|
|
...profile,
|
|
completed_courses: [
|
|
...(profile?.completed_courses || []),
|
|
{ course_id: course.id, title: course.title, score: 100, completed_date: new Date().toISOString() },
|
|
],
|
|
});
|
|
|
|
/* ── Position requirements and matching ────────────────────────────────── */
|
|
|
|
/**
|
|
* Match bands. Four names, because a recruiter acts on a name, not a number —
|
|
* and a candidate should never have to interpret a percentage on their own.
|
|
*/
|
|
export const MATCH_BANDS = [
|
|
{ min: 90, label: 'Excellent Match', tone: 'success' },
|
|
{ min: 75, label: 'Strong Match', tone: 'brand' },
|
|
{ min: 50, label: 'Potential Match', tone: 'warning' },
|
|
{ min: 0, label: 'Not Ready', tone: 'risk' },
|
|
];
|
|
|
|
export const bandFor = (score) => MATCH_BANDS.find((b) => score >= b.min) || MATCH_BANDS[3];
|
|
|
|
/**
|
|
* Partial credit for being close.
|
|
*
|
|
* One level below the requirement earns half the weight: that candidate is a
|
|
* training module away from qualifying, and scoring them zero would hide exactly
|
|
* the people this system exists to find. Two levels below earns nothing —
|
|
* at that distance it is a different job.
|
|
*/
|
|
const creditFor = (gap) => (gap <= 0 ? 1 : gap === 1 ? 0.5 : 0);
|
|
|
|
/**
|
|
* Score one candidate against one position's skill requirements.
|
|
*
|
|
* Returns null when the position defines no skill requirements — a position that
|
|
* asks for nothing should show no score rather than an invented 100%.
|
|
*
|
|
* Weights are normalised, so requirements that do not add to 100 still produce a
|
|
* percentage that means what it says.
|
|
*/
|
|
export function matchPosition(position, held = {}) {
|
|
const requirements = position?.skill_requirements || [];
|
|
if (!requirements.length) return null;
|
|
|
|
const totalWeight = requirements.reduce((sum, r) => sum + (Number(r.weight) || 0), 0) || 1;
|
|
|
|
const lines = requirements.map((req) => {
|
|
const ladder = skillById(req.skill_id)?.levels || LEVELS;
|
|
const need = ladder.indexOf(req.level);
|
|
const have = held[req.skill_id] ? ladder.indexOf(held[req.skill_id]) : -1;
|
|
const gap = need - have;
|
|
const weight = (Number(req.weight) || 0) / totalWeight * 100;
|
|
const credit = creditFor(gap);
|
|
|
|
return {
|
|
skillId: req.skill_id,
|
|
name: skillName(req.skill_id),
|
|
required: req.level,
|
|
requiredLabel: levelLabel(req.level),
|
|
held: held[req.skill_id] || null,
|
|
heldLabel: held[req.skill_id] ? levelLabel(held[req.skill_id]) : 'Not started',
|
|
weight: Math.round(weight),
|
|
earned: Math.round(weight * credit),
|
|
met: gap <= 0,
|
|
close: gap === 1,
|
|
gap: Math.max(0, gap),
|
|
note: gap <= 0
|
|
? null
|
|
: gap === 1
|
|
? `One level below — ${levelLabel(req.level)} required`
|
|
: `Missing required ${skillName(req.skill_id)} level: ${levelLabel(req.level)}`,
|
|
};
|
|
});
|
|
|
|
const score = Math.round(lines.reduce((sum, l) => sum + (l.weight * creditFor(l.gap)), 0));
|
|
const band = bandFor(score);
|
|
|
|
return {
|
|
score,
|
|
band: band.label,
|
|
tone: band.tone,
|
|
lines,
|
|
met: lines.filter((l) => l.met),
|
|
gaps: lines.filter((l) => !l.met),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The training that closes the largest gap, and what completing it would do.
|
|
*
|
|
* This is the whole Forge↔Positions loop in one function: it names a module, the
|
|
* level that module unlocks, and the match score the candidate would hold after
|
|
* Owliver verifies it — computed by re-running the same derivation over a
|
|
* profile with that module added, so the promised number is the number they get.
|
|
*/
|
|
export function recommendTraining(position, profile, courses) {
|
|
const held = verifiedLevels(courses, profile);
|
|
const match = matchPosition(position, held);
|
|
if (!match || !match.gaps.length) return null;
|
|
|
|
/* Heaviest unmet requirement first — the one that moves the score most. */
|
|
const target = [...match.gaps].sort((a, b) => b.weight - a.weight)[0];
|
|
const state = skillStateFor(target.skillId, courses, profile);
|
|
|
|
/* The next module on the ladder, not the module at the required level: the
|
|
progression rule means levels are climbed in order, so the honest next step
|
|
is the first thing they have not done. */
|
|
const module = state.nextRequirement;
|
|
if (!module) return null;
|
|
|
|
const projectedHeld = verifiedLevels(courses, withCourseCompleted(profile, module));
|
|
const projected = matchPosition(position, projectedHeld);
|
|
|
|
return {
|
|
module,
|
|
skillId: target.skillId,
|
|
skillName: target.name,
|
|
fromLevel: state.verifiedLevel,
|
|
toLevel: module.target_level,
|
|
unlocks: unlocksLabel(module),
|
|
currentScore: match.score,
|
|
projectedScore: projected?.score ?? match.score,
|
|
requiredLabel: target.requiredLabel,
|
|
/* Whether this one module actually moves the match. It often does not —
|
|
a level takes every module on its rung, so someone three modules out gains
|
|
nothing measurable from the first. Callers that promise a number to a
|
|
recruiter should say nothing rather than promise "50% → 50%". */
|
|
improves: (projected?.score ?? match.score) > match.score,
|
|
};
|
|
}
|
|
|
|
/* ── Reading the workforce against a position ──────────────────────────── */
|
|
|
|
/** Links an application to the worker profile behind it, by email. */
|
|
export const profileForEmail = (profiles = [], email) =>
|
|
(email && profiles.find((p) => p.email?.toLowerCase() === String(email).toLowerCase())) || null;
|
|
|
|
/**
|
|
* Every worker scored against one position, best first.
|
|
*
|
|
* `applications` is optional: pass it and each row carries the application that
|
|
* connects that person to this role, which is what lets the admin Position page
|
|
* be one list — applicants and eligible workers — rather than two.
|
|
*/
|
|
export function rankWorkforce(position, profiles = [], courses = [], applications = []) {
|
|
const appliedBy = new Map(
|
|
applications
|
|
.filter((a) => a.job_posting_id === position?.id)
|
|
.map((a) => [String(a.email || '').toLowerCase(), a])
|
|
);
|
|
|
|
return profiles
|
|
.map((profile) => {
|
|
const held = verifiedLevels(courses, profile);
|
|
const match = matchPosition(position, held);
|
|
if (!match) return null;
|
|
return {
|
|
profile,
|
|
match,
|
|
held,
|
|
application: appliedBy.get(String(profile.email || '').toLowerCase()) || null,
|
|
recommendation: recommendTraining(position, profile, courses),
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => b.match.score - a.match.score);
|
|
}
|
|
|
|
/** Positions this worker should be looking at, best match first. */
|
|
export function matchingPositions(profile, positions = [], courses = []) {
|
|
const held = verifiedLevels(courses, profile);
|
|
return positions
|
|
.map((position) => ({ position, match: matchPosition(position, held) }))
|
|
.filter((row) => row.match)
|
|
.sort((a, b) => b.match.score - a.match.score);
|
|
}
|