Files
krow_talent_app/src/lib/skills/surfaces.js

681 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a skill definition is allowed to say about the product.
*
* A skill can extend a KROW page: name a surface, declare a section, and the
* page renders it. That only stays safe — and only stays a *product* rather
* than a scripting host — because the vocabulary is closed. Everything a
* definition may name is in this file: the surfaces, the placements on each
* surface, the component types, and the data sources.
*
* The rule that makes it safe: **nothing here is code, and nothing here is
* looked up dynamically from the file.** A definition names a key; this module
* says whether that key exists; the renderer maps it to a component the app
* already ships. A definition that names something absent is rejected with a
* message, never rendered as an unknown thing and never executed.
*/
/**
* The surfaces a skill can extend.
*
* `aliases` keep the page keys the existing skills already use — `hired`,
* `university` — working under the names the product now shows, so the eight
* definitions on disk did not have to be rewritten to gain this feature.
*/
export const SKILL_SURFACES = [
{
id: 'control-center',
label: 'Control Center',
route: '/admin',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'positions',
label: 'Positions',
route: '/admin/positions',
/* Mounted in three places, because Positions is three experiences: the page
that lists the roles, the card for each one, and the drawer "View
position" opens.
`after-position-list-summary` and `after-position-list` are the *page*:
they render once, above and below the grid, with no position in context.
`after-position-card` renders inside each card. The rest render inside
the drawer and on the full position page, where one position is being
read. Every one of these is mounted — a placement the vocabulary offers
and no page provides is a definition that validates and then silently
does nothing. */
placements: [
'after-position-list-summary',
'after-position-list',
'after-header',
'after-position-card',
'after-position-summary',
'before-candidates',
'after-candidates',
'before-footer',
],
/**
* What each of those placements actually hands a section, read off the
* `<SkillSurface>` call sites rather than assumed from the surface.
*
* The distinction is the whole point: the two list placements render above
* and below the grid with no position in context, while every other one
* renders inside a card, a drawer or the position page and passes the
* record. A section reading `position.*` is answerable at one and not at
* the other, and until this was written down both validated identically.
*/
provides: {
'after-position-list-summary': [],
'after-position-list': [],
'after-header': ['positionId'],
'after-position-card': ['positionId'],
'after-position-summary': ['positionId'],
'before-candidates': ['positionId'],
'after-candidates': ['positionId'],
'before-footer': ['positionId'],
},
},
{
/* The authoring form, which is a surface in its own right: what a skill has
to say there is about the position being specified, not about the ones
that already exist. Its placements follow the form's own three parts, so
a definition can sit beside the field group it is about. */
id: 'create-position',
label: 'Create Position',
route: '/admin/positions/new',
aliases: ['new-position'],
placements: [
'after-header',
'after-job-description',
'after-vetting-weights',
'before-footer',
],
/* The draft in the form is the position, so every placement here supplies
one — which is what lets `position.vetting` be read and written while the
role is still being specified. */
provides: {
'after-header': ['positionId'],
'after-job-description': ['positionId'],
'after-vetting-weights': ['positionId'],
'before-footer': ['positionId'],
},
},
{
id: 'candidates',
label: 'Candidates',
route: '/admin/candidates',
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
/* Only the summary placement renders against one person — it is mounted on
the candidate profile, not on the list. */
provides: { 'after-candidate-summary': ['candidateId'] },
},
{
id: 'hired-history',
label: 'Hired History',
route: '/admin/hired',
aliases: ['hired'],
placements: ['after-header', 'before-footer'],
/* `before-footer` is inside the record drawer; `after-header` is the page. */
provides: { 'before-footer': ['candidateId'] },
},
{
id: 'talent-pool',
label: 'Talent Pool',
route: '/admin/talent-pool',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'krow-forge',
label: 'KROW Forge',
route: '/admin/university',
aliases: ['university', 'forge'],
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'analytics',
label: 'Analytics',
route: '/admin/analytics',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'activity',
label: 'Activity',
route: '/admin/activity',
placements: ['after-header', 'before-footer'],
provides: {},
},
/* Not in the eight product surfaces, but skills already attach to it and the
account page reads them. Kept so nothing that works today stops working. */
{
id: 'profile',
label: 'Profile',
route: '/admin/profile',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'candidates-analysis',
label: 'Candidate Analysis',
route: '/admin/candidates-analysis',
placements: ['after-header', 'before-footer'],
provides: {},
},
];
const BY_KEY = new Map();
for (const surface of SKILL_SURFACES) {
BY_KEY.set(surface.id, surface);
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
}
/** Every name a definition may use for a surface, for error messages. */
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/**
* The surface a declared page name refers to, or null.
*
* Matched on the normalized name, not the literal one. A definition writing
* `Positions` or `Talent Pool` — which is how the page is spelled everywhere in
* the product — used to resolve to nothing, and a page name that resolves to
* nothing takes the whole `ui:` block with it. The keys stay canonical; only
* what an author may type to reach them widens.
*/
const normalizeKey = (page) => String(page ?? '')
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
export const surfaceFor = (page) => BY_KEY.get(normalizeKey(page)) || null;
/**
* Placement names a definition may use, beyond the canonical ones.
*
* A placement is a position in a page's layout, and the canonical names are
* written from the page's point of view — `after-position-card`. Authors write
* them from the definition's: `grid-card` is where it goes, `panel` is what it
* looks like. An alias only ever resolves to a placement the surface really
* offers, so the vocabulary stays exactly as closed as it was; what changes is
* how many ways there are to name a member of it.
*/
const PLACEMENT_ALIASES = {
'grid-card': 'after-position-card',
card: 'after-position-card',
panel: 'after-header',
top: 'after-header',
header: 'after-header',
footer: 'before-footer',
bottom: 'before-footer',
list: 'after-position-list',
summary: 'after-position-summary',
};
/**
* The canonical placement a declared name refers to on this surface, or null.
*
* An alias that points at a placement this surface does not offer resolves to
* null rather than to some other surface's placement — `panel` means
* `after-header` where there is one and nothing where there is not.
*/
export function placementFor(page, placement) {
const surface = surfaceFor(page);
if (!surface) return null;
const declared = normalizeKey(placement);
if (!declared) return null;
if (surface.placements.includes(declared)) return declared;
const aliased = PLACEMENT_ALIASES[declared];
return aliased && surface.placements.includes(aliased) ? aliased : null;
}
/** The canonical id for a declared page name — `hired` → `hired-history`. */
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
/**
* What a placement hands a section, as the page actually mounts it.
*
* A source declares the record it needs — a position, a candidate, or nothing —
* and until this existed nothing compared that need against the pages a
* definition named. So a section reading `position.activity` could be attached
* to Analytics, validate cleanly, register, draw its title, and then report
* "This section needs a position to read" forever. That is the whole of the
* "the skill saved but does nothing" report, and it is an authoring mistake the
* product can catch rather than one the reader has to discover.
*
* Read per *placement*, never per surface: the Positions grid renders one
* section inside each card, with a position, and two more above and below the
* grid, with none. Both are `positions`.
*
* With no placement named, a definition is answered for the surface's default —
* `surface.placements[0]`, which is what `normalizeSection` fills in.
*/
export function placementProvides(page, placement = null) {
const surface = surfaceFor(page);
if (!surface) return [];
const at = String(placement || '').trim() || surface.placements[0];
return surface.provides?.[at] || [];
}
/**
* Everything the declared pages can supply at this placement, deduplicated.
*
* One page supplying a position is enough — a definition naming several pages
* is saying it belongs on all of them, and refusing it because one cannot
* answer would refuse the definition for the pages that can. The load-time
* diagnostic is where the partial case is reported.
*/
export function contextSuppliedBy(pages = [], placement = null) {
return [...new Set(
(Array.isArray(pages) ? pages : [pages])
.flatMap((page) => placementProvides(page, placement))
)];
}
/** "a position" / "a candidate", for a message an author can act on. */
export const contextLabel = (need) => (
need === 'positionId' ? 'a position' : need === 'candidateId' ? 'a candidate' : 'nothing'
);
/**
* The surface an Admin route belongs to — `/admin/positions/new` →
* `create-position`.
*
* The surfaces already carry their routes, so this reads the answer off the
* table rather than deriving a page key from the path a second time. That
* matters for the surfaces whose key is not their path tail: without it,
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
* declares and no definition could attach to.
*/
export const surfaceForRoute = (route) =>
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
/**
* The section types a definition may ask for.
*
* Each entry names a component the application already ships. A type is a key
* in this table and nothing else: there is no path from a definition to a
* component that is not listed here, which is what stops `type:` from being an
* import statement in disguise.
*/
export const SECTION_TYPES = [
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
/* Weighted criteria that share a budget. Distinct from `progress`, which is
bars against an independent maximum: these bars are shares of one total,
and the total is a fact about the set rather than about any one row. */
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
];
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
/**
* What a definition may ask *Owliver* to do with the same reading.
*
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
* name the same data source. A capability is the second half of that: the shape
* the answer takes when it is asked for in conversation rather than rendered on
* the page.
*
* `shape` is the section type the answer is drawn with, so `flow` in a chat
* reply is the *same* component the page renders — there is one flow renderer,
* not one per consumer. `summary` has no shape because a summary is prose: the
* figures are read back as sentences rather than drawn.
*
* `terms` are how a question is recognised as asking for this shape. They are
* deliberately about the *shape* and never about a subject: "as a flow" belongs
* here, "hiring activity" belongs in a definition's `triggers`. That split is
* what keeps this table closed while the skills stay open.
*/
export const OWLIVER_CAPABILITIES = [
{
id: 'summary',
label: 'Summary',
shape: null,
summary: 'Reads the figures back as sentences.',
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
'what is the', 'how is'],
},
{
id: 'flow',
label: 'Flow',
shape: 'flow',
summary: 'Draws the stages or periods as a sequence.',
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
'visualise', 'visualize', 'step by step'],
},
{
id: 'stats',
label: 'Stats',
shape: 'stats',
summary: 'A row of counted figures.',
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
},
{
id: 'list',
label: 'List',
shape: 'list',
summary: 'A ranked or plain list of records.',
terms: ['list', 'who are', 'which ones', 'show me the records'],
},
{
id: 'table',
label: 'Table',
shape: 'table',
summary: 'Rows and columns.',
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
},
{
id: 'timeline',
label: 'Timeline',
shape: 'timeline',
summary: 'Dated events, most recent first.',
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
},
{
id: 'progress',
label: 'Progress',
shape: 'progress',
summary: 'Bars against a total.',
terms: ['progress', 'bars', 'completion', 'how far'],
},
{
id: 'weights',
label: 'Weights',
shape: 'weights',
summary: 'The weighted criteria, adjustable when the page accepts the write.',
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
'criteria'],
},
{
id: 'insight',
label: 'Insight',
shape: 'insight',
summary: 'One finding, stated plainly.',
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
},
{
id: 'card',
label: 'Card',
shape: 'card',
summary: 'A titled panel of figures.',
terms: ['card', 'panel', 'at a glance'],
},
];
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
/** The capability a declared name refers to, or null. */
export const owliverCapabilityFor = (id) =>
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
/** `flow` → `Flow`. */
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
/**
* The data a section may ask for.
*
* Each source is a named reading of data the application already holds, and
* `context` says what a page must know for the reading to be possible — a
* position id, a candidate id, or nothing. A source is resolved by
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
* and cannot name a field that is not offered here.
*/
export const DATA_SOURCES = [
{
id: 'position.activity',
label: 'Position activity',
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
options: ['periods'],
},
{
id: 'position.pipeline',
label: 'Position pipeline',
context: 'positionId',
summary: 'Applied → screened → shortlisted → interviewed → hired.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
},
{
id: 'position.candidates',
label: 'Position candidates',
context: 'positionId',
summary: 'Candidates matched to this position, best first.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
/**
* Who could actually do this work, ranked.
*
* Distinct from `position.candidates`, which lists the people who *applied
* here*. This one reads the whole candidate pool against what the position
* states — so a role published a minute ago, with no applications at all,
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
* same engine the panel's workforce conversation and the position page use;
* nothing about matching is decided in the resolver.
*/
id: 'position.matches',
label: 'Position candidate matches',
context: 'positionId',
summary: 'The candidate pool scored against this position, best first.',
shapes: ['list', 'table', 'stats', 'card', 'insight'],
options: ['limit'],
},
{
id: 'position.requirements',
label: 'Position requirements',
context: 'positionId',
summary: 'What this position states it needs.',
shapes: ['list', 'table', 'card'],
},
{
id: 'candidate.readiness',
label: 'Candidate readiness',
context: 'candidateId',
summary: 'Screening dimensions for one candidate.',
shapes: ['progress', 'stats', 'list', 'table', 'card'],
},
{
id: 'candidate.activity',
label: 'Candidate activity',
context: 'candidateId',
summary: 'What has happened on this candidate’s record.',
shapes: ['timeline', 'list', 'table', 'card'],
},
{
id: 'candidates.pipeline',
label: 'Candidate pipeline',
context: null,
summary: 'Every candidate, counted by stage.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
},
{
id: 'candidates.activity',
label: 'Candidate activity',
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
options: ['periods'],
},
{
id: 'positions.demand',
label: 'Position demand',
context: null,
summary: 'Open positions and what they still need.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
id: 'workforce.training',
label: 'Workforce training',
context: null,
summary: 'Training paths and progress against them.',
shapes: ['progress', 'list', 'stats', 'table', 'card'],
options: ['limit'],
},
{
/* The vetting weights a position is being specified with. `positionId`
context, but the "position" on Create Position is the draft in the form
rather than a saved record — which is the point: the resolver reads the
same field either way, so one source serves the form and the position it
becomes. */
id: 'position.vetting',
label: 'Position vetting weights',
context: 'positionId',
summary: 'How this position weights each screening criterion.',
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
/**
* This reading can be written back.
*
* `writable` is what lets a definition declare `editable: true` and get
* controls instead of a read-out. It is a property of the *source*, not of
* the definition — a skill cannot make a reading writable by asking, and a
* source with no page willing to accept the write simply renders read-only.
* That keeps the closed vocabulary closed in both directions.
*/
writable: true,
writeSummary: 'Sets the screening weights on the position being specified.',
},
{
id: 'hires.recent',
label: 'Recent hires',
context: null,
summary: 'Who was hired, for which role, and when.',
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
options: ['limit'],
},
{
id: 'hires.performance',
label: 'Hiring performance',
context: null,
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
},
{
id: 'activity.events',
label: 'Workspace activity',
context: null,
summary: 'What has happened across the workspace, most recent first.',
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
options: ['limit'],
},
];
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
/* ── Source / shape compatibility ───────────────────────────────────────────
One resolver, three consumers: `normalizeSection` refuses on it, the Board
editor's source picker filters on it, and the Owliver editor's per-capability
picker filters on it. They used to be three readings of `source.shapes` —
inline in the normalizer, absent from one editor and approximated in the
other — which is how a form could compose `list` against a source that has no
list in it and only find out at save. */
/**
* The section type a capability is drawn with, or null when it is prose.
*
* `summary` is the one capability with no component, so it is compatible with
* every source: the figures are read back as sentences rather than drawn.
*/
export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
/** Can this source be drawn as this shape? A null shape is prose, and always can. */
export function sourceSupportsShape(sourceId, shape) {
const source = dataSourceFor(sourceId);
if (!source) return false;
if (!shape) return true;
return Boolean(source.shapes?.includes(shape));
}
/**
* The sources that can fill this shape, optionally narrowed to what a placement
* can supply context for.
*
* `context` is the list a placement provides — `contextSuppliedBy(pages,
* placement)`. Passing it is how the Board editor offers only sources that can
* actually resolve where the section is mounted; the Owliver editor passes
* nothing, because a response with an unmet need asks which record is meant
* rather than rendering dead.
*/
export function sourcesForShape(shape, { context = null } = {}) {
return DATA_SOURCES.filter((source) => {
if (!sourceSupportsShape(source.id, shape)) return false;
if (!context) return true;
return !source.context || context.includes(source.context);
});
}
/**
* Whether a source reads an option a form can offer — `periods` or `limit`.
*
* Declared per source rather than inferred from its shapes, because the two do
* not line up: `positions.demand` is a list that honours `limit` and has no
* periods at all, while `candidates.activity` is the reverse. The editors used
* to guess from the shape list and offered period checkboxes that changed
* nothing.
*/
export const sourceSupportsOption = (sourceId, option) =>
Boolean(dataSourceFor(sourceId)?.options?.includes(option));
/**
* Can this reading be written back?
*
* The one question `editable:` is checked against. A page opts in by publishing
* a handler for the source (see `usePublishPageActions`); a definition opts in
* by declaring `editable: true`. Both have to hold before a control is drawn,
* so neither the author nor the page can enable editing on its own.
*/
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
/**
* The periods a time-based section may ask for.
*
* Only the ones the data layer can actually compute from record timestamps.
* A period is a window over `created_date`, resolved at read time against the
* current date — never a stored figure and never a hard-coded date.
*/
export const PERIODS = [
{ id: 'today', label: 'Today' },
{ id: 'yesterday', label: 'Yesterday' },
{ id: 'last-7-days', label: 'Last 7 days' },
{ id: 'last-week', label: 'Last week' },
{ id: 'this-month', label: 'This month' },
{ id: 'previous-month', label: 'Previous month' },
];
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
/* ── Human labels ───────────────────────────────────────────────────────────
The vocabulary is written in kebab-case because it is configuration; it is
read by people, so every id has a label. One place, so the preview, the
rendered section and any future surface all say the same words. */
/** `flow` → `Flow`. */
export const sectionTypeLabel = (id) =>
SECTION_TYPES.find((t) => t.id === id)?.label || id;
/** `position.activity` → `Position activity`. */
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
/** `after-position-summary` → `After position summary`. */
export const placementLabel = (id) => {
const words = String(id || '').replace(/-/g, ' ').trim();
return words ? words[0].toUpperCase() + words.slice(1) : id;
};