Files
krow_talent_app/src/lib/skills/dataResolver.js
2026-08-14 17:33:16 +05:30

517 lines
18 KiB
JavaScript

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.' };
}
}