chore(ts-migration): migrate domain logic to TypeScript

Phase 5. Twelve modules under `src/lib` and `src/lib/admin`. All twelve emit
byte-identical JavaScript; eight needed no annotation at all.

Where the generated entity types fit, they are used. `positionModel` is typed
against `JobPosting` — and that is where TypeScript earned its keep. Annotating
the label functions made `experienceLabel`'s `years === ''` guard a comparison
the compiler called impossible, because the registry types
`min_experience_years` as `number`, which is correct for a record the API has
returned. The guard is not dead: the same functions are handed drafts, and an
untouched numeric form input yields `''` — which is why `toPositionPayload`
coerces all five numerics with `Number(...)`.

So the module now has two types rather than one. `PositionRecord` is a saved
posting with the registry's column types; `PositionDraft` widens the five
numerics to `number | string` and is taken by `toPositionPayload` alone. The
one comparison the split cannot express keeps its guard and carries a cast with
the reason written next to it. Deleting a live guard to satisfy a type would be
the type rewriting the code.

`workforce` keeps its records as `any`: 574 lines of demand and availability
arithmetic over profiles, postings, assignments and staff read largely through
jsonb columns the registry does not describe. What IS described is the module's
own contract — the `WorkforceContext` option bag and the `Availability` result,
whose two shapes differ by whether a worker's commitments are known.

Two of my own type declarations were too narrow and were caught by the
set-difference rather than by inspection. `activitySignals`' accumulator seeds
`{ email, name, count, privileged }` and I had named only the two counters;
`WorkforceContext` omitted `profiles` and `courses`, which `PositionDetail`
passes in a single call with three more. The bag now carries an index signature,
because that is what the call site assumes: callers hand the whole thing over
and each function picks what it needs.

`skillGraph` gains a `SkillLevel` interface with an optional `earned`, set in a
second pass that stops at the first incomplete rung — so the levels above the
gap never receive it, and optional is the honest description.

Verified: tsc 40 -> 37, zero introduced; all twelve emitted outputs
byte-identical; npm test 1684/1691 with the same seven failures; lint 0 errors;
build succeeds with the API origin inlined; baseline artifacts untouched.

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-17 23:46:26 +05:30
parent 3e654c2bf7
commit 2b8f5746bd
12 changed files with 121 additions and 18 deletions

View File

@@ -43,7 +43,10 @@ export function activitySignals(activity = [], today = new Date()) {
const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type)); const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
const perUser = activity.reduce((acc, e) => { /* `Record<…>` on both accumulators below. `reduce` with a `{}` seed infers
the accumulator as `{}`, so `acc[key].count += 1` has nothing to add to.
Naming the value shape is the whole fix; the arithmetic is unchanged. */
const perUser: Record<string, { email: any; name: any; count: number; privileged: number }> = activity.reduce((acc, e) => {
acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 }; acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
acc[e.user_email].count += 1; acc[e.user_email].count += 1;
if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1; if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
@@ -55,7 +58,7 @@ export function activitySignals(activity = [], today = new Date()) {
const busiestShare = busiest ? pct(busiest.count, activity.length) : 0; const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
/* A burst is more than three actions from one account inside one hour. */ /* A burst is more than three actions from one account inside one hour. */
const perAccountHour = activity.reduce((acc, e) => { const perAccountHour: Record<string, number> = activity.reduce((acc, e) => {
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`; const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
acc[key] = (acc[key] || 0) + 1; acc[key] = (acc[key] || 0) + 1;
return acc; return acc;

View File

@@ -1,3 +1,34 @@
import type { JobPosting } from '@/types/entities';
/**
* Two shapes reach this module, and they are not the same.
*
* `PositionRecord` is a posting the API has returned. Fields carry the types
* the backend's column registry declares. `Partial`, because every function
* takes `= {}` and guards each field — those defaults render labels on a page
* that mounts before the posting query resolves, so requiring the whole record
* would force them out, which is a behaviour change.
*
* `PositionDraft` is a form in progress. Its numbers are still strings, which
* is not a guess: `toPositionPayload` coerces all five with `Number(...)` and
* treats `duration_months === ''` as "not stated". Only that function takes a
* draft; the label functions below are given saved postings.
*
* `certifications_required` is a real `text[]` column and arrives as
* `string[]`. `skill_requirements` and `vetting_criteria` are `jsonb`, typed
* `unknown` by the registry; this module reads through neither.
*/
type PositionRecord = Partial<JobPosting>;
type PositionDraft = Omit<PositionRecord,
'pay_range_min' | 'pay_range_max' | 'min_experience_years' | 'headcount' | 'duration_months'> & {
pay_range_min?: number | string;
pay_range_max?: number | string;
min_experience_years?: number | string;
headcount?: number | string;
duration_months?: number | string | null;
};
/** /**
* The position record, as one definition. * The position record, as one definition.
* *
@@ -165,7 +196,7 @@ export function defaultPosition() {
* rather than at each call site. Everything absent falls back to the same * rather than at each call site. Everything absent falls back to the same
* defaults the form starts from. * defaults the form starts from.
*/ */
export function toPositionPayload(draft = {}, { status = 'active' } = {}) { export function toPositionPayload(draft: PositionDraft = {}, { status = 'active' }: { status?: string } = {}) {
const record = { ...defaultPosition(), ...draft }; const record = { ...defaultPosition(), ...draft };
return { return {
...record, ...record,
@@ -195,7 +226,7 @@ export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
* nothing, rather than "1 person · starts immediately · ongoing" — which is * nothing, rather than "1 person · starts immediately · ongoing" — which is
* three schema defaults wearing the appearance of an employer's answer. * three schema defaults wearing the appearance of an employer's answer.
*/ */
export function demandLabel(position = {}) { export function demandLabel(position: PositionRecord = {}) {
const parts = []; const parts = [];
const count = Number(position.headcount); const count = Number(position.headcount);
@@ -227,7 +258,7 @@ export function demandLabel(position = {}) {
} }
/** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */ /** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */
export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) { export function payLabel({ pay_range_min: min, pay_range_max: max }: PositionRecord = {}) {
const low = Number(min) || 0; const low = Number(min) || 0;
const high = Number(max) || 0; const high = Number(max) || 0;
if (!low && !high) return null; if (!low && !high) return null;
@@ -250,9 +281,14 @@ export function englishLabel(value) {
* rewording the employer rather than reporting them. `null` is returned only * rewording the employer rather than reporting them. `null` is returned only
* when the field carries nothing at all. * when the field carries nothing at all.
*/ */
export function experienceLabel(position = {}) { export function experienceLabel(position: PositionRecord = {}) {
const years = position.min_experience_years; const years = position.min_experience_years;
if (years === undefined || years === null || years === '') return null; /* `=== ''` is cast because the registry types this column `number`, so
TypeScript calls the comparison impossible — and against a saved record it
is. The guard stays because the value has also arrived from a form, where
an untouched numeric input yields ''. Deleting a live guard to satisfy a
type would be the type rewriting the code. */
if (years === undefined || years === null || (years as unknown) === '') return null;
const n = Number(years); const n = Number(years);
if (!Number.isFinite(n)) return null; if (!Number.isFinite(n)) return null;
return `${n} ${n === 1 ? 'year' : 'years'}`; return `${n} ${n === 1 ? 'year' : 'years'}`;
@@ -293,14 +329,14 @@ export const REQUIREMENT_FIELDS = [
* behave the opposite way and are handled separately: those are questions every * behave the opposite way and are handled separately: those are questions every
* position answers, so an unanswered one is worth showing as unanswered. * position answers, so an unanswered one is worth showing as unanswered.
*/ */
export function statedRequirements(position = {}) { export function statedRequirements(position: PositionRecord = {}) {
return REQUIREMENT_FIELDS return REQUIREMENT_FIELDS
.map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() })) .map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() }))
.filter((field) => field.value); .filter((field) => field.value);
} }
/** The custom requirement text, or `''` when none was written. */ /** The custom requirement text, or `''` when none was written. */
export const customRequirementsText = (position = {}) => export const customRequirementsText = (position: PositionRecord = {}) =>
String(position.custom_requirements ?? '').trim(); String(position.custom_requirements ?? '').trim();
/** /**
@@ -309,7 +345,7 @@ export const customRequirementsText = (position = {}) =>
* The gate a surface uses before drawing the heading — a section title standing * The gate a surface uses before drawing the heading — a section title standing
* over nothing is worse than no section. * over nothing is worse than no section.
*/ */
export const hasRequirements = (position = {}) => export const hasRequirements = (position: PositionRecord = {}) =>
statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0; statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0;
/** /**
@@ -318,7 +354,7 @@ export const hasRequirements = (position = {}) =>
* Company is deliberately absent: it belongs above the title, not beside the * Company is deliberately absent: it belongs above the title, not beside the
* category. Parts the record does not carry are omitted rather than padded. * category. Parts the record does not carry are omitted rather than padded.
*/ */
export function roleMetaLine(position = {}) { export function roleMetaLine(position: PositionRecord = {}) {
return [position.role_category, position.location, payLabel(position)] return [position.role_category, position.location, payLabel(position)]
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');

View File

@@ -1,3 +1,21 @@
/**
* One rung of a skill ladder.
*
* `earned` is set in a second pass — the loop below walks up from the bottom
* and stops at the first incomplete level, so the flag cannot be decided while
* the object is built. Optional rather than required for exactly that reason:
* the rungs above the gap never receive it.
*/
interface SkillLevel {
level: string;
label: any;
modules: any[];
completed: any[];
complete: boolean;
progress: number;
earned?: boolean;
}
/** /**
* The skill graph: the one definition of how training becomes a verified level, * The skill graph: the one definition of how training becomes a verified level,
* and how a verified level becomes eligibility for a position. * and how a verified level becomes eligibility for a position.
@@ -135,7 +153,7 @@ export function skillStateFor(skillId, courses, profile) {
const done = completedCourseIds(profile); const done = completedCourseIds(profile);
const grouped = modulesByLevel(courses, skillId); const grouped = modulesByLevel(courses, skillId);
const levels = ladder.map((level) => { const levels: SkillLevel[] = ladder.map((level) => {
const modules = grouped[level] || []; const modules = grouped[level] || [];
const completed = modules.filter((m) => done.has(m.id)); const completed = modules.filter((m) => done.has(m.id));
return { return {

View File

@@ -1,3 +1,49 @@
/**
* The records this module reads are typed `any`, and the option bags are named.
*
* `any` for the records because they are WorkerProfile, JobPosting, Assignment
* and Staff rows read through jsonb columns the registry cannot describe, in a
* module that is 574 lines of demand and availability arithmetic. The generated
* types are used where they fit — see `positionModel.ts` — and this is not one
* of those places; asserting a shape here would be the compiler defending a
* guess about the parts of the record it cannot see.
*
* The option bags ARE described, because they are this module's own contract
* rather than the database's: every one has a `= {}` default and every field a
* fallback, so a caller may supply none, some or all.
*/
interface WorkforceContext {
assignments?: any[];
staff?: any[];
applications?: any[];
profiles?: any[];
courses?: any[];
pool?: any[];
interviews?: any[];
today?: Date;
/* Callers hand the whole bag over — `PositionDetail` passes five collections
in one call — and each function picks the ones it needs. The index
signature says that: naming a closed set described the functions rather
than the call, and rejected a caller for supplying data it had. */
[key: string]: any;
}
/**
* Whether a worker can take a position, and why not when they cannot.
*
* Two shapes, deliberately: a worker committed elsewhere carries the date they
* free up and what blocks them, while one whose commitments are unknown carries
* `known: false` instead. Optional fields rather than two unions, so the twelve
* call sites reading `.available` keep working without narrowing first.
*/
interface Availability {
available: boolean;
reason: string;
committedUntil?: Date | null;
blockedBy?: any;
known?: boolean;
}
/** /**
* Workforce allocation: who is needed where, who is actually free, and which * Workforce allocation: who is needed where, who is actually free, and which
* demand gets the people first. * demand gets the people first.
@@ -68,7 +114,7 @@ export function startLabel(position, today = new Date()) {
* role, because both are people who are actually on it — a system that counted * role, because both are people who are actually on it — a system that counted
* only one of them would report a gap the floor does not have. * only one of them would report a gap the floor does not have.
*/ */
export function demandFor(position, { assignments = [], staff = [] } = {}) { export function demandFor(position: any, { assignments = [], staff = [] }: WorkforceContext = {}) {
/** /**
* Whether this position states how many people it wants. * Whether this position states how many people it wants.
* *
@@ -113,7 +159,7 @@ export function demandFor(position, { assignments = [], staff = [] } = {}) {
* Someone finishing a role next Tuesday is available for work starting next * Someone finishing a role next Tuesday is available for work starting next
* month — reporting them as "committed" would hide a person the roster needs. * month — reporting them as "committed" would hide a person the roster needs.
*/ */
export function availabilityOf(profile, position, { assignments = [], today = new Date() } = {}) { export function availabilityOf(profile: any, position: any, { assignments = [], today = new Date() }: WorkforceContext = {}): Availability {
const email = String(profile?.email || '').toLowerCase(); const email = String(profile?.email || '').toLowerCase();
const start = startsAt(position, today); const start = startsAt(position, today);
@@ -153,7 +199,7 @@ export function availabilityOf(profile, position, { assignments = [], today = ne
* anything; the penalty only applies when their own availability actually ends * anything; the penalty only applies when their own availability actually ends
* before the work does. * before the work does.
*/ */
export function durationFitOf(profile, position, { today = new Date() } = {}) { export function durationFitOf(profile: any, position: any, { today = new Date() }: WorkforceContext = {}) {
const end = endsAt(position, today); const end = endsAt(position, today);
if (!end) { if (!end) {
/* Open-ended work. Anyone with a stated end to their availability is a /* Open-ended work. Anyone with a stated end to their availability is a
@@ -403,7 +449,7 @@ export function poolFor(position, {
* The workforce picture for one position — the figures the detail page and * The workforce picture for one position — the figures the detail page and
* Owliver both report, computed once so they cannot disagree. * Owliver both report, computed once so they cannot disagree.
*/ */
export function workforceStatusFor(position, context = {}) { export function workforceStatusFor(position: any, context: WorkforceContext = {}) {
const demand = demandFor(position, context); const demand = demandFor(position, context);
const pool = poolFor(position, context); const pool = poolFor(position, context);
const today = context.today || new Date(); const today = context.today || new Date();
@@ -481,7 +527,7 @@ export function workforceStatusFor(position, context = {}) {
* needs somebody to press the button. What ranks highest is a role that starts * needs somebody to press the button. What ranks highest is a role that starts
* soon, is badly short, and has few people who can fill it. * soon, is badly short, and has few people who can fill it.
*/ */
export function prioritise(positions = [], context = {}) { export function prioritise(positions: any[] = [], context: WorkforceContext = {}) {
const today = context.today || new Date(); const today = context.today || new Date();
return positions return positions
@@ -546,7 +592,7 @@ export function prioritise(positions = [], context = {}) {
* the only possible path rather than a convention someone has to remember. * the only possible path rather than a convention someone has to remember.
*/ */
/** @param {any} position @param {any} [options] */ /** @param {any} position @param {any} [options] */
export function prepareAssignment(position, options = {}) { export function prepareAssignment(position: any, options: any = {}) {
const { count, ...context } = options || {}; const { count, ...context } = options || {};
const status = workforceStatusFor(position, context); const status = workforceStatusFor(position, context);
const take = Math.min( const take = Math.min(