position ui and owliver update

This commit is contained in:
2026-08-14 01:18:59 +05:30
parent 1c653264b5
commit cccada9bd2
29 changed files with 3718 additions and 1313 deletions

View File

@@ -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(' · ');
}

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

View File

@@ -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.
*

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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,