update position page design
This commit is contained in:
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.')
|
||||
);
|
||||
Reference in New Issue
Block a user