Files
krow_talent_app/src/api/attendanceSeed.ts
Aravind e8207038dd 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
2026-09-18 17:18:18 +05:30

268 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}