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:
@@ -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<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 ────────────────────────────────────────────── */
|
||||
|
||||
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<string, string[]> = {
|
||||
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<string, number> = {};
|
||||
criteria.forEach((criterion, i) => {
|
||||
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
||||
});
|
||||
|
||||
@@ -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<string, ShiftBehaviour> = {
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -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<any> | 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<PreferencesUpdateResult> {
|
||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||
currentUser = { ...currentUser, preferences };
|
||||
cacheUser();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
138
src/api/seed.ts
138
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<string, number>) => ({
|
||||
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<string, any>) => ({
|
||||
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<string, string> = {
|
||||
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<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.`,
|
||||
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,
|
||||
|
||||
@@ -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<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) => {
|
||||
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<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);
|
||||
|
||||
if (departments.length > 1) {
|
||||
|
||||
@@ -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<string, number>)[position.priority] ?? 6;
|
||||
score += urgency;
|
||||
if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user