position ui and owliver update
This commit is contained in:
@@ -173,3 +173,92 @@ export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
|
||||
if (!high || high === low) return `$${low}/hr`;
|
||||
return `$${low}–$${high}/hr`;
|
||||
}
|
||||
|
||||
/** `fluent` → `Fluent`. Returns null for a level the record does not state. */
|
||||
export function englishLabel(value) {
|
||||
if (!value) return null;
|
||||
return ENGLISH_LEVELS.find((l) => l.value === value)?.label
|
||||
?? String(value).replace(/\b\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* "0 years", "1 year", "3 years" — the number the employer actually entered.
|
||||
*
|
||||
* Zero is an answer, not an absence: a position open to people with no
|
||||
* experience says so, and reading it back as "No minimum" would be this code
|
||||
* rewording the employer rather than reporting them. `null` is returned only
|
||||
* when the field carries nothing at all.
|
||||
*/
|
||||
export function experienceLabel(position = {}) {
|
||||
const years = position.min_experience_years;
|
||||
if (years === undefined || years === null || years === '') return null;
|
||||
const n = Number(years);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return `${n} ${n === 1 ? 'year' : 'years'}`;
|
||||
}
|
||||
|
||||
/** A single rate as its own value: `$24/hr`, or null when it was not set. */
|
||||
export function rateLabel(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return `$${n}/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prose expectations the employer wrote, in the order the form asks for them.
|
||||
*
|
||||
* `custom_requirements` is deliberately not in this list: it is a paragraph
|
||||
* rather than a value, and it gets a block of its own — see
|
||||
* `PositionCustomRequirements`.
|
||||
*/
|
||||
export const REQUIREMENT_FIELDS = [
|
||||
{ key: 'physical_requirements', label: 'Physical Requirements' },
|
||||
{ key: 'leadership_expectations', label: 'Leadership Expectations' },
|
||||
{ key: 'attendance_expectations', label: 'Attendance Expectations' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The requirements this position actually states.
|
||||
*
|
||||
* Only the ones with something in them. A requirement is role-specific by
|
||||
* nature — a warehouse role states what it needs lifted, a server role does not
|
||||
* — so a field left blank is not an omission to be reported, it is that role
|
||||
* saying the question does not apply to it. Rendering an empty "Physical
|
||||
* requirements" card on every position turns a blank answer into a demand, and
|
||||
* makes every role look like the same template.
|
||||
*
|
||||
* The universal fields (title, category, location, pay, experience, English)
|
||||
* behave the opposite way and are handled separately: those are questions every
|
||||
* position answers, so an unanswered one is worth showing as unanswered.
|
||||
*/
|
||||
export function statedRequirements(position = {}) {
|
||||
return REQUIREMENT_FIELDS
|
||||
.map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() }))
|
||||
.filter((field) => field.value);
|
||||
}
|
||||
|
||||
/** The custom requirement text, or `''` when none was written. */
|
||||
export const customRequirementsText = (position = {}) =>
|
||||
String(position.custom_requirements ?? '').trim();
|
||||
|
||||
/**
|
||||
* Does this position state anything under Requirements at all?
|
||||
*
|
||||
* The gate a surface uses before drawing the heading — a section title standing
|
||||
* over nothing is worse than no section.
|
||||
*/
|
||||
export const hasRequirements = (position = {}) =>
|
||||
statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0;
|
||||
|
||||
/**
|
||||
* "Server · San Jose, CA · $24–$34/hr" — the terms of the role in one line.
|
||||
*
|
||||
* Company is deliberately absent: it belongs above the title, not beside the
|
||||
* category. Parts the record does not carry are omitted rather than padded.
|
||||
*/
|
||||
export function roleMetaLine(position = {}) {
|
||||
return [position.role_category, position.location, payLabel(position)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
78
src/lib/skills/customSkills.js
Normal file
78
src/lib/skills/customSkills.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { parseSkill } from './registry';
|
||||
|
||||
/**
|
||||
* Account-authored skills, as stored.
|
||||
*
|
||||
* A custom skill is its Markdown source and nothing else — the same artefact a
|
||||
* file in `src/skills/` is, read back by the same parser. These helpers exist so
|
||||
* the Add Skill dialog and the Skills page write that list identically; two
|
||||
* writers with two shapes would be a second skill system by accident.
|
||||
*/
|
||||
|
||||
/** The starting definition offered to an author, in the existing format. */
|
||||
export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `---
|
||||
id: ${id || 'my-skill'}
|
||||
name: ${name || 'My Skill'}
|
||||
description: ${description || 'What this skill helps Owliver do.'}
|
||||
pages:
|
||||
${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')}
|
||||
status: active
|
||||
triggers:
|
||||
- ${(name || 'my skill').toLowerCase()}
|
||||
---
|
||||
|
||||
# ${name || 'My Skill'}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should help with on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the skill can do.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
/** Parses a stored entry, tolerating the bare-string form. */
|
||||
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
|
||||
|
||||
/**
|
||||
* The stored list with `source` added or replaced.
|
||||
*
|
||||
* Matching is by skill id, so editing a skill overwrites its own entry rather
|
||||
* than adding a near-duplicate beside it.
|
||||
*/
|
||||
export function upsertCustomSkill(existing = [], source) {
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
const rest = existing.filter((entry) => {
|
||||
try {
|
||||
return parseSkill(sourceOf(entry), { custom: true }).id !== skill.id;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return { skill, next: [...rest, { path: `custom/${skill.id}.md`, raw: source }] };
|
||||
}
|
||||
|
||||
/** The stored list without the skill of this id. */
|
||||
export function removeCustomSkill(existing = [], id) {
|
||||
return existing.filter((entry) => {
|
||||
try {
|
||||
return parseSkill(sourceOf(entry), { custom: true }).id !== id;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The stored Markdown for one custom skill, or null if the account has none. */
|
||||
export function customSkillSource(existing = [], id) {
|
||||
for (const entry of existing) {
|
||||
try {
|
||||
if (parseSkill(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry);
|
||||
} catch {
|
||||
/* An unparseable entry cannot be the one being edited. */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -73,6 +73,32 @@ export function skillStatesForPage(pageId, courses = [], profile = null, options
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every training path the platform has, paired with one person's state in it.
|
||||
*
|
||||
* Page attachment is deliberately not consulted here. `pages:` says which
|
||||
* *product surfaces* may talk about a path; a workforce management view is
|
||||
* asking a different question — what training exists at all — and filtering it
|
||||
* by attachment would quietly hide a path from the one screen whose job is to
|
||||
* account for every one of them.
|
||||
*
|
||||
* Same definitions, same graph, same derivation as the page-scoped version
|
||||
* above: this is a second question asked of one dataset, not a second dataset.
|
||||
*/
|
||||
export function workforceSkillStates(courses = [], profile = null, options = {}) {
|
||||
const { customSources = [], disabled = [] } = options;
|
||||
return allSkills(customSources)
|
||||
.filter((s) => s.kind === 'workforce' && s.status === 'active' && !disabled.includes(s.id))
|
||||
.map((definition) => {
|
||||
if (!definition.skillId) return null;
|
||||
const state = skillStateFor(definition.skillId, courses, profile);
|
||||
/* A definition bound to a capability the graph does not carry is an
|
||||
authoring error; showing it as "Not started" would conceal that. */
|
||||
return state.ladder?.length ? { definition, state } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition governing one capability, or null.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import {
|
||||
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillsForPage,
|
||||
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillStates,
|
||||
workforceSkillsForPage,
|
||||
} from './pageSkills';
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,33 @@ import {
|
||||
* already exists — a training prompt beside a position requirement, say — where
|
||||
* the page is not listing skills but is about to mention one.
|
||||
*/
|
||||
/**
|
||||
* Every training path, for the views that manage training rather than surface it.
|
||||
*
|
||||
* The same preferences plumbing as `usePageSkills`, asking `pageSkills.js` the
|
||||
* other question it answers — so Workspace & Skills and Skill Development count
|
||||
* paths from the registry rather than keeping a list of their own.
|
||||
*/
|
||||
export function useWorkforcePaths() {
|
||||
const preferences = usePreferences();
|
||||
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const options = {
|
||||
customSources: JSON.parse(customKey),
|
||||
disabled: JSON.parse(disabledKey),
|
||||
};
|
||||
return {
|
||||
/** The training paths themselves, before anyone's progress is applied. */
|
||||
paths: workforceSkillStates([], null, options).map((entry) => entry.definition),
|
||||
/** Each path paired with one person's state in it. */
|
||||
statesFor: (courses, profile) => workforceSkillStates(courses, profile, options),
|
||||
};
|
||||
}, [customKey, disabledKey]);
|
||||
}
|
||||
|
||||
export function usePageSkills(pageId) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
|
||||
@@ -140,13 +140,24 @@ export function resolveCandidate(question, pool = []) {
|
||||
function reasonsFor(row) {
|
||||
const out = [];
|
||||
|
||||
if (!row.match) {
|
||||
return ['This position states no requirements this candidate can be scored against'];
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
/* Availability is only known for people the workforce system holds a record
|
||||
for. For everyone else it is reported as unknown rather than as free. */
|
||||
if (row.availability.known === false) {
|
||||
out.push('Availability not on file');
|
||||
} else {
|
||||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
}
|
||||
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
|
||||
|
||||
return out;
|
||||
@@ -155,31 +166,23 @@ function reasonsFor(row) {
|
||||
/**
|
||||
* 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.
|
||||
* One id, decided in one place. `poolFor` resolved this row against the
|
||||
* Candidates dataset before it was ever ranked, so `candidateId` is the id of
|
||||
* an application that genuinely exists — never a name, a rank, or a record
|
||||
* invented to make the link work. A row without one never reaches here, because
|
||||
* a person with no candidate record is not returned as a match at all.
|
||||
*
|
||||
* The position travels with the link so the profile knows what the person was
|
||||
* being considered for.
|
||||
* The route is the existing candidate route, opening the existing full profile.
|
||||
* The position travels with it 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];
|
||||
const id = row?.candidateId;
|
||||
if (!id) return null;
|
||||
|
||||
return position
|
||||
? `/admin/candidates/${chosen.id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${chosen.id}`;
|
||||
? `/admin/candidates/${id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,15 +192,53 @@ export function candidateRoute(row, applications = [], position = null) {
|
||||
* — 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,
|
||||
}]);
|
||||
const candidateBlock = (row, index, { position = null, applications = [], inspectable = true } = {}) => {
|
||||
/**
|
||||
* Two different things a reader wants from a match, kept apart.
|
||||
*
|
||||
* **View Profile** opens that person's record on the Candidates page. It is an
|
||||
* explicit control rather than the whole card, so reading the match reasons
|
||||
* cannot navigate away by accident — and it resolves for everyone, applied or
|
||||
* not, because the profile page reads both kinds of record.
|
||||
*
|
||||
* **The card itself** still asks Owliver about this person for this role, which
|
||||
* is the existing inspect-and-assign conversation, unchanged.
|
||||
*
|
||||
* The hint stays informational: it explains what opening the profile will and
|
||||
* will not show, and is no longer the only way in.
|
||||
*/
|
||||
const to = candidateRoute(row, applications, position);
|
||||
|
||||
/**
|
||||
* The headline says what kind of evidence the number rests on.
|
||||
*
|
||||
* A skill match is scored on training this person completed; a requirement
|
||||
* match is scored on what their application states. Printing both as "match"
|
||||
* would make the weaker claim borrow the authority of the stronger one, and a
|
||||
* candidate with nothing to score says so rather than showing a 0.
|
||||
*/
|
||||
const headline = !row.scored
|
||||
? 'not scored'
|
||||
: row.basis === 'verified'
|
||||
? `${row.score}% match`
|
||||
: `${row.score}% requirement fit`;
|
||||
|
||||
return insights([{
|
||||
tone: row.strong ? 'success' : row.scored ? 'info' : 'neutral',
|
||||
title: `${index}. ${row.name} — ${headline}`,
|
||||
body: reasonsFor(row).join(' · '),
|
||||
prompt: !inspectable || !position ? null : `Show ${row.name} for ${position.title}`,
|
||||
action: to ? { label: 'View Profile', to } : null,
|
||||
/* Two informational states, never a control: what the score rests on, and
|
||||
whether they have applied for this role. The profile opens either way. */
|
||||
hint: [
|
||||
row.basis === 'stated'
|
||||
? 'Scored from their application — no verified training on file.'
|
||||
: !row.scored ? 'Insufficient profile data to score against this role.' : null,
|
||||
row.applied ? null : 'No application for this role yet — assigning them creates one.',
|
||||
].filter(Boolean).join(' ') || null,
|
||||
}]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The people who could do this work, ranked.
|
||||
@@ -213,23 +254,31 @@ export function candidateMatches(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.')
|
||||
text(`There are no candidates on file to score against **${position.title}**.`),
|
||||
note('Candidates come from the Candidates page. Once somebody applies — to this role or any other — they can be scored against this position.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Committed elsewhere is a workforce fact, so it can only exclude somebody the
|
||||
workforce system holds a record for. A candidate with no such record is
|
||||
ranked below the verified ones, never filtered out by an availability
|
||||
nobody has recorded. */
|
||||
const eligible = pool.filter((r) => r.availability.available);
|
||||
const blocked = pool.filter((r) => !r.availability.available);
|
||||
const shown = eligible.slice(0, 5);
|
||||
const verified = pool.filter((r) => r.basis === 'verified').length;
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`Best matches for ${position.title}`,
|
||||
`${status.strong.length} strong · ${eligible.length} available · ${pool.length} scored`),
|
||||
`${pool.length} candidate${pool.length === 1 ? '' : 's'} · ${verified} with verified skills · ${status.strong.length} strong`),
|
||||
...(shown.length
|
||||
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
|
||||
: [text('Nobody is currently free for this role.')]),
|
||||
pool.length > shown.length + blocked.length
|
||||
? note(`${pool.length - shown.length - blocked.length} more candidate${pool.length - shown.length - blocked.length === 1 ? '' : 's'} on file — ask for the full list or open Candidates to see them all.`)
|
||||
: null,
|
||||
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,
|
||||
@@ -250,7 +299,7 @@ export function candidateMatches(position, context) {
|
||||
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(text(`There are no candidates on file to assess for **${position.title}**.`)) };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -185,78 +185,218 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
/* ── Candidate and workforce pool ──────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Everyone who could do this work, ranked, with the reasoning attached.
|
||||
* How well a candidate meets the requirements this position actually states.
|
||||
*
|
||||
* 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.
|
||||
* The fallback for somebody with no worker profile — which is most candidates.
|
||||
* It compares fields both records genuinely carry: the minimum experience,
|
||||
* English level and certifications the position asks for, against the ones the
|
||||
* application states. Nothing is inferred and nothing is invented; a
|
||||
* requirement the position does not state is not scored, and an answer the
|
||||
* application does not give counts as unmet rather than assumed.
|
||||
*
|
||||
* Returns `null` when the position states none of these, because then there is
|
||||
* nothing to measure and a number would be decoration.
|
||||
*/
|
||||
const ENGLISH_ORDER = ['basic', 'conversational', 'fluent', 'native'];
|
||||
|
||||
function statedRequirementMatch(position, application) {
|
||||
const lines = [];
|
||||
|
||||
const required = Number(position?.min_experience_years) || 0;
|
||||
if (required > 0) {
|
||||
const held = Number(application?.years_experience) || 0;
|
||||
lines.push({
|
||||
skillId: 'experience',
|
||||
name: 'Experience',
|
||||
met: held >= required,
|
||||
heldLabel: `${held} ${held === 1 ? 'year' : 'years'}`,
|
||||
requiredLabel: `${required} ${required === 1 ? 'year' : 'years'}`,
|
||||
});
|
||||
}
|
||||
|
||||
const englishNeeded = ENGLISH_ORDER.indexOf(String(position?.english_required || '').toLowerCase());
|
||||
if (englishNeeded > 0) {
|
||||
const heldIndex = ENGLISH_ORDER.indexOf(String(application?.english_level || '').toLowerCase());
|
||||
const label = (i) => (i < 0 ? 'Not stated' : ENGLISH_ORDER[i][0].toUpperCase() + ENGLISH_ORDER[i].slice(1));
|
||||
lines.push({
|
||||
skillId: 'english',
|
||||
name: 'English',
|
||||
met: heldIndex >= englishNeeded,
|
||||
heldLabel: label(heldIndex),
|
||||
requiredLabel: label(englishNeeded),
|
||||
});
|
||||
}
|
||||
|
||||
const certsNeeded = position?.certifications_required || [];
|
||||
if (certsNeeded.length) {
|
||||
const held = new Set((application?.certifications || []).map((c) => String(c).toLowerCase()));
|
||||
const missing = certsNeeded.filter((c) => !held.has(String(c).toLowerCase()));
|
||||
const heldList = certsNeeded.filter((c) => held.has(String(c).toLowerCase()));
|
||||
lines.push({
|
||||
skillId: 'certifications',
|
||||
name: 'Certifications',
|
||||
met: missing.length === 0,
|
||||
/* What they hold, not what they are missing — the requirement half of the
|
||||
line already names what is needed, and saying it twice reads as two
|
||||
different facts. */
|
||||
heldLabel: missing.length === 0
|
||||
? 'All on file'
|
||||
: heldList.length ? `Holds ${heldList.join(', ')}` : 'None on file',
|
||||
requiredLabel: certsNeeded.join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
if (!lines.length) return null;
|
||||
|
||||
const met = lines.filter((l) => l.met);
|
||||
return {
|
||||
score: Math.round((met.length / lines.length) * 100),
|
||||
lines,
|
||||
met,
|
||||
gaps: lines.filter((l) => !l.met),
|
||||
band: met.length === lines.length ? 'Meets stated requirements' : 'Partial requirement match',
|
||||
tone: met.length === lines.length ? 'success' : met.length ? 'warning' : 'risk',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates for this position, ranked, with the reasoning attached.
|
||||
*
|
||||
* ── Where the pool comes from ────────────────────────────────────────────
|
||||
*
|
||||
* It starts from the **candidate records** — the applications the Candidates
|
||||
* page lists — and enriches each one with a worker profile *if that person has
|
||||
* one*. Never the other way round. Three concepts stay separate:
|
||||
*
|
||||
* candidate a person on the Candidates page. The identity, and the id
|
||||
* every link uses.
|
||||
* application that person's application to a role. Present or not.
|
||||
* worker profile verified skill levels, availability, training. Optional
|
||||
* supporting intelligence, held by 9 of 24 people here.
|
||||
*
|
||||
* Building the pool from worker profiles inverted that: it recommended people
|
||||
* the Candidates page had never heard of, and — worse — dropped real candidates
|
||||
* for the crime of not having a training record. A candidate is a candidate
|
||||
* whether or not the workforce system knows anything else about them.
|
||||
*
|
||||
* ── How a row is scored ──────────────────────────────────────────────────
|
||||
*
|
||||
* Two bases, and a row says which one it used, because they are not the same
|
||||
* claim:
|
||||
*
|
||||
* `verified` the skill graph, scored on training this person actually
|
||||
* completed. Timing adjusts it, since somebody who cannot start
|
||||
* is not the better answer.
|
||||
* `stated` the position's own stated requirements against what the
|
||||
* application states. Real fields, weaker evidence.
|
||||
*
|
||||
* A candidate the position gives nothing to measure against is still returned,
|
||||
* marked unscored, rather than being dropped or given an invented number.
|
||||
*/
|
||||
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])
|
||||
const profileByEmail = new Map(
|
||||
profiles.map((p) => [String(p.email || '').toLowerCase(), p])
|
||||
);
|
||||
|
||||
return profiles
|
||||
.map((profile) => {
|
||||
const held = verifiedLevels(courses, profile);
|
||||
const match = matchPosition(position, held);
|
||||
if (!match) return null;
|
||||
/* One row per person, not per application: somebody who applied to three
|
||||
roles is one candidate. Their record for this position is the application
|
||||
to it when there is one, otherwise their most recent. */
|
||||
const candidates = new Map();
|
||||
for (const application of applications) {
|
||||
const key = String(application.email || '').toLowerCase() || application.id;
|
||||
const held = candidates.get(key);
|
||||
const isForThisRole = application.job_posting_id === position?.id;
|
||||
const heldIsForThisRole = held?.job_posting_id === position?.id;
|
||||
|
||||
const availability = availabilityOf(profile, position, { assignments, today });
|
||||
const duration = durationFitOf(profile, position, { today });
|
||||
const application = appliedBy.get(String(profile.email || '').toLowerCase()) || null;
|
||||
if (!held
|
||||
|| (isForThisRole && !heldIsForThisRole)
|
||||
|| (isForThisRole === heldIsForThisRole
|
||||
&& new Date(application.created_date) > new Date(held.created_date))) {
|
||||
candidates.set(key, application);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
return [...candidates.values()]
|
||||
.map((candidate) => {
|
||||
const email = String(candidate.email || '').toLowerCase();
|
||||
const profile = profileByEmail.get(email) || null;
|
||||
const application = candidate.job_posting_id === position?.id ? candidate : null;
|
||||
|
||||
/* Verified skills where the workforce system holds them; the position's
|
||||
stated requirements where it does not. */
|
||||
const verified = profile ? matchPosition(position, verifiedLevels(courses, profile)) : null;
|
||||
const match = verified || statedRequirementMatch(position, candidate);
|
||||
const basis = verified ? 'verified' : match ? 'stated' : 'none';
|
||||
|
||||
/* Availability is a workforce fact. Without a profile it is not known,
|
||||
and "not known" is said rather than assumed either way. */
|
||||
const availability = profile
|
||||
? availabilityOf(profile, position, { assignments, today })
|
||||
: { available: true, known: false, reason: 'Availability not on file' };
|
||||
const duration = profile
|
||||
? durationFitOf(profile, position, { today })
|
||||
: { covers: true, ratio: 1, note: null };
|
||||
|
||||
/* Timing multiplies the skill score rather than replacing it — but only
|
||||
where timing is actually known. A stated-requirement score is not
|
||||
adjusted by an availability nobody has recorded. */
|
||||
const timingFactor = profile
|
||||
? (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio)
|
||||
: 1;
|
||||
const score = match ? Math.round(match.score * timingFactor) : null;
|
||||
|
||||
const reasons = [
|
||||
`${match.score}% skill match against this role's requirements`,
|
||||
availability.reason,
|
||||
match
|
||||
? basis === 'verified'
|
||||
? `${match.score}% skill match against this role's requirements`
|
||||
: `${match.score}% of this role's stated requirements met`
|
||||
: 'No requirements on this position to score against',
|
||||
profile ? availability.reason : null,
|
||||
duration.note,
|
||||
application ? `Applied ${new Date(application.created_date).toLocaleDateString()}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
/* Identity: always the candidate record. */
|
||||
candidate,
|
||||
candidateId: candidate.id,
|
||||
name: candidate.applicant_name,
|
||||
email: candidate.email,
|
||||
/* Enrichment: present only when this person has it. */
|
||||
profile,
|
||||
email: profile.email,
|
||||
name: profile.full_name,
|
||||
basis,
|
||||
scored: Boolean(match),
|
||||
match,
|
||||
skillScore: match.score,
|
||||
skillScore: match ? match.score : null,
|
||||
score,
|
||||
availability,
|
||||
duration,
|
||||
application,
|
||||
applied: Boolean(application),
|
||||
/* Ready to be put forward: qualified enough, and actually free. */
|
||||
strong: match.score >= 75 && availability.available,
|
||||
/* Only verified evidence earns "strong". A stated-requirement match is
|
||||
real information, but it is not proof the person can do the work. */
|
||||
strong: basis === 'verified' && match.score >= 75 && availability.available,
|
||||
reasons,
|
||||
recommendation: recommendTraining(position, profile, courses),
|
||||
recommendation: profile ? recommendTraining(position, profile, courses) : null,
|
||||
};
|
||||
})
|
||||
.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.
|
||||
* Verified matches first, then stated ones, then candidates with nothing to
|
||||
* score — so the strongest evidence leads and no real candidate is buried
|
||||
* under an ordering rule they had no way to satisfy. Ties fall through to
|
||||
* things that are also true: who can cover more of the run, and who has
|
||||
* done this longer.
|
||||
*/
|
||||
.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)));
|
||||
.sort((a, b) => {
|
||||
const rank = (row) => (row.basis === 'verified' ? 0 : row.basis === 'stated' ? 1 : 2);
|
||||
return rank(a) - rank(b)
|
||||
|| (b.score ?? -1) - (a.score ?? -1)
|
||||
|| b.duration.ratio - a.duration.ratio
|
||||
|| (b.candidate.years_experience || 0) - (a.candidate.years_experience || 0)
|
||||
|| String(a.name).localeCompare(String(b.name));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,18 +409,11 @@ export function workforceStatusFor(position, context = {}) {
|
||||
const today = context.today || new Date();
|
||||
|
||||
/**
|
||||
* Applicants are counted from the application records, not from the matched
|
||||
* pool.
|
||||
* Applicants are counted from the application records for *this* position;
|
||||
* the pool is every candidate, whichever role they applied to.
|
||||
*
|
||||
* 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.
|
||||
* Two different questions — "who applied here" and "who could do this work" —
|
||||
* so each row is paired with its pool entry rather than the two being merged.
|
||||
*/
|
||||
const byEmail = new Map(pool.map((row) => [String(row.email || '').toLowerCase(), row]));
|
||||
|
||||
@@ -303,8 +436,15 @@ export function workforceStatusFor(position, context = {}) {
|
||||
position,
|
||||
demand,
|
||||
pool,
|
||||
/* Existing workforce free for this work and not already on it. */
|
||||
existingAvailable: pool.filter((p) => p.availability.available && !p.applied),
|
||||
/* Free for this work and not already on it. Availability is a workforce
|
||||
fact, so this counts only people it is actually known for — a candidate
|
||||
with no workforce record is not reported as free. */
|
||||
existingAvailable: pool.filter(
|
||||
(p) => p.availability.known !== false && p.availability.available && !p.applied
|
||||
),
|
||||
/* Candidates whose score rests on completed training rather than on what
|
||||
their application states. Reported so a surface can say which. */
|
||||
verified: pool.filter((p) => p.basis === 'verified'),
|
||||
applicants,
|
||||
newToday,
|
||||
strong,
|
||||
|
||||
Reference in New Issue
Block a user