update position page design
This commit is contained in:
399
src/lib/skillGraph.js
Normal file
399
src/lib/skillGraph.js
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user