The remaining fifteen modules under `src/lib/skills/`, including the
three under `flows/`. `src/lib/skills` now holds no JavaScript.
Renaming them raised 134 errors, which came from nineteen values, not
134 places. Eleven were accumulators or parameters written `= {}`, whose
type is then `{}` — an object with no properties — so every later read of
a key looked like a mistake. Five were `Object.entries`/`values` on a
dynamic value, which yields `unknown` rather than `any` because
inference into their union parameter does not distribute. The rest were
`reduce` accumulators in the same position.
Annotating the nineteen sources cleared all 134. Where the keys were
knowable they are written down rather than waved away: both `prefill`
accumulators in `actions.ts` name the fields their own following lines
assign, and `dataResolver`'s two event tallies are
`Record<string, number>`, which is what they are. Where the value is
genuinely whatever an author wrote — a parsed YAML mapping, a skill
context — it stays `any`.
The one structural addition is `Frontmatter`, the return of
`parseFrontmatter`. Its no-frontmatter early return hands back a literal
`{}`, so TypeScript took the common shape of the two returns, which has
no properties; that single empty object is what made twenty-five later
readings of `data` look wrong. Typing the return also resolved four
pre-existing errors in this file and four more that had cascaded into
`lib/agents/registry.js`, so the project total is 16, below the 20 this
phase started from. Nothing was suppressed to get there.
Measured against `e73929f`:
typecheck 16 errors, down from 20; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 33/33 byte-identical, all of Phase 11 so far
CORRECTION to the previous two commits. Both claim the migrated files
emit "byte-identical minified JavaScript". That check was broken when it
ran and proved nothing: it passed `--loader=js`/`--loader=ts` to esbuild
on named files, and esbuild accepts `--loader` without an extension only
for stdin. Both sides errored, both outputs were empty, and `cmp` found
two empty files equal. Eighteen "IDENTICAL" lines meant eighteen pairs of
nothing.
Repaired here and re-run over all 33 files. Two further things had to
change for the check to mean anything. It now proves it can detect a
difference before it is trusted, against a pair of files differing in one
character. And it compares with `--minify-whitespace --minify-syntax`
rather than `--minify`: full minification renames locals, and esbuild's
choice of names shifts with token counts, so twelve files differed only
in whether a binding was called `g` or `u` — alpha-equivalent, at
identical byte counts. Stripping comments and whitespace while keeping
identifiers is the comparison that answers the actual question.
The result is that the substantive claim was true throughout, and is now
actually evidenced: all 33 files erase to byte-identical JavaScript. It
was never the only evidence either — the production bundle hash and the
1691-check suite were compared in every batch, both valid, and both
unchanged.
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
1211 lines
47 KiB
TypeScript
1211 lines
47 KiB
TypeScript
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
|
||
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
|
||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||
import { poolFor } from '@/lib/workforce';
|
||
import { candidateRoute } from './workforceFlow';
|
||
import {
|
||
attendanceAnomalies, attendanceByDepartment, attendanceByWorker, attendanceSummary,
|
||
overtimeByWorker, overtimeSummary, weeklyTrend,
|
||
} from '@/lib/attendance';
|
||
import { activitySignals, signalLabel } from '@/lib/activitySignals';
|
||
import { demandFor } from '@/lib/workforce';
|
||
import { getScoreBand } from '@/lib/talentHome';
|
||
import { buildPosition } from '@/lib/admin/positionInsights';
|
||
|
||
/**
|
||
* The one place a skill's declared data source becomes real data.
|
||
*
|
||
* A section says `data.source: position.activity`. It does not say where that
|
||
* comes from, cannot reach a store, and cannot name a field. This module owns
|
||
* the mapping — source id in, normalized reading out — which is what keeps a
|
||
* declarative file from turning into a query language.
|
||
*
|
||
* Two rules hold throughout:
|
||
*
|
||
* - **Real records only.** Every figure is counted from the collections the
|
||
* application already holds. Nothing is generated to make a section look
|
||
* populated; a source with nothing to report returns `empty: true` and the
|
||
* renderer says so.
|
||
* - **Time is computed, never stored.** "Today" is a window over the record
|
||
* timestamps, evaluated against the current date at read time. No date is
|
||
* written into a definition and none is hard-coded here.
|
||
*/
|
||
|
||
const DAY = 24 * 60 * 60 * 1000;
|
||
|
||
/** Midnight at the start of the given day, in local time. */
|
||
const startOfDay = (date) => {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
};
|
||
|
||
/**
|
||
* The window a period covers, as `[from, to)`.
|
||
*
|
||
* Weeks run Monday to Monday and months from the 1st, which is how the rest of
|
||
* the product reports them.
|
||
*/
|
||
export function periodRange(period, now = new Date()) {
|
||
const today = startOfDay(now);
|
||
|
||
switch (period) {
|
||
case 'today':
|
||
return { from: today, to: new Date(today.getTime() + DAY) };
|
||
case 'yesterday':
|
||
return { from: new Date(today.getTime() - DAY), to: today };
|
||
case 'last-7-days':
|
||
return { from: new Date(today.getTime() - 7 * DAY), to: new Date(today.getTime() + DAY) };
|
||
case 'last-week': {
|
||
/* The calendar week before the one we are in. */
|
||
const weekday = (today.getDay() + 6) % 7;
|
||
const thisMonday = new Date(today.getTime() - weekday * DAY);
|
||
return { from: new Date(thisMonday.getTime() - 7 * DAY), to: thisMonday };
|
||
}
|
||
case 'this-month': {
|
||
const from = new Date(today.getFullYear(), today.getMonth(), 1);
|
||
return { from, to: new Date(today.getFullYear(), today.getMonth() + 1, 1) };
|
||
}
|
||
case 'previous-month': {
|
||
const from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||
return { from, to: new Date(today.getFullYear(), today.getMonth(), 1) };
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Records whose `created_date` falls inside the window. */
|
||
const inPeriod = (records, period, now) => {
|
||
const range = periodRange(period, now);
|
||
if (!range) return [];
|
||
return records.filter((record) => {
|
||
const at = new Date(record.created_date || record.updated_date || 0).getTime();
|
||
return at >= range.from.getTime() && at < range.to.getTime();
|
||
});
|
||
};
|
||
|
||
/**
|
||
* Why a matched candidate fits, in one line.
|
||
*
|
||
* Met requirements first, then the gaps, then what is known about availability —
|
||
* the same order and the same words the panel's match cards use, because they
|
||
* are read from the same row. A gap is stated as a gap: a line that only listed
|
||
* strengths would make every candidate look like a strong match.
|
||
*/
|
||
function matchDetail(row) {
|
||
if (!row.match) return 'Nothing on this position to score this candidate against';
|
||
|
||
const parts = [
|
||
...row.match.met.map((line) => `✓ ${line.name} — ${line.heldLabel}`),
|
||
...row.match.gaps.map((line) => `⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`),
|
||
];
|
||
|
||
if (row.availability?.known === false) parts.push('Availability not on file');
|
||
else if (row.availability) {
|
||
parts.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||
}
|
||
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
/* The stage ladder lives in hiringRecords: one derivation, so a skill and the
|
||
page it is read beside cannot disagree about how many were screened. */
|
||
|
||
/** Applications counted by period — the reading behind an activity section. */
|
||
function activityOverTime(applications, periods, now) {
|
||
const wanted = periods.length ? periods : ['today', 'yesterday', 'last-week'];
|
||
|
||
const steps = wanted
|
||
.filter((period) => SUPPORTED_PERIODS.includes(period))
|
||
.map((period) => {
|
||
const records = inPeriod(applications, period, now);
|
||
return {
|
||
id: period,
|
||
label: periodLabel(period),
|
||
value: records.length,
|
||
detail: records.length
|
||
? `${records.length} application${records.length === 1 ? '' : 's'}`
|
||
: 'No applications',
|
||
records,
|
||
};
|
||
});
|
||
|
||
return {
|
||
steps,
|
||
total: applications.length,
|
||
empty: steps.every((s) => s.value === 0),
|
||
emptyNote: applications.length
|
||
? 'No applications in these periods.'
|
||
: 'No applications on this position yet.',
|
||
};
|
||
}
|
||
|
||
/** The hiring funnel for a set of applications. */
|
||
function pipelineOf(applications) {
|
||
const steps = [
|
||
{ id: 'applied', label: 'Applied', value: applications.length },
|
||
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
|
||
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
|
||
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
|
||
{ id: 'hired', label: 'Hired', value: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
|
||
];
|
||
|
||
return {
|
||
steps,
|
||
total: applications.length,
|
||
empty: applications.length === 0,
|
||
emptyNote: 'No applications on this position yet.',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Every source the vocabulary offers, and how each is read.
|
||
*
|
||
* Keyed by the same ids `surfaces.js` publishes, so the list an author can
|
||
* choose from and the list that can actually be resolved are the same list.
|
||
*/
|
||
const RESOLVERS = {
|
||
'position.activity': ({ position, applications }, section, now) => {
|
||
const mine = applications.filter((a) => a.job_posting_id === position?.id);
|
||
return activityOverTime(mine, section.periods, now);
|
||
},
|
||
|
||
'position.pipeline': ({ position, applications }) =>
|
||
pipelineOf(applications.filter((a) => a.job_posting_id === position?.id)),
|
||
|
||
'position.candidates': ({ position, applications }, section) => {
|
||
const mine = applications
|
||
.filter((a) => a.job_posting_id === position?.id)
|
||
.sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))
|
||
.slice(0, section.limit || 5);
|
||
|
||
return {
|
||
items: mine.map((a) => ({
|
||
id: a.id,
|
||
title: a.applicant_name,
|
||
detail: [
|
||
a.years_experience != null ? `${a.years_experience} yrs experience` : null,
|
||
String(a.status || '').replace(/_/g, ' '),
|
||
].filter(Boolean).join(' · '),
|
||
value: a.ai_score > 0 ? a.ai_score : null,
|
||
to: `/admin/candidates/${a.id}`,
|
||
})),
|
||
columns: [
|
||
{ key: 'title', label: 'Candidate' },
|
||
{ key: 'detail', label: 'Status' },
|
||
{ key: 'value', label: 'Score', align: 'right' },
|
||
],
|
||
empty: mine.length === 0,
|
||
emptyNote: 'No candidates have applied to this position yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* The candidate pool, scored against this position.
|
||
*
|
||
* Every figure here comes from `poolFor` — the same engine behind the
|
||
* workforce conversation and the position page's own recommendations. This
|
||
* resolver ranks nothing and scores nothing; it reads the rows the engine
|
||
* returned and states them, including *why* each one fits, in the engine's own
|
||
* terms. A candidate the position gives nothing to measure against is reported
|
||
* as unscored rather than given a number.
|
||
*
|
||
* `to` is the candidate's real record, carrying the position they were being
|
||
* considered for — the same route the panel's match cards open.
|
||
*/
|
||
'position.matches': ({ position, ...context }, section) => {
|
||
if (!position) {
|
||
return { items: [], empty: true, emptyNote: 'No position to match candidates against.' };
|
||
}
|
||
|
||
const rows = poolFor(position, {
|
||
profiles: context.profiles || context.workerProfiles || [],
|
||
applications: context.applications || [],
|
||
assignments: context.assignments || [],
|
||
courses: context.courses || [],
|
||
staff: context.staff || [],
|
||
}).slice(0, section.limit || 5);
|
||
|
||
return {
|
||
items: rows.map((row) => ({
|
||
id: row.candidateId,
|
||
title: row.name,
|
||
/* The met requirements and the gaps, as the engine stated them. */
|
||
detail: matchDetail(row),
|
||
value: row.scored ? row.score : null,
|
||
to: candidateRoute(row, context.applications || [], position),
|
||
})),
|
||
columns: [
|
||
{ key: 'title', label: 'Candidate' },
|
||
{ key: 'detail', label: 'Why' },
|
||
{ key: 'value', label: 'Match', align: 'right' },
|
||
],
|
||
empty: rows.length === 0,
|
||
emptyNote: 'No candidates on file to score against this position yet.',
|
||
};
|
||
},
|
||
|
||
'position.requirements': ({ position }) => {
|
||
const items = [
|
||
position?.min_experience_years
|
||
? { id: 'experience', title: 'Minimum experience', detail: `${position.min_experience_years} years` }
|
||
: null,
|
||
position?.english_required
|
||
? { id: 'english', title: 'English level', detail: String(position.english_required) }
|
||
: null,
|
||
...(position?.certifications_required || []).map((c) => ({
|
||
id: `cert-${c}`, title: 'Certification', detail: c,
|
||
})),
|
||
...(position?.skill_requirements || []).map((r) => ({
|
||
id: `skill-${r.skill_id}`, title: r.skill_id, detail: `${r.level} · weight ${r.weight}`,
|
||
})),
|
||
].filter(Boolean);
|
||
|
||
return {
|
||
items,
|
||
columns: [{ key: 'title', label: 'Requirement' }, { key: 'detail', label: 'Needs' }],
|
||
empty: items.length === 0,
|
||
emptyNote: 'This position states no requirements.',
|
||
};
|
||
},
|
||
|
||
'candidate.readiness': ({ candidate }) => {
|
||
const breakdown = candidate?.score_breakdown || {};
|
||
const items = Object.entries(breakdown)
|
||
.filter(([, value]) => Number(value) > 0)
|
||
.map(([key, value]) => ({
|
||
id: key,
|
||
title: key.replace(/_/g, ' '),
|
||
value: Number(value),
|
||
max: 100,
|
||
}));
|
||
|
||
return {
|
||
items,
|
||
columns: [{ key: 'title', label: 'Dimension' }, { key: 'value', label: 'Score', align: 'right' }],
|
||
empty: items.length === 0,
|
||
emptyNote: 'This candidate has not been screened, so there are no dimensions to show.',
|
||
};
|
||
},
|
||
|
||
'candidate.activity': ({ candidate, interviews = [] }) => {
|
||
const events = [
|
||
candidate?.created_date && {
|
||
id: 'applied', title: 'Applied', detail: candidate.job_title, at: candidate.created_date,
|
||
},
|
||
candidate?.ai_score > 0 && {
|
||
id: 'screened', title: 'AI screened', detail: `Scored ${candidate.ai_score}`, at: candidate.updated_date,
|
||
},
|
||
...interviews
|
||
.filter((i) => i.application_id === candidate?.id)
|
||
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
|
||
HIRED_STATUSES.includes(candidate?.status) && {
|
||
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
|
||
},
|
||
].filter(Boolean);
|
||
|
||
return {
|
||
items: events,
|
||
empty: events.length === 0,
|
||
emptyNote: 'Nothing has happened on this record yet.',
|
||
};
|
||
},
|
||
|
||
'candidates.pipeline': ({ applications }) => pipelineOf(applications),
|
||
|
||
'candidates.activity': ({ applications }, section, now) =>
|
||
activityOverTime(applications, section.periods, now),
|
||
|
||
'positions.demand': ({ positions = [], applications }, section) => {
|
||
const items = positions
|
||
.filter((p) => p.status === 'active')
|
||
.slice(0, section.limit || 5)
|
||
.map((p) => {
|
||
const mine = applications.filter((a) => a.job_posting_id === p.id);
|
||
return {
|
||
id: p.id,
|
||
title: p.title,
|
||
detail: [p.company, p.location].filter(Boolean).join(' · '),
|
||
value: mine.length,
|
||
to: `/admin/positions/${p.id}`,
|
||
};
|
||
});
|
||
|
||
return {
|
||
items,
|
||
columns: [
|
||
{ key: 'title', label: 'Position' },
|
||
{ key: 'detail', label: 'Client' },
|
||
{ key: 'value', label: 'Applicants', align: 'right' },
|
||
],
|
||
empty: items.length === 0,
|
||
emptyNote: 'No open positions.',
|
||
};
|
||
},
|
||
|
||
'workforce.training': ({ trainingPaths = [] }, section) => {
|
||
const items = trainingPaths.slice(0, section.limit || 10).map(({ definition, state }) => ({
|
||
id: definition.id,
|
||
title: state.name,
|
||
detail: state.verifiedLabel,
|
||
value: state.totalModules ? Math.round((state.totalCompleted / state.totalModules) * 100) : 0,
|
||
max: 100,
|
||
}));
|
||
|
||
return {
|
||
items,
|
||
columns: [
|
||
{ key: 'title', label: 'Path' },
|
||
{ key: 'detail', label: 'Level' },
|
||
{ key: 'value', label: 'Complete', align: 'right' },
|
||
],
|
||
empty: items.length === 0,
|
||
emptyNote: 'No training paths are registered.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* The vetting weights on the position in context.
|
||
*
|
||
* The same reading whether that position is a saved record or the draft being
|
||
* typed into Create Position — both carry `vetting_criteria`, so a section
|
||
* declared once reports the specification as it stands on either surface.
|
||
*/
|
||
'position.vetting': ({ position }) => {
|
||
const criteria = position?.vetting_criteria || {};
|
||
const steps = Object.entries(criteria).map(([key, value]) => ({
|
||
id: key,
|
||
label: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
|
||
title: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
|
||
value: Number(value) || 0,
|
||
max: 100,
|
||
detail: `${Number(value) || 0}% of the screening score`,
|
||
}));
|
||
|
||
const total = steps.reduce((sum, s) => sum + s.value, 0);
|
||
|
||
return {
|
||
steps,
|
||
items: steps,
|
||
total,
|
||
columns: [
|
||
{ key: 'title', label: 'Criterion' },
|
||
{ key: 'value', label: 'Weight', align: 'right' },
|
||
],
|
||
empty: steps.length === 0,
|
||
emptyNote: 'This position states no vetting weights.',
|
||
};
|
||
},
|
||
|
||
/** Everyone hired, most recent first — the record rather than the analysis. */
|
||
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
|
||
const items = [...staff]
|
||
.sort((a, b) => new Date(b.hire_date || b.created_date || 0).getTime() - new Date(a.hire_date || a.created_date || 0).getTime())
|
||
.slice(0, section.limit || 10)
|
||
.map((s) => {
|
||
const app = applications.find((a) => a.id === s.application_id);
|
||
const posting = positions.find((p) => p.id === s.job_posting_id);
|
||
return {
|
||
id: s.id,
|
||
title: s.name,
|
||
detail: [s.role || posting?.title, posting?.role_category || s.department]
|
||
.filter(Boolean).join(' · '),
|
||
value: s.ai_score || app?.ai_score || s.score || null,
|
||
at: s.hire_date || s.created_date || null,
|
||
};
|
||
});
|
||
|
||
return {
|
||
items,
|
||
columns: [
|
||
{ key: 'title', label: 'Hire' },
|
||
{ key: 'detail', label: 'Role' },
|
||
{ key: 'value', label: 'Score', align: 'right' },
|
||
],
|
||
empty: items.length === 0,
|
||
emptyNote: 'Nobody has been hired yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* The four figures that answer "how is our hiring performing" — counted from
|
||
* applications and the staff records they became, never stored.
|
||
*/
|
||
'hires.performance': ({ applications = [], staff = [] }) => {
|
||
const hired = applications.filter((a) => HIRED_STATUSES.includes(a.status));
|
||
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
||
const days = hired
|
||
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))
|
||
.filter((n) => Number.isFinite(n) && n >= 0);
|
||
|
||
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
|
||
const total = staff.length || hired.length;
|
||
|
||
const steps = [
|
||
{ id: 'hires', label: 'Total hires', title: 'Total hires', value: total },
|
||
{ id: 'speed', label: 'Avg days to hire', title: 'Avg days to hire', value: mean(days) },
|
||
{ id: 'quality', label: 'Quality of hire', title: 'Quality of hire', value: mean(scores) },
|
||
{
|
||
id: 'conversion',
|
||
label: 'Conversion rate',
|
||
title: 'Conversion rate',
|
||
value: applications.length ? Math.round((hired.length / applications.length) * 100) : 0,
|
||
},
|
||
];
|
||
|
||
return {
|
||
steps,
|
||
items: steps,
|
||
total,
|
||
columns: [
|
||
{ key: 'title', label: 'Measure' },
|
||
{ key: 'value', label: 'Value', align: 'right' },
|
||
],
|
||
empty: applications.length === 0 && total === 0,
|
||
emptyNote: 'No hiring activity has been recorded yet.',
|
||
};
|
||
},
|
||
|
||
/** The workspace audit trail, most recent first. */
|
||
/**
|
||
* Open roles that will not fill on their own.
|
||
*
|
||
* Ranked by how stuck they are rather than by age: a role posted this morning
|
||
* with no applicants is not yet a problem, and one posted three weeks ago with
|
||
* a strong candidate nobody has moved on is. `buildPosition` is the same
|
||
* reading the Positions page renders from, so a risk here and a health badge
|
||
* there cannot disagree.
|
||
*/
|
||
'positions.risk': ({ positions = [], applications = [] }, section) => {
|
||
const open = positions.filter((p) => p.status === 'active');
|
||
|
||
const rows = open.map((posting) => {
|
||
const built = buildPosition(posting, applications);
|
||
const { applied, unscreened, qualified, readyForInterview } = built.stats;
|
||
|
||
/* Each reason is a distinct failure with a distinct fix — no applicants
|
||
needs sourcing, an unscreened backlog needs screening, and a decision
|
||
owed needs a person. Collapsing them into one score would lose the
|
||
only part a reader can act on. */
|
||
const reasons = [
|
||
applied === 0 && 'no applicants yet',
|
||
applied > 0 && qualified === 0 && 'no candidate scoring 70 or above',
|
||
unscreened >= 3 && `${unscreened} unscreened`,
|
||
readyForInterview > 0 && `${readyForInterview} awaiting a decision`,
|
||
].filter(Boolean);
|
||
|
||
/* Severity is the count of distinct problems, weighted so an empty
|
||
pipeline outranks a busy one that needs attention. */
|
||
const severity = (applied === 0 ? 3 : 0)
|
||
+ (applied > 0 && qualified === 0 ? 2 : 0)
|
||
+ (unscreened >= 3 ? 1 : 0)
|
||
+ (readyForInterview > 0 ? 1 : 0);
|
||
|
||
return {
|
||
id: posting.id,
|
||
title: posting.title,
|
||
label: posting.title,
|
||
department: posting.role_category || '—',
|
||
value: severity,
|
||
applied,
|
||
qualified,
|
||
unscreened,
|
||
readyForInterview,
|
||
reasons,
|
||
detail: reasons.length
|
||
? `${posting.role_category || 'Uncategorized'} · ${reasons.join(' · ')}`
|
||
: `${posting.role_category || 'Uncategorized'} · filling normally`,
|
||
to: `/admin/positions`,
|
||
};
|
||
});
|
||
|
||
const atRisk = rows
|
||
.filter((row) => row.reasons.length > 0)
|
||
.sort((a, b) => b.value - a.value || b.unscreened - a.unscreened);
|
||
const limited = section.limit ? atRisk.slice(0, section.limit) : atRisk;
|
||
|
||
return {
|
||
items: limited,
|
||
steps: [
|
||
{ id: 'at-risk', label: 'Roles at risk', title: 'Roles at risk', value: atRisk.length },
|
||
{ id: 'open', label: 'Open roles', title: 'Open roles', value: open.length },
|
||
{ id: 'starved', label: 'No applicants', title: 'No applicants', value: rows.filter((r) => r.applied === 0).length },
|
||
{ id: 'owed', label: 'Decisions owed', title: 'Decisions owed', value: rows.reduce((n, r) => n + r.readyForInterview, 0) },
|
||
],
|
||
total: atRisk.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Position' },
|
||
{ key: 'detail', label: 'Why' },
|
||
],
|
||
/* No open roles and no risks are different states, and saying "nothing is
|
||
at risk" when nothing is posted would be a false reassurance. */
|
||
empty: atRisk.length === 0,
|
||
emptyNote: open.length
|
||
? 'Every open role is filling normally.'
|
||
: 'No positions are open.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* How strong the applicant pool is, and how much of it anyone has looked at.
|
||
*
|
||
* Coverage sits beside quality deliberately: an average score computed from a
|
||
* fifth of the pool is not the pool's average, and reporting the first
|
||
* without the second is how a hiring dashboard talks itself into confidence.
|
||
*/
|
||
'candidates.quality': ({ applications = [], interviews = [] }, section, now) => {
|
||
const pool = section.periods?.length
|
||
? section.periods
|
||
.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||
.flatMap((p) => inPeriod(applications, p, now))
|
||
: applications;
|
||
|
||
/* Deduped: overlapping windows — `today` inside `last-7-days` — would
|
||
otherwise count the same application twice. */
|
||
const unique: any[] = [...new Map(pool.map((a) => [a.id, a])).values()];
|
||
|
||
const scored = unique.filter((a) => a.ai_score > 0);
|
||
const bands = [
|
||
{ id: 'strong', label: 'Strong (80+)', title: 'Strong (80+)', value: scored.filter((a) => a.ai_score >= 80).length },
|
||
{ id: 'viable', label: 'Viable (70–79)', title: 'Viable (70–79)', value: scored.filter((a) => a.ai_score >= 70 && a.ai_score < 80).length },
|
||
{ id: 'marginal', label: 'Marginal (50–69)', title: 'Marginal (50–69)', value: scored.filter((a) => a.ai_score >= 50 && a.ai_score < 70).length },
|
||
{ id: 'weak', label: 'Below 50', title: 'Below 50', value: scored.filter((a) => a.ai_score < 50).length },
|
||
];
|
||
|
||
const interviewed = new Set(interviews.map((i) => i.application_id));
|
||
const avgScore = scored.length
|
||
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
|
||
: 0;
|
||
|
||
const steps = [
|
||
{ id: 'pool', label: 'Candidates', title: 'Candidates', value: unique.length },
|
||
{ id: 'coverage', label: 'Screened', title: 'Screened', value: unique.length ? Math.round((scored.length / unique.length) * 100) : 0, max: 100, detail: `${scored.length} of ${unique.length} scored` },
|
||
{ id: 'quality', label: 'Average score', title: 'Average score', value: avgScore, max: 100, detail: scored.length ? `across ${scored.length} scored` : 'nothing scored yet' },
|
||
{ id: 'interviews', label: 'Interviewed', title: 'Interviewed', value: unique.filter((a) => interviewed.has(a.id)).length },
|
||
];
|
||
|
||
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
|
||
const limited = section.limit ? ranked.slice(0, section.limit) : ranked;
|
||
|
||
return {
|
||
steps,
|
||
bands,
|
||
items: limited.map((a) => ({
|
||
id: a.id,
|
||
title: a.applicant_name,
|
||
label: a.applicant_name,
|
||
value: a.ai_score,
|
||
max: 100,
|
||
detail: `${a.job_title || 'Unassigned'} · ${String(a.status || '').replace(/_/g, ' ')}`,
|
||
to: `/admin/candidates/${a.id}`,
|
||
})),
|
||
total: unique.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Candidate' },
|
||
{ key: 'detail', label: 'Role' },
|
||
{ key: 'value', label: 'Score', align: 'right' },
|
||
],
|
||
empty: unique.length === 0,
|
||
emptyNote: applications.length
|
||
? 'No candidates applied in that period.'
|
||
: 'No candidates have applied yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* The talent this workspace already knows.
|
||
*
|
||
* Profiles with no score are counted separately rather than averaged in as
|
||
* zero: an unscored profile is unscored, and folding it into the mean would
|
||
* make a healthy pool look poor in exact proportion to how much of it nobody
|
||
* has assessed.
|
||
*/
|
||
'talent.pool': ({ profiles = [], workerProfiles = [] }, section) => {
|
||
const pool = profiles.length ? profiles : workerProfiles;
|
||
const scored = pool.filter((p) => (p.krow_score || 0) > 0);
|
||
const available = pool.filter((p) => (p.availability || []).length > 0);
|
||
const certified = pool.filter((p) => (p.certifications || []).length > 0);
|
||
|
||
const avgScore = scored.length
|
||
? Math.round(scored.reduce((sum, p) => sum + (p.krow_score || 0), 0) / scored.length)
|
||
: 0;
|
||
|
||
const steps = [
|
||
{ id: 'size', label: 'In the pool', title: 'In the pool', value: pool.length },
|
||
{ id: 'scored', label: 'Scored', title: 'Scored', value: scored.length, detail: `${pool.length - scored.length} not yet assessed` },
|
||
{ id: 'quality', label: 'Average score', title: 'Average score', value: avgScore, max: 100, detail: scored.length ? `across ${scored.length} scored` : 'nothing scored yet' },
|
||
{ id: 'available', label: 'With availability', title: 'With availability', value: available.length },
|
||
{ id: 'certified', label: 'Certified', title: 'Certified', value: certified.length },
|
||
];
|
||
|
||
const ranked = [...pool].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
||
const limited = section.limit ? ranked.slice(0, section.limit) : ranked;
|
||
|
||
return {
|
||
steps,
|
||
items: limited.map((p) => ({
|
||
id: p.id,
|
||
title: p.full_name,
|
||
label: p.full_name,
|
||
value: p.krow_score || 0,
|
||
max: 100,
|
||
detail: [
|
||
p.current_position || p.desired_position || 'No role stated',
|
||
/* Stated plainly rather than shown as a zero, which reads as a bad
|
||
score rather than an absent one. */
|
||
(p.krow_score || 0) > 0 ? getScoreBand(p.krow_score).label : 'Not yet scored',
|
||
(p.availability || []).length ? (p.availability || []).join(', ') : 'Availability not on file',
|
||
].join(' · '),
|
||
})),
|
||
total: pool.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Person' },
|
||
{ key: 'detail', label: 'Profile' },
|
||
{ key: 'value', label: 'Score', align: 'right' },
|
||
],
|
||
empty: pool.length === 0,
|
||
emptyNote: 'No talent profiles have been created yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Open roles against the people hired into them.
|
||
*
|
||
* `demandFor` is the same reading the Positions page uses, and it refuses to
|
||
* invent a headcount for a position that never declared one. That refusal is
|
||
* carried through here rather than papered over: where no role states how
|
||
* many people it wants, this reports hires and says the target is unstated
|
||
* instead of quietly assuming one person per role and reporting a fill rate
|
||
* that means nothing.
|
||
*/
|
||
'workforce.coverage': ({ positions = [], staff = [], assignments = [] }, section) => {
|
||
const open = positions.filter((p) => p.status === 'active');
|
||
const rows = open.map((posting) => {
|
||
const demand = demandFor(posting, { assignments, staff });
|
||
return {
|
||
id: posting.id,
|
||
title: posting.title,
|
||
label: posting.title,
|
||
department: posting.role_category || '—',
|
||
declared: demand.declared,
|
||
required: demand.required,
|
||
assigned: demand.assigned,
|
||
value: demand.assigned,
|
||
max: demand.declared ? demand.required : undefined,
|
||
detail: demand.declared
|
||
? `${demand.assigned}/${demand.required} filled · ${posting.role_category || 'Uncategorized'}`
|
||
: `${demand.assigned} hired · headcount not stated · ${posting.role_category || 'Uncategorized'}`,
|
||
};
|
||
});
|
||
|
||
const declaring = rows.filter((r) => r.declared);
|
||
const covered = rows.filter((r) => r.assigned > 0);
|
||
const limited = section.limit ? rows.slice(0, section.limit) : rows;
|
||
|
||
const steps = [
|
||
{ id: 'open', label: 'Open roles', title: 'Open roles', value: open.length },
|
||
{ id: 'covered', label: 'With someone hired', title: 'With someone hired', value: covered.length },
|
||
{ id: 'uncovered', label: 'Nobody hired yet', title: 'Nobody hired yet', value: open.length - covered.length },
|
||
{
|
||
id: 'declared',
|
||
label: 'Stating a headcount',
|
||
title: 'Stating a headcount',
|
||
value: declaring.length,
|
||
/* Said out loud, because a coverage figure computed against an unstated
|
||
target is the kind of number that gets quoted in a meeting. */
|
||
detail: declaring.length
|
||
? `${declaring.length} of ${open.length} open roles`
|
||
: 'No open role states how many people it needs',
|
||
},
|
||
];
|
||
|
||
return {
|
||
steps,
|
||
items: limited,
|
||
total: open.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Position' },
|
||
{ key: 'detail', label: 'Coverage' },
|
||
],
|
||
empty: open.length === 0,
|
||
emptyNote: 'No positions are open.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Activity that departs from this workspace's own pattern.
|
||
*
|
||
* The detection is `lib/activitySignals.js`, the same function `buildFacts`
|
||
* calls, so a flag counted in the greeting and a flag drawn on a card are the
|
||
* same flag. Each is a deviation from a baseline, not a verdict — the wording
|
||
* here says so rather than asserting wrongdoing.
|
||
*/
|
||
'activity.signals': ({ activity = [] }, section, now) => {
|
||
const signals = activitySignals(activity, now);
|
||
|
||
const items = signals.flags.map((flag) => ({
|
||
id: flag,
|
||
title: signalLabel(flag),
|
||
label: signalLabel(flag),
|
||
detail: flag === 'concentration'
|
||
? `${signals.busiest?.name || 'One account'} accounts for ${signals.busiestShare}% of events`
|
||
: flag === 'burst'
|
||
? `${signals.bursts} burst${signals.bursts === 1 ? '' : 's'} of more than three actions in an hour`
|
||
: flag === 'off-hours'
|
||
? `${signals.offHours.length} event${signals.offHours.length === 1 ? '' : 's'} outside working hours`
|
||
: flag === 'silent'
|
||
? 'Nothing has happened in the last 24 hours'
|
||
: `${signals.privilegedShare}% of events change who is employed or what is being hired for`,
|
||
value: 1,
|
||
}));
|
||
|
||
const limited = section.limit ? items.slice(0, section.limit) : items;
|
||
|
||
return {
|
||
items: limited,
|
||
steps: [
|
||
{ id: 'signals', label: 'Signals', title: 'Signals', value: signals.flags.length },
|
||
{ id: 'events', label: 'Events', title: 'Events', value: activity.length },
|
||
{ id: 'accounts', label: 'Accounts', title: 'Accounts', value: signals.accounts.length },
|
||
{ id: 'privileged', label: 'Privileged actions', title: 'Privileged actions', value: signals.privileged.length, detail: `${signals.privilegedShare}% of events` },
|
||
],
|
||
signals,
|
||
total: signals.flags.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Signal' },
|
||
{ key: 'detail', label: 'Detail' },
|
||
],
|
||
/* Nothing out of pattern is a real and good answer, distinct from having
|
||
no log to read. */
|
||
empty: signals.flags.length === 0,
|
||
emptyNote: activity.length
|
||
? 'Nothing in the activity log departs from the usual pattern.'
|
||
: 'No activity has been recorded yet.',
|
||
};
|
||
},
|
||
|
||
/** What happened, counted by kind and by who did it. */
|
||
'activity.breakdown': ({ activity = [] }, section, now) => {
|
||
const windowed = section.periods?.length
|
||
? section.periods.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||
: [];
|
||
|
||
const events = windowed.length
|
||
? [...new Map(
|
||
windowed.flatMap((p) => inPeriod(activity, p, now)).map((e) => [e.id, e])
|
||
).values()]
|
||
: activity;
|
||
|
||
const byType = events.reduce<Record<string, number>>((acc, e) => {
|
||
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
const byAccount = events.reduce<Record<string, number>>((acc, e) => {
|
||
acc[e.user_name || e.user_email] = (acc[e.user_name || e.user_email] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
|
||
const rows = Object.entries(byType)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([type, count]) => ({
|
||
id: type,
|
||
/* The stored event name is a machine key; a reader should not have to
|
||
translate `hire_candidate` in their head. */
|
||
title: type.replace(/_/g, ' '),
|
||
label: type.replace(/_/g, ' '),
|
||
value: count,
|
||
detail: `${Math.round((count / (events.length || 1)) * 100)}% of events`,
|
||
}));
|
||
|
||
const limited = section.limit ? rows.slice(0, section.limit) : rows;
|
||
|
||
return {
|
||
items: limited,
|
||
steps: [
|
||
{ id: 'events', label: 'Events', title: 'Events', value: events.length },
|
||
{ id: 'kinds', label: 'Kinds of event', title: 'Kinds of event', value: Object.keys(byType).length },
|
||
{ id: 'accounts', label: 'Accounts active', title: 'Accounts active', value: Object.keys(byAccount).length },
|
||
],
|
||
byType,
|
||
byAccount,
|
||
total: events.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Event' },
|
||
{ key: 'detail', label: 'Share' },
|
||
{ key: 'value', label: 'Count', align: 'right' },
|
||
],
|
||
empty: events.length === 0,
|
||
emptyNote: activity.length
|
||
? 'No activity in that period.'
|
||
: 'No activity has been recorded yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* What is going wrong operationally, across domains.
|
||
*
|
||
* Cross-domain on purpose. A decision owed to a strong candidate, a backlog
|
||
* nobody has screened and shifts going unworked are stored in three different
|
||
* places and are the same kind of problem to the person who has to fix them.
|
||
*
|
||
* Findings are only included when they exist — an empty list here means the
|
||
* operation is running, not that the check did not run.
|
||
*/
|
||
'operations.risk': ({ applications = [], positions = [], shifts = [] }, section, now) => {
|
||
const findings = [];
|
||
|
||
/* Screened, strong, and nobody has moved on them. */
|
||
const owed = applications.filter(
|
||
(a) => (a.ai_score || 0) >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
|
||
);
|
||
if (owed.length) {
|
||
findings.push({
|
||
id: 'decisions-owed',
|
||
title: `${owed.length} strong candidate${owed.length === 1 ? '' : 's'} awaiting a decision`,
|
||
label: 'Decisions owed',
|
||
value: owed.length,
|
||
severity: owed.length >= 5 ? 'high' : 'medium',
|
||
detail: owed
|
||
.slice(0, 3)
|
||
.map((a) => `${a.applicant_name} (${a.ai_score})`)
|
||
.join(', ') + (owed.length > 3 ? `, +${owed.length - 3} more` : ''),
|
||
});
|
||
}
|
||
|
||
const unscreened = applications.filter((a) => a.status === 'applied');
|
||
if (unscreened.length >= 3) {
|
||
findings.push({
|
||
id: 'unscreened-backlog',
|
||
title: `${unscreened.length} applications not yet screened`,
|
||
label: 'Unscreened backlog',
|
||
value: unscreened.length,
|
||
severity: unscreened.length >= 10 ? 'high' : 'medium',
|
||
detail: `${Math.round((unscreened.length / applications.length) * 100)}% of the pool has no score`,
|
||
});
|
||
}
|
||
|
||
const starved = positions.filter(
|
||
(p) => p.status === 'active' && !applications.some((a) => a.job_posting_id === p.id)
|
||
);
|
||
if (starved.length) {
|
||
findings.push({
|
||
id: 'starved-positions',
|
||
title: `${starved.length} open role${starved.length === 1 ? '' : 's'} with no applicants`,
|
||
label: 'Roles with no applicants',
|
||
value: starved.length,
|
||
severity: 'medium',
|
||
detail: starved.slice(0, 3).map((p) => p.title).join(', '),
|
||
});
|
||
}
|
||
|
||
/* Shifts going unworked, over the last fortnight — the operational half of
|
||
the same question the attendance source answers analytically. */
|
||
const recent = [...inPeriod(shifts, 'last-7-days', now)];
|
||
const missed = recent.filter((s) => s.status === 'absent' || s.status === 'no_show');
|
||
if (missed.length >= 2) {
|
||
findings.push({
|
||
id: 'shifts-unworked',
|
||
title: `${missed.length} shifts went unworked in the last 7 days`,
|
||
label: 'Shifts unworked',
|
||
value: missed.length,
|
||
severity: missed.length >= 4 ? 'high' : 'medium',
|
||
detail: `${Math.round((missed.length / recent.length) * 100)}% of ${recent.length} scheduled`,
|
||
});
|
||
}
|
||
|
||
const rank = { high: 0, medium: 1, low: 2 };
|
||
findings.sort((a, b) => rank[a.severity] - rank[b.severity] || b.value - a.value);
|
||
const limited = section.limit ? findings.slice(0, section.limit) : findings;
|
||
|
||
return {
|
||
items: limited,
|
||
steps: [
|
||
{ id: 'risks', label: 'Open risks', title: 'Open risks', value: findings.length },
|
||
{ id: 'owed', label: 'Decisions owed', title: 'Decisions owed', value: owed.length },
|
||
{ id: 'unscreened', label: 'Unscreened', title: 'Unscreened', value: unscreened.length },
|
||
{ id: 'starved', label: 'Roles with no applicants', title: 'Roles with no applicants', value: starved.length },
|
||
],
|
||
findings,
|
||
total: findings.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Risk' },
|
||
{ key: 'detail', label: 'Detail' },
|
||
],
|
||
empty: findings.length === 0,
|
||
emptyNote: applications.length || shifts.length
|
||
? 'Nothing is currently at operational risk.'
|
||
: 'There is nothing to assess yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* The whole workspace in one row of figures.
|
||
*
|
||
* Every number here is read from a source that owns it rather than recomputed,
|
||
* so a summary and the page it summarizes cannot drift apart. A domain with
|
||
* no records contributes a zero and says so in its detail line — the summary
|
||
* reports what is there, including the absence.
|
||
*/
|
||
'workspace.summary': ({
|
||
positions = [], applications = [], staff = [], profiles = [], workerProfiles = [],
|
||
activity = [], shifts = [],
|
||
}) => {
|
||
const open = positions.filter((p) => p.status === 'active');
|
||
const pool = profiles.length ? profiles : workerProfiles;
|
||
const scored = applications.filter((a) => a.ai_score > 0);
|
||
const attendance = attendanceSummary(shifts);
|
||
|
||
const steps = [
|
||
{ id: 'positions', label: 'Open roles', title: 'Open roles', value: open.length, detail: `${positions.length} in total` },
|
||
{ id: 'candidates', label: 'Candidates', title: 'Candidates', value: applications.length, detail: `${scored.length} scored` },
|
||
{ id: 'hires', label: 'Hires', title: 'Hires', value: staff.length },
|
||
{ id: 'talent', label: 'Talent pool', title: 'Talent pool', value: pool.length },
|
||
{
|
||
id: 'attendance',
|
||
label: 'Attendance',
|
||
title: 'Attendance',
|
||
value: attendance.scheduled ? attendance.attendanceRate : 0,
|
||
max: 100,
|
||
detail: attendance.scheduled
|
||
? `${attendance.worked} of ${attendance.scheduled} shifts worked`
|
||
: 'No shifts recorded',
|
||
},
|
||
{ id: 'activity', label: 'Events logged', title: 'Events logged', value: activity.length },
|
||
];
|
||
|
||
return {
|
||
steps,
|
||
items: steps,
|
||
total: steps.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Measure' },
|
||
{ key: 'value', label: 'Value', align: 'right' },
|
||
],
|
||
/* Only genuinely empty when the workspace holds nothing at all. */
|
||
empty: !positions.length && !applications.length && !pool.length && !activity.length,
|
||
emptyNote: 'This workspace has no records yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Attendance, read whichever way the definition asks for it.
|
||
*
|
||
* One source, several shapes, because "how is attendance" is four different
|
||
* questions depending on what is drawn: a headline, a trend, a comparison
|
||
* between people, or the one thing worth acting on. Which one a definition
|
||
* gets is decided by its declared capability, never by parsing the question —
|
||
* the same rule every other source here follows.
|
||
*
|
||
* Every figure comes from `lib/attendance.js`, which the pages could call
|
||
* too. Nothing is computed twice, and nothing is written down as prose.
|
||
*/
|
||
'workforce.attendance': ({ shifts = [] }, section, now) => {
|
||
const windowed = section.periods?.length
|
||
? section.periods.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||
: [];
|
||
|
||
/* A period-shaped reading: one step per window, for a flow or a timeline. */
|
||
if (windowed.length) {
|
||
const steps = windowed.map((period) => {
|
||
const records = inPeriod(shifts, period, now);
|
||
const summary = attendanceSummary(records);
|
||
return {
|
||
id: period,
|
||
label: periodLabel(period),
|
||
title: periodLabel(period),
|
||
value: summary.scheduled ? summary.attendanceRate : 0,
|
||
max: 100,
|
||
detail: summary.scheduled
|
||
? `${summary.worked}/${summary.scheduled} worked · ${summary.missed} missed · ${summary.late} late`
|
||
: 'No shifts scheduled',
|
||
records,
|
||
};
|
||
});
|
||
|
||
return {
|
||
steps,
|
||
items: steps,
|
||
total: shifts.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Period' },
|
||
{ key: 'value', label: 'Attendance %', align: 'right' },
|
||
],
|
||
empty: steps.every((step) => !step.records.length),
|
||
emptyNote: shifts.length
|
||
? 'No shifts fall in those periods.'
|
||
: 'No shift records have been logged yet.',
|
||
};
|
||
}
|
||
|
||
const summary = attendanceSummary(shifts);
|
||
const workers = attendanceByWorker(shifts);
|
||
const limited = section.limit ? workers.slice(0, section.limit) : workers;
|
||
|
||
/* The headline figures, in the `{id,label,title,value}` shape every stats
|
||
and card renderer already reads. */
|
||
const steps = [
|
||
{ id: 'rate', label: 'Attendance', title: 'Attendance', value: summary.attendanceRate, max: 100, detail: `${summary.worked} of ${summary.scheduled} shifts worked` },
|
||
{ id: 'punctuality', label: 'Punctuality', title: 'Punctuality', value: summary.punctualityRate, max: 100, detail: `${summary.late} late arrival${summary.late === 1 ? '' : 's'}` },
|
||
{ id: 'missed', label: 'Missed shifts', title: 'Missed shifts', value: summary.missed, detail: `${summary.absent} absent · ${summary.noShow} no-show` },
|
||
{ id: 'scheduled', label: 'Shifts scheduled', title: 'Shifts scheduled', value: summary.scheduled, detail: `${summary.hoursWorked}h worked` },
|
||
];
|
||
|
||
return {
|
||
steps,
|
||
/* Per person for a list or a table — attendance is a question about
|
||
people, and a row per person is what a reader can act on. */
|
||
items: limited.map((worker) => ({
|
||
id: worker.id,
|
||
title: worker.name,
|
||
label: worker.name,
|
||
value: worker.attendanceRate,
|
||
max: 100,
|
||
detail: `${worker.department} · ${worker.worked}/${worker.scheduled} worked · ${worker.missed} missed · ${worker.late} late`,
|
||
})),
|
||
summary,
|
||
workers,
|
||
departments: attendanceByDepartment(shifts),
|
||
total: summary.scheduled,
|
||
columns: [
|
||
{ key: 'title', label: 'Worker' },
|
||
{ key: 'detail', label: 'Record' },
|
||
{ key: 'value', label: 'Attendance %', align: 'right' },
|
||
],
|
||
empty: summary.empty,
|
||
emptyNote: 'No shift records have been logged yet.',
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Overtime — scheduled hours against the hours actually worked.
|
||
*
|
||
* Reports the share as well as the total, because forty hours of overtime
|
||
* means one thing across a fortnight and another across a year, and only the
|
||
* ratio makes two teams comparable.
|
||
*/
|
||
'workforce.overtime': ({ shifts = [] }, section, now) => {
|
||
const windowed = section.periods?.length
|
||
? section.periods.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||
: [];
|
||
|
||
if (windowed.length) {
|
||
const steps = windowed.map((period) => {
|
||
const records = inPeriod(shifts, period, now);
|
||
const summary = overtimeSummary(records);
|
||
return {
|
||
id: period,
|
||
label: periodLabel(period),
|
||
title: periodLabel(period),
|
||
value: summary.hours,
|
||
detail: records.length
|
||
? `${summary.hours}h across ${summary.shiftsWithOvertime} of ${summary.shifts} shifts`
|
||
: 'No shifts scheduled',
|
||
records,
|
||
};
|
||
});
|
||
|
||
return {
|
||
steps,
|
||
items: steps,
|
||
total: shifts.length,
|
||
columns: [
|
||
{ key: 'title', label: 'Period' },
|
||
{ key: 'value', label: 'Overtime hours', align: 'right' },
|
||
],
|
||
empty: steps.every((step) => !step.records.length),
|
||
emptyNote: shifts.length
|
||
? 'No shifts fall in those periods.'
|
||
: 'No shift records have been logged yet.',
|
||
};
|
||
}
|
||
|
||
const summary = overtimeSummary(shifts);
|
||
const workers = overtimeByWorker(shifts);
|
||
const limited = section.limit ? workers.slice(0, section.limit) : workers;
|
||
|
||
const steps = [
|
||
{ id: 'hours', label: 'Overtime hours', title: 'Overtime hours', value: summary.hours, detail: `across ${summary.shiftsWithOvertime} of ${summary.shifts} shifts` },
|
||
{ id: 'share', label: 'Share of scheduled', title: 'Share of scheduled', value: summary.overtimeShare, max: 100, detail: `${summary.hoursWorked}h worked against ${summary.hoursScheduled}h scheduled` },
|
||
{ id: 'average', label: 'Avg per shift', title: 'Avg per shift', value: summary.averagePerShift, detail: 'hours' },
|
||
{ id: 'scheduled', label: 'Hours scheduled', title: 'Hours scheduled', value: summary.hoursScheduled },
|
||
];
|
||
|
||
return {
|
||
steps,
|
||
items: limited.map((worker) => ({
|
||
id: worker.id,
|
||
title: worker.name,
|
||
label: worker.name,
|
||
value: worker.hours,
|
||
detail: `${worker.department} · ${worker.overtimeShare}% of scheduled · ${worker.shiftsWithOvertime}/${worker.shifts} shifts`,
|
||
})),
|
||
summary,
|
||
workers,
|
||
/* The findings this reading supports, for a definition that asks for an
|
||
insight rather than a table. Empty when nothing clears the bar — which
|
||
is the point of `attendanceAnomalies`. */
|
||
anomalies: attendanceAnomalies(shifts, { now }),
|
||
trend: weeklyTrend(shifts, { now }),
|
||
total: summary.hours,
|
||
columns: [
|
||
{ key: 'title', label: 'Worker' },
|
||
{ key: 'detail', label: 'Overtime' },
|
||
{ key: 'value', label: 'Hours', align: 'right' },
|
||
],
|
||
empty: summary.empty,
|
||
emptyNote: 'No shift records have been logged yet.',
|
||
};
|
||
},
|
||
|
||
'activity.events': ({ activity = [] }, section) => {
|
||
const items = [...activity]
|
||
.sort((a, b) => new Date(b.created_date || 0).getTime() - new Date(a.created_date || 0).getTime())
|
||
.slice(0, section.limit || 10)
|
||
.map((event) => ({
|
||
id: event.id,
|
||
title: String(event.event_type || 'event').replace(/_/g, ' '),
|
||
detail: [event.user_name, event.details].filter(Boolean).join(' — '),
|
||
at: event.created_date,
|
||
}));
|
||
|
||
return {
|
||
items,
|
||
columns: [
|
||
{ key: 'title', label: 'Event' },
|
||
{ key: 'detail', label: 'Who' },
|
||
],
|
||
empty: items.length === 0,
|
||
emptyNote: 'No activity has been recorded yet.',
|
||
};
|
||
},
|
||
};
|
||
|
||
/**
|
||
* One section's data, read from the application's own records.
|
||
*
|
||
* `context` is what the page supplies — the position or candidate being looked
|
||
* at, plus the collections it already loaded. A source whose required context
|
||
* is missing returns `unavailable`, which the renderer states rather than
|
||
* filling in.
|
||
*/
|
||
export function resolveSkillData(section, context: any = {}, now = new Date()) {
|
||
const resolve = RESOLVERS[section?.source];
|
||
if (!resolve) return { unavailable: true, emptyNote: `No resolver for ${section?.source}.` };
|
||
|
||
if (section.context === 'positionId' && !context.position) {
|
||
return { unavailable: true, emptyNote: 'This section needs a position to read.' };
|
||
}
|
||
if (section.context === 'candidateId' && !context.candidate) {
|
||
return { unavailable: true, emptyNote: 'This section needs a candidate to read.' };
|
||
}
|
||
|
||
try {
|
||
return resolve(context, section, now);
|
||
} catch {
|
||
/* A resolver that throws is a bug in this file, not in the definition —
|
||
the section reports it has nothing rather than taking the page down. */
|
||
return { unavailable: true, emptyNote: 'This section could not be read.' };
|
||
}
|
||
}
|