fix(clock): keep old unmatched scans out of today board

This commit is contained in:
DEls 2026-09-25 07:06:24 +02:00
parent 8d728333f9
commit 176b6180bf
2 changed files with 68 additions and 18 deletions

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
bucketClockEventsForToday,
latestClockEventByEmployee,
unresolvedClockIns,
} from "./clock-events";
import type { ClockEventRecord } from "./pb-types";
@ -78,15 +79,13 @@ describe("bucketClockEventsForToday", () => {
expect(result.notArrivedIds).toEqual(["emp1"]);
});
it("surfaces employees who clocked in yesterday and forgot to clock out as onSite", () => {
// Open shift across midnight — the foreman needs to see this
// rather than silently demote the worker to "clocked out".
it("keeps yesterday's unmatched IN out of On site now", () => {
const events = [
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-21T22:00:00.000Z" }),
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-21T21:59:00.000Z" }),
];
const result = bucketClockEventsForToday(events, NOW);
expect(result.onSite.map((e) => e.employee)).toEqual(["emp1"]);
expect(result.notArrivedIds).toEqual([]);
expect(result.onSite).toEqual([]);
expect(result.notArrivedIds).toEqual(["emp1"]);
});
it("places the same employee into exactly one bucket even with many events today", () => {
@ -113,13 +112,13 @@ describe("bucketClockEventsForToday", () => {
// C: clocked out yesterday, no event today
event({ employee: "C", direction: "out", scanned_at: "2026-09-21T17:00:00.000Z" }),
event({ employee: "C", direction: "in", scanned_at: "2026-09-21T08:00:00.000Z" }),
// D: clocked in yesterday, no out (forgot) — still surfaces as onSite
event({ employee: "D", direction: "in", scanned_at: "2026-09-21T22:00:00.000Z" }),
// D: clocked in yesterday, no out (forgot) — belongs in attention
event({ employee: "D", direction: "in", scanned_at: "2026-09-21T21:59:00.000Z" }),
];
const result = bucketClockEventsForToday(events, NOW);
expect(result.onSite.map((e) => e.employee).sort()).toEqual(["A", "D"]);
expect(result.onSite.map((e) => e.employee).sort()).toEqual(["A"]);
expect(result.clockedOutToday.map((e) => e.employee)).toEqual(["B"]);
expect(result.notArrivedIds).toEqual(["C"]);
expect(result.notArrivedIds).toEqual(["C", "D"]);
});
it("returns empty buckets for an empty list", () => {
@ -129,3 +128,26 @@ describe("bucketClockEventsForToday", () => {
expect(result.notArrivedIds).toEqual([]);
});
});
describe("unresolvedClockIns", () => {
const now = new Date("2026-09-25T22:15:00Z"); // 26 September SAST
it("keeps an old exception while the same worker is in today", () => {
const old = event({ employee: "emp1", direction: "in", scanned_at: "2026-09-23T05:00:00Z" });
const today = event({ employee: "emp1", direction: "in", scanned_at: "2026-09-25T22:05:00Z" });
expect(unresolvedClockIns([today, old], now, new Set()).map((row) => row.id)).toEqual([old.id]);
expect(bucketClockEventsForToday([today, old], now).onSite.map((row) => row.id)).toEqual([today.id]);
});
it("does not show a corrected or naturally closed shift", () => {
const old = event({ employee: "emp1", direction: "in", scanned_at: "2026-09-23T05:00:00Z" });
const out = event({ employee: "emp1", direction: "out", scanned_at: "2026-09-23T14:00:00Z" });
expect(unresolvedClockIns([out, old], now, new Set())).toEqual([]);
expect(unresolvedClockIns([old], now, new Set([old.id]))).toEqual([]);
});
it("does not expire exceptions older than seven days", () => {
const old = event({ employee: "emp1", direction: "in", scanned_at: "2026-08-01T05:00:00Z" });
expect(unresolvedClockIns([old], now, new Set())).toHaveLength(1);
});
});

View file

@ -1,4 +1,5 @@
import type { ClockEventRecord } from "./pb-types";
import { workDate } from "./clock-day";
/**
* Reduces a `-scanned_at`-sorted list of clock_events down to one row
@ -80,10 +81,10 @@ export function bucketClockEventsForToday(
const bucketed = new Set<string>();
for (const event of latest.values()) {
if (event.direction === "in") {
if (event.direction === "in" && workDate(event.scanned_at) === workDate(now.toISOString())) {
onSite.push(event);
bucketed.add(event.employee);
} else if (isSameUtcDay(new Date(event.scanned_at), now)) {
} else if (event.direction === "out" && workDate(event.scanned_at) === workDate(now.toISOString())) {
clockedOutToday.push(event);
bucketed.add(event.employee);
}
@ -100,10 +101,37 @@ export function bucketClockEventsForToday(
return { onSite, clockedOutToday, notArrivedIds };
}
function isSameUtcDay(a: Date, b: Date): boolean {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate()
);
/** Find old IN scans with no later matching OUT, regardless of age. */
export function unresolvedClockIns(
events: ClockEventRecord[],
now: Date,
correctedInEventIds: ReadonlySet<string>,
): ClockEventRecord[] {
const byEmployee = new Map<string, ClockEventRecord[]>();
for (const event of events) {
const rows = byEmployee.get(event.employee) ?? [];
rows.push(event);
byEmployee.set(event.employee, rows);
}
const today = workDate(now.toISOString());
const unresolved: ClockEventRecord[] = [];
const addIfOld = (event: ClockEventRecord) => {
if (workDate(event.scanned_at) < today && !correctedInEventIds.has(event.id)) {
unresolved.push(event);
}
};
for (const rows of byEmployee.values()) {
rows.sort((a, b) => a.scanned_at.localeCompare(b.scanned_at) || a.id.localeCompare(b.id));
let open: ClockEventRecord | null = null;
for (const event of rows) {
if (event.direction === "in") {
if (open) addIfOld(open);
open = event;
} else {
open = null;
}
}
if (open) addIfOld(open);
}
return unresolved.sort((a, b) => a.scanned_at.localeCompare(b.scanned_at));
}