chore(ts-migration): migrate seed fixtures to TypeScript

Phase 4c, completing `src/api`. Both files emit byte-identical JavaScript -
86,831 and 6,449 bytes - and neither needed a single annotation: they are data
and pure helpers, and inference already describes them.

Neither reaches the production bundle. No module under `src` imports either one;
`base44Client` mentions `attendanceSeed` in a comment and nothing more. They
exist for `skill-check.mjs`, `owliver-capture.mjs` and `seed-fixture.mjs`, which
is why this was the safest phase in the whole migration and why it was left
until the transport layer was done.

`npm run seed:check` still answers "seed.json is stale", which it has since
before this migration began - the backend fixture drifted from `src/api/seed`
independently of any of this. What matters here is that it ANSWERS: the
generator loaded the renamed module through the Phase 0 resolver rather than
failing to find it.

tsc unchanged at 40, npm test 1684/1691 with the same seven failures, lint 0
errors.

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-17 23:00:29 +05:30
parent 1133eca16a
commit 3e654c2bf7
2 changed files with 0 additions and 0 deletions

256
src/api/attendanceSeed.ts Normal file
View File

@@ -0,0 +1,256 @@
/**
* 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, 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 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.
*/
const BEHAVIOUR = {
/* Reliable. One late arrival every couple of months, and overtime only on
the nights events actually overrun. */
staff_marco: (i, weekday) => ({
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) => {
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, weekday) => {
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, anchor) {
const offsets = [];
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) => String(n).padStart(2, '0');
const localDate = (d) => `${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 = [];
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) {
return buildShifts(anchor);
}