skill issue
This commit is contained in:
@@ -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 ─────────────── */
|
/* ── 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',
|
record('the seeded attendance decline is found',
|
||||||
findings.some((f) => f.kind === 'attendance'),
|
findings.some((f) => f.kind === 'attendance'),
|
||||||
findings.filter((f) => f.kind === 'attendance').map((f) => f.title).join(' | ') || 'NOT FOUND');
|
findings.filter((f) => f.kind === 'attendance').map((f) => f.title).join(' | ') || 'NOT FOUND');
|
||||||
record('the seeded overtime climb is found',
|
record('the seeded overtime climb is found',
|
||||||
findings.some((f) => f.kind === 'overtime'),
|
findings.some((f) => f.kind === 'overtime'),
|
||||||
findings.find((f) => f.kind === 'overtime')?.detail || 'NOT FOUND');
|
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',
|
record('every finding carries the figures behind it',
|
||||||
findings.every((f) => f.title && f.detail && f.severity && f.metric));
|
findings.every((f) => f.title && f.detail && f.severity && f.metric));
|
||||||
record('findings are ordered most severe first',
|
record('findings are ordered most severe first',
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ const WINDOW_DAYS = 56;
|
|||||||
* Local rather than UTC because a shift belongs to the day it was worked in the
|
* 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.
|
* place it was worked, and `periodRange` windows on local day boundaries too.
|
||||||
*/
|
*/
|
||||||
function daysAgo(n, hour = 9, minute = 0) {
|
function daysAgo(n, hour = 9, minute = 0, anchor = new Date()) {
|
||||||
const d = new Date();
|
const d = new Date(anchor.getTime());
|
||||||
d.setDate(d.getDate() - n);
|
d.setDate(d.getDate() - n);
|
||||||
d.setHours(hour, minute, 0, 0);
|
d.setHours(hour, minute, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
@@ -154,10 +154,10 @@ const BEHAVIOUR = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Every shift date for one worker, most recent first. */
|
/** Every shift date for one worker, most recent first. */
|
||||||
function shiftOffsets(weekdays) {
|
function shiftOffsets(weekdays, anchor) {
|
||||||
const offsets = [];
|
const offsets = [];
|
||||||
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
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);
|
if (weekdays.includes(day)) offsets.push(offset);
|
||||||
}
|
}
|
||||||
return offsets;
|
return offsets;
|
||||||
@@ -166,14 +166,19 @@ function shiftOffsets(weekdays) {
|
|||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
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 = [];
|
const records = [];
|
||||||
|
|
||||||
for (const worker of ROSTER) {
|
for (const worker of ROSTER) {
|
||||||
const offsets = shiftOffsets(worker.weekdays);
|
const offsets = shiftOffsets(worker.weekdays, anchor);
|
||||||
|
|
||||||
offsets.forEach((offset, i) => {
|
offsets.forEach((offset, i) => {
|
||||||
const scheduledStart = daysAgo(offset, worker.startHour);
|
const scheduledStart = daysAgo(offset, worker.startHour, 0, anchor);
|
||||||
const weekday = scheduledStart.getDay();
|
const weekday = scheduledStart.getDay();
|
||||||
const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR);
|
const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR);
|
||||||
|
|
||||||
@@ -231,3 +236,21 @@ function buildShifts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SHIFT_RECORDS = 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,6 +241,14 @@ const RECENT_WEEKS = 2;
|
|||||||
* Each finding carries the figures it was derived from, so a caller can state
|
* Each finding carries the figures it was derived from, so a caller can state
|
||||||
* *why* rather than asserting that something is wrong.
|
* *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() } = {}) {
|
export function attendanceAnomalies(shifts = [], { now = new Date() } = {}) {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const trend = weeklyTrend(shifts, { weeks: 12, now });
|
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.
|
* against whole ones reports a fall in overtime every Monday morning.
|
||||||
*/
|
*/
|
||||||
const thisWeekStart = startOfWeek(now).getTime();
|
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) {
|
if (complete.length >= 4) {
|
||||||
const perShift = complete.map((w) => (w.scheduled ? w.overtimeHours / w.scheduled : 0));
|
const perShift = complete.map((w) => (w.scheduled ? w.overtimeHours / w.scheduled : 0));
|
||||||
|
|||||||
Reference in New Issue
Block a user