skill issue

This commit is contained in:
2026-08-28 12:22:08 +05:30
parent 814073e97b
commit 88c412fda6
3 changed files with 100 additions and 9 deletions

View File

@@ -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);
}

View File

@@ -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));