update position page design
This commit is contained in:
@@ -359,6 +359,154 @@ export function useUpdateCourse() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Workforce assignment ──────────────────────────────────────────────── */
|
||||
|
||||
/** Every assignment on file. The workforce side of the source of truth. */
|
||||
export function useAssignments() {
|
||||
return useQuery({
|
||||
queryKey: ['assignments'],
|
||||
queryFn: () => base44.entities.Assignment.list('-created_date', 500),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put people on a position.
|
||||
*
|
||||
* Deliberately a bulk mutation taking an already-decided list: the deciding
|
||||
* happens in `lib/workforce.js` and the confirming happens in the UI, so by the
|
||||
* time anything reaches here the admin has seen exactly who and how many. There
|
||||
* is no code path that assigns somebody without that preview having been shown.
|
||||
*
|
||||
* Writes three things per person, because an assignment that updated only one
|
||||
* of them would leave the system disagreeing with itself: the assignment
|
||||
* record, the application's status where the person applied, and an activity
|
||||
* event carrying every id involved.
|
||||
*/
|
||||
export function useAssignWorkers() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ position, workers = [], applications = [] }) => {
|
||||
const startsAt = position.start_date
|
||||
? new Date(position.start_date).toISOString()
|
||||
: new Date().toISOString();
|
||||
const endsAt = position.duration_months == null
|
||||
? null
|
||||
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
|
||||
|
||||
const created = [];
|
||||
for (const worker of workers) {
|
||||
const record = await base44.entities.Assignment.create({
|
||||
job_posting_id: position.id,
|
||||
worker_email: worker.email,
|
||||
worker_name: worker.name,
|
||||
starts_at: startsAt,
|
||||
ends_at: endsAt,
|
||||
status: 'active',
|
||||
source: 'owliver',
|
||||
match_score: worker.score ?? null,
|
||||
});
|
||||
created.push(record);
|
||||
|
||||
/**
|
||||
* The application is what puts a person *in the pipeline for this role*,
|
||||
* and it is what every downstream step keys on — the candidate profile
|
||||
* is addressed by it, and the AI interview takes one as its subject.
|
||||
*
|
||||
* So assigning somebody from the existing workforce creates one where
|
||||
* none exists, exactly as the Position page's own "admit talent" path
|
||||
* does. Without it an assigned worker would be unreachable: no record to
|
||||
* open, and no way to interview them.
|
||||
*/
|
||||
let application = applications.find(
|
||||
(a) => a.job_posting_id === position.id
|
||||
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
application = await base44.entities.JobApplication.create({
|
||||
applicant_name: worker.name,
|
||||
email: worker.email || '',
|
||||
phone: worker.profile?.phone || '',
|
||||
years_experience: worker.profile?.experience_years || 0,
|
||||
skills: worker.profile?.skills || [],
|
||||
availability: worker.profile?.availability || [],
|
||||
certifications: worker.profile?.certifications || [],
|
||||
selfie_url: worker.profile?.selfie_url || '',
|
||||
professional_summary: worker.profile?.career_goals || '',
|
||||
cover_letter: '',
|
||||
job_posting_id: position.id,
|
||||
job_title: position.title,
|
||||
status: 'assigned',
|
||||
ai_score: worker.score ?? 0,
|
||||
});
|
||||
} else {
|
||||
await base44.entities.JobApplication.update(application.id, { status: 'assigned' });
|
||||
}
|
||||
record.application_id = application.id;
|
||||
|
||||
await logActivity('assign_employee', {
|
||||
details: `${worker.name} assigned to ${position.company ? `${position.company} — ` : ''}${position.title}`,
|
||||
position_id: position.id,
|
||||
application_id: application?.id || null,
|
||||
worker_email: worker.email,
|
||||
});
|
||||
}
|
||||
return created;
|
||||
},
|
||||
onSuccess: () => {
|
||||
/* Everything that reads workforce state refreshes together, so the
|
||||
position card, the detail page and Owliver's next answer cannot be
|
||||
looking at three different moments. */
|
||||
queryClient.invalidateQueries({ queryKey: ['assignments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an assigned candidate in front of the AI interview.
|
||||
*
|
||||
* Deliberately *not* a call to `useCreateInterview`. `AIInterviewModal` — the AI
|
||||
* Interview Owliver — writes its own `AIInterview` record when the interview
|
||||
* actually finishes, carrying the transcript, the scores and the verdict.
|
||||
* Creating one here would produce a second, empty interview record for the same
|
||||
* person: a duplicate system, and a row claiming an interview happened when
|
||||
* nobody has spoken yet.
|
||||
*
|
||||
* What is missing before an interview can run is the application being *at*
|
||||
* interview stage. That is what this writes — the same status transition the
|
||||
* Position and Candidates pages already perform — which is what makes the
|
||||
* candidate appear as interview-ready everywhere and lets the existing modal
|
||||
* open on them.
|
||||
*/
|
||||
export function useMarkInterviewReady() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, position }) => {
|
||||
if (!application?.id) throw new Error('No application to schedule against');
|
||||
|
||||
const updated = await base44.entities.JobApplication.update(application.id, {
|
||||
status: 'interview',
|
||||
});
|
||||
|
||||
await logActivity('interview_ready', {
|
||||
details: `${application.applicant_name} moved to interview for ${position?.title || application.job_title}`,
|
||||
position_id: position?.id || application.job_posting_id,
|
||||
application_id: application.id,
|
||||
});
|
||||
|
||||
return updated;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLearningPaths() {
|
||||
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
|
||||
}
|
||||
@@ -417,7 +565,10 @@ export function useUpdateWorkerProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['workerProfile'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,7 +591,13 @@ export function useCompleteCourse() {
|
||||
const patch = { completed_courses, earned_badges, xp, ...recalcProfilePatch(merged) };
|
||||
return base44.entities.WorkerProfile.update(profile.id, patch);
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['workerProfile'] }),
|
||||
/* Completing training changes a verified skill level, and a level is read by
|
||||
Forge, the profile and every position match — so the workforce list is
|
||||
invalidated too, not just the one profile. */
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -503,8 +660,12 @@ export function useSubmitChallenge() {
|
||||
await base44.entities.WorkerProfile.update(profile.id, patch);
|
||||
return { evidence, passed, result };
|
||||
},
|
||||
/* The whole loop lands here: evidence written, profile updated, and every
|
||||
reader of a skill level — Forge, profiles, position matching — refreshed
|
||||
from the same mutation that Owliver's verdict triggered. */
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,10 +44,43 @@ export const CRITERIA_LABELS = {
|
||||
* A function rather than a constant, so two callers cannot end up sharing — and
|
||||
* mutating — the same `vetting_criteria` or certifications array.
|
||||
*/
|
||||
/** How long a position needs people for, in months. `null` is open-ended. */
|
||||
export const DURATION_OPTIONS = [
|
||||
{ value: '0.5', label: '2 weeks' },
|
||||
{ value: '1', label: '1 month' },
|
||||
{ value: '3', label: '3 months' },
|
||||
{ value: '6', label: '6 months' },
|
||||
{ value: '12', label: '1 year' },
|
||||
{ value: '', label: '1+ year / ongoing' },
|
||||
];
|
||||
|
||||
/** Urgency, as the employer states it. Drives allocation priority, not display. */
|
||||
export const PRIORITY_OPTIONS = [
|
||||
{ value: 'urgent', label: 'Urgent' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
];
|
||||
|
||||
export function defaultPosition() {
|
||||
return {
|
||||
title: '',
|
||||
role_category: 'Server',
|
||||
/* ── Workforce demand ────────────────────────────────────────────────
|
||||
What the employer is actually asking for. Everything the allocation
|
||||
engine reasons about — who needs people first, whether one person can
|
||||
cover a role for its whole run, whether they are free when it starts —
|
||||
comes from these five fields, so they live on the position record rather
|
||||
than being inferred from prose. */
|
||||
company: '',
|
||||
/* How many people. One is the common case and the old implicit one, so a
|
||||
position created before this existed still means what it meant. */
|
||||
headcount: 1,
|
||||
/* ISO date, or '' for "as soon as possible" — which is not the same as
|
||||
today, and the engine treats it as an immediate start. */
|
||||
start_date: '',
|
||||
/* Months. '' means open-ended, which is a real answer, not a missing one. */
|
||||
duration_months: '',
|
||||
priority: 'normal',
|
||||
custom_requirements: '',
|
||||
physical_requirements: '',
|
||||
leadership_expectations: '',
|
||||
@@ -58,6 +91,11 @@ export function defaultPosition() {
|
||||
pay_range_min: '',
|
||||
pay_range_max: '',
|
||||
certifications_required: [],
|
||||
/* What the work requires in verified skill levels — the field the matching
|
||||
engine reads (lib/skillGraph.js). Empty means "no skill requirements
|
||||
defined", and a position with none shows no match score rather than an
|
||||
invented one. */
|
||||
skill_requirements: [],
|
||||
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
|
||||
};
|
||||
}
|
||||
@@ -77,9 +115,56 @@ export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
|
||||
pay_range_min: Number(record.pay_range_min) || 0,
|
||||
pay_range_max: Number(record.pay_range_max) || 0,
|
||||
min_experience_years: Number(record.min_experience_years) || 0,
|
||||
/* At least one person is being asked for, whatever the field says. */
|
||||
headcount: Math.max(1, Number(record.headcount) || 1),
|
||||
/* Kept as a number or null rather than a string, because the allocation
|
||||
engine compares it against assignment windows. Null is open-ended. */
|
||||
duration_months: record.duration_months === '' || record.duration_months == null
|
||||
? null
|
||||
: Number(record.duration_months),
|
||||
company: String(record.company || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "30 people · starts 4 Sep · 1 month" — the demand, in one line.
|
||||
*
|
||||
* Only the parts the record actually states. A position that declares no
|
||||
* headcount, no start and no duration returns `null` and the caller renders
|
||||
* nothing, rather than "1 person · starts immediately · ongoing" — which is
|
||||
* three schema defaults wearing the appearance of an employer's answer.
|
||||
*/
|
||||
export function demandLabel(position = {}) {
|
||||
const parts = [];
|
||||
|
||||
const count = Number(position.headcount);
|
||||
if (Number.isFinite(count) && count > 0) {
|
||||
parts.push(`${count} ${count === 1 ? 'person' : 'people'}`);
|
||||
}
|
||||
|
||||
if (position.start_date) {
|
||||
parts.push(`starts ${new Date(position.start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`);
|
||||
} else if (position.start_date === '') {
|
||||
/* An explicit empty start means "as soon as possible" — a stated answer.
|
||||
An absent field means the question was never asked. */
|
||||
parts.push('starts immediately');
|
||||
}
|
||||
|
||||
const months = position.duration_months;
|
||||
if (months != null) {
|
||||
parts.push(
|
||||
months < 1 ? `${Math.round(months * 4)} weeks`
|
||||
: months === 1 ? '1 month'
|
||||
: months < 12 ? `${months} months`
|
||||
: months === 12 ? '1 year' : `${Math.round(months / 12)}+ years`
|
||||
);
|
||||
} else if ('duration_months' in position) {
|
||||
parts.push('ongoing');
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
/** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */
|
||||
export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
|
||||
const low = Number(min) || 0;
|
||||
|
||||
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);
|
||||
}
|
||||
101
src/lib/skills/pageSkills.js
Normal file
101
src/lib/skills/pageSkills.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { allSkills, getSkillsForPage } from './registry';
|
||||
import { skillStateFor } from '@/lib/skillGraph';
|
||||
|
||||
/**
|
||||
* Page attachment, resolved against the skill graph.
|
||||
*
|
||||
* Two systems meet here, and keeping them apart is the point:
|
||||
*
|
||||
* the definition a Markdown file in Forge — what the skill is, the rungs of
|
||||
* its ladder, and the `pages:` it may be surfaced on
|
||||
* the graph `lib/skillGraph.js` — what a given worker has actually
|
||||
* verified, and how that scores against a position
|
||||
*
|
||||
* A page asks this module what to show, gets back definitions already paired
|
||||
* with the reader's state, and renders. It never asks for a skill by name, and
|
||||
* it never decides for itself whether a skill belongs to it — which is what
|
||||
* makes a skill authored tomorrow appear tonight without a page being touched.
|
||||
*
|
||||
* Three ideas that are easy to conflate and must not be:
|
||||
*
|
||||
* skill.pages where this skill may be *surfaced*
|
||||
* position.skill_requirements what a particular job *requires*
|
||||
* profile.completed_courses what a person has actually *verified*
|
||||
*
|
||||
* A skill attached to `positions` does not make every position require it. It
|
||||
* makes the Positions experience allowed to talk about it when a position
|
||||
* happens to require it.
|
||||
*/
|
||||
|
||||
/** Workforce definitions attached to a page, in the order Forge lists them. */
|
||||
export const workforceSkillsForPage = (pageId, options = {}) =>
|
||||
getSkillsForPage(pageId, { ...options, kind: 'workforce' });
|
||||
|
||||
/**
|
||||
* The graph skill ids a page may surface. Callers gating an existing panel on
|
||||
* attachment want this rather than the definitions themselves.
|
||||
*/
|
||||
export const skillIdsForPage = (pageId, options = {}) =>
|
||||
new Set(workforceSkillsForPage(pageId, options).map((s) => s.skillId).filter(Boolean));
|
||||
|
||||
/**
|
||||
* Is this capability allowed to be surfaced here?
|
||||
*
|
||||
* The gate for contextual additions to a page that already exists — a training
|
||||
* recommendation on a position, say. `false` means render nothing, not render an
|
||||
* explanation of why nothing rendered.
|
||||
*/
|
||||
export const isSkillOnPage = (skillId, pageId, options = {}) =>
|
||||
skillIdsForPage(pageId, options).has(skillId);
|
||||
|
||||
/**
|
||||
* What a page should show for one reader: every skill attached to it, paired
|
||||
* with that person's state in it.
|
||||
*
|
||||
* Definitions whose `skill:` binding matches nothing in the graph are dropped
|
||||
* rather than rendered as an empty ladder — a definition pointing at a
|
||||
* capability the platform does not model is an authoring error, and showing it
|
||||
* as "Not started" would hide that.
|
||||
*
|
||||
* Returns `[]` when nothing is attached. Callers render nothing at all in that
|
||||
* case: a page with no skills attached should look like a page that was never
|
||||
* given any, not like one whose skill section is empty.
|
||||
*/
|
||||
export function skillStatesForPage(pageId, courses = [], profile = null, options = {}) {
|
||||
return workforceSkillsForPage(pageId, options)
|
||||
.map((definition) => {
|
||||
if (!definition.skillId) return null;
|
||||
const state = skillStateFor(definition.skillId, courses, profile);
|
||||
/* `skillStateFor` answers for any id; a ladder with no rungs means the
|
||||
graph does not carry this capability. */
|
||||
return state.ladder?.length ? { definition, state } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition governing one capability, or null.
|
||||
*
|
||||
* Lets a component that already holds a graph skill id — a position requirement
|
||||
* line, a match gap — reach the training path behind it, and with it the pages
|
||||
* that path may be surfaced on.
|
||||
*/
|
||||
export function definitionForSkillId(skillId, { customSources = [] } = {}) {
|
||||
if (!skillId) return null;
|
||||
return allSkills(customSources).find(
|
||||
(s) => s.kind === 'workforce' && s.status === 'active' && s.skillId === skillId
|
||||
) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition behind one capability, but only if this page may surface it.
|
||||
*
|
||||
* The common case for a contextual addition: a position requirement names a
|
||||
* capability, and the page wants the training path for it *if* the author
|
||||
* attached that path here. One call, so no component has to remember to check
|
||||
* attachment separately and none can forget.
|
||||
*/
|
||||
export function pageDefinitionForSkillId(skillId, pageId, options = {}) {
|
||||
const definition = definitionForSkillId(skillId, options);
|
||||
return definition && definition.pages.includes(pageId) ? definition : null;
|
||||
}
|
||||
@@ -91,6 +91,43 @@ function sectionSteps(body, heading) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The prose under a `## Heading`, with its bullets and blank lines stripped to
|
||||
* one line of summary. Used for a level's description, which is a sentence
|
||||
* rather than a list.
|
||||
*/
|
||||
function sectionText(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return '';
|
||||
return section[1]
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The rungs a workforce skill defines, read from its own body.
|
||||
*
|
||||
* A definition names its ladder as `## Beginner`, `## Intermediate` and so on,
|
||||
* each followed by what a person must be able to do at that level. Only the
|
||||
* headings that are actually present become rungs, so a skill that tops out at
|
||||
* Advanced has a three-rung ladder rather than a fourth empty one — the ladder
|
||||
* is what the author wrote, not a fixed shape they are padded into.
|
||||
*/
|
||||
const LEVEL_HEADINGS = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
|
||||
|
||||
function sectionLevels(body) {
|
||||
return LEVEL_HEADINGS
|
||||
.map((heading) => ({
|
||||
level: heading.toLowerCase(),
|
||||
label: heading,
|
||||
summary: sectionText(body, heading),
|
||||
}))
|
||||
.filter((rung) => rung.summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* The page key a route belongs to — `/admin/positions` → `positions`.
|
||||
*
|
||||
@@ -128,12 +165,44 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
{
|
||||
const { data, body } = parseFrontmatter(raw);
|
||||
const pages = Array.isArray(data.pages) ? data.pages : [];
|
||||
const id = data.id || path.split('/').pop().replace(/\.md$/, '');
|
||||
const levels = sectionLevels(body);
|
||||
|
||||
/**
|
||||
* Two things wear the same format.
|
||||
*
|
||||
* An *assistant* skill teaches Owliver to do something on a page. A
|
||||
* *workforce* skill defines a capability the workforce can hold, at levels,
|
||||
* and says which pages may surface it. A definition that names a ladder is
|
||||
* the second kind — nothing else distinguishes them, so an author declares a
|
||||
* workforce skill by writing one, not by setting a flag.
|
||||
*/
|
||||
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
|
||||
|
||||
return {
|
||||
id: data.id || path.split('/').pop().replace(/\.md$/, ''),
|
||||
id,
|
||||
kind,
|
||||
name: data.name || 'Untitled skill',
|
||||
description: data.description || '',
|
||||
status: data.status === 'inactive' ? 'inactive' : 'active',
|
||||
pages,
|
||||
/**
|
||||
* The capability in the skill graph this definition governs.
|
||||
*
|
||||
* Declared as `skill:`, or inferred by dropping a `-training` suffix and
|
||||
* swapping dashes for underscores — so `server-training.md` governs
|
||||
* `server` and `customer-service-training.md` governs `customer_service`
|
||||
* without the author restating it. Only meaningful for workforce skills.
|
||||
*/
|
||||
skillId: kind === 'workforce'
|
||||
? (data.skill || id.replace(/-training$/, '')).replace(/-/g, '_')
|
||||
: null,
|
||||
/* The ladder, in order, each rung carrying what it means to hold it. */
|
||||
levels,
|
||||
/* The definition as written. Forge edits this; every other page reads the
|
||||
parsed form, so there is exactly one artefact behind all of them. */
|
||||
markdown: raw,
|
||||
source: custom ? 'account' : 'repository',
|
||||
/* The page label Settings shows, taken from the context the page carries
|
||||
so the two never disagree. */
|
||||
actions: Array.isArray(data.actions) ? data.actions : [],
|
||||
@@ -214,18 +283,42 @@ export function contextIdsForSkill(skill) {
|
||||
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every skill attached to a page — the one answer to "what belongs here".
|
||||
*
|
||||
* This is what `pages:` is for. A definition listing `positions` is not
|
||||
* documenting itself; it is saying that the Positions experience may surface it,
|
||||
* and this function is the only place that question is answered. Pages call it
|
||||
* and render what comes back, which is what lets a skill authored tomorrow
|
||||
* appear on the right pages tonight without any page component being edited.
|
||||
*
|
||||
* Nothing downstream may test a page name against a skill id. The moment a page
|
||||
* asks "is this Server Training?" the attachment has stopped being data.
|
||||
*/
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
|
||||
if (!pageId) return [];
|
||||
return allSkills(customSources).filter(
|
||||
(s) => s.status === 'active'
|
||||
&& !disabled.includes(s.id)
|
||||
&& s.pages.includes(pageId)
|
||||
&& (!kind || s.kind === kind)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills available on a page.
|
||||
*
|
||||
* `disabled` is the account's list of switched-off skill ids, so a skill can be
|
||||
* turned off from Settings without being deleted from disk.
|
||||
*
|
||||
* Assistant skills only: a workforce skill defines a capability, not something
|
||||
* Owliver can be asked to do, and offering one as a chat action would promise
|
||||
* behaviour that does not exist.
|
||||
*/
|
||||
export function skillsForContext(contextId, disabled = [], customSources = []) {
|
||||
const pageKey = PAGE_KEY_BY_CONTEXT[contextId];
|
||||
if (!pageKey) return [];
|
||||
return allSkills(customSources).filter(
|
||||
(s) => s.status === 'active' && !disabled.includes(s.id) && s.pages.includes(pageKey)
|
||||
);
|
||||
return getSkillsForPage(pageKey, { disabled, customSources, kind: 'assistant' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
56
src/lib/skills/usePageSkills.js
Normal file
56
src/lib/skills/usePageSkills.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import {
|
||||
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillsForPage,
|
||||
} from './pageSkills';
|
||||
|
||||
/**
|
||||
* A page's skill attachments, resolved once.
|
||||
*
|
||||
* Every consuming page needs the same two things before it can ask anything
|
||||
* else: the account's custom definitions (a skill added in Settings is as real
|
||||
* as one on disk) and the ids it has switched off. Reading those in one hook
|
||||
* means no page can forget either, and none has to know where they are stored.
|
||||
*
|
||||
* The hook is deliberately thin — it resolves attachment and hands back the
|
||||
* helpers already bound to this page. The rules themselves stay in
|
||||
* `pageSkills.js`, which is what keeps them testable without React.
|
||||
*
|
||||
* Usage is always the same shape:
|
||||
*
|
||||
* const { skills, statesFor } = usePageSkills('profile');
|
||||
* if (!skills.length) return null; // nothing attached: no section
|
||||
*
|
||||
* `attaches(skillId)` is the gate for a contextual addition to something that
|
||||
* already exists — a training prompt beside a position requirement, say — where
|
||||
* the page is not listing skills but is about to mention one.
|
||||
*/
|
||||
export function usePageSkills(pageId) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* `usePreferences` rebuilds its object on every read, so the arrays inside it
|
||||
are new identities each render. Keying the memo on their content is what
|
||||
stops every consumer from recomputing — and re-rendering — continuously. */
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const customSources = JSON.parse(customKey);
|
||||
const disabled = JSON.parse(disabledKey);
|
||||
const options = { customSources, disabled };
|
||||
const skills = workforceSkillsForPage(pageId, options);
|
||||
|
||||
return {
|
||||
/** The definitions attached here. Empty means render nothing. */
|
||||
skills,
|
||||
/** True when this page may surface that capability at all. */
|
||||
attaches: (skillId) => isSkillOnPage(skillId, pageId, options),
|
||||
/** The definition behind a capability, but only if attached here. */
|
||||
definitionFor: (skillId) => pageDefinitionForSkillId(skillId, pageId, options),
|
||||
/** Each attached definition paired with one person's state in it. */
|
||||
statesFor: (courses, profile) => skillStatesForPage(pageId, courses, profile, options),
|
||||
/** Passed through to components that resolve attachment themselves. */
|
||||
customSources,
|
||||
};
|
||||
}, [pageId, customKey, disabledKey]);
|
||||
}
|
||||
728
src/lib/skills/workforceFlow.js
Normal file
728
src/lib/skills/workforceFlow.js
Normal file
@@ -0,0 +1,728 @@
|
||||
import { doc, heading, insights, list, note, table, text } from '@/components/ai-assistant/blocks';
|
||||
import {
|
||||
demandFor, poolFor, prepareAssignment, prioritise, startLabel, workforceStatusFor,
|
||||
} from '@/lib/workforce';
|
||||
import { levelLabel, skillName } from '@/lib/skillGraph';
|
||||
|
||||
/**
|
||||
* Owliver's workforce layer: the conversation, not the reasoning.
|
||||
*
|
||||
* Every number, ranking, availability check and eligibility decision below comes
|
||||
* from `lib/workforce.js`. This module's entire job is to work out which
|
||||
* question was asked, which position it is about, and how to say the engine's
|
||||
* answer — plus the one thing a chat can do that a page cannot, which is to
|
||||
* propose an action and wait to be told to take it.
|
||||
*
|
||||
* The rule that shapes the file: **nothing here writes, and nothing here
|
||||
* decides.** A preview is built by calling `prepareAssignment`, and executing it
|
||||
* is a separate turn that re-derives the plan from live data before the existing
|
||||
* mutation runs. That re-derivation is deliberate — between seeing a preview and
|
||||
* confirming it, somebody else may have taken the person.
|
||||
*/
|
||||
|
||||
/* ── Which question is this? ───────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...terms) => terms.some((t) => q.includes(t));
|
||||
|
||||
/**
|
||||
* The workforce intent behind a question, or null to let the page's own
|
||||
* responder answer it.
|
||||
*
|
||||
* Ordered by specificity: confirmation before proposal, proposal before
|
||||
* enquiry. "Assign them" after a preview must not be read as a fresh request
|
||||
* for recommendations.
|
||||
*/
|
||||
export function matchWorkforceIntent(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
/* Inspection and record-opening are checked before assignment, so "show X"
|
||||
and "open the full profile for X" never read as a request to assign. */
|
||||
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
|
||||
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
|
||||
|
||||
if (has(q, 'confirm interview')) return 'confirm_interview';
|
||||
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
|
||||
'interview for ', 'set up their interview')) return 'interview_setup';
|
||||
|
||||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
|
||||
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
|
||||
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
|
||||
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
|
||||
'which position should i fill', 'priority')) return 'priority';
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Which position? ───────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The position a question is about.
|
||||
*
|
||||
* Named titles win, longest match first so "Event Server – Fine Dining" is not
|
||||
* resolved as "Event Server". Failing that, the position the reader currently
|
||||
* has open. Failing that, nothing — and the caller asks rather than guessing,
|
||||
* because assigning somebody to the wrong role is not a recoverable mistake.
|
||||
*/
|
||||
export function resolvePosition(question, positions = [], currentId = null) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
let best = null;
|
||||
for (const position of positions) {
|
||||
const title = String(position.title || '').toLowerCase();
|
||||
/* Titles use an en dash; people type a hyphen. */
|
||||
const loose = title.replace(/[–—]/g, '-');
|
||||
const asked = q.replace(/[–—]/g, '-');
|
||||
if (title && (q.includes(title) || asked.includes(loose))
|
||||
&& (!best || title.length > String(best.title).length)) {
|
||||
best = position;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
|
||||
return positions.find((p) => p.id === currentId) || null;
|
||||
}
|
||||
|
||||
/** The question Owliver asks when it cannot tell which role is meant. */
|
||||
export const askWhichPosition = (positions = []) => ({
|
||||
doc: doc(
|
||||
text('Which position should I work on?'),
|
||||
list(positions.filter((p) => p.status === 'active').slice(0, 6).map((p) => p.title)),
|
||||
note('Name the role and I will read its requirements, demand and the people available for it.')
|
||||
),
|
||||
followUp: positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.slice(0, 4)
|
||||
.map((p) => ({ label: p.title, prompt: `Who matches ${p.title}?` })),
|
||||
});
|
||||
|
||||
/**
|
||||
* The person a question names, matched against the pool the engine scored.
|
||||
*
|
||||
* Full name first, then a unique first name — "assign Maria" is how people
|
||||
* actually speak, but only when exactly one Maria is in play. An ambiguous first
|
||||
* name resolves to nothing and the caller asks, because assigning the wrong
|
||||
* person is not a recoverable mistake either.
|
||||
*/
|
||||
export function resolveCandidate(question, pool = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
const byFull = pool.filter((row) => row.name && q.includes(String(row.name).toLowerCase()));
|
||||
if (byFull.length === 1) return { row: byFull[0] };
|
||||
if (byFull.length > 1) {
|
||||
/* Longest name wins: "Arun Kumar" over a hypothetical "Arun". */
|
||||
return { row: [...byFull].sort((a, b) => b.name.length - a.name.length)[0] };
|
||||
}
|
||||
|
||||
const byFirst = pool.filter((row) => {
|
||||
const first = String(row.name || '').split(' ')[0].toLowerCase();
|
||||
return first.length > 2 && new RegExp(`\\b${first}\\b`).test(q);
|
||||
});
|
||||
if (byFirst.length === 1) return { row: byFirst[0] };
|
||||
if (byFirst.length > 1) return { ambiguous: byFirst };
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/* ── Saying what the engine found ──────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Why this person fits, in the engine's own terms.
|
||||
*
|
||||
* Requirement lines come from the match; availability and duration come from the
|
||||
* commitment check. A gap is stated as a gap — a candidate one level short is
|
||||
* useful to see, and calling them a strong match would be the single most
|
||||
* damaging thing this assistant could do.
|
||||
*/
|
||||
function reasonsFor(row) {
|
||||
const out = [];
|
||||
|
||||
for (const line of row.match.met) {
|
||||
out.push(`✓ ${line.name} — ${line.heldLabel}`);
|
||||
}
|
||||
for (const line of row.match.gaps) {
|
||||
out.push(`⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`);
|
||||
}
|
||||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record to open for a recommended person.
|
||||
*
|
||||
* Matching reads worker profiles, because that is where verified skill levels
|
||||
* live. The candidate profile is addressed by *application* id, because that is
|
||||
* what the page was built to show. So the link is only real when this person has
|
||||
* an application on file — and when they do not, the card says so rather than
|
||||
* pointing somewhere that is not their record.
|
||||
*
|
||||
* The position travels with the link so the profile knows what the person was
|
||||
* being considered for.
|
||||
*/
|
||||
export function candidateRoute(row, applications = [], position = null) {
|
||||
const email = String(row.email || '').toLowerCase();
|
||||
if (!email) return null;
|
||||
|
||||
const mine = applications.filter((a) => String(a.email || '').toLowerCase() === email);
|
||||
if (!mine.length) return null;
|
||||
|
||||
/* An application for *this* role is the right record to open; failing that,
|
||||
their most recent one, which is still their profile. */
|
||||
const forThis = position && mine.find((a) => a.job_posting_id === position.id);
|
||||
const chosen = forThis
|
||||
|| [...mine].sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
|
||||
|
||||
return position
|
||||
? `/admin/candidates/${chosen.id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${chosen.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One candidate, as a card.
|
||||
*
|
||||
* Clickable when there is a record behind the name. The reasons are the engine's
|
||||
* — met requirements, gaps, and the availability verdict — so a card can never
|
||||
* read as a stronger endorsement than the match actually is.
|
||||
*/
|
||||
const candidateBlock = (row, index, { position = null, inspectable = true } = {}) => insights([{
|
||||
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
|
||||
title: `${index}. ${row.name} — ${row.score}% match`,
|
||||
body: reasonsFor(row).join(' · '),
|
||||
/* Clicking inspects the person *here*. Opening their full record is offered
|
||||
separately, from the detail view, because leaving the panel loses the
|
||||
position the admin is working on. */
|
||||
prompt: inspectable && position ? `Show ${row.name} for ${position.title}` : null,
|
||||
}]);
|
||||
|
||||
/**
|
||||
* The people who could do this work, ranked.
|
||||
*
|
||||
* Shows the unavailable and the under-qualified too, marked as such: "nobody is
|
||||
* free" is something an admin needs to see, and filtering it away leaves them
|
||||
* asking the same question again tomorrow.
|
||||
*/
|
||||
export function candidateMatches(position, context) {
|
||||
const pool = poolFor(position, context);
|
||||
const status = workforceStatusFor(position, context);
|
||||
|
||||
if (!pool.length) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`No worker profiles can be scored against **${position.title}** — it defines no skill requirements, or no profiles carry the skills it asks for.`),
|
||||
note('Matching reads verified skill levels from worker profiles. Applicants without a profile appear in the pipeline but cannot be scored.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const eligible = pool.filter((r) => r.availability.available);
|
||||
const blocked = pool.filter((r) => !r.availability.available);
|
||||
const shown = eligible.slice(0, 5);
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`Best matches for ${position.title}`,
|
||||
`${status.strong.length} strong · ${eligible.length} available · ${pool.length} scored`),
|
||||
...(shown.length
|
||||
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
|
||||
: [text('Nobody is currently free for this role.')]),
|
||||
blocked.length
|
||||
? note(`${blocked.length} other ${blocked.length === 1 ? 'person is' : 'people are'} qualified but committed elsewhere — ask who is available next to see when they free up.`)
|
||||
: null,
|
||||
status.demand.declared
|
||||
? text(`This position needs **${status.demand.remaining}** more of **${status.demand.required}**.`)
|
||||
: null
|
||||
),
|
||||
followUp: shown.length
|
||||
? [
|
||||
{ label: 'Assign the best', prompt: `Assign the best candidates to ${position.title}` },
|
||||
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
|
||||
]
|
||||
: [{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Who is free, who is not, and when the committed ones come back. */
|
||||
export function availability(position, context) {
|
||||
const pool = poolFor(position, context);
|
||||
if (!pool.length) {
|
||||
return { doc: doc(text(`No worker profiles can be assessed for **${position.title}**.`)) };
|
||||
}
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`Availability for ${position.title}`, `starts ${startLabel(position)}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Person' },
|
||||
{ key: 'state', label: 'Availability' },
|
||||
{ key: 'match', label: 'Match', align: 'right' },
|
||||
],
|
||||
pool.slice(0, 8).map((row) => ({
|
||||
name: row.name,
|
||||
state: row.availability.available ? 'Available' : row.availability.reason,
|
||||
match: `${row.score}%`,
|
||||
}))
|
||||
),
|
||||
note('Availability is measured against this position’s start date — somebody finishing a role before it begins counts as free.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Applications created today, across a position or the whole board. */
|
||||
export function appliedToday(position, context, positions = []) {
|
||||
const scope = position ? [position] : positions;
|
||||
const rows = scope.flatMap((p) =>
|
||||
workforceStatusFor(p, context).newToday.map((row) => ({
|
||||
name: row.name,
|
||||
position: p.title,
|
||||
time: new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
|
||||
status: row.application.status,
|
||||
match: row.match ? `${row.match.score}%` : '—',
|
||||
}))
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return {
|
||||
doc: doc(text(position
|
||||
? `No applications for **${position.title}** today.`
|
||||
: 'No applications arrived today.')),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`${rows.length} new ${rows.length === 1 ? 'application' : 'applications'} today`),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Candidate' },
|
||||
{ key: 'position', label: 'Position' },
|
||||
{ key: 'time', label: 'Applied' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'match', label: 'Match', align: 'right' },
|
||||
],
|
||||
rows
|
||||
),
|
||||
note('Match is shown only for people who carry a worker profile; an applicant without one cannot be scored.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Candidates whose application has reached a stage where an interview is next. */
|
||||
export function interviewReady(position, context, positions = []) {
|
||||
const scope = position ? [position] : positions;
|
||||
const rows = scope.flatMap((p) => {
|
||||
const status = workforceStatusFor(p, context);
|
||||
return status.applicants
|
||||
.filter((a) => ['ai_screened', 'shortlisted'].includes(a.application.status))
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
position: p.title,
|
||||
stage: a.application.status === 'ai_screened' ? 'Screened' : 'Shortlisted',
|
||||
match: a.match ? `${a.match.score}%` : '—',
|
||||
}));
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
return {
|
||||
doc: doc(text(position
|
||||
? `Nobody is waiting on an interview decision for **${position.title}**.`
|
||||
: 'Nobody is currently screened or shortlisted and waiting on an interview.')),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`${rows.length} ready for interview`),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Candidate' },
|
||||
{ key: 'position', label: 'Position' },
|
||||
{ key: 'stage', label: 'Stage' },
|
||||
{ key: 'match', label: 'Match', align: 'right' },
|
||||
],
|
||||
rows
|
||||
),
|
||||
note('Interviews are scheduled from the candidate’s record on the position page — I can tell you who is ready, but I do not create the booking.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which role gets people first, and the engine's stated reasons. */
|
||||
export function positionPriority(positions, context) {
|
||||
const ranked = prioritise(positions, context);
|
||||
if (!ranked.length) {
|
||||
return { doc: doc(text('No active position currently has an unfilled headcount.')) };
|
||||
}
|
||||
|
||||
const top = ranked[0];
|
||||
return {
|
||||
doc: doc(
|
||||
heading('Hiring priority', `${ranked.length} ${ranked.length === 1 ? 'position needs' : 'positions need'} people`),
|
||||
insights(ranked.slice(0, 3).map((row) => ({
|
||||
tone: row.band === 'High' ? 'warning' : 'info',
|
||||
title: `${row.position.title} — ${row.band}`,
|
||||
body: row.reasons.join(' · '),
|
||||
}))),
|
||||
text(`Start with **${top.position.title}**.`)
|
||||
),
|
||||
followUp: [{ label: `Who matches ${top.position.title}?`, prompt: `Who matches ${top.position.title}?` }],
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Proposing an assignment ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The preview. Nothing is written here.
|
||||
*
|
||||
* `prepareAssignment` picks the people; this states who, why, and what the
|
||||
* position looks like afterwards. The confirmation is a follow-up carrying the
|
||||
* position title, so accepting it goes back through the same resolution path a
|
||||
* typed sentence would — and re-checks the data before writing.
|
||||
*/
|
||||
export function assignmentPreview(position, context) {
|
||||
const plan = prepareAssignment(position, context);
|
||||
const demand = demandFor(position, context);
|
||||
|
||||
if (!demand.declared) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`**${position.title}** does not state how many people it needs, so there is no gap for me to fill.`),
|
||||
note('Set a headcount on the position and I can propose an assignment against it.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (plan.blocked === 'full') {
|
||||
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
|
||||
}
|
||||
|
||||
if (plan.blocked === 'no_available_matches') {
|
||||
const blocked = poolFor(position, context).filter((r) => !r.availability.available);
|
||||
return {
|
||||
doc: doc(
|
||||
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
|
||||
blocked.length
|
||||
? insights(blocked.slice(0, 3).map((row) => ({
|
||||
tone: 'warning',
|
||||
title: row.name,
|
||||
body: `${row.match.score}% on skills, but ${row.availability.reason.toLowerCase()}`,
|
||||
})))
|
||||
: null,
|
||||
note(`${demand.remaining} of ${demand.required} still unfilled.`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`Assignment preview — ${position.title}`, `${plan.selected.length} proposed`),
|
||||
...plan.selected.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position })),
|
||||
text(
|
||||
`**${demand.assigned} of ${demand.required}** assigned now · `
|
||||
+ `**${plan.after} of ${plan.required}** after · `
|
||||
+ `**${plan.remainingAfter}** remaining`
|
||||
),
|
||||
note('Nothing is written until you confirm. I re-check availability at that moment, so anyone taken in the meantime drops out.')
|
||||
),
|
||||
followUp: [
|
||||
{ label: 'Confirm assignment', prompt: `Confirm assignment for ${position.title}` },
|
||||
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One candidate, examined — inside the panel.
|
||||
*
|
||||
* Deliberately not a copy of the Candidate Profile page. It carries only what a
|
||||
* hiring decision needs: how they match, whether they are free, what they have
|
||||
* done, and where their application stands. The full record stays one explicit
|
||||
* click away, and remains the source of truth.
|
||||
*
|
||||
* The actions offered depend on the person's real state, so the panel never
|
||||
* offers an assignment it would then have to refuse.
|
||||
*/
|
||||
export function candidateDetail(position, row, context) {
|
||||
const profile = row.profile || {};
|
||||
const applications = context.applications || [];
|
||||
const application = applications.find(
|
||||
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
|
||||
&& a.job_posting_id === position.id
|
||||
) || applications.find(
|
||||
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
|
||||
) || null;
|
||||
|
||||
const facts = [
|
||||
profile.experience_years ? `${profile.experience_years} years experience` : null,
|
||||
profile.current_position || profile.desired_position || null,
|
||||
profile.address || null,
|
||||
(profile.certifications || []).length ? (profile.certifications || []).join(', ') : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const assignable = row.availability.available && !row.match.gaps.length;
|
||||
|
||||
/* Actions follow the state. An unavailable person is not offered an
|
||||
assignment; somebody with no record is not offered a record to open. */
|
||||
const followUp = [
|
||||
assignable
|
||||
? { label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }
|
||||
: { label: 'Why not eligible?', prompt: `Can ${row.name} be assigned to ${position.title}?` },
|
||||
{ label: '← Back to matches', prompt: `Who matches ${position.title}?` },
|
||||
application
|
||||
? { label: 'View full profile', prompt: `Open the full profile for ${row.name}` }
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`${row.name} — ${row.score}% match`, `Evaluating for ${position.title}`),
|
||||
insights([{
|
||||
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
|
||||
title: 'Match',
|
||||
body: reasonsFor(row).join(' · '),
|
||||
}]),
|
||||
facts.length ? insights([{ tone: 'neutral', title: 'Background', body: facts.join(' · ') }]) : null,
|
||||
insights([{
|
||||
tone: row.availability.available ? 'success' : 'warning',
|
||||
title: 'Availability',
|
||||
body: row.availability.available
|
||||
? `Free when this starts (${startLabel(position)})`
|
||||
: row.availability.reason,
|
||||
}]),
|
||||
insights([{
|
||||
tone: application ? 'info' : 'neutral',
|
||||
title: 'Application',
|
||||
body: application
|
||||
? `${application.status.replace(/_/g, ' ')} · applied ${new Date(application.created_date).toLocaleDateString()}`
|
||||
: 'No application on file for this role — assigning them creates one.',
|
||||
}]),
|
||||
assignable
|
||||
? null
|
||||
: note('I will not offer an assignment the engine would refuse. The reason is above.')
|
||||
),
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview for one named person.
|
||||
*
|
||||
* Separate from the ranked preview because the question is different: the admin
|
||||
* has already chosen, and what they need is whether that choice holds. So this
|
||||
* validates rather than recommends — and refuses, with the engine's reason, when
|
||||
* the person is committed elsewhere or short of a required level.
|
||||
*/
|
||||
export function namedAssignmentPreview(position, row, context) {
|
||||
const demand = demandFor(position, context);
|
||||
|
||||
if (!row.availability.available) {
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`${row.name} cannot take ${position.title}`),
|
||||
insights([{ tone: 'warning', title: row.availability.reason, body: reasonsFor(row).join(' · ') }]),
|
||||
note('Availability is measured against this position’s start date. Ask who is available if you want the people who can take it.')
|
||||
),
|
||||
followUp: [{ label: 'Who is available?', prompt: `Who is available for ${position.title}?` }],
|
||||
};
|
||||
}
|
||||
|
||||
if (row.match.gaps.length) {
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`${row.name} is short of ${position.title}`, `${row.score}% match`),
|
||||
insights([{
|
||||
tone: 'warning',
|
||||
title: row.match.gaps.map((g) => `${g.name}: ${g.heldLabel} → needs ${g.requiredLabel}`).join(' · '),
|
||||
body: reasonsFor(row).join(' · '),
|
||||
}]),
|
||||
note('I will not put somebody forward as qualified when the engine says they are not. Assign them anyway by naming the gap you are willing to accept, or ask who does meet it.')
|
||||
),
|
||||
followUp: [{ label: 'Who fully matches?', prompt: `Who matches ${position.title}?` }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading('Assignment ready', `${row.name} → ${position.title}`),
|
||||
candidateBlock(row, 1, { applications: context.applications, position }),
|
||||
demand.declared
|
||||
? text(`**${demand.assigned} of ${demand.required}** assigned now · **${demand.assigned + 1}** after · **${Math.max(0, demand.remaining - 1)}** remaining`)
|
||||
: text('This position states no headcount, so there is no coverage figure to move.'),
|
||||
note('Nothing is written until you confirm.')
|
||||
),
|
||||
followUp: [
|
||||
{ label: 'Confirm assignment', prompt: `Confirm assignment of ${row.name} to ${position.title}` },
|
||||
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The single-person plan, re-derived at confirmation. */
|
||||
export function namedAssignmentToExecute(position, row, context) {
|
||||
if (!row || !row.availability.available || row.match.gaps.length) return null;
|
||||
const demand = demandFor(position, context);
|
||||
return {
|
||||
position,
|
||||
workers: [{ email: row.email, name: row.name, score: row.score }],
|
||||
before: demand.assigned,
|
||||
after: demand.assigned + 1,
|
||||
required: demand.required,
|
||||
remainingAfter: Math.max(0, demand.remaining - 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan, re-derived at the moment of confirmation.
|
||||
*
|
||||
* Returned to the caller to execute through the existing mutation. Re-deriving
|
||||
* rather than replaying a stored preview is what makes the confirmation honest:
|
||||
* the people written are the people who are still free.
|
||||
*/
|
||||
export function assignmentToExecute(position, context) {
|
||||
const plan = prepareAssignment(position, context);
|
||||
if (plan.blocked || !plan.selected.length) return null;
|
||||
return {
|
||||
position,
|
||||
workers: plan.selected.map((row) => ({ email: row.email, name: row.name, score: row.score })),
|
||||
before: plan.before,
|
||||
after: plan.after,
|
||||
required: plan.required,
|
||||
remainingAfter: plan.remainingAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Interview ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The application a person holds for this role, or null.
|
||||
*
|
||||
* Everything about interviewing keys on this record: it is the subject the AI
|
||||
* interview takes, and the thing a status transition moves.
|
||||
*/
|
||||
export function applicationFor(row, position, context) {
|
||||
const email = String(row?.email || '').toLowerCase();
|
||||
if (!email) return null;
|
||||
return (context.applications || []).find(
|
||||
(a) => a.job_posting_id === position?.id && String(a.email || '').toLowerCase() === email
|
||||
) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interview setup, previewed.
|
||||
*
|
||||
* The blockers are real states, not guesses: somebody with no application for
|
||||
* this role has not entered its pipeline, and somebody already at interview
|
||||
* stage does not need moving there again. Both are reported rather than
|
||||
* papered over with a second record.
|
||||
*/
|
||||
export function interviewPreview(position, row, context) {
|
||||
const application = applicationFor(row, position, context);
|
||||
|
||||
if (!application) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`**${row.name}** has no application for **${position.title}**, so there is nothing to interview against.`),
|
||||
note('Assigning them creates the application, and the interview can be set up straight after.')
|
||||
),
|
||||
followUp: [{ label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }],
|
||||
};
|
||||
}
|
||||
|
||||
if (application.status === 'interview') {
|
||||
return {
|
||||
doc: doc(
|
||||
heading('Already at interview stage', `${row.name} · ${position.title}`),
|
||||
text('The AI interview can be started from their candidate record.'),
|
||||
insights([{
|
||||
tone: 'info',
|
||||
title: 'Open the AI interview',
|
||||
body: `${row.name} — ${position.title}`,
|
||||
to: `/admin/candidates/${application.id}`,
|
||||
}])
|
||||
),
|
||||
followUp: [{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const requirements = (position.skill_requirements || [])
|
||||
.map((r) => `${skillName(r.skill_id)} — ${levelLabel(r.level)}`)
|
||||
.join(' · ');
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading('Interview setup', `${row.name} → ${position.title}`),
|
||||
insights([{
|
||||
tone: 'info',
|
||||
title: 'AI Interview',
|
||||
body: requirements
|
||||
? `Scored against this role's own requirements: ${requirements}`
|
||||
: 'This position defines no skill requirements, so the interview covers the role generally.',
|
||||
}]),
|
||||
insights([{
|
||||
tone: 'neutral',
|
||||
title: 'Application',
|
||||
body: `Currently ${String(application.status).replace(/_/g, ' ')} · moving to interview`,
|
||||
}]),
|
||||
note('This moves the application to interview stage. The interview itself is conducted by the AI interviewer on the candidate’s record, which writes the result.')
|
||||
),
|
||||
followUp: [
|
||||
{ label: 'Confirm interview', prompt: `Confirm interview for ${row.name} at ${position.title}` },
|
||||
{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The interview step to execute, or null when it cannot proceed. */
|
||||
export function interviewToExecute(position, row, context) {
|
||||
const application = applicationFor(row, position, context);
|
||||
if (!application || application.status === 'interview') return null;
|
||||
return { position, row, application };
|
||||
}
|
||||
|
||||
/** What to say once the application has actually moved. */
|
||||
export const interviewDone = (plan) => doc(
|
||||
heading('Interview ready', `${plan.row.name} · ${plan.position.title}`),
|
||||
text(`**${plan.row.name}** is now at interview stage for **${plan.position.title}**.`),
|
||||
insights([{
|
||||
tone: 'success',
|
||||
title: 'Open the AI interview',
|
||||
body: 'The AI interviewer scores against this role’s requirements and writes the result to their record.',
|
||||
to: `/admin/candidates/${plan.application.id}`,
|
||||
}]),
|
||||
note('Candidates, the position pipeline and the activity log all reflect this now.')
|
||||
);
|
||||
|
||||
export const interviewFailed = () => doc(
|
||||
text('I could not move that application to interview stage.'),
|
||||
note('Nothing was changed. Open the candidate’s record and try from there.')
|
||||
);
|
||||
|
||||
/** What to say once the write has actually happened. */
|
||||
export const assignmentDone = (plan) => doc(
|
||||
heading('Assignment complete', `${plan.workers.length} assigned to ${plan.position.title}`),
|
||||
list(plan.workers.map((w) => `${w.name} — ${w.score}% match`)),
|
||||
text(
|
||||
`**${plan.position.title}** is now **${plan.after} of ${plan.required}** assigned · `
|
||||
+ `**${plan.remainingAfter}** remaining.`
|
||||
),
|
||||
note('The position, the applications and the activity log have been updated.')
|
||||
);
|
||||
|
||||
/** The next step to offer once an assignment lands. */
|
||||
export const assignmentFollowUp = (plan) => [
|
||||
plan.workers.length === 1
|
||||
? { label: 'Set up interview', prompt: `Set up an interview for ${plan.workers[0].name} at ${plan.position.title}` }
|
||||
: { label: 'Who is ready for interview?', prompt: 'Who is ready for interview?' },
|
||||
{ label: '← Back to matches', prompt: `Who matches ${plan.position.title}?` },
|
||||
];
|
||||
|
||||
/** What to say when the write could not proceed. */
|
||||
export const assignmentFailed = () => doc(
|
||||
text('I could not complete that assignment.'),
|
||||
note('Nothing was written. The people I proposed may have been taken since the preview — ask again and I will re-check.')
|
||||
);
|
||||
@@ -58,7 +58,7 @@ export const FACTOR_WEIGHTS = {
|
||||
|
||||
// Score band — the "credit rating" tier for the talent
|
||||
export function getScoreBand(score = 0) {
|
||||
if (score >= 90) return { label: 'Elite', color: '#0A39DF' };
|
||||
if (score >= 90) return { label: 'Elite', color: '#0838E0' };
|
||||
if (score >= 75) return { label: 'Excellent', color: '#16A34A' };
|
||||
if (score >= 60) return { label: 'Solid', color: '#2563EB' };
|
||||
if (score >= 40) return { label: 'Building', color: '#D97706' };
|
||||
|
||||
@@ -24,6 +24,23 @@ export async function logActivity(eventType, extra = {}) {
|
||||
user_name: me?.full_name || extra.name || '',
|
||||
account_type: extra.account_type || me?.account_type || 'unknown',
|
||||
details: extra.details || '',
|
||||
/**
|
||||
* The records this event is about.
|
||||
*
|
||||
* An event that says only "a candidate was contacted" cannot be read back
|
||||
* against the position it was about, so the ids travel with it. They are
|
||||
* written only when supplied — an event with no application does not get
|
||||
* an `application_id: null` that later reads as a missing link.
|
||||
*
|
||||
* Kept as flat fields rather than a metadata blob so the store's own
|
||||
* `filter({ position_id })` can find them without a custom query path.
|
||||
*/
|
||||
...(extra.position_id ? { position_id: extra.position_id } : {}),
|
||||
...(extra.application_id ? { application_id: extra.application_id } : {}),
|
||||
...(extra.candidate_id ? { candidate_id: extra.candidate_id } : {}),
|
||||
...(extra.interview_id ? { interview_id: extra.interview_id } : {}),
|
||||
...(extra.worker_email ? { worker_email: extra.worker_email } : {}),
|
||||
...(extra.metadata ? { metadata: extra.metadata } : {}),
|
||||
};
|
||||
await base44.entities.UserActivity.create(record);
|
||||
base44.analytics.track({
|
||||
|
||||
432
src/lib/workforce.js
Normal file
432
src/lib/workforce.js
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Workforce allocation: who is needed where, who is actually free, and which
|
||||
* demand gets the people first.
|
||||
*
|
||||
* `skillGraph.js` answers "can this person do this work". This module answers
|
||||
* the questions that come after it, and they are the ones that decide a real
|
||||
* roster:
|
||||
*
|
||||
* demand how many people a position needs, has, and still lacks
|
||||
* availability whether a person is free *when the work starts*, and free
|
||||
* long enough to see it out
|
||||
* priority which position gets the scarce people first, and why
|
||||
*
|
||||
* Two rules shape everything here.
|
||||
*
|
||||
* **Availability is a window, not a flag.** A person committed to a six-month
|
||||
* role is not "unavailable" — they are unavailable *until a date*. Treating it
|
||||
* as a boolean is what makes a system recommend someone who is already on a
|
||||
* floor somewhere else, so every check below is against a start date.
|
||||
*
|
||||
* **Every score carries its reasons.** An allocation moves a person's work and a
|
||||
* company's staffing; a number nobody can interrogate is not usable for that.
|
||||
* Every function that ranks returns the reasons alongside the rank.
|
||||
*
|
||||
* Reads the existing entities only — JobPosting, JobApplication, WorkerProfile,
|
||||
* Staff and the Assignment records the store now carries. Nothing here writes.
|
||||
*/
|
||||
|
||||
import { matchPosition, recommendTraining, verifiedLevels } from './skillGraph';
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
const MONTH_MS = DAY_MS * 30;
|
||||
|
||||
/* ── Dates ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A position's start, as a date. No start date means "as soon as possible". */
|
||||
export function startsAt(position, today = new Date()) {
|
||||
return position?.start_date ? new Date(position.start_date) : new Date(today);
|
||||
}
|
||||
|
||||
/** When the work ends, or null when it is open-ended. */
|
||||
export function endsAt(position, today = new Date()) {
|
||||
const months = position?.duration_months;
|
||||
if (months == null) return null;
|
||||
return new Date(startsAt(position, today).getTime() + months * MONTH_MS);
|
||||
}
|
||||
|
||||
/** Days from now until the work starts. Negative or zero means it is live. */
|
||||
export const daysUntilStart = (position, today = new Date()) =>
|
||||
Math.round((startsAt(position, today) - today) / DAY_MS);
|
||||
|
||||
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
|
||||
export function startLabel(position, today = new Date()) {
|
||||
const days = daysUntilStart(position, today);
|
||||
if (days <= 0) return 'immediately';
|
||||
if (days === 1) return 'tomorrow';
|
||||
if (days < 14) return `in ${days} days`;
|
||||
if (days < 60) return `in ${Math.round(days / 7)} weeks`;
|
||||
return `in ${Math.round(days / 30)} months`;
|
||||
}
|
||||
|
||||
/* ── Demand ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* What a position needs, has and still lacks.
|
||||
*
|
||||
* "Assigned" counts live assignment records plus anyone already hired onto the
|
||||
* role, because both are people who are actually on it — a system that counted
|
||||
* only one of them would report a gap the floor does not have.
|
||||
*/
|
||||
export function demandFor(position, { assignments = [], staff = [] } = {}) {
|
||||
/**
|
||||
* Whether this position states how many people it wants.
|
||||
*
|
||||
* Most records predate the field, and a position that never declared a
|
||||
* headcount has no demand figure — it has an unknown one. Callers read
|
||||
* `declared` and omit the demand entirely rather than presenting the fallback
|
||||
* of 1 as though the employer had asked for one person.
|
||||
*/
|
||||
const declared = Number.isFinite(Number(position?.headcount)) && Number(position.headcount) > 0;
|
||||
const required = declared ? Number(position.headcount) : 1;
|
||||
|
||||
const assigned = assignments.filter(
|
||||
(a) => a.job_posting_id === position?.id && a.status === 'active'
|
||||
);
|
||||
/* Hired staff who never got an explicit assignment record still occupy a
|
||||
slot. Counted by email so the two sources cannot double-count one person. */
|
||||
const assignedEmails = new Set(assigned.map((a) => String(a.worker_email || '').toLowerCase()));
|
||||
const hired = staff.filter(
|
||||
(s) => s.job_posting_id === position?.id
|
||||
&& !assignedEmails.has(String(s.email || '').toLowerCase())
|
||||
);
|
||||
|
||||
const filled = assigned.length + hired.length;
|
||||
|
||||
return {
|
||||
declared,
|
||||
required,
|
||||
assigned: filled,
|
||||
remaining: Math.max(0, required - filled),
|
||||
filledPct: required ? Math.round((filled / required) * 100) : 0,
|
||||
assignments: assigned,
|
||||
isFull: filled >= required,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Availability ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Is this person free for this work, and if not, when do they come free?
|
||||
*
|
||||
* The commitment that matters is the one covering the position's start date.
|
||||
* Someone finishing a role next Tuesday is available for work starting next
|
||||
* month — reporting them as "committed" would hide a person the roster needs.
|
||||
*/
|
||||
export function availabilityOf(profile, position, { assignments = [], today = new Date() } = {}) {
|
||||
const email = String(profile?.email || '').toLowerCase();
|
||||
const start = startsAt(position, today);
|
||||
|
||||
const commitments = assignments.filter(
|
||||
(a) => String(a.worker_email || '').toLowerCase() === email && a.status === 'active'
|
||||
);
|
||||
|
||||
/* A commitment blocks this work when it is still running when the work
|
||||
starts. Open-ended commitments (`ends_at: null`) block everything. */
|
||||
const blocking = commitments.filter((a) => !a.ends_at || new Date(a.ends_at) > start);
|
||||
|
||||
if (!blocking.length) {
|
||||
return { available: true, committedUntil: null, blockedBy: null, reason: 'Free when this starts' };
|
||||
}
|
||||
|
||||
/* The one that clears last decides when they are actually free. */
|
||||
const openEnded = blocking.find((a) => !a.ends_at);
|
||||
const until = openEnded
|
||||
? null
|
||||
: new Date(Math.max(...blocking.map((a) => new Date(a.ends_at).getTime())));
|
||||
|
||||
return {
|
||||
available: false,
|
||||
committedUntil: until,
|
||||
blockedBy: blocking[0],
|
||||
reason: until
|
||||
? `Committed until ${until.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
|
||||
: 'Committed to an open-ended assignment',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this person cover the whole run?
|
||||
*
|
||||
* A two-week availability against a one-year role is a real mismatch, and the
|
||||
* spec is explicit that duration must matter. Someone free open-endedly covers
|
||||
* anything; the penalty only applies when their own availability actually ends
|
||||
* before the work does.
|
||||
*/
|
||||
export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
const end = endsAt(position, today);
|
||||
if (!end) {
|
||||
/* Open-ended work. Anyone with a stated end to their availability is a
|
||||
partial fit for it, and anyone without is a full one. */
|
||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||
if (!free) return { covers: true, ratio: 1, note: null };
|
||||
const months = Math.max(0, (free - startsAt(position, today)) / MONTH_MS);
|
||||
return {
|
||||
covers: false,
|
||||
ratio: Math.min(1, months / 12),
|
||||
note: `Available for about ${Math.round(months)} more months on open-ended work`,
|
||||
};
|
||||
}
|
||||
|
||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
|
||||
|
||||
const needed = end - startsAt(position, today);
|
||||
const have = Math.max(0, free - startsAt(position, today));
|
||||
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
|
||||
|
||||
return {
|
||||
covers: false,
|
||||
ratio,
|
||||
note: `Available for ${Math.round(have / DAY_MS)} of the ${Math.round(needed / DAY_MS)} days this runs`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Candidate and workforce pool ──────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Everyone who could do this work, ranked, with the reasoning attached.
|
||||
*
|
||||
* Skill match is the base score; timing adjusts it. A perfect skill match who
|
||||
* cannot start until after the work begins is not a better answer than a
|
||||
* slightly weaker one who can, and the ranking says so — but the person is
|
||||
* still returned, with the reason, because "nobody is free" is something the
|
||||
* admin needs to see rather than have filtered away.
|
||||
*/
|
||||
export function poolFor(position, {
|
||||
profiles = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(),
|
||||
} = {}) {
|
||||
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;
|
||||
|
||||
const availability = availabilityOf(profile, position, { assignments, today });
|
||||
const duration = durationFitOf(profile, position, { today });
|
||||
const application = appliedBy.get(String(profile.email || '').toLowerCase()) || null;
|
||||
|
||||
/* Timing multiplies the skill score rather than replacing it: the skill
|
||||
match is still the thing being measured, and the reader can see both
|
||||
numbers. A person who cannot start loses most of the score but keeps a
|
||||
visible one, so "strong but unavailable" stays legible. */
|
||||
const timingFactor = (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio);
|
||||
const score = Math.round(match.score * timingFactor);
|
||||
|
||||
const reasons = [
|
||||
`${match.score}% skill match against this role's requirements`,
|
||||
availability.reason,
|
||||
duration.note,
|
||||
application ? `Applied ${new Date(application.created_date).toLocaleDateString()}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
profile,
|
||||
email: profile.email,
|
||||
name: profile.full_name,
|
||||
match,
|
||||
skillScore: match.score,
|
||||
score,
|
||||
availability,
|
||||
duration,
|
||||
application,
|
||||
applied: Boolean(application),
|
||||
/* Ready to be put forward: qualified enough, and actually free. */
|
||||
strong: match.score >= 75 && availability.available,
|
||||
reasons,
|
||||
recommendation: recommendTraining(position, profile, courses),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
/**
|
||||
* Equally qualified people are common and the score says so honestly —
|
||||
* everyone who meets every requirement scores the same. Rather than
|
||||
* manufacture variance to break the tie, the order falls through to things
|
||||
* that are also true and also matter: who can cover more of the run, who
|
||||
* has done this longer, and who is more reliable on the record.
|
||||
*/
|
||||
.sort((a, b) =>
|
||||
b.score - a.score
|
||||
|| b.duration.ratio - a.duration.ratio
|
||||
|| (b.profile.experience_years || 0) - (a.profile.experience_years || 0)
|
||||
|| (b.profile.reliability_score || 0) - (a.profile.reliability_score || 0)
|
||||
|| String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The workforce picture for one position — the figures the detail page and
|
||||
* Owliver both report, computed once so they cannot disagree.
|
||||
*/
|
||||
export function workforceStatusFor(position, context = {}) {
|
||||
const demand = demandFor(position, context);
|
||||
const pool = poolFor(position, context);
|
||||
const today = context.today || new Date();
|
||||
|
||||
/**
|
||||
* Applicants are counted from the application records, not from the matched
|
||||
* pool.
|
||||
*
|
||||
* These are two different populations and conflating them under-reports the
|
||||
* real one. Matching needs a worker profile — that is where verified skill
|
||||
* levels live — but most applicants are external people who have applied
|
||||
* without one. Counting "applied" off the pool would report a position with
|
||||
* four applications as having none, because none of the four happened to
|
||||
* carry a profile.
|
||||
*
|
||||
* So: applications answer "who applied", the pool answers "who can do the
|
||||
* work", and each row is paired with its pool entry when one exists.
|
||||
*/
|
||||
const byEmail = new Map(pool.map((row) => [String(row.email || '').toLowerCase(), row]));
|
||||
|
||||
const applicants = (context.applications || [])
|
||||
.filter((a) => a.job_posting_id === position?.id)
|
||||
.map((application) => ({
|
||||
application,
|
||||
email: application.email,
|
||||
name: application.applicant_name,
|
||||
/* The engine's assessment, when this person is someone we can assess. */
|
||||
match: byEmail.get(String(application.email || '').toLowerCase()) || null,
|
||||
}));
|
||||
|
||||
const newToday = applicants.filter(
|
||||
(a) => new Date(a.application.created_date).toDateString() === today.toDateString()
|
||||
);
|
||||
const strong = pool.filter((p) => p.strong);
|
||||
|
||||
return {
|
||||
position,
|
||||
demand,
|
||||
pool,
|
||||
/* Existing workforce free for this work and not already on it. */
|
||||
existingAvailable: pool.filter((p) => p.availability.available && !p.applied),
|
||||
applicants,
|
||||
newToday,
|
||||
strong,
|
||||
interviewReady: applicants.filter((a) => a.application.status === 'interview'),
|
||||
/**
|
||||
* Hired onto this role, from the application record.
|
||||
*
|
||||
* Reported separately from `demand.assigned` because they are different
|
||||
* facts: hiring is a decision about a person, assignment is their presence
|
||||
* on the roster. Someone can be hired and not yet assigned, and a card that
|
||||
* conflated the two would report coverage the floor does not have.
|
||||
*/
|
||||
hired: applicants.filter((a) => a.application.status === 'hired'),
|
||||
/* What is still missing after everyone free and qualified is counted. */
|
||||
gap: Math.max(0, demand.remaining - strong.length),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Priority across companies ─────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Which position gets the scarce people first.
|
||||
*
|
||||
* Four things decide it, and each contributes a stated number of points so the
|
||||
* ranking can always be read back as a sentence:
|
||||
*
|
||||
* urgency what the employer declared
|
||||
* timing work starting today outranks work starting next month
|
||||
* shortage how many people are still missing, and what share of the role
|
||||
* supply whether anyone is actually available to fill it
|
||||
*
|
||||
* Supply counts *against* priority when it is plentiful: a position with more
|
||||
* strong matches than open slots does not need to be first in the queue, it
|
||||
* needs somebody to press the button. What ranks highest is a role that starts
|
||||
* soon, is badly short, and has few people who can fill it.
|
||||
*/
|
||||
export function prioritise(positions = [], context = {}) {
|
||||
const today = context.today || new Date();
|
||||
|
||||
return positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.map((position) => {
|
||||
const status = workforceStatusFor(position, { ...context, today });
|
||||
const { demand } = status;
|
||||
if (!demand.remaining) return null;
|
||||
|
||||
const days = daysUntilStart(position, today);
|
||||
const reasons = [];
|
||||
let score = 0;
|
||||
|
||||
const urgency = { urgent: 30, high: 18, normal: 6 }[position.priority] ?? 6;
|
||||
score += urgency;
|
||||
if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer');
|
||||
|
||||
/* Timing. Live work is the strongest signal in the model — every day it
|
||||
runs short is a day the client is under-staffed. */
|
||||
const timing = days <= 0 ? 35 : days <= 7 ? 26 : days <= 30 ? 14 : 5;
|
||||
score += timing;
|
||||
reasons.push(days <= 0 ? 'Starts immediately' : `Starts ${startLabel(position, today)}`);
|
||||
|
||||
/* Shortage, as both a count and a proportion: 18 of 30 missing is worse
|
||||
than 2 of 30, and worse again than 18 of 200. */
|
||||
const shortfall = demand.remaining / demand.required;
|
||||
const shortage = Math.round(shortfall * 20) + Math.min(15, demand.remaining);
|
||||
score += shortage;
|
||||
reasons.push(`${demand.remaining} of ${demand.required} still unfilled`);
|
||||
|
||||
/* Supply. Scarcity raises priority; a full bench lowers it. */
|
||||
const coverage = demand.remaining ? status.strong.length / demand.remaining : 1;
|
||||
const supply = coverage >= 1 ? -10 : Math.round((1 - coverage) * 20);
|
||||
score += supply;
|
||||
reasons.push(
|
||||
status.strong.length
|
||||
? `${status.strong.length} strong ${status.strong.length === 1 ? 'match' : 'matches'} available now`
|
||||
: 'No strong matches available'
|
||||
);
|
||||
|
||||
return {
|
||||
position,
|
||||
status,
|
||||
score: Math.max(0, score),
|
||||
band: score >= 70 ? 'High' : score >= 45 ? 'Medium' : 'Low',
|
||||
reasons,
|
||||
breakdown: { urgency, timing, shortage, supply },
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/* ── Preparing an assignment ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The assignment Owliver would make, as a proposal — never as a write.
|
||||
*
|
||||
* Returns the people, the counts before and after, and nothing else: executing
|
||||
* it is a separate, explicitly confirmed step (see `useAssignWorkers`). This
|
||||
* function existing separately from the mutation is what makes "show me first"
|
||||
* the only possible path rather than a convention someone has to remember.
|
||||
*/
|
||||
export function prepareAssignment(position, { count, ...context } = {}) {
|
||||
const status = workforceStatusFor(position, context);
|
||||
const take = Math.min(
|
||||
count ?? status.demand.remaining,
|
||||
status.demand.remaining,
|
||||
status.strong.length
|
||||
);
|
||||
|
||||
const selected = status.strong.slice(0, Math.max(0, take));
|
||||
|
||||
return {
|
||||
position,
|
||||
status,
|
||||
selected,
|
||||
before: status.demand.assigned,
|
||||
after: status.demand.assigned + selected.length,
|
||||
required: status.demand.required,
|
||||
remainingAfter: Math.max(0, status.demand.required - status.demand.assigned - selected.length),
|
||||
/* Why this is not simply "the top N": either nobody is free, or the role is
|
||||
already full. Callers say which rather than showing an empty preview. */
|
||||
blocked: selected.length === 0
|
||||
? (status.demand.remaining === 0 ? 'full' : 'no_available_matches')
|
||||
: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user