refactor(ts-migration): type src/api and the first of src/lib for noImplicitAny

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<typeof byDepartment>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 17:18:18 +05:30
parent 21133a6064
commit e8207038dd
7 changed files with 228 additions and 149 deletions

View File

@@ -13,7 +13,7 @@
* and results that actually respond to the data instead of being canned. * 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. */ /** Simulated model latency so streaming/pending UI behaves as designed. */
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms)); 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 ──────────────────────────────────────────────── */ /* ── Prompt field readers ──────────────────────────────────────────────── */
/** Reads a `Label: value` line out of a prompt. */ /** 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')); const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
return match ? match[1].trim() : ''; return match ? match[1].trim() : '';
} }
function numField(prompt, label) { function numField(prompt: string, label: string): number {
const raw = field(prompt, label); const raw = field(prompt, label);
const match = raw.match(/-?\d+(\.\d+)?/); const match = raw.match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : 0; return match ? Number(match[0]) : 0;
} }
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */ /** 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); const raw = field(prompt, label);
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return []; if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean); return raw.split(',').map((s) => s.trim()).filter(Boolean);
} }
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */ /** 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*"""/); const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
return match ? match[1].trim() : ''; return match ? match[1].trim() : '';
} }
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 }; const ENGLISH_RANK: Record<string, number> = { 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 ────────────────────────────────────────────── */ /* ── 1. Candidate screening ────────────────────────────────────────────── */
function screenCandidate(prompt) { function screenCandidate(prompt: string) {
// Weights are stated inline: "experience 25%, english 20%, …" // Weights are stated inline: "experience 25%, english 20%, …"
const weightOf = (name) => { const weightOf = (name: string) => {
const match = prompt.match(new RegExp(`${name} (\\d+)%`)); const match = prompt.match(new RegExp(`${name} (\\d+)%`));
return match ? Number(match[1]) : 20; return match ? Number(match[1]) : 20;
}; };
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
/* ── 2. Job description generator ──────────────────────────────────────── */ /* ── 2. Job description generator ──────────────────────────────────────── */
const RESPONSIBILITY_LIBRARY = { const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
Bartender: [ Bartender: [
'Set up and break down a full service bar', 'Set up and break down a full service bar',
'Pour to spec at event pace without sacrificing presentation', 'Pour to spec at event pace without sacrificing presentation',
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
'Complete close-out duties before leaving', 'Complete close-out duties before leaving',
]; ];
function generateJobDescription(prompt) { function generateJobDescription(prompt: string) {
const title = field(prompt, 'Role Title'); const title = field(prompt, 'Role Title');
const category = field(prompt, 'Role Category'); const category = field(prompt, 'Role Category');
const minYears = numField(prompt, 'Min Experience'); const minYears = numField(prompt, 'Min Experience');
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'], ['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
]; ];
function buildResume(prompt) { function buildResume(prompt: string) {
const text = quotedBlock(prompt); const text = quotedBlock(prompt);
const lower = text.toLowerCase(); const lower = text.toLowerCase();
@@ -343,14 +343,14 @@ function buildResume(prompt) {
const INTERVIEW_QUESTIONS = { const INTERVIEW_QUESTIONS = {
en: [ 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?`, () => `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.`, () => `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?`, () => `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?`, () => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
], ],
es: [ 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?`, () => `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.`, () => `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?`, () => `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 lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/Ask question (\d+) of 5/); const numberMatch = prompt.match(/Ask question (\d+) of 5/);
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1)); const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
const OWLIVER_QUESTIONS = { const OWLIVER_QUESTIONS = {
en: [ 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.`, () => `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?`, () => `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?`, () => `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?`, () => `That's everything I needed. Anything you want to add before I put this together?`,
], ],
es: [ 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.`, () => `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?`, () => `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?`, () => `¿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 lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/you are on question (\d+)/); const numberMatch = prompt.match(/you are on question (\d+)/);
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1)); const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
/* ── 5. Interview evaluation ───────────────────────────────────────────── */ /* ── 5. Interview evaluation ───────────────────────────────────────────── */
/** Scores a transcript on how substantive the candidate's own answers are. */ /** 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 transcript = prompt.split('TRANSCRIPT:')[1] || '';
const answers = transcript const answers = transcript
.split('\n') .split('\n')
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
return { answers, words, avgWords, specifics }; return { answers, words, avgWords, specifics };
} }
function evaluateInterview(prompt) { function evaluateInterview(prompt: string) {
const { answers, avgWords, specifics } = readTranscript(prompt); const { answers, avgWords, specifics } = readTranscript(prompt);
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/); const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
const fast = fastMatch ? Number(fastMatch[1]) : 0; const fast = fastMatch ? Number(fastMatch[1]) : 0;
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
/* ── 6. Talent matching ────────────────────────────────────────────────── */ /* ── 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/); 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 { try {
pool = block ? JSON.parse(block[1]) : []; pool = block ? JSON.parse(block[1]) : [];
} catch { } catch {
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
? Math.min(20, ((p.experience_years || 0) / minYears) * 20) ? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
: Math.min(20, (p.experience_years || 0) * 3); : 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 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); : (certs.length ? 12 : 5);
const scoreFit = ((p.krow_score || 0) / 100) * 20; const scoreFit = ((p.krow_score || 0) / 100) * 20;
@@ -577,7 +582,7 @@ const AVAILABILITY_TOKENS: [RegExp, string][] = [
[/on.?call|flexible/i, 'On-Call'], [/on.?call|flexible/i, 'On-Call'],
]; ];
function buildCareerDna(prompt) { function buildCareerDna(prompt: string) {
const transcript = quotedBlock(prompt); const transcript = quotedBlock(prompt);
const lower = transcript.toLowerCase(); const lower = transcript.toLowerCase();
const { answers, avgWords, specifics } = (() => { const { answers, avgWords, specifics } = (() => {
@@ -701,7 +706,7 @@ function buildCareerDna(prompt) {
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */ /* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
function evaluateChallenge(prompt) { function evaluateChallenge(prompt: string) {
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/); const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
const criteria = criteriaMatch const criteria = criteriaMatch
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean) ? 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'; const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
// Spread the overall score across the rubric so criteria are not identical. // Spread the overall score across the rubric so criteria are not identical.
const rubric = {}; const rubric: Record<string, number> = {};
criteria.forEach((criterion, i) => { criteria.forEach((criterion, i) => {
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1)); rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
}); });

View File

@@ -39,15 +39,15 @@ const WINDOW_DAYS = 56;
* Local rather than UTC because a shift belongs to the day it was worked in the * 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. * 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()); const d = new Date(anchor.getTime());
d.setDate(d.getDate() - n); d.setDate(d.getDate() - n);
d.setHours(hour, minute, 0, 0); d.setHours(hour, minute, 0, 0);
return d; return d;
} }
const round1 = (n) => Math.round(n * 10) / 10; const round1 = (n: number) => Math.round(n * 10) / 10;
const round2 = (n) => Math.round(n * 100) / 100; const round2 = (n: number) => Math.round(n * 100) / 100;
const HOUR = 60 * 60 * 1000; const HOUR = 60 * 60 * 1000;
/** /**
@@ -105,10 +105,21 @@ const ROSTER = [
* Returning a plain record keeps every rule visible in one place instead of * Returning a plain record keeps every rule visible in one place instead of
* spread across the generator. * 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<string, ShiftBehaviour> = {
/* Reliable. One late arrival every couple of months, and overtime only on /* Reliable. One late arrival every couple of months, and overtime only on
the nights events actually overrun. */ the nights events actually overrun. */
staff_marco: (i, weekday) => ({ staff_marco: (i: number, weekday?: number) => ({
status: i === 14 ? 'late' : 'present', status: i === 14 ? 'late' : 'present',
minutesLate: i === 14 ? 9 : 0, minutesLate: i === 14 ? 9 : 0,
/* Friday and Saturday events overrun; midweek ones do not. */ /* 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 * the six weeks before it — a change big enough to be worth surfacing and
* specific enough to act on. * specific enough to act on.
*/ */
staff_marcus: (i) => { staff_marcus: (i: number) => {
if (i === 2 || i === 7) { if (i === 2 || i === 7) {
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' }; 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 * 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. * 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 weekIndex = Math.floor(i / 5);
const busy = weekday === 4 || weekday === 5 || weekday === 6; const busy = weekday === 4 || weekday === 5 || weekday === 6;
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0; 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. */ /** Every shift date for one worker, most recent first. */
function shiftOffsets(weekdays, anchor) { function shiftOffsets(weekdays: number[], anchor: Date) {
const offsets = []; const offsets: number[] = [];
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) { for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
const day = daysAgo(offset, 9, 0, anchor).getDay(); const day = daysAgo(offset, 9, 0, anchor).getDay();
if (weekdays.includes(day)) offsets.push(offset); if (weekdays.includes(day)) offsets.push(offset);
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
return offsets; return offsets;
} }
const pad = (n) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 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 * @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 * the point of this collection; a caller passes one only to hold the window
* still — see buildShiftsAt. * still — see buildShiftsAt.
*/ */
function buildShifts(anchor = new Date()) { function buildShifts(anchor: Date = new Date()) {
const records = []; const records: any[] = [];
for (const worker of ROSTER) { for (const worker of ROSTER) {
const offsets = shiftOffsets(worker.weekdays, anchor); 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 * shifts built around one day and bucketed around another describe two
* different windows. * different windows.
*/ */
export function buildShiftsAt(anchor) { export function buildShiftsAt(anchor: Date) {
return buildShifts(anchor); return buildShifts(anchor);
} }

View File

@@ -22,6 +22,7 @@
*/ */
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient'; import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
import type { PreferencesUpdateResult } from '@/types/user';
import { invokeLLM, uploadFile } from './aiEngine'; import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER } from './demoUser'; import { DEMO_USER } from './demoUser';
@@ -154,7 +155,7 @@ let hydration: Promise<any> | null = request('GET', '/me')
cacheUser(); cacheUser();
return user; return user;
}) })
.catch(() => { .catch((): null => {
authenticated = false; authenticated = false;
return null; return null;
}); });
@@ -255,7 +256,7 @@ const auth = {
* question the shape exists to answer is now answered by whether this * question the shape exists to answer is now answered by whether this
* function resolved at all. * function resolved at all.
*/ */
async updatePreferences(patch: any) { async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
const preferences = await request('PATCH', '/me/preferences', { body: patch }); const preferences = await request('PATCH', '/me/preferences', { body: patch });
currentUser = { ...currentUser, preferences }; currentUser = { ...currentUser, preferences };
cacheUser(); cacheUser();

View File

@@ -319,19 +319,19 @@ export function createEntity(name: string) {
return request('GET', base, { query: { ...query, sort, limit } }); return request('GET', base, { query: { ...query, sort, limit } });
}, },
async get(id) { async get(id: string) {
return request('GET', `${base}/${encodeURIComponent(id)}`); return request('GET', `${base}/${encodeURIComponent(id)}`);
}, },
async create(data) { async create(data: any) {
return request('POST', base, { body: data }); return request('POST', base, { body: data });
}, },
async update(id, data) { async update(id: string, data: any) {
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data }); return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
}, },
async delete(id) { async delete(id: string) {
return request('DELETE', `${base}/${encodeURIComponent(id)}`); 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 * identical — the first rejection stops the run and the records before it
* are already written. * are already written.
*/ */
async bulkCreate(records = []) { async bulkCreate(records: any[] = []) {
const created = []; const created: any[] = [];
for (const record of records) created.push(await this.create(record)); for (const record of records) created.push(await this.create(record));
return created; return created;
}, },

View File

@@ -11,7 +11,7 @@
import { SHIFT_RECORDS } from './attendanceSeed'; import { SHIFT_RECORDS } from './attendanceSeed';
import { DEMO_USER } from './demoUser'; 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 ────────────────────────────────────────────────────── */ /* ── Reference data ────────────────────────────────────────────────────── */
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
/* ── Applications ──────────────────────────────────────────────────────── */ /* ── Applications ──────────────────────────────────────────────────────── */
const breakdown = (over) => ({ const breakdown = (over?: Record<string, number>) => ({
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0, experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0, personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 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. */ /** An applicant who has not been screened yet — every AI field stays empty. */
const unscreened = (over) => ({ const unscreened = (over?: Record<string, any>) => ({
status: 'applied', status: 'applied',
ai_score: 0, ai_score: 0,
certifications: [], certifications: [] as string[],
availability: [], availability: [] as string[],
skills: [], skills: [] as string[],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
created_date: iso('2026-08-04'), created_date: iso('2026-08-04'),
updated_date: iso('2026-08-04'), updated_date: iso('2026-08-04'),
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
certifications: ['ServSafe', 'CPR / First Aid'], certifications: ['ServSafe', 'CPR / First Aid'],
availability: ['Weekdays', 'Weekends', 'Evenings'], availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'], skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.9, client_rating: 4.9,
professional_summary: 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.', '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'], certifications: ['Guard Card', 'CPR / First Aid'],
availability: ['Weekends', 'Evenings', 'Overnight'], availability: ['Weekends', 'Evenings', 'Overnight'],
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'], skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.8, client_rating: 4.8,
professional_summary: professional_summary:
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.', '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'], certifications: ['Guard Card'],
availability: ['Weekends', 'Evenings'], availability: ['Weekends', 'Evenings'],
skills: ['Access control', 'Incident reporting'], skills: ['Access control', 'Incident reporting'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
professional_summary: 'Venue security officer transitioning into private event work.', professional_summary: 'Venue security officer transitioning into private event work.',
job_posting_id: 'job_security', job_posting_id: 'job_security',
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
certifications: ['RBS Alcohol Server', 'TIPS Certified'], certifications: ['RBS Alcohol Server', 'TIPS Certified'],
availability: ['Weekdays', 'Evenings'], availability: ['Weekdays', 'Evenings'],
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'], skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.7, client_rating: 4.7,
professional_summary: professional_summary:
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.', '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'], certifications: ['Food Handler Card'],
availability: ['Weekends', 'Evenings'], availability: ['Weekends', 'Evenings'],
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'], skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.6, client_rating: 4.6,
professional_summary: professional_summary:
'Fine dining event server with four years of coursed banquet service and wine knowledge.', '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'], certifications: ['Food Handler Card'],
availability: ['Weekends'], availability: ['Weekends'],
skills: ['Banquet service', 'Tray service', 'Table resets'], skills: ['Banquet service', 'Tray service', 'Table resets'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.4, client_rating: 4.4,
professional_summary: 'Banquet server with three years of seated dinner service.', professional_summary: 'Banquet server with three years of seated dinner service.',
selfie_url: 'https://i.pravatar.cc/240?img=44', selfie_url: 'https://i.pravatar.cc/240?img=44',
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
certifications: [], certifications: [],
availability: ['Weekends', 'Evenings'], availability: ['Weekends', 'Evenings'],
skills: ['Table service'], skills: ['Table service'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
professional_summary: 'Restaurant server moving into event work.', professional_summary: 'Restaurant server moving into event work.',
job_posting_id: 'job_server_fine', job_posting_id: 'job_server_fine',
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
certifications: ['TIPS Certified'], certifications: ['TIPS Certified'],
availability: ['Weekends', 'Evenings'], availability: ['Weekends', 'Evenings'],
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'], skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.2, client_rating: 4.2,
professional_summary: 'Event bartender with three years of high-volume weekend service.', professional_summary: 'Event bartender with three years of high-volume weekend service.',
job_posting_id: 'job_bartender', job_posting_id: 'job_bartender',
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
certifications: ['TIPS Certified'], certifications: ['TIPS Certified'],
availability: ['Evenings'], availability: ['Evenings'],
skills: ['Bar setup', 'Wine service'], skills: ['Bar setup', 'Wine service'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 4.0, client_rating: 4.0,
professional_summary: 'Barback stepping up to bartending at private events.', professional_summary: 'Barback stepping up to bartending at private events.',
job_posting_id: 'job_bartender', job_posting_id: 'job_bartender',
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
certifications: [], certifications: [],
availability: ['Weekends'], availability: ['Weekends'],
skills: [], skills: [],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
professional_summary: 'Looking for a first hospitality role.', professional_summary: 'Looking for a first hospitality role.',
job_posting_id: 'job_bartender', job_posting_id: 'job_bartender',
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
certifications: ['Forklift Operator'], certifications: ['Forklift Operator'],
availability: ['Weekdays', 'Early mornings'], availability: ['Weekdays', 'Early mornings'],
skills: ['Order picking', 'Scanner operation', 'Pallet staging'], skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 3.9, client_rating: 3.9,
professional_summary: 'Warehouse picker with forklift certification.', professional_summary: 'Warehouse picker with forklift certification.',
job_posting_id: 'job_picker', job_posting_id: 'job_picker',
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
certifications: [], certifications: [],
availability: ['Weekdays'], availability: ['Weekdays'],
skills: ['Order picking'], skills: ['Order picking'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.', professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
job_posting_id: 'job_picker', job_posting_id: 'job_picker',
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'], certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends'], availability: ['Weekdays', 'Weekends'],
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'], skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
professional_summary: 'Banquet server with lead-shift experience in hotel events.', professional_summary: 'Banquet server with lead-shift experience in hotel events.',
job_posting_id: 'job_banquet', job_posting_id: 'job_banquet',
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'], certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends', 'Evenings'], availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Floor leadership', 'Client relations', 'Event reporting'], skills: ['Floor leadership', 'Client relations', 'Event reporting'],
companies_worked: [], companies_worked: [] as string[],
client_rating: 0, client_rating: 0,
professional_summary: 'Hotel banquet captain with six years directing event floors.', professional_summary: 'Hotel banquet captain with six years directing event floors.',
job_posting_id: 'job_banquet', job_posting_id: 'job_banquet',
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
/* ── AI interviews ─────────────────────────────────────────────────────── */ /* ── 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: '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: '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') }, { 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', verdict: 'maybe',
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.', hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
integrity_score: 100, integrity_score: 100,
ai_flags: [], ai_flags: [] as string[],
category_scores: { category_scores: {
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76, communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
problem_solving: 69, personality: 77, communication_style: 76, problem_solving: 69, personality: 77, communication_style: 76,
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe', verdict: 'maybe',
hire_recommendation: '', hire_recommendation: '',
integrity_score: 100, integrity_score: 100,
ai_flags: [], ai_flags: [] as string[],
category_scores: {}, category_scores: {},
strengths: [], strengths: [] as string[],
concerns: [], concerns: [] as string[],
best_fit_roles: [], best_fit_roles: [] as string[],
summary: '', summary: '',
reasoning: '', reasoning: '',
created_date: iso('2026-08-05'), created_date: iso('2026-08-05'),
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe', verdict: 'maybe',
hire_recommendation: '', hire_recommendation: '',
integrity_score: 100, integrity_score: 100,
ai_flags: [], ai_flags: [] as string[],
category_scores: {}, category_scores: {},
strengths: [], strengths: [] as string[],
concerns: [], concerns: [] as string[],
best_fit_roles: [], best_fit_roles: [] as string[],
summary: '', summary: '',
reasoning: '', reasoning: '',
created_date: iso('2026-08-05'), created_date: iso('2026-08-05'),
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe', verdict: 'maybe',
hire_recommendation: '', hire_recommendation: '',
integrity_score: 100, integrity_score: 100,
ai_flags: [], ai_flags: [] as string[],
category_scores: {}, category_scores: {},
strengths: [], strengths: [] as string[],
concerns: [], concerns: [] as string[],
best_fit_roles: [], best_fit_roles: [] as string[],
summary: '', summary: '',
reasoning: '', reasoning: '',
created_date: iso('2026-08-03'), created_date: iso('2026-08-03'),
@@ -1015,7 +1015,7 @@ const STAFF = [
ai_score: 92, ai_score: 92,
status: 'onboarding', status: 'onboarding',
client_rating: 0, client_rating: 0,
endorsed_skills: [], endorsed_skills: [] as string[],
created_date: iso('2026-07-25'), created_date: iso('2026-07-25'),
}, },
{ {
@@ -1031,7 +1031,7 @@ const STAFF = [
ai_score: 94, ai_score: 94,
status: 'onboarding', status: 'onboarding',
client_rating: 0, client_rating: 0,
endorsed_skills: [], endorsed_skills: [] as string[],
created_date: iso('2026-07-25'), created_date: iso('2026-07-25'),
}, },
{ {
@@ -1047,7 +1047,7 @@ const STAFF = [
ai_score: 97, ai_score: 97,
status: 'onboarding', status: 'onboarding',
client_rating: 0, client_rating: 0,
endorsed_skills: [], endorsed_skills: [] as string[],
created_date: iso('2026-07-25'), created_date: iso('2026-07-25'),
}, },
]; ];
@@ -1085,7 +1085,7 @@ const COURSES = [
{ criterion: 'Protects the table experience', weight: 30 }, { 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: [ quiz: [
{ {
question: 'A guest flags an allergy mid-service. What happens first?', question: 'A guest flags an allergy mid-service. What happens first?',
@@ -1127,7 +1127,7 @@ const COURSES = [
{ criterion: 'Escalates appropriately', weight: 30 }, { 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: [ quiz: [
{ {
question: 'A guest shows clear signs of intoxication. What do you do?', question: 'A guest shows clear signs of intoxication. What do you do?',
@@ -1151,7 +1151,7 @@ const COURSES = [
proof_skill: 'Hazard Identification', proof_skill: 'Hazard Identification',
skill_id: 'food_safety', skill_id: 'food_safety',
target_level: 'beginner', target_level: 'beginner',
required_level: null, required_level: null as string | null,
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'], completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'], verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
challenge: { challenge: {
@@ -1164,7 +1164,7 @@ const COURSES = [
{ criterion: 'Notes obstruction hazards', weight: 20 }, { 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: [ quiz: [
{ {
question: 'What is the danger zone for cold-held food?', 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'] }, unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
quiz: [], quiz: [] as unknown[],
pass_score: 80, pass_score: 80,
status: 'active', status: 'active',
created_date: iso('2026-06-16'), 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']], ['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<string, string> = {
server: 'Service', server: 'Service',
bartending: 'Bar', bartending: 'Bar',
leadership: 'Leadership', leadership: 'Leadership',
@@ -1304,20 +1304,20 @@ const SKILL_CATEGORY = {
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert']; const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
/** Level → the rung below it, which is what a module requires before it opens. */ /** 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. */ /** 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<string, string> = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 }; const XP_FOR: Record<string, number> = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
const PROMPT_FOR = { const PROMPT_FOR: Record<string, (title: string) => string> = {
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`, 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.`, 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.`, 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( const TRAINING_MODULES = MODULE_TABLE.map(
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({ ([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, 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 })), rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
}, },
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] }, unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
quiz: [], quiz: [] as unknown[],
pass_score: 70, pass_score: 70,
status: 'active', status: 'active',
created_date: iso('2026-06-20'), created_date: iso('2026-06-20'),
@@ -1354,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES]; const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
/** A module id from its skill and title, so profiles read like the ladder does. */ /** 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. * 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 * 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. * 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) => ({ ids.map((id) => ({
course_id: id, course_id: id,
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id, title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
@@ -1535,7 +1535,7 @@ const WORKER_PROFILES = [
earned_badges: [ earned_badges: [
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') }, { name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
], ],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 6, shifts_completed: 6,
attendance_score: 92, attendance_score: 92,
performance_score: 0, performance_score: 0,
@@ -1605,8 +1605,8 @@ const WORKER_PROFILES = [
'2026-08-02', '2026-08-02',
89 89
), ),
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 38, shifts_completed: 38,
attendance_score: 96, attendance_score: 96,
performance_score: 88, performance_score: 88,
@@ -1653,8 +1653,8 @@ const WORKER_PROFILES = [
'2026-07-30', '2026-07-30',
85 85
), ),
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 21, shifts_completed: 21,
attendance_score: 93, attendance_score: 93,
performance_score: 81, performance_score: 81,
@@ -1701,8 +1701,8 @@ const WORKER_PROFILES = [
'2026-08-05', '2026-08-05',
76 76
), ),
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 4, shifts_completed: 4,
attendance_score: 100, attendance_score: 100,
performance_score: 0, performance_score: 0,
@@ -1745,8 +1745,8 @@ const WORKER_PROFILES = [
}, },
xp: 0, xp: 0,
completed_courses: [], completed_courses: [],
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 0, shifts_completed: 0,
attendance_score: 100, attendance_score: 100,
performance_score: 0, performance_score: 0,
@@ -1789,8 +1789,8 @@ const WORKER_PROFILES = [
}, },
xp: 0, xp: 0,
completed_courses: [], completed_courses: [],
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 0, shifts_completed: 0,
attendance_score: 100, attendance_score: 100,
performance_score: 0, performance_score: 0,
@@ -1833,8 +1833,8 @@ const WORKER_PROFILES = [
}, },
xp: 0, xp: 0,
completed_courses: [], completed_courses: [],
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 0, shifts_completed: 0,
attendance_score: 100, attendance_score: 100,
performance_score: 0, performance_score: 0,
@@ -1877,8 +1877,8 @@ const WORKER_PROFILES = [
}, },
xp: 0, xp: 0,
completed_courses: [], completed_courses: [],
earned_badges: [], earned_badges: [] as unknown[],
capabilities: [], capabilities: [] as unknown[],
shifts_completed: 0, shifts_completed: 0,
attendance_score: 100, attendance_score: 100,
performance_score: 0, performance_score: 0,
@@ -2024,7 +2024,7 @@ const ASSIGNMENTS = [
worker_email: 'marco.rivera@email.com', worker_email: 'marco.rivera@email.com',
worker_name: 'Marco Rivera', worker_name: 'Marco Rivera',
starts_at: iso('2026-07-01'), starts_at: iso('2026-07-01'),
ends_at: null, ends_at: null as string | null,
status: 'active', status: 'active',
source: 'seed', source: 'seed',
match_score: 92, match_score: 92,

View File

@@ -1,3 +1,6 @@
import type {
AIInterview, JobApplication, JobPosting, Staff, WorkerProfile,
} from '@/types/entities';
/** /**
* The hiring record, derived once. * The hiring record, derived once.
* *
@@ -13,7 +16,7 @@
*/ */
/** The mean of a numeric list, rounded. Zero for an empty list. */ /** 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)); const values = xs.filter((n) => Number.isFinite(n));
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0; 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 * so neither page repeats the join — and so "department" means the same thing
* on both. * 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<Staff, 'hire_date' | 'profile_tier'> {
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) => { return staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id); const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_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. */ /** Headline figures: volume, speed, quality, and how many are still on. */
export function summarise(hires) { export function summarise(hires: Hire[]) {
return { return {
total: hires.length, total: hires.length,
speed: avg(hires.map((h) => h.timeToHire)), 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 * than a key, so it is second, and it is here rather than in two pages so both
* agree about who somebody is. * agree about who somebody is.
*/ */
export const applicationsForProfile = (applications = [], profile) => { export const applicationsForProfile = (applications: JobApplication[] = [], profile: WorkerProfile) => {
if (!profile) return []; if (!profile) return [];
const email = String(profile.email || '').toLowerCase(); const email = String(profile.email || '').toLowerCase();
return applications.filter((a) => ( 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 * both when an interview is *arranged* and when one is *completed* — only the
* interview row distinguishes the two. * 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); 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 * 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. * 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) Boolean(application)
&& !DECIDED_STATUSES.includes(application.status) && !DECIDED_STATUSES.includes(application.status)
&& hasInterview(application, interviews); && hasInterview(application, interviews);
/** Everyone awaiting a decision, newest-scored first. */ /** Everyone awaiting a decision, newest-scored first. */
export const finalSelection = (applications = [], interviews = []) => export const finalSelection = (applications: JobApplication[] = [], interviews: AIInterview[] = []) =>
applications applications
.filter((a) => isFinalSelection(a, interviews)) .filter((a) => isFinalSelection(a, interviews))
.sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0)); .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 * 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. * (`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; 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. */ /** 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)); Math.max(0, (posting?.headcount ?? 1) - filledFor(posting?.id, staff));
/** Whether this position has every seat taken. */ /** 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; 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 * including `applied` — which is how `rejected` and `assigned` used to vanish
* from a position's funnel entirely. The suite checks this covers the schema. * 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); status === 'rejected' ? STAGE_ORDER.indexOf('ai_screened') : STAGE_ORDER.indexOf(status);
/** Everyone who reached this stage or went past it. */ /** 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); const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => rankOf(a.status) >= from); 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 * having been screened — a funnel that counts only the current status shows
* later stages as larger than earlier ones, which is not a funnel. * later stages as larger than earlier ones, which is not a funnel.
*/ */
export function buildFunnel(applications) { export function buildFunnel(applications: JobApplication[]) {
const reached = (stage) => atOrBeyond(applications, stage).length; const reached = (stage: string) => atOrBeyond(applications, stage).length;
const stages = [ const stages = [
{ key: 'applied', label: 'Applied', count: applications.length }, { 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. */ /** 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(); const byMonth = new Map();
hires.filter((h) => h.hire_date).forEach((h) => { hires.filter((h) => h.hire_date).forEach((h) => {
const d = new Date(h.hire_date); const d = new Date(h.hire_date);
@@ -262,7 +307,7 @@ export function buildTrend(hires) {
} }
/** Hires grouped by department, best-performing first. */ /** Hires grouped by department, best-performing first. */
export function byDepartment(hires) { export function byDepartment(hires: Hire[]) {
const map = new Map(); const map = new Map();
hires.forEach((h) => { hires.forEach((h) => {
@@ -293,7 +338,7 @@ export function byDepartment(hires) {
} }
/** Hires grouped by the role they were hired into, most-filled first. */ /** 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(); const map = new Map();
hires.forEach((h) => { hires.forEach((h) => {
@@ -314,7 +359,7 @@ export function byPosition(hires) {
avgDays: avg(e.days), avgDays: avg(e.days),
rated: e.rated, rated: e.rated,
avgRating: e.ratings.length 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, : null,
})) }))
.sort((a, b) => b.count - a.count); .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 * against the workspace's own median rather than an industry figure nobody
* here can check. * here can check.
*/ */
export function buildEfficiency(hires) { export function buildEfficiency(hires: Hire[]) {
const timed = hires.filter((h) => h.timeToHire); const timed = hires.filter((h) => h.timeToHire);
if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 }; 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 * a risk is only raised when something is actually at risk. A page that always
* shows three insights is showing decoration. * shows three insights is showing decoration.
*/ */
export function buildInsights({ hires, departments, positions, funnel, efficiency }) { export function buildInsights({ hires, departments, positions, funnel, efficiency }: {
const out = []; hires: Hire[];
departments: ReturnType<typeof byDepartment>;
positions: ReturnType<typeof byPosition>;
funnel: ReturnType<typeof buildFunnel>;
efficiency: ReturnType<typeof buildEfficiency>;
}) {
const out: { tone: string; title: string; body: string }[] = [];
const summary = summarise(hires); const summary = summarise(hires);
if (departments.length > 1) { if (departments.length > 1) {

View File

@@ -73,6 +73,9 @@ interface Availability {
*/ */
import { matchPosition, recommendTraining, verifiedLevels } from './skillGraph'; import { matchPosition, recommendTraining, verifiedLevels } from './skillGraph';
import type {
Assignment, Course, JobApplication, JobPosting, Staff, WorkerProfile,
} from '@/types/entities';
const DAY_MS = 86400000; const DAY_MS = 86400000;
const MONTH_MS = DAY_MS * 30; const MONTH_MS = DAY_MS * 30;
@@ -80,23 +83,23 @@ const MONTH_MS = DAY_MS * 30;
/* ── Dates ─────────────────────────────────────────────────────────────── */ /* ── Dates ─────────────────────────────────────────────────────────────── */
/** A position's start, as a date. No start date means "as soon as possible". */ /** 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); return position?.start_date ? new Date(position.start_date) : new Date(today);
} }
/** When the work ends, or null when it is open-ended. */ /** 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; const months = position?.duration_months;
if (months == null) return null; if (months == null) return null;
return new Date(startsAt(position, today).getTime() + months * MONTH_MS); return new Date(startsAt(position, today).getTime() + months * MONTH_MS);
} }
/** Days from now until the work starts. Negative or zero means it is live. */ /** 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); Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS);
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */ /** "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); const days = daysUntilStart(position, today);
if (days <= 0) return 'immediately'; if (days <= 0) return 'immediately';
if (days === 1) return 'tomorrow'; 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']; const ENGLISH_ORDER = ['basic', 'conversational', 'fluent', 'native'];
function statedRequirementMatch(position, application) { function statedRequirementMatch(position: JobPosting, application: JobApplication) {
const lines = []; const lines = [];
const required = Number(position?.min_experience_years) || 0; 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()); const englishNeeded = ENGLISH_ORDER.indexOf(String(position?.english_required || '').toLowerCase());
if (englishNeeded > 0) { if (englishNeeded > 0) {
const heldIndex = ENGLISH_ORDER.indexOf(String(application?.english_level || '').toLowerCase()); 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({ lines.push({
skillId: 'english', skillId: 'english',
name: 'English', name: 'English',
@@ -339,8 +342,11 @@ function statedRequirementMatch(position, application) {
* A candidate the position gives nothing to measure against is still returned, * A candidate the position gives nothing to measure against is still returned,
* marked unscored, rather than being dropped or given an invented number. * 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 = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(),
}: {
profiles?: WorkerProfile[]; applications?: JobApplication[]; assignments?: Assignment[];
courses?: Course[]; staff?: Staff[]; today?: Date;
} = {}) { } = {}) {
const profileByEmail = new Map( const profileByEmail = new Map(
profiles.map((p) => [String(p.email || '').toLowerCase(), p]) profiles.map((p) => [String(p.email || '').toLowerCase(), p])
@@ -436,7 +442,7 @@ export function poolFor(position, {
* done this longer. * done this longer.
*/ */
.sort((a, b) => { .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) return rank(a) - rank(b)
|| (b.score ?? -1) - (a.score ?? -1) || (b.score ?? -1) - (a.score ?? -1)
|| b.duration.ratio - a.duration.ratio || 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 * 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: any[] = [], context: WorkforceContext = {}) { export function prioritise(positions: JobPosting[] = [], context: WorkforceContext = {}) {
const today = context.today || new Date(); const today = context.today || new Date();
return positions return positions
@@ -541,7 +547,12 @@ export function prioritise(positions: any[] = [], context: WorkforceContext = {}
const reasons = []; const reasons = [];
let score = 0; 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<string, number>)[position.priority] ?? 6;
score += urgency; score += urgency;
if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer'); if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer');