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>((acc, e) => { acc[e.event_type] = (acc[e.event_type] || 0) + 1; return acc; }, {}); const byAccount = events.reduce>((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.' }; } }