160 lines
7 KiB
TypeScript
160 lines
7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
bucketClockEventsForToday,
|
|
latestClockEventByEmployee,
|
|
unresolvedClockIns,
|
|
} from "./clock-events";
|
|
import type { ClockEventRecord } from "./pb-types";
|
|
|
|
function event(overrides: {
|
|
employee: string;
|
|
direction: "in" | "out";
|
|
scanned_at: string;
|
|
}): ClockEventRecord {
|
|
return {
|
|
id: `evt-${overrides.employee}-${overrides.scanned_at}`,
|
|
collectionId: "col1",
|
|
collectionName: "clock_events",
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
updated: "2026-01-01T00:00:00.000Z",
|
|
site: "site1",
|
|
...overrides,
|
|
} as ClockEventRecord;
|
|
}
|
|
|
|
describe("latestClockEventByEmployee", () => {
|
|
it("keeps only the first (most recent) event per employee from a newest-first list", () => {
|
|
const events = [
|
|
event({ employee: "emp1", direction: "out", scanned_at: "2026-09-11T16:00:00.000Z" }),
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-11T08:00:00.000Z" }),
|
|
event({ employee: "emp2", direction: "in", scanned_at: "2026-09-11T09:00:00.000Z" }),
|
|
];
|
|
|
|
const result = latestClockEventByEmployee(events);
|
|
|
|
expect(result.size).toBe(2);
|
|
expect(result.get("emp1")?.direction).toBe("out");
|
|
expect(result.get("emp2")?.direction).toBe("in");
|
|
});
|
|
|
|
it("returns an empty map for an empty list", () => {
|
|
expect(latestClockEventByEmployee([]).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("bucketClockEventsForToday", () => {
|
|
// Fixed UTC reference: 2026-09-22 09:00 UTC. "Today" in the bucket
|
|
// sense is the UTC date 2026-09-22.
|
|
const NOW = new Date("2026-09-22T09:00:00.000Z");
|
|
|
|
it("classifies an employee whose latest event is in as onSite", () => {
|
|
const events = [
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-22T08:00:00.000Z" }),
|
|
];
|
|
const result = bucketClockEventsForToday(events, NOW);
|
|
expect(result.onSite.map((e) => e.employee)).toEqual(["emp1"]);
|
|
expect(result.clockedOutToday).toEqual([]);
|
|
expect(result.notArrivedIds).toEqual([]);
|
|
});
|
|
|
|
it("classifies an employee who clocked in and out today as clockedOutToday", () => {
|
|
const events = [
|
|
event({ employee: "emp1", direction: "out", scanned_at: "2026-09-22T17:00:00.000Z" }),
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-22T08:00:00.000Z" }),
|
|
];
|
|
const result = bucketClockEventsForToday(events, NOW);
|
|
expect(result.onSite).toEqual([]);
|
|
expect(result.clockedOutToday.map((e) => e.employee)).toEqual(["emp1"]);
|
|
expect(result.notArrivedIds).toEqual([]);
|
|
});
|
|
|
|
it("classifies an employee who only clocked out yesterday as not arrived today", () => {
|
|
const events = [
|
|
event({ employee: "emp1", direction: "out", scanned_at: "2026-09-21T17:00:00.000Z" }),
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-21T08:00:00.000Z" }),
|
|
];
|
|
const result = bucketClockEventsForToday(events, NOW);
|
|
expect(result.onSite).toEqual([]);
|
|
expect(result.clockedOutToday).toEqual([]);
|
|
expect(result.notArrivedIds).toEqual(["emp1"]);
|
|
});
|
|
|
|
it("keeps yesterday's unmatched IN out of On site now", () => {
|
|
const events = [
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-21T21:59:00.000Z" }),
|
|
];
|
|
const result = bucketClockEventsForToday(events, NOW);
|
|
expect(result.onSite).toEqual([]);
|
|
expect(result.notArrivedIds).toEqual(["emp1"]);
|
|
});
|
|
|
|
it("places the same employee into exactly one bucket even with many events today", () => {
|
|
const events = [
|
|
event({ employee: "emp1", direction: "out", scanned_at: "2026-09-22T14:00:00.000Z" }),
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-22T12:00:00.000Z" }),
|
|
event({ employee: "emp1", direction: "out", scanned_at: "2026-09-22T11:00:00.000Z" }),
|
|
event({ employee: "emp1", direction: "in", scanned_at: "2026-09-22T08:00:00.000Z" }),
|
|
];
|
|
const result = bucketClockEventsForToday(events, NOW);
|
|
expect(result.onSite).toEqual([]);
|
|
expect(result.clockedOutToday).toHaveLength(1);
|
|
expect(result.clockedOutToday[0].employee).toBe("emp1");
|
|
expect(result.notArrivedIds).toEqual([]);
|
|
});
|
|
|
|
it("partitions a mixed roster into the right buckets", () => {
|
|
const events = [
|
|
// A: on site, clocked in today
|
|
event({ employee: "A", direction: "in", scanned_at: "2026-09-22T07:30:00.000Z" }),
|
|
// B: clocked in and out today
|
|
event({ employee: "B", direction: "out", scanned_at: "2026-09-22T16:00:00.000Z" }),
|
|
event({ employee: "B", direction: "in", scanned_at: "2026-09-22T08:00:00.000Z" }),
|
|
// 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) — 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"]);
|
|
expect(result.clockedOutToday.map((e) => e.employee)).toEqual(["B"]);
|
|
expect(result.notArrivedIds).toEqual(["C", "D"]);
|
|
});
|
|
|
|
it("returns empty buckets for an empty list", () => {
|
|
const result = bucketClockEventsForToday([], NOW);
|
|
expect(result.onSite).toEqual([]);
|
|
expect(result.clockedOutToday).toEqual([]);
|
|
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);
|
|
});
|
|
|
|
it("moves yesterday's IN to attention at the exact South African midnight", () => {
|
|
const old = event({ employee: "emp1", direction: "in", scanned_at: "2026-09-25T21:59:00Z" });
|
|
const midnight = new Date("2026-09-25T22:00:00Z");
|
|
expect(bucketClockEventsForToday([old], midnight).onSite).toEqual([]);
|
|
expect(unresolvedClockIns([old], midnight, new Set()).map((row) => row.id)).toEqual([old.id]);
|
|
});
|
|
});
|