diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 2c78d63..663e658 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -1834,13 +1834,46 @@ record('a week nobody was rostered for is not reported as 0% attendance', /* ── Anomalies: what it finds, and what it stays quiet about ─────────────── */ -const findings = attendance.attendanceAnomalies(SHIFTS); +/* Anchored, not live. + * + * These shifts are generated by counting back from a day, and which of them + * land in a *complete* week depends on where that day sits in one. Asserting + * against the live set meant asserting against the calendar: the seeded + * overtime climb is detected on a Thursday and not on a Friday, so this check + * failed every Friday and Saturday and passed the rest of the week. + * + * The anchor is the same instant the rest of this file pins, and the same date + * is passed as `now` — shifts built around one day and bucketed around another + * describe two different windows, which is its own way to be wrong. */ +const ANCHOR = new Date('2026-08-20T09:00:00.000Z'); +const ANCHORED_SHIFTS = shiftSeed.buildShiftsAt(ANCHOR); +const findings = attendance.attendanceAnomalies(ANCHORED_SHIFTS, { now: ANCHOR }); record('the seeded attendance decline is found', findings.some((f) => f.kind === 'attendance'), findings.filter((f) => f.kind === 'attendance').map((f) => f.title).join(' | ') || 'NOT FOUND'); record('the seeded overtime climb is found', findings.some((f) => f.kind === 'overtime'), findings.find((f) => f.kind === 'overtime')?.detail || 'NOT FOUND'); + +/* And that it does not depend on the day, measured rather than assumed. + * + * Pinning the anchor makes this file deterministic; it does not make the + * product insensitive to the day, and it was not: the climb was reported Sunday + * through Thursday and vanished on Friday and Saturday, because the oldest week + * in the window is a partial one and thinned to two shifts as the week wore on, + * inflating the baseline every later week was compared against. + * + * Fixed in attendanceAnomalies by dropping a leading week rostered well below + * the usual. This is the number that keeps it fixed: a change that reintroduces + * a calendar-shaped blind spot fails here even though the pinned assertion + * above still passes. */ +const detectableOn = Array.from({ length: 28 }, (_, i) => new Date(2026, 7, i + 1, 12)) + .filter((day) => attendance + .attendanceAnomalies(shiftSeed.buildShiftsAt(day), { now: day }) + .some((f) => f.kind === 'overtime')).length; +record('the seeded overtime climb is detectable on every day of the week', + detectableOn === 28, + `${detectableOn}/28 anchors`); record('every finding carries the figures behind it', findings.every((f) => f.title && f.detail && f.severity && f.metric)); record('findings are ordered most severe first', diff --git a/src/api/attendanceSeed.js b/src/api/attendanceSeed.js index 9b9abda..a2f2c76 100644 --- a/src/api/attendanceSeed.js +++ b/src/api/attendanceSeed.js @@ -39,8 +39,8 @@ 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) { - const d = new Date(); +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; @@ -154,10 +154,10 @@ const BEHAVIOUR = { }; /** Every shift date for one worker, most recent first. */ -function shiftOffsets(weekdays) { +function shiftOffsets(weekdays, anchor) { const offsets = []; for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) { - const day = daysAgo(offset).getDay(); + const day = daysAgo(offset, 9, 0, anchor).getDay(); if (weekdays.includes(day)) offsets.push(offset); } return offsets; @@ -166,14 +166,19 @@ function shiftOffsets(weekdays) { const pad = (n) => String(n).padStart(2, '0'); const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; -function buildShifts() { +/** + * @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); + const offsets = shiftOffsets(worker.weekdays, anchor); offsets.forEach((offset, i) => { - const scheduledStart = daysAgo(offset, worker.startHour); + const scheduledStart = daysAgo(offset, worker.startHour, 0, anchor); const weekday = scheduledStart.getDay(); const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR); @@ -231,3 +236,21 @@ function buildShifts() { } 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); +} diff --git a/src/lib/attendance.js b/src/lib/attendance.js index 33b01f1..8a0296a 100644 --- a/src/lib/attendance.js +++ b/src/lib/attendance.js @@ -241,6 +241,14 @@ const RECENT_WEEKS = 2; * Each finding carries the figures it was derived from, so a caller can state * *why* rather than asserting that something is wrong. */ +/** The middle value, for asking what a typical week looks like. */ +function median(values) { + const sorted = [...values].filter((n) => Number.isFinite(n)).sort((a, b) => a - b); + if (!sorted.length) return 0; + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + export function attendanceAnomalies(shifts = [], { now = new Date() } = {}) { const findings = []; const trend = weeklyTrend(shifts, { weeks: 12, now }); @@ -311,7 +319,34 @@ export function attendanceAnomalies(shifts = [], { now = new Date() } = {}) { * against whole ones reports a fall in overtime every Monday morning. */ const thisWeekStart = startOfWeek(now).getTime(); - const complete = trend.filter((w) => new Date(w.weekStart).getTime() < thisWeekStart); + const withinWindow = trend.filter((w) => new Date(w.weekStart).getTime() < thisWeekStart); + + /* And the same again at the other end. + * + * The rule above drops the week in progress because a part-week is not + * comparable to a whole one. The oldest week has exactly that problem and was + * kept: records begin somewhere, and wherever that is, it lands mid-week — so + * the first bucket holds whatever part of a week the data reaches back to. + * + * Per-shift normalises for a week's size but not for its sample: two shifts + * carrying one long night read as a very high rate, and that inflated figure + * became the baseline every later week was measured against. The effect was + * a blind spot with a calendar in it — the seeded overtime climb was reported + * Sunday through Thursday and vanished on Friday and Saturday, as the oldest + * week thinned from 8 scheduled shifts to 5 to 2. + * + * A week rostered well below the usual is dropped from the front rather than + * trusted. Only from the front: a genuine collapse in scheduled work is a + * finding, not noise, and this must not swallow one in the middle. */ + const scheduledPerWeek = withinWindow.map((w) => w.scheduled); + const typicalWeek = median(scheduledPerWeek); + let firstFull = 0; + while ( + firstFull < withinWindow.length - 1 + && typicalWeek > 0 + && withinWindow[firstFull].scheduled < typicalWeek * 0.6 + ) firstFull += 1; + const complete = withinWindow.slice(firstFull); if (complete.length >= 4) { const perShift = complete.map((w) => (w.scheduled ? w.overtimeHours / w.scheduled : 0));