diff --git a/src/lib/clock-events.test.ts b/src/lib/clock-events.test.ts index 00ea97f..c1bf509 100644 --- a/src/lib/clock-events.test.ts +++ b/src/lib/clock-events.test.ts @@ -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); + }); +}); diff --git a/src/lib/clock-events.ts b/src/lib/clock-events.ts index de4c3f4..bf89e13 100644 --- a/src/lib/clock-events.ts +++ b/src/lib/clock-events.ts @@ -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(); 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, +): ClockEventRecord[] { + const byEmployee = new Map(); + 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)); }