fix(clock): keep old unmatched scans out of today board
This commit is contained in:
parent
8d728333f9
commit
176b6180bf
2 changed files with 68 additions and 18 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue