Files
doormilxpress_astryx/src/lib/skillGraph.js
2026-08-13 21:41:14 +05:30

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);
}