refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules

The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.

79 errors, and two optional markers cleared 63 of them.

`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.

Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.

The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.

Measured against `dde4ba6`:

  typecheck   6 errors, unchanged; 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  83/83 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 15:35:15 +05:30
parent dde4ba62c6
commit 3f835eee93
11 changed files with 6 additions and 4 deletions

View File

@@ -0,0 +1,378 @@
/**
* Dashboard fact sheet.
*
* Every assistant answer is derived from here, using the same formulas the
* dashboard components use — so the assistant can never state a figure that
* contradicts the card or chart sitting next to it.
*
* This module is pure: dashboard data in, derived facts out. No UI, no
* transport, no dependency on any AI product.
*/
import { isUnlocked } from '@/lib/provingGround';
import { getScoreBand } from '@/lib/talentHome';
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
export const plural = (n, word, irregular?) =>
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
const avg = (values) =>
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Events an auditor looks at first: they change who is employed or what is being
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
* uses it, and re-exported here because the fact sheet, the Activity page's
* severity column and the assistant's security answers all import it from this
* module and must keep agreeing on what "privileged" means.
*/
export { PRIVILEGED_EVENTS };
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
applications = [],
postings = [],
interviews = [],
staff = [],
profiles = [],
activity = [],
courses = [],
profile = null,
user = null,
today = new Date(),
}) {
const scored = applications.filter((a) => a.ai_score > 0);
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
const unscreened = byStatus('applied');
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
const interviewing = byStatus('interview');
const hired = byStatus('hired');
const openPositions = postings.filter((p) => p.status === 'active');
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
const timeToHire = hired.length
? Math.round(avg(hired.map((a) =>
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
: 0;
/* Open roles attracting nobody. */
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
const singleCandidateRoles = openPositions
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
.filter((e) => e.viable.length === 1);
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
/* Screened and waiting on a decision — which includes the shortlisted, who
are waiting on exactly that. Restricting this to `ai_screened` read as
correct only while nothing was ever shortlisted: the first shortlisted
candidate would have dropped silently out of the count. positionInsights
draws the same set with ['ai_screened', 'shortlisted']. */
const stalled = ranked.filter(
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
);
const funnel = [
{ key: 'applied', label: 'Applied', count: applications.length },
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
{ key: 'hired', label: 'Hired', count: hired.length },
];
const transitions = funnel.slice(1).map((stage, i) => ({
from: funnel[i].label,
to: stage.label,
rate: pct(stage.count, funnel[i].count),
lost: Math.max(0, funnel[i].count - stage.count),
}));
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
const byRole = openPositions.map((p) => {
const apps = applicationsFor(p.id);
const roleScored = apps.filter((a) => a.ai_score > 0);
return {
posting: p,
title: p.title,
category: p.role_category || 'Uncategorized',
applied: apps.length,
screened: apps.filter((a) => a.status !== 'applied').length,
hired: apps.filter((a) => a.status === 'hired').length,
avgScore: avg(roleScored.map((a) => a.ai_score)),
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
};
});
const since = (days) => {
const cutoff = today.getTime() - days * DAY_MS;
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
};
const eventCounts = activity.reduce((acc, e) => {
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
return acc;
}, {});
/**
* Out-of-pattern activity.
*
* Computed by `lib/activitySignals.js` rather than here, because it now has
* a second reader: the `activity.signals` data source resolves it for a card
* or an answer, and `dataResolver.js` cannot import from this directory
* without a library reaching up into the component tree.
*
* Moved, not changed. "Two unusual patterns" has to mean the same two
* everywhere it is said, and one function is the only way to guarantee that.
*/
const signals = activitySignals(activity, today);
/* ── KROW Forge ──────────────────────────────────────────────────────────
Derived with the same gate (`isUnlocked`) and the same completion record the
Forge page renders from, so the panel and the page cannot disagree about
what is ready, what is blocked, or why. `profile` is the signed-in worker's
record when one is already in cache; without it the counts are the library's
rather than the person's, which is still true — just less personal. */
const forgeCourses = courses.filter((c) => c.status === 'active');
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
const forgeGate = (course) => isUnlocked(course, profile || {});
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
const forge = {
courses: forgeCourses,
verified: profile?.earned_badges || [],
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
ready: forgeReady,
locked: forgeLocked,
/* The page features the first unfinished challenge whether or not it is
unlocked, so the panel names the same one — a chip that recommends a
different challenge than the one on screen reads as a second opinion. */
next: forgeUnfinished[0] || null,
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
/* Every blocked challenge with the reasons that block it, in page order. */
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
level,
count: forgeCourses.filter((c) => c.difficulty === level).length,
})).filter((d) => d.count),
/* ── The same library, read by the administrator who owns it ──────────
Everything above answers "what can I prove"; everything below answers
"what have we built, and how is the workforce doing with it". Both are
derived from the same records, so the panel cannot disagree with the page
whichever question is asked. Publication state is read with the same
mapping the Forge page uses: `active` is Published. */
library: courses,
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
drafts: courses.filter((c) => c.status === 'draft'),
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
withChallenge: courses.filter((c) => c.challenge?.type),
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
byCategory: [...courses.reduce((map, c) => {
const key = c.category || 'Uncategorised';
map.set(key, (map.get(key) || 0) + 1);
return map;
}, new Map())]
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count),
/* Verifications the workforce actually holds, counted off worker profiles
rather than off the library — a badge is something a person has. */
workforceVerified: profiles.reduce((sum, p) => {
const ids = new Set(courses.map((c) => c.id));
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
}, 0),
usage: courses.map((c) => ({
course: c,
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
})),
};
/* ── Talent pool ─────────────────────────────────────────────────────────
The same score bands the Talent Pool page labels profiles with. */
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
const talent = {
ranked: rankedProfiles,
top: rankedProfiles.slice(0, 3),
available: profiles.filter((p) => (p.availability || []).length > 0),
unverified: profiles.filter(
(p) => !(p.earned_badges || []).length
&& !(p.capabilities || []).some((c) => c.status === 'verified')
),
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
const { label } = getScoreBand(p.krow_score || 0);
acc[label] ||= { label, count: 0 };
acc[label].count += 1;
return acc;
}, {})).sort((a, b) => b.count - a.count),
};
/* ── Hired history ───────────────────────────────────────────────────────
Staff joined to the application and posting they came from — the same join
the Hired History page performs, so department, speed and score match. */
const hires = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
return {
...s,
department: posting?.role_category || '—',
timeToHire: app
? Math.max(1, Math.round(
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
))
: null,
score: s.ai_score || app?.ai_score || 0,
};
});
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
}>>((acc, h) => {
const name = h[key] || 'Unspecified';
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
acc[name].count += 1;
if (h.score) acc[name].scores.push(h.score);
if (h.timeToHire) acc[name].days.push(h.timeToHire);
if (h.client_rating) acc[name].ratings.push(h.client_rating);
return acc;
}, {})).map((e) => ({
name: e.name,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
avgRating: e.ratings.length
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
: null,
})).sort((a, b) => b.count - a.count);
const hiring = {
hires,
byDepartment: groupHires('department'),
byRoleTitle: groupHires('role'),
unrated: hires.filter((h) => !h.client_rating),
rated: hires.filter((h) => h.client_rating),
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
onboarding: hires.filter((h) => h.status === 'onboarding'),
};
return {
today,
/* The signed-in account — identity and preferences, for the pages that are
about the operator rather than the workforce. */
user,
/* Applications */
applications,
total: applications.length,
scored,
ranked,
top: ranked.slice(0, 3),
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
unscreened,
screened,
interviewing,
hired,
stalled,
avgScore: avg(scored.map((a) => a.ai_score)),
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
/**
* Candidates a human should look at more closely — either because there is
* no score to judge them on, or because the record has a gap the score does
* not reflect. Deliberately not "weak candidates": every one of these is
* under-assessed rather than assessed and found wanting.
*/
needsDeeperReview: applications.filter((a) => {
if (!(a.ai_score > 0)) return true;
const flaggedInterview = interviews.some(
(i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
);
return flaggedInterview
|| !(a.certifications || []).length
|| !a.years_experience;
}),
/* Positions */
postings,
openPositions,
starvedPositions,
singleCandidateRoles,
byRole,
applicationsFor,
/* Interviews */
interviews,
completedInterviews,
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
interviewCompletion: pct(completedInterviews.length, interviews.length),
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
/* Hires */
staff,
unratedStaff: staff.filter((s) => !s.client_rating),
timeToHire,
hireRate: pct(hired.length, applications.length),
/* Funnel */
funnel,
transitions,
bottleneck,
/* Talent pool */
profiles,
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
/* Activity */
activity,
activity7d: since(7),
activity24h: since(1),
eventCounts,
activitySignals: signals,
/* Signed-in worker, and the pages built on these records */
profile,
courses,
forge,
talent,
hiring,
/* Economics — mirrors ImpactMetrics */
costSaved: scored.length * 45 + interviews.length * 80,
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
standardizedPct: pct(scored.length, applications.length),
};
}
/* ── Answer formatting ──────────────────────────────────────────────────── */
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
export const numbered = (items) =>
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
export const heading = (text) => `**${text}**`;
/** Joins answer blocks, dropping empties so templates can use conditionals. */
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');