import { SUPPORTED_PERIODS, periodLabel } from './surfaces'; import { CRITERIA_LABELS } from '@/lib/positionModel'; import { poolFor } from '@/lib/workforce'; import { candidateRoute } from './workforceFlow'; /** * 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 application stages this product counts, in the order they happen. */ const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired']; const atOrBeyond = (applications, stage) => { const from = STAGE_ORDER.indexOf(stage); return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from); }; /** 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) => a.status === 'hired').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 })), candidate?.status === 'hired' && { 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) - new Date(a.hire_date || a.created_date || 0)) .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) => a.status === 'hired'); const scores = applications.map((a) => a.ai_score).filter((n) => n > 0); const days = hired .map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 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. */ 'activity.events': ({ activity = [] }, section) => { const items = [...activity] .sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)) .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 = {}, 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.' }; } }