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