From e8207038dd7ed3dac31c852568c8d7c5065869d9 Mon Sep 17 00:00:00 2001 From: Aravind Date: Fri, 18 Sep 2026 17:18:18 +0530 Subject: [PATCH] refactor(ts-migration): type src/api and the first of src/lib for noImplicitAny MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 12 step 1, in progress. The flag is not enabled yet — these are the annotations it will require, landed first so the switch itself is a one-line commit with a number that is already zero. `noImplicitAny` projects 3496 errors across 242 files. This clears 179 of them in 7 files: all of `src/api` (107 -> 0) and `workforce`, `hiringRecords` in `src/lib`. The leverage is real and worth recording, because it shapes the rest of the step. Eight annotations on `aiEngine`'s prompt readers cleared 27 errors: where a value is `any`, every callback beneath it — `.map((c) => …)`, `.filter((r) => …)` — has no contextual type and errors on its own. Typing the source fixes the callbacks for free, so this works bottom-up, sources first. Types are taken from what already exists wherever possible. The generated entity types fit `workforce` and `hiringRecords` without a single cascade: `JobPosting`, `JobApplication`, `Staff`, `AIInterview`, `WorkerProfile`, `Assignment`, `Course`. `PreferencesUpdateResult` in `src/types/user.ts` already described `updatePreferences`'s return. `buildInsights` takes the other builders' outputs, so its parameters are `ReturnType` and friends rather than a restatement that could drift. Two things are recorded rather than fixed: `buildHires` probes four fields that are not `staff` columns — `timeToHire`, `score`, `company` and `department` are absent from `information_schema` and from the generated `Staff`. They are read as fallbacks, so at run time they are always `undefined` and the other branch always wins. `HireSourceRow` writes them down as optional so the dead fallbacks are visible; removing the reads would be a behaviour change. `Hire` likewise widens `profile_tier`, because the builder's default `'skilled'` is lower-case where the column's check constraint spells it `'Skilled'`. `aiEngine`'s talent pool stays `any[]`. It is `JSON.parse` output from a block embedded in a prompt, carrying computed fields like `match_score` that no entity declares — typing it `WorkerProfile[]` would assert a shape nothing validates. One mistake worth keeping. I replaced an inline lookup with a hoisted `const URGENCY = {…}`, and per-file esbuild said the output was unchanged: with `--minify-syntax` it inlines a single-use const straight back. The production bundle disagreed — `43e7f268` against `74d17e2d`. Reverted to a type assertion, which erases. Hoisting reads as a tidy-up and is a real change to the emitted code; the two checks disagreeing is exactly why both are run. typecheck 0 under the committed config; 3317 under the probe, from 3496 lint exit 0 npm test 1684/1691, the same 7 failures build exit 0, bundle back to 74d17e2d… emitted JS 7/7 identical Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8 --- src/api/aiEngine.ts | 59 ++++++++-------- src/api/attendanceSeed.ts | 39 +++++++---- src/api/base44Client.ts | 5 +- src/api/httpClient.ts | 12 ++-- src/api/seed.ts | 138 +++++++++++++++++++------------------- src/lib/hiringRecords.ts | 93 +++++++++++++++++++------ src/lib/workforce.ts | 31 ++++++--- 7 files changed, 228 insertions(+), 149 deletions(-) diff --git a/src/api/aiEngine.ts b/src/api/aiEngine.ts index fdd0f29..66c6fcc 100644 --- a/src/api/aiEngine.ts +++ b/src/api/aiEngine.ts @@ -13,7 +13,7 @@ * and results that actually respond to the data instead of being canned. */ -const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0))); +const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0))); /** Simulated model latency so streaming/pending UI behaves as designed. */ const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -21,39 +21,39 @@ const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms)); /* ── Prompt field readers ──────────────────────────────────────────────── */ /** Reads a `Label: value` line out of a prompt. */ -function field(prompt, label) { +function field(prompt: string, label: string): string { const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi')); return match ? match[1].trim() : ''; } -function numField(prompt, label) { +function numField(prompt: string, label: string): number { const raw = field(prompt, label); const match = raw.match(/-?\d+(\.\d+)?/); return match ? Number(match[0]) : 0; } /** Reads a comma-separated line, treating the app's "None" sentinel as empty. */ -function listField(prompt, label) { +function listField(prompt: string, label: string): string[] { const raw = field(prompt, label); if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return []; return raw.split(',').map((s) => s.trim()).filter(Boolean); } /** Pulls the first triple-quoted block (transcripts, free-text resumes). */ -function quotedBlock(prompt) { +function quotedBlock(prompt: string): string { const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/); return match ? match[1].trim() : ''; } -const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 }; +const ENGLISH_RANK: Record = { basic: 1, conversational: 2, fluent: 3, native: 4 }; -const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt); +const isSpanish = (prompt: string) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt); /* ── 1. Candidate screening ────────────────────────────────────────────── */ -function screenCandidate(prompt) { +function screenCandidate(prompt: string) { // Weights are stated inline: "experience 25%, english 20%, …" - const weightOf = (name) => { + const weightOf = (name: string) => { const match = prompt.match(new RegExp(`${name} (\\d+)%`)); return match ? Number(match[1]) : 20; }; @@ -184,7 +184,7 @@ function screenCandidate(prompt) { /* ── 2. Job description generator ──────────────────────────────────────── */ -const RESPONSIBILITY_LIBRARY = { +const RESPONSIBILITY_LIBRARY: Record = { Bartender: [ 'Set up and break down a full service bar', 'Pour to spec at event pace without sacrificing presentation', @@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [ 'Complete close-out duties before leaving', ]; -function generateJobDescription(prompt) { +function generateJobDescription(prompt: string) { const title = field(prompt, 'Role Title'); const category = field(prompt, 'Role Category'); const minYears = numField(prompt, 'Min Experience'); @@ -301,7 +301,7 @@ const CERT_KEYWORDS = [ ['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'], ]; -function buildResume(prompt) { +function buildResume(prompt: string) { const text = quotedBlock(prompt); const lower = text.toLowerCase(); @@ -343,14 +343,14 @@ function buildResume(prompt) { const INTERVIEW_QUESTIONS = { en: [ - (role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`, + (role: string) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`, () => `Walk me through your busiest shift. What actually happened, and what did you do about it?`, () => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`, () => `Tell me about feedback that changed how you work. What did you do differently afterward?`, () => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`, ], es: [ - (role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`, + (role: string) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`, () => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`, () => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`, () => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`, @@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = { ], }; -function interviewQuestion(prompt) { +function interviewQuestion(prompt: string) { const lang = isSpanish(prompt) ? 'es' : 'en'; const numberMatch = prompt.match(/Ask question (\d+) of 5/); const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1)); @@ -369,7 +369,7 @@ function interviewQuestion(prompt) { const OWLIVER_QUESTIONS = { en: [ - (name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`, + (name: string) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`, () => `I like that. Tell me about something you got through that you're genuinely proud of.`, () => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`, () => `What's a piece of feedback that stuck with you — and what changed after it?`, @@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = { () => `That's everything I needed. Anything you want to add before I put this together?`, ], es: [ - (name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`, + (name: string) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`, () => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`, () => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`, () => `¿Qué comentario se te quedó grabado — y qué cambió después?`, @@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = { ], }; -function owliverQuestion(prompt) { +function owliverQuestion(prompt: string) { const lang = isSpanish(prompt) ? 'es' : 'en'; const numberMatch = prompt.match(/you are on question (\d+)/); const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1)); @@ -398,7 +398,7 @@ function owliverQuestion(prompt) { /* ── 5. Interview evaluation ───────────────────────────────────────────── */ /** Scores a transcript on how substantive the candidate's own answers are. */ -function readTranscript(prompt) { +function readTranscript(prompt: string) { const transcript = prompt.split('TRANSCRIPT:')[1] || ''; const answers = transcript .split('\n') @@ -412,7 +412,7 @@ function readTranscript(prompt) { return { answers, words, avgWords, specifics }; } -function evaluateInterview(prompt) { +function evaluateInterview(prompt: string) { const { answers, avgWords, specifics } = readTranscript(prompt); const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/); const fast = fastMatch ? Number(fastMatch[1]) : 0; @@ -489,9 +489,14 @@ function evaluateInterview(prompt) { /* ── 6. Talent matching ────────────────────────────────────────────────── */ -function matchTalent(prompt) { +function matchTalent(prompt: string) { const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/); - let pool = []; + /* Whatever the caller embedded in the prompt's talent-pool block, as + `JSON.parse` hands it back. Not `WorkerProfile[]`: this is a simulated + engine reading a blob out of free text, and the rows carry computed + fields like `match_score` that no entity declares. Typing it as a + record would assert a shape nothing validates. */ + let pool: any[] = []; try { pool = block ? JSON.parse(block[1]) : []; } catch { @@ -513,9 +518,9 @@ function matchTalent(prompt) { ? Math.min(20, ((p.experience_years || 0) / minYears) * 20) : Math.min(20, (p.experience_years || 0) * 3); - const certs = (p.certifications || []).map((c) => c.toLowerCase()); + const certs = (p.certifications || []).map((c: string) => c.toLowerCase()); const certFit = requiredCerts.length - ? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15 + ? (requiredCerts.filter((r) => certs.some((c: string) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15 : (certs.length ? 12 : 5); const scoreFit = ((p.krow_score || 0) / 100) * 20; @@ -577,7 +582,7 @@ const AVAILABILITY_TOKENS: [RegExp, string][] = [ [/on.?call|flexible/i, 'On-Call'], ]; -function buildCareerDna(prompt) { +function buildCareerDna(prompt: string) { const transcript = quotedBlock(prompt); const lower = transcript.toLowerCase(); const { answers, avgWords, specifics } = (() => { @@ -701,7 +706,7 @@ function buildCareerDna(prompt) { /* ── 8. Proving Ground evaluator ───────────────────────────────────────── */ -function evaluateChallenge(prompt) { +function evaluateChallenge(prompt: string) { const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/); const criteria = criteriaMatch ? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean) @@ -748,7 +753,7 @@ function evaluateChallenge(prompt) { const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed'; // Spread the overall score across the rubric so criteria are not identical. - const rubric = {}; + const rubric: Record = {}; criteria.forEach((criterion, i) => { rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1)); }); diff --git a/src/api/attendanceSeed.ts b/src/api/attendanceSeed.ts index a2f2c76..e5812ce 100644 --- a/src/api/attendanceSeed.ts +++ b/src/api/attendanceSeed.ts @@ -39,15 +39,15 @@ const WINDOW_DAYS = 56; * Local rather than UTC because a shift belongs to the day it was worked in the * place it was worked, and `periodRange` windows on local day boundaries too. */ -function daysAgo(n, hour = 9, minute = 0, anchor = new Date()) { +function daysAgo(n: number, hour = 9, minute = 0, anchor = new Date()) { const d = new Date(anchor.getTime()); d.setDate(d.getDate() - n); d.setHours(hour, minute, 0, 0); return d; } -const round1 = (n) => Math.round(n * 10) / 10; -const round2 = (n) => Math.round(n * 100) / 100; +const round1 = (n: number) => Math.round(n * 10) / 10; +const round2 = (n: number) => Math.round(n * 100) / 100; const HOUR = 60 * 60 * 1000; /** @@ -105,10 +105,21 @@ const ROSTER = [ * Returning a plain record keeps every rule visible in one place instead of * spread across the generator. */ -const BEHAVIOUR = { +/** + * One worker's shift outcome, as the rules below return it. + * + * `i` is the index of the shift counting back from the anchor, `weekday` the + * day it falls on. Rules that ignore the weekday take one argument, which is + * why the second is optional here. + */ +type ShiftBehaviour = (i: number, weekday?: number) => { + status: string; minutesLate: number; overtime: number; notes: string; +}; + +const BEHAVIOUR: Record = { /* Reliable. One late arrival every couple of months, and overtime only on the nights events actually overrun. */ - staff_marco: (i, weekday) => ({ + staff_marco: (i: number, weekday?: number) => ({ status: i === 14 ? 'late' : 'present', minutesLate: i === 14 ? 9 : 0, /* Friday and Saturday events overrun; midweek ones do not. */ @@ -124,7 +135,7 @@ const BEHAVIOUR = { * the six weeks before it — a change big enough to be worth surfacing and * specific enough to act on. */ - staff_marcus: (i) => { + staff_marcus: (i: number) => { if (i === 2 || i === 7) { return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' }; } @@ -145,7 +156,7 @@ const BEHAVIOUR = { * covers, on the three busiest shifts of each week. A steady climb rather * than a spike, which is exactly the shape that hides in a table of totals. */ - staff_antoine: (i, weekday) => { + staff_antoine: (i: number, weekday?: number) => { const weekIndex = Math.floor(i / 5); const busy = weekday === 4 || weekday === 5 || weekday === 6; const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0; @@ -154,8 +165,8 @@ const BEHAVIOUR = { }; /** Every shift date for one worker, most recent first. */ -function shiftOffsets(weekdays, anchor) { - const offsets = []; +function shiftOffsets(weekdays: number[], anchor: Date) { + const offsets: number[] = []; for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) { const day = daysAgo(offset, 9, 0, anchor).getDay(); if (weekdays.includes(day)) offsets.push(offset); @@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) { return offsets; } -const pad = (n) => String(n).padStart(2, '0'); -const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const pad = (n: number) => String(n).padStart(2, '0'); +const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; /** * @param {Date} [anchor] the day to count back from. Defaults to now, which is * the point of this collection; a caller passes one only to hold the window * still — see buildShiftsAt. */ -function buildShifts(anchor = new Date()) { - const records = []; +function buildShifts(anchor: Date = new Date()) { + const records: any[] = []; for (const worker of ROSTER) { const offsets = shiftOffsets(worker.weekdays, anchor); @@ -251,6 +262,6 @@ export const SHIFT_RECORDS = buildShifts(); * shifts built around one day and bucketed around another describe two * different windows. */ -export function buildShiftsAt(anchor) { +export function buildShiftsAt(anchor: Date) { return buildShifts(anchor); } diff --git a/src/api/base44Client.ts b/src/api/base44Client.ts index f8369f2..59f1767 100644 --- a/src/api/base44Client.ts +++ b/src/api/base44Client.ts @@ -22,6 +22,7 @@ */ import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient'; +import type { PreferencesUpdateResult } from '@/types/user'; import { invokeLLM, uploadFile } from './aiEngine'; import { DEMO_USER } from './demoUser'; @@ -154,7 +155,7 @@ let hydration: Promise | null = request('GET', '/me') cacheUser(); return user; }) - .catch(() => { + .catch((): null => { authenticated = false; return null; }); @@ -255,7 +256,7 @@ const auth = { * question the shape exists to answer is now answered by whether this * function resolved at all. */ - async updatePreferences(patch: any) { + async updatePreferences(patch: any): Promise { const preferences = await request('PATCH', '/me/preferences', { body: patch }); currentUser = { ...currentUser, preferences }; cacheUser(); diff --git a/src/api/httpClient.ts b/src/api/httpClient.ts index ff58c3b..ab2488a 100644 --- a/src/api/httpClient.ts +++ b/src/api/httpClient.ts @@ -319,19 +319,19 @@ export function createEntity(name: string) { return request('GET', base, { query: { ...query, sort, limit } }); }, - async get(id) { + async get(id: string) { return request('GET', `${base}/${encodeURIComponent(id)}`); }, - async create(data) { + async create(data: any) { return request('POST', base, { body: data }); }, - async update(id, data) { + async update(id: string, data: any) { return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data }); }, - async delete(id) { + async delete(id: string) { return request('DELETE', `${base}/${encodeURIComponent(id)}`); }, @@ -343,8 +343,8 @@ export function createEntity(name: string) { * identical — the first rejection stops the run and the records before it * are already written. */ - async bulkCreate(records = []) { - const created = []; + async bulkCreate(records: any[] = []) { + const created: any[] = []; for (const record of records) created.push(await this.create(record)); return created; }, diff --git a/src/api/seed.ts b/src/api/seed.ts index acb78e6..736d3a7 100644 --- a/src/api/seed.ts +++ b/src/api/seed.ts @@ -11,7 +11,7 @@ import { SHIFT_RECORDS } from './attendanceSeed'; import { DEMO_USER } from './demoUser'; -const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString(); +const iso = (date: string) => new Date(`${date}T09:00:00.000Z`).toISOString(); /* ── Reference data ────────────────────────────────────────────────────── */ @@ -347,7 +347,7 @@ const JOB_POSTINGS = [ /* ── Applications ──────────────────────────────────────────────────────── */ -const breakdown = (over) => ({ +const breakdown = (over?: Record) => ({ experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0, personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0, physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0, @@ -356,13 +356,13 @@ const breakdown = (over) => ({ }); /** An applicant who has not been screened yet — every AI field stays empty. */ -const unscreened = (over) => ({ +const unscreened = (over?: Record) => ({ status: 'applied', ai_score: 0, - certifications: [], - availability: [], - skills: [], - companies_worked: [], + certifications: [] as string[], + availability: [] as string[], + skills: [] as string[], + companies_worked: [] as string[], client_rating: 0, created_date: iso('2026-08-04'), updated_date: iso('2026-08-04'), @@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [ certifications: ['ServSafe', 'CPR / First Aid'], availability: ['Weekdays', 'Weekends', 'Evenings'], skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.9, professional_summary: 'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.', @@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [ certifications: ['Guard Card', 'CPR / First Aid'], availability: ['Weekends', 'Evenings', 'Overnight'], skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.8, professional_summary: 'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.', @@ -514,7 +514,7 @@ const JOB_APPLICATIONS = [ certifications: ['Guard Card'], availability: ['Weekends', 'Evenings'], skills: ['Access control', 'Incident reporting'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 0, professional_summary: 'Venue security officer transitioning into private event work.', job_posting_id: 'job_security', @@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [ certifications: ['RBS Alcohol Server', 'TIPS Certified'], availability: ['Weekdays', 'Evenings'], skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.7, professional_summary: 'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.', @@ -605,7 +605,7 @@ const JOB_APPLICATIONS = [ certifications: ['Food Handler Card'], availability: ['Weekends', 'Evenings'], skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.6, professional_summary: 'Fine dining event server with four years of coursed banquet service and wine knowledge.', @@ -639,7 +639,7 @@ const JOB_APPLICATIONS = [ certifications: ['Food Handler Card'], availability: ['Weekends'], skills: ['Banquet service', 'Tray service', 'Table resets'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.4, professional_summary: 'Banquet server with three years of seated dinner service.', selfie_url: 'https://i.pravatar.cc/240?img=44', @@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [ certifications: [], availability: ['Weekends', 'Evenings'], skills: ['Table service'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 0, professional_summary: 'Restaurant server moving into event work.', job_posting_id: 'job_server_fine', @@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [ certifications: ['TIPS Certified'], availability: ['Weekends', 'Evenings'], skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.2, professional_summary: 'Event bartender with three years of high-volume weekend service.', job_posting_id: 'job_bartender', @@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [ certifications: ['TIPS Certified'], availability: ['Evenings'], skills: ['Bar setup', 'Wine service'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 4.0, professional_summary: 'Barback stepping up to bartending at private events.', job_posting_id: 'job_bartender', @@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [ certifications: [], availability: ['Weekends'], skills: [], - companies_worked: [], + companies_worked: [] as string[], client_rating: 0, professional_summary: 'Looking for a first hospitality role.', job_posting_id: 'job_bartender', @@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [ certifications: ['Forklift Operator'], availability: ['Weekdays', 'Early mornings'], skills: ['Order picking', 'Scanner operation', 'Pallet staging'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 3.9, professional_summary: 'Warehouse picker with forklift certification.', job_posting_id: 'job_picker', @@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [ certifications: [], availability: ['Weekdays'], skills: ['Order picking'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 0, professional_summary: 'Fulfillment associate seeking steady warehouse shifts.', job_posting_id: 'job_picker', @@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [ certifications: ['Food Handler Card'], availability: ['Weekdays', 'Weekends'], skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 0, professional_summary: 'Banquet server with lead-shift experience in hotel events.', job_posting_id: 'job_banquet', @@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [ certifications: ['Food Handler Card'], availability: ['Weekdays', 'Weekends', 'Evenings'], skills: ['Floor leadership', 'Client relations', 'Event reporting'], - companies_worked: [], + companies_worked: [] as string[], client_rating: 0, professional_summary: 'Hotel banquet captain with six years directing event floors.', job_posting_id: 'job_banquet', @@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [ /* ── AI interviews ─────────────────────────────────────────────────────── */ -const transcript = (candidate, role) => [ +const transcript = (candidate: string, role: string) => [ { role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') }, { role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 }, { role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') }, @@ -919,7 +919,7 @@ const AI_INTERVIEWS = [ verdict: 'maybe', hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.', integrity_score: 100, - ai_flags: [], + ai_flags: [] as string[], category_scores: { communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76, problem_solving: 69, personality: 77, communication_style: 76, @@ -947,11 +947,11 @@ const AI_INTERVIEWS = [ verdict: 'maybe', hire_recommendation: '', integrity_score: 100, - ai_flags: [], + ai_flags: [] as string[], category_scores: {}, - strengths: [], - concerns: [], - best_fit_roles: [], + strengths: [] as string[], + concerns: [] as string[], + best_fit_roles: [] as string[], summary: '', reasoning: '', created_date: iso('2026-08-05'), @@ -967,11 +967,11 @@ const AI_INTERVIEWS = [ verdict: 'maybe', hire_recommendation: '', integrity_score: 100, - ai_flags: [], + ai_flags: [] as string[], category_scores: {}, - strengths: [], - concerns: [], - best_fit_roles: [], + strengths: [] as string[], + concerns: [] as string[], + best_fit_roles: [] as string[], summary: '', reasoning: '', created_date: iso('2026-08-05'), @@ -987,11 +987,11 @@ const AI_INTERVIEWS = [ verdict: 'maybe', hire_recommendation: '', integrity_score: 100, - ai_flags: [], + ai_flags: [] as string[], category_scores: {}, - strengths: [], - concerns: [], - best_fit_roles: [], + strengths: [] as string[], + concerns: [] as string[], + best_fit_roles: [] as string[], summary: '', reasoning: '', created_date: iso('2026-08-03'), @@ -1015,7 +1015,7 @@ const STAFF = [ ai_score: 92, status: 'onboarding', client_rating: 0, - endorsed_skills: [], + endorsed_skills: [] as string[], created_date: iso('2026-07-25'), }, { @@ -1031,7 +1031,7 @@ const STAFF = [ ai_score: 94, status: 'onboarding', client_rating: 0, - endorsed_skills: [], + endorsed_skills: [] as string[], created_date: iso('2026-07-25'), }, { @@ -1047,7 +1047,7 @@ const STAFF = [ ai_score: 97, status: 'onboarding', client_rating: 0, - endorsed_skills: [], + endorsed_skills: [] as string[], created_date: iso('2026-07-25'), }, ]; @@ -1085,7 +1085,7 @@ const COURSES = [ { criterion: 'Protects the table experience', weight: 30 }, ], }, - unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] }, + unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] }, quiz: [ { question: 'A guest flags an allergy mid-service. What happens first?', @@ -1127,7 +1127,7 @@ const COURSES = [ { criterion: 'Escalates appropriately', weight: 30 }, ], }, - unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] }, + unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] }, quiz: [ { question: 'A guest shows clear signs of intoxication. What do you do?', @@ -1151,7 +1151,7 @@ const COURSES = [ proof_skill: 'Hazard Identification', skill_id: 'food_safety', target_level: 'beginner', - required_level: null, + required_level: null as string | null, completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'], verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'], challenge: { @@ -1164,7 +1164,7 @@ const COURSES = [ { criterion: 'Notes obstruction hazards', weight: 20 }, ], }, - unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] }, + unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] }, quiz: [ { question: 'What is the danger zone for cold-held food?', @@ -1201,7 +1201,7 @@ const COURSES = [ ], }, unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] }, - quiz: [], + quiz: [] as unknown[], pass_score: 80, status: 'active', created_date: iso('2026-06-16'), @@ -1293,7 +1293,7 @@ const MODULE_TABLE: [string, string, string, number, string, string, string[]][] ['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']], ]; -const SKILL_CATEGORY = { +const SKILL_CATEGORY: Record = { server: 'Service', bartending: 'Bar', leadership: 'Leadership', @@ -1304,20 +1304,20 @@ const SKILL_CATEGORY = { const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert']; /** Level → the rung below it, which is what a module requires before it opens. */ -const requiredLevelFor = (level) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null; +const requiredLevelFor = (level: string) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null; /** Difficulty is the existing field; level drives it so the two never disagree. */ -const DIFFICULTY_FOR = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' }; +const DIFFICULTY_FOR: Record = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' }; -const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 }; +const XP_FOR: Record = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 }; -const PROMPT_FOR = { +const PROMPT_FOR: Record string> = { roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`, video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`, photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`, }; -const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); +const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); const TRAINING_MODULES = MODULE_TABLE.map( ([skill_id, target_level, title, minutes, type, description, criteria], i) => ({ @@ -1341,8 +1341,8 @@ const TRAINING_MODULES = MODULE_TABLE.map( ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined, rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })), }, - unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] }, - quiz: [], + unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] }, + quiz: [] as unknown[], pass_score: 70, status: 'active', created_date: iso('2026-06-20'), @@ -1354,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map( const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES]; /** A module id from its skill and title, so profiles read like the ladder does. */ -const mod = (skill, title) => `mod_${skill}_${slug(title)}`; +const mod = (skill: string, title: string) => `mod_${skill}_${slug(title)}`; /** * Completed training on a worker profile. @@ -1362,7 +1362,7 @@ const mod = (skill, title) => `mod_${skill}_${slug(title)}`; * Titles are looked up rather than repeated, so a renamed module cannot leave a * profile claiming a course that no longer goes by that name. */ -const completions = (ids, date, score = 88) => +const completions = (ids: string[], date: string, score = 88) => ids.map((id) => ({ course_id: id, title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id, @@ -1535,7 +1535,7 @@ const WORKER_PROFILES = [ earned_badges: [ { name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') }, ], - capabilities: [], + capabilities: [] as unknown[], shifts_completed: 6, attendance_score: 92, performance_score: 0, @@ -1605,8 +1605,8 @@ const WORKER_PROFILES = [ '2026-08-02', 89 ), - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 38, attendance_score: 96, performance_score: 88, @@ -1653,8 +1653,8 @@ const WORKER_PROFILES = [ '2026-07-30', 85 ), - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 21, attendance_score: 93, performance_score: 81, @@ -1701,8 +1701,8 @@ const WORKER_PROFILES = [ '2026-08-05', 76 ), - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 4, attendance_score: 100, performance_score: 0, @@ -1745,8 +1745,8 @@ const WORKER_PROFILES = [ }, xp: 0, completed_courses: [], - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 0, attendance_score: 100, performance_score: 0, @@ -1789,8 +1789,8 @@ const WORKER_PROFILES = [ }, xp: 0, completed_courses: [], - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 0, attendance_score: 100, performance_score: 0, @@ -1833,8 +1833,8 @@ const WORKER_PROFILES = [ }, xp: 0, completed_courses: [], - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 0, attendance_score: 100, performance_score: 0, @@ -1877,8 +1877,8 @@ const WORKER_PROFILES = [ }, xp: 0, completed_courses: [], - earned_badges: [], - capabilities: [], + earned_badges: [] as unknown[], + capabilities: [] as unknown[], shifts_completed: 0, attendance_score: 100, performance_score: 0, @@ -2024,7 +2024,7 @@ const ASSIGNMENTS = [ worker_email: 'marco.rivera@email.com', worker_name: 'Marco Rivera', starts_at: iso('2026-07-01'), - ends_at: null, + ends_at: null as string | null, status: 'active', source: 'seed', match_score: 92, diff --git a/src/lib/hiringRecords.ts b/src/lib/hiringRecords.ts index 7f6a831..6a8135a 100644 --- a/src/lib/hiringRecords.ts +++ b/src/lib/hiringRecords.ts @@ -1,3 +1,6 @@ +import type { + AIInterview, JobApplication, JobPosting, Staff, WorkerProfile, +} from '@/types/entities'; /** * The hiring record, derived once. * @@ -13,7 +16,7 @@ */ /** The mean of a numeric list, rounded. Zero for an empty list. */ -export const avg = (xs) => { +export const avg = (xs: (number | null | undefined)[]) => { const values = xs.filter((n) => Number.isFinite(n)); return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0; }; @@ -26,7 +29,49 @@ export const avg = (xs) => { * so neither page repeats the join — and so "department" means the same thing * on both. */ -export function buildHires({ staff = [], applications = [], postings = [] }) { +/** + * A `staff` row as this module reads it. + * + * Four of the fields below are NOT columns — `information_schema` has no + * `timeToHire`, `score`, `company` or `department` on `staff`, and the + * generated `Staff` type therefore does not declare them. `buildHires` still + * probes all four as fallbacks (`s.timeToHire || null`), so at run time they + * are always `undefined` and the other branch always wins. + * + * They are written here as optional rather than removed, because removing the + * reads would be a behaviour change and this phase does not make those. Typed + * this way the dead fallbacks are at least visible to the next reader. + */ +type HireSourceRow = Staff & { + timeToHire?: number | null; + score?: number | null; + company?: string; + department?: string; +}; + +/** + * One hire: the `staff` row, with the posting and application resolved onto it. + * + * `hire_date` and `profile_tier` are widened from `Staff` because this builder + * falls back past them — `s.hire_date || s.created_date || null` can be null, + * and the tier default `'skilled'` is lower-case where the column's check + * constraint spells it `'Skilled'`. Both are existing behaviour, described + * rather than corrected. + */ +export interface Hire extends Omit { + company: string; + department: string; + role: string; + timeToHire: number | null; + score: number | null; + profile_tier: string; + hire_date: string | null; + applicationId: string | null; +} + +export function buildHires({ staff = [], applications = [], postings = [] }: { + staff?: HireSourceRow[]; applications?: JobApplication[]; postings?: JobPosting[]; +}): Hire[] { return staff.map((s) => { const app = applications.find((a) => a.id === s.application_id); const posting = postings.find((p) => p.id === s.job_posting_id); @@ -63,7 +108,7 @@ export function buildHires({ staff = [], applications = [], postings = [] }) { */ /** Headline figures: volume, speed, quality, and how many are still on. */ -export function summarise(hires) { +export function summarise(hires: Hire[]) { return { total: hires.length, speed: avg(hires.map((h) => h.timeToHire)), @@ -108,7 +153,7 @@ export const DECIDED_STATUSES = [...HIRED_STATUSES, 'rejected']; * than a key, so it is second, and it is here rather than in two pages so both * agree about who somebody is. */ -export const applicationsForProfile = (applications = [], profile) => { +export const applicationsForProfile = (applications: JobApplication[] = [], profile: WorkerProfile) => { if (!profile) return []; const email = String(profile.email || '').toLowerCase(); return applications.filter((a) => ( @@ -127,7 +172,7 @@ export const applicationsForProfile = (applications = [], profile) => { * both when an interview is *arranged* and when one is *completed* — only the * interview row distinguishes the two. */ -export const hasInterview = (application, interviews = []) => +export const hasInterview = (application: JobApplication, interviews: AIInterview[] = []) => interviews.some((i) => i.application_id === application?.id); /** @@ -143,13 +188,13 @@ export const hasInterview = (application, interviews = []) => * The interview verdict is deliberately NOT a gate here — a `no` verdict still * belongs in front of a person, because the decision is theirs to make. */ -export const isFinalSelection = (application, interviews = []) => +export const isFinalSelection = (application: JobApplication, interviews: AIInterview[] = []) => Boolean(application) && !DECIDED_STATUSES.includes(application.status) && hasInterview(application, interviews); /** Everyone awaiting a decision, newest-scored first. */ -export const finalSelection = (applications = [], interviews = []) => +export const finalSelection = (applications: JobApplication[] = [], interviews: AIInterview[] = []) => applications .filter((a) => isFinalSelection(a, interviews)) .sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0)); @@ -163,15 +208,15 @@ export const finalSelection = (applications = [], interviews = []) => * rows there is no way to tell which is lying. Someone who has left * (`status: 'inactive'`) is not occupying a seat, so the role is open again. */ -export const filledFor = (postingId, staff = []) => +export const filledFor = (postingId: string, staff: Staff[] = []) => staff.filter((s) => s.job_posting_id === postingId && s.status !== 'inactive').length; /** Seats still open. Never negative — over-hiring is a fact, not a negative. */ -export const remainingFor = (posting, staff = []) => +export const remainingFor = (posting: JobPosting, staff: Staff[] = []) => Math.max(0, (posting?.headcount ?? 1) - filledFor(posting?.id, staff)); /** Whether this position has every seat taken. */ -export const isFullyStaffed = (posting, staff = []) => +export const isFullyStaffed = (posting: JobPosting, staff: Staff[] = []) => Boolean(posting) && remainingFor(posting, staff) === 0; /** @@ -186,11 +231,11 @@ export const isFullyStaffed = (posting, staff = []) => * including `applied` — which is how `rejected` and `assigned` used to vanish * from a position's funnel entirely. The suite checks this covers the schema. */ -export const rankOf = (status) => +export const rankOf = (status: string) => status === 'rejected' ? STAGE_ORDER.indexOf('ai_screened') : STAGE_ORDER.indexOf(status); /** Everyone who reached this stage or went past it. */ -export const atOrBeyond = (applications, stage) => { +export const atOrBeyond = (applications: JobApplication[], stage: string) => { const from = STAGE_ORDER.indexOf(stage); return applications.filter((a) => rankOf(a.status) >= from); }; @@ -203,8 +248,8 @@ export const atOrBeyond = (applications, stage) => { * having been screened — a funnel that counts only the current status shows * later stages as larger than earlier ones, which is not a funnel. */ -export function buildFunnel(applications) { - const reached = (stage) => atOrBeyond(applications, stage).length; +export function buildFunnel(applications: JobApplication[]) { + const reached = (stage: string) => atOrBeyond(applications, stage).length; const stages = [ { key: 'applied', label: 'Applied', count: applications.length }, @@ -241,7 +286,7 @@ export function buildFunnel(applications) { } /** Cumulative hires by month — a trend needs a baseline, not a single bar. */ -export function buildTrend(hires) { +export function buildTrend(hires: Hire[]) { const byMonth = new Map(); hires.filter((h) => h.hire_date).forEach((h) => { const d = new Date(h.hire_date); @@ -262,7 +307,7 @@ export function buildTrend(hires) { } /** Hires grouped by department, best-performing first. */ -export function byDepartment(hires) { +export function byDepartment(hires: Hire[]) { const map = new Map(); hires.forEach((h) => { @@ -293,7 +338,7 @@ export function byDepartment(hires) { } /** Hires grouped by the role they were hired into, most-filled first. */ -export function byPosition(hires) { +export function byPosition(hires: Hire[]) { const map = new Map(); hires.forEach((h) => { @@ -314,7 +359,7 @@ export function byPosition(hires) { avgDays: avg(e.days), rated: e.rated, avgRating: e.ratings.length - ? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1)) + ? Number((e.ratings.reduce((a: number, b: number) => a + b, 0) / e.ratings.length).toFixed(1)) : null, })) .sort((a, b) => b.count - a.count); @@ -327,7 +372,7 @@ export function byPosition(hires) { * against the workspace's own median rather than an industry figure nobody * here can check. */ -export function buildEfficiency(hires) { +export function buildEfficiency(hires: Hire[]) { const timed = hires.filter((h) => h.timeToHire); if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 }; @@ -352,8 +397,14 @@ export function buildEfficiency(hires) { * a risk is only raised when something is actually at risk. A page that always * shows three insights is showing decoration. */ -export function buildInsights({ hires, departments, positions, funnel, efficiency }) { - const out = []; +export function buildInsights({ hires, departments, positions, funnel, efficiency }: { + hires: Hire[]; + departments: ReturnType; + positions: ReturnType; + funnel: ReturnType; + efficiency: ReturnType; +}) { + const out: { tone: string; title: string; body: string }[] = []; const summary = summarise(hires); if (departments.length > 1) { diff --git a/src/lib/workforce.ts b/src/lib/workforce.ts index 6fc2eb4..f3338a1 100644 --- a/src/lib/workforce.ts +++ b/src/lib/workforce.ts @@ -73,6 +73,9 @@ interface Availability { */ import { matchPosition, recommendTraining, verifiedLevels } from './skillGraph'; +import type { + Assignment, Course, JobApplication, JobPosting, Staff, WorkerProfile, +} from '@/types/entities'; const DAY_MS = 86400000; const MONTH_MS = DAY_MS * 30; @@ -80,23 +83,23 @@ const MONTH_MS = DAY_MS * 30; /* ── Dates ─────────────────────────────────────────────────────────────── */ /** A position's start, as a date. No start date means "as soon as possible". */ -export function startsAt(position, today = new Date()) { +export function startsAt(position: JobPosting, today = new Date()) { return position?.start_date ? new Date(position.start_date) : new Date(today); } /** When the work ends, or null when it is open-ended. */ -export function endsAt(position, today = new Date()) { +export function endsAt(position: JobPosting, today = new Date()) { const months = position?.duration_months; if (months == null) return null; return new Date(startsAt(position, today).getTime() + months * MONTH_MS); } /** Days from now until the work starts. Negative or zero means it is live. */ -export const daysUntilStart = (position, today = new Date()) => +export const daysUntilStart = (position: JobPosting, today = new Date()) => Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS); /** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */ -export function startLabel(position, today = new Date()) { +export function startLabel(position: JobPosting, today = new Date()) { const days = daysUntilStart(position, today); if (days <= 0) return 'immediately'; if (days === 1) return 'tomorrow'; @@ -245,7 +248,7 @@ export function durationFitOf(profile: any, position: any, { today = new Date() */ const ENGLISH_ORDER = ['basic', 'conversational', 'fluent', 'native']; -function statedRequirementMatch(position, application) { +function statedRequirementMatch(position: JobPosting, application: JobApplication) { const lines = []; const required = Number(position?.min_experience_years) || 0; @@ -263,7 +266,7 @@ function statedRequirementMatch(position, application) { const englishNeeded = ENGLISH_ORDER.indexOf(String(position?.english_required || '').toLowerCase()); if (englishNeeded > 0) { const heldIndex = ENGLISH_ORDER.indexOf(String(application?.english_level || '').toLowerCase()); - const label = (i) => (i < 0 ? 'Not stated' : ENGLISH_ORDER[i][0].toUpperCase() + ENGLISH_ORDER[i].slice(1)); + const label = (i: number) => (i < 0 ? 'Not stated' : ENGLISH_ORDER[i][0].toUpperCase() + ENGLISH_ORDER[i].slice(1)); lines.push({ skillId: 'english', name: 'English', @@ -339,8 +342,11 @@ function statedRequirementMatch(position, application) { * A candidate the position gives nothing to measure against is still returned, * marked unscored, rather than being dropped or given an invented number. */ -export function poolFor(position, { +export function poolFor(position: JobPosting, { profiles = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(), +}: { + profiles?: WorkerProfile[]; applications?: JobApplication[]; assignments?: Assignment[]; + courses?: Course[]; staff?: Staff[]; today?: Date; } = {}) { const profileByEmail = new Map( profiles.map((p) => [String(p.email || '').toLowerCase(), p]) @@ -436,7 +442,7 @@ export function poolFor(position, { * done this longer. */ .sort((a, b) => { - const rank = (row) => (row.basis === 'verified' ? 0 : row.basis === 'stated' ? 1 : 2); + const rank = (row: { basis?: string }) => (row.basis === 'verified' ? 0 : row.basis === 'stated' ? 1 : 2); return rank(a) - rank(b) || (b.score ?? -1) - (a.score ?? -1) || b.duration.ratio - a.duration.ratio @@ -527,7 +533,7 @@ export function workforceStatusFor(position: any, context: WorkforceContext = {} * 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: any[] = [], context: WorkforceContext = {}) { +export function prioritise(positions: JobPosting[] = [], context: WorkforceContext = {}) { const today = context.today || new Date(); return positions @@ -541,7 +547,12 @@ export function prioritise(positions: any[] = [], context: WorkforceContext = {} const reasons = []; let score = 0; - const urgency = { urgent: 30, high: 18, normal: 6 }[position.priority] ?? 6; + /* `priority` is open text in the schema — no CHECK constraint — so the + lookup really is keyed by an arbitrary string, with `?? 6` covering + anything unrecognised. Asserted rather than hoisted to a named const: + the assertion erases, and a hoist is a real change to the emitted + code even though it reads as a tidy-up. */ + const urgency = ({ urgent: 30, high: 18, normal: 6 } as Record)[position.priority] ?? 6; score += urgency; if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer');