/** * 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 = { /* 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); }