position ui and owliver update
This commit is contained in:
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 {
|
||||
|
||||
Reference in New Issue
Block a user