diff --git a/src/lib/activitySignals.js b/src/lib/activitySignals.ts similarity index 89% rename from src/lib/activitySignals.js rename to src/lib/activitySignals.ts index d52fb97..c6d956c 100644 --- a/src/lib/activitySignals.js +++ b/src/lib/activitySignals.ts @@ -43,7 +43,10 @@ export function activitySignals(activity = [], today = new Date()) { 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 = activity.reduce((acc, e) => { acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 }; acc[e.user_email].count += 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; /* A burst is more than three actions from one account inside one hour. */ - const perAccountHour = activity.reduce((acc, e) => { + const perAccountHour: Record = activity.reduce((acc, e) => { const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`; acc[key] = (acc[key] || 0) + 1; return acc; diff --git a/src/lib/admin/permissions.js b/src/lib/admin/permissions.ts similarity index 100% rename from src/lib/admin/permissions.js rename to src/lib/admin/permissions.ts diff --git a/src/lib/admin/positionInsights.js b/src/lib/admin/positionInsights.ts similarity index 100% rename from src/lib/admin/positionInsights.js rename to src/lib/admin/positionInsights.ts diff --git a/src/lib/admin/session.js b/src/lib/admin/session.ts similarity index 100% rename from src/lib/admin/session.js rename to src/lib/admin/session.ts diff --git a/src/lib/attendance.js b/src/lib/attendance.ts similarity index 100% rename from src/lib/attendance.js rename to src/lib/attendance.ts diff --git a/src/lib/candidateIntelligence.js b/src/lib/candidateIntelligence.ts similarity index 100% rename from src/lib/candidateIntelligence.js rename to src/lib/candidateIntelligence.ts diff --git a/src/lib/hiringRecords.js b/src/lib/hiringRecords.ts similarity index 100% rename from src/lib/hiringRecords.js rename to src/lib/hiringRecords.ts diff --git a/src/lib/humanInterviews.js b/src/lib/humanInterviews.ts similarity index 100% rename from src/lib/humanInterviews.js rename to src/lib/humanInterviews.ts diff --git a/src/lib/krowAi.js b/src/lib/krowAi.ts similarity index 100% rename from src/lib/krowAi.js rename to src/lib/krowAi.ts diff --git a/src/lib/positionModel.js b/src/lib/positionModel.ts similarity index 84% rename from src/lib/positionModel.js rename to src/lib/positionModel.ts index 10820a1..d03a14a 100644 --- a/src/lib/positionModel.js +++ b/src/lib/positionModel.ts @@ -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; + +type PositionDraft = Omit & { + 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. * @@ -165,7 +196,7 @@ export function defaultPosition() { * rather than at each call site. Everything absent falls back to the same * defaults the form starts from. */ -export function toPositionPayload(draft = {}, { status = 'active' } = {}) { +export function toPositionPayload(draft: PositionDraft = {}, { status = 'active' }: { status?: string } = {}) { const record = { ...defaultPosition(), ...draft }; return { ...record, @@ -195,7 +226,7 @@ export function toPositionPayload(draft = {}, { status = 'active' } = {}) { * nothing, rather than "1 person · starts immediately · ongoing" — which is * three schema defaults wearing the appearance of an employer's answer. */ -export function demandLabel(position = {}) { +export function demandLabel(position: PositionRecord = {}) { const parts = []; 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. */ -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 high = Number(max) || 0; if (!low && !high) return null; @@ -250,9 +281,14 @@ export function englishLabel(value) { * rewording the employer rather than reporting them. `null` is returned only * when the field carries nothing at all. */ -export function experienceLabel(position = {}) { +export function experienceLabel(position: PositionRecord = {}) { 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); if (!Number.isFinite(n)) return null; 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 * position answers, so an unanswered one is worth showing as unanswered. */ -export function statedRequirements(position = {}) { +export function statedRequirements(position: PositionRecord = {}) { return REQUIREMENT_FIELDS .map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() })) .filter((field) => field.value); } /** The custom requirement text, or `''` when none was written. */ -export const customRequirementsText = (position = {}) => +export const customRequirementsText = (position: PositionRecord = {}) => 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 * 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; /** @@ -318,7 +354,7 @@ export const hasRequirements = (position = {}) => * Company is deliberately absent: it belongs above the title, not beside the * 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)] .filter(Boolean) .join(' · '); diff --git a/src/lib/skillGraph.js b/src/lib/skillGraph.ts similarity index 96% rename from src/lib/skillGraph.js rename to src/lib/skillGraph.ts index abfff4a..3476051 100644 --- a/src/lib/skillGraph.js +++ b/src/lib/skillGraph.ts @@ -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, * 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 grouped = modulesByLevel(courses, skillId); - const levels = ladder.map((level) => { + const levels: SkillLevel[] = ladder.map((level) => { const modules = grouped[level] || []; const completed = modules.filter((m) => done.has(m.id)); return { diff --git a/src/lib/workforce.js b/src/lib/workforce.ts similarity index 91% rename from src/lib/workforce.js rename to src/lib/workforce.ts index 50d3e82..6fc2eb4 100644 --- a/src/lib/workforce.js +++ b/src/lib/workforce.ts @@ -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 * 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 * 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. * @@ -113,7 +159,7 @@ export function demandFor(position, { assignments = [], staff = [] } = {}) { * Someone finishing a role next Tuesday is available for work starting next * 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 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 * 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); if (!end) { /* 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 * 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 pool = poolFor(position, context); 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 * 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(); return positions @@ -546,7 +592,7 @@ export function prioritise(positions = [], context = {}) { * the only possible path rather than a convention someone has to remember. */ /** @param {any} position @param {any} [options] */ -export function prepareAssignment(position, options = {}) { +export function prepareAssignment(position: any, options: any = {}) { const { count, ...context } = options || {}; const status = workforceStatusFor(position, context); const take = Math.min(