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
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
/**
|
||
* Shift records — the workforce actually turning up, or not.
|
||
*
|
||
* This is the one collection in the demo whose dates are **anchored to now**
|
||
* rather than written as calendar dates. Everything else in `seed.js` is a
|
||
* fixed narrative: 22 people applied on particular days and three were hired,
|
||
* and those dates are the story. Attendance is not a story, it is a rolling
|
||
* operational record — "how was attendance last week" has to mean *last week*,
|
||
* every week, or the feature reads as permanently empty and looks broken.
|
||
*
|
||
* The snapshot persists on first load (see `store.js`), so the figures are
|
||
* stable for a browser once written; only a fresh workspace re-anchors them.
|
||
* Every foreign key points at a fixed seeded record, so correlation with
|
||
* positions and hires stays exact however the dates land.
|
||
*
|
||
* Nothing here is random. The distribution is written out below and generated
|
||
* deterministically, so the same workspace always produces the same figures and
|
||
* a test can assert against them. What it is *shaped* to contain:
|
||
*
|
||
* - **Marco** — the control. Reliable, occasional event-night overtime.
|
||
* - **Marcus** — attendance degrading in the last fortnight: two absences, a
|
||
* no-show and repeated lateness, against a clean record before that. This
|
||
* is the attendance anomaly, and it is recent enough to be actionable.
|
||
* - **Antoine** — present throughout, but overtime climbing steadily week on
|
||
* week. This is the overtime anomaly, and it is a trend rather than a
|
||
* spike, which is the kind a person reading a table would miss.
|
||
*
|
||
* The roster is three people because three people have been hired — `Staff` is
|
||
* the workforce, and inventing a fourth to make the charts look busier would be
|
||
* inventing an employee.
|
||
*/
|
||
|
||
/** Eight weeks: long enough for a week-on-week trend and a month comparison. */
|
||
const WINDOW_DAYS = 56;
|
||
|
||
/**
|
||
* A local instant `n` days back, at a given hour.
|
||
*
|
||
* 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: 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: number) => Math.round(n * 10) / 10;
|
||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||
const HOUR = 60 * 60 * 1000;
|
||
|
||
/**
|
||
* The roster, joined to the records they were hired against.
|
||
*
|
||
* `job_posting_id` and `role_category` are the posting's own, so "department"
|
||
* means here exactly what it means on Hired History and Analytics — see the
|
||
* join in `lib/hiringRecords.js`.
|
||
*/
|
||
const ROSTER = [
|
||
{
|
||
staff_id: 'staff_marco',
|
||
worker_name: 'Marco Rivera',
|
||
worker_email: 'marco.rivera@email.com',
|
||
job_posting_id: 'job_bartender_corp',
|
||
role: 'Experienced Bartender – Corporate Events',
|
||
role_category: 'Bartender',
|
||
/* Wed–Sat: corporate events run late in the week. */
|
||
weekdays: [3, 4, 5, 6],
|
||
startHour: 16,
|
||
scheduledHours: 8,
|
||
},
|
||
{
|
||
staff_id: 'staff_marcus',
|
||
worker_name: 'Marcus Williams',
|
||
worker_email: 'marcus.w@email.com',
|
||
job_posting_id: 'job_security',
|
||
role: 'Event Security Officer',
|
||
role_category: 'Security',
|
||
/* Mon–Fri: a fixed security rota, which is what makes the recent
|
||
absences stand out rather than read as an irregular schedule. */
|
||
weekdays: [1, 2, 3, 4, 5],
|
||
startHour: 14,
|
||
scheduledHours: 8,
|
||
},
|
||
{
|
||
staff_id: 'staff_antoine',
|
||
worker_name: 'Chef Antoine Dubois',
|
||
worker_email: 'antoine.dubois@email.com',
|
||
job_posting_id: 'job_chef',
|
||
role: 'Executive Chef – Catering',
|
||
role_category: 'Chef',
|
||
/* Tue–Sat: kitchen service. */
|
||
weekdays: [2, 3, 4, 5, 6],
|
||
startHour: 12,
|
||
scheduledHours: 9,
|
||
},
|
||
];
|
||
|
||
/**
|
||
* How each worker's series behaves, by position in it.
|
||
*
|
||
* `i` counts back from the most recent shift, so "the last fortnight" is a
|
||
* range of small indices and stays that way as the window rolls forward.
|
||
* Returning a plain record keeps every rule visible in one place instead of
|
||
* spread across the generator.
|
||
*/
|
||
/**
|
||
* 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: number, weekday?: number) => ({
|
||
status: i === 14 ? 'late' : 'present',
|
||
minutesLate: i === 14 ? 9 : 0,
|
||
/* Friday and Saturday events overrun; midweek ones do not. */
|
||
overtime: weekday === 5 || weekday === 6 ? 1 : 0,
|
||
notes: '',
|
||
}),
|
||
|
||
/**
|
||
* Clean for six weeks, then coming apart.
|
||
*
|
||
* Indices 0–9 are roughly the last fortnight. Two absences, one no-show and
|
||
* three late arrivals inside that window, against a single late arrival in
|
||
* the six weeks before it — a change big enough to be worth surfacing and
|
||
* specific enough to act on.
|
||
*/
|
||
staff_marcus: (i: number) => {
|
||
if (i === 2 || i === 7) {
|
||
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
||
}
|
||
if (i === 4) {
|
||
return { status: 'no_show', minutesLate: 0, overtime: 0, notes: 'No contact' };
|
||
}
|
||
if (i === 1) return { status: 'late', minutesLate: 24, overtime: 0, notes: '' };
|
||
if (i === 5) return { status: 'late', minutesLate: 16, overtime: 0, notes: '' };
|
||
if (i === 9) return { status: 'late', minutesLate: 12, overtime: 0, notes: '' };
|
||
if (i === 26) return { status: 'late', minutesLate: 7, overtime: 0, notes: '' };
|
||
return { status: 'present', minutesLate: 0, overtime: 0, notes: '' };
|
||
},
|
||
|
||
/**
|
||
* Always there, increasingly late leaving.
|
||
*
|
||
* Overtime rises about half an hour a week as the kitchen carries more
|
||
* 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: 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;
|
||
return { status: 'present', minutesLate: 0, overtime, notes: '' };
|
||
},
|
||
};
|
||
|
||
/** Every shift date for one worker, most recent first. */
|
||
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);
|
||
}
|
||
return offsets;
|
||
}
|
||
|
||
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: Date = new Date()) {
|
||
const records: any[] = [];
|
||
|
||
for (const worker of ROSTER) {
|
||
const offsets = shiftOffsets(worker.weekdays, anchor);
|
||
|
||
offsets.forEach((offset, i) => {
|
||
const scheduledStart = daysAgo(offset, worker.startHour, 0, anchor);
|
||
const weekday = scheduledStart.getDay();
|
||
const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR);
|
||
|
||
const { status, minutesLate, overtime, notes } = BEHAVIOUR[worker.staff_id](i, weekday);
|
||
const worked = status !== 'absent' && status !== 'no_show';
|
||
|
||
const actualStart = worked
|
||
? new Date(scheduledStart.getTime() + minutesLate * 60 * 1000)
|
||
: null;
|
||
const actualEnd = worked
|
||
? new Date(scheduledEnd.getTime() + overtime * HOUR)
|
||
: null;
|
||
|
||
records.push({
|
||
id: `shift_${worker.staff_id.replace('staff_', '')}_${pad(offsets.length - i)}`,
|
||
staff_id: worker.staff_id,
|
||
worker_name: worker.worker_name,
|
||
worker_email: worker.worker_email,
|
||
job_posting_id: worker.job_posting_id,
|
||
role: worker.role,
|
||
role_category: worker.role_category,
|
||
|
||
shift_date: localDate(scheduledStart),
|
||
scheduled_start: scheduledStart.toISOString(),
|
||
scheduled_end: scheduledEnd.toISOString(),
|
||
scheduled_hours: worker.scheduledHours,
|
||
|
||
actual_start: actualStart ? actualStart.toISOString() : null,
|
||
actual_end: actualEnd ? actualEnd.toISOString() : null,
|
||
/* Arriving late shortens the shift; staying on lengthens it. A missed
|
||
shift is zero hours worked, not a short one. */
|
||
actual_hours: worked
|
||
? round2(worker.scheduledHours - minutesLate / 60 + overtime)
|
||
: 0,
|
||
overtime_hours: worked ? round1(overtime) : 0,
|
||
minutes_late: worked ? minutesLate : 0,
|
||
status,
|
||
notes,
|
||
|
||
/* Load-bearing: `inPeriod` in `lib/skills/dataResolver.js` windows every
|
||
collection on `created_date`, so a shift's created date *is* the
|
||
instant it was worked. Without that, every period reading of this
|
||
collection would be empty and nothing would say why. */
|
||
created_date: scheduledStart.toISOString(),
|
||
updated_date: (actualEnd || scheduledEnd).toISOString(),
|
||
});
|
||
});
|
||
}
|
||
|
||
/* Most recent first, matching the `-created_date` order every other
|
||
collection is listed in. */
|
||
return records.sort(
|
||
(a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime()
|
||
);
|
||
}
|
||
|
||
export const SHIFT_RECORDS = buildShifts();
|
||
|
||
/**
|
||
* The same shifts, counted back from a day you choose.
|
||
*
|
||
* The distribution is deterministic given its anchor but not across anchors:
|
||
* which shifts fall in a complete week depends on where the anchor sits in one,
|
||
* so an assertion about the seeded overtime climb held on a Thursday and failed
|
||
* on a Friday. That is a test that reports the calendar, not the code.
|
||
*
|
||
* The app keeps `SHIFT_RECORDS` above, anchored to now, because attendance
|
||
* "last week" has to mean last week on the day somebody looks. Pin the anchor
|
||
* here instead, and pass the SAME date as `now` to whatever reads the result —
|
||
* shifts built around one day and bucketed around another describe two
|
||
* different windows.
|
||
*/
|
||
export function buildShiftsAt(anchor: Date) {
|
||
return buildShifts(anchor);
|
||
}
|