fix(clock): start new local day with an in scan

This commit is contained in:
DEls 2026-09-25 07:04:45 +02:00
parent 29edf2e5aa
commit 8d728333f9
4 changed files with 54 additions and 25 deletions

View file

@ -89,7 +89,10 @@ describe("GET /api/clock/employee-lookup", () => {
scanned_at: "2026-09-22T08:00:00.000Z",
});
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-22T08:30:00.000Z"));
const res = await get("MCW058");
vi.useRealTimers();
const json = await res.json();
expect(json.lastEvent).toEqual({
direction: "in",
@ -99,6 +102,18 @@ describe("GET /api/clock/employee-lookup", () => {
expect(json.nextDirection).toBe("out");
});
it("starts a new local day with IN even when yesterday never had an OUT", async () => {
employeesGetFirst.mockResolvedValueOnce(EMPLOYEE);
clockEventsGetFirst.mockResolvedValueOnce({
id: "yesterday-in", direction: "in", scanned_at: "2026-09-25T21:59:00.000Z",
});
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-25T22:00:00.000Z"));
const res = await get("MCW058");
vi.useRealTimers();
expect((await res.json()).nextDirection).toBe("in");
});
it("flips nextDirection when the last event was an out", async () => {
employeesGetFirst.mockResolvedValueOnce(EMPLOYEE);
clockEventsGetFirst.mockResolvedValueOnce({
@ -133,4 +148,4 @@ describe("GET /api/clock/employee-lookup", () => {
expect.stringContaining(`employee_no="MCW-\\"EVIL\\""`),
);
});
});
});

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { workDate } from "@/lib/clock-day";
import type { ClockEventRecord, EmployeeRecord } from "@/lib/pb-types";
function escapePb(s: string): string {
@ -72,9 +73,11 @@ export async function GET(request: Request) {
// No events yet — first scan on file. lastEvent stays null.
}
// No previous event → first scan of the day is an "in". Otherwise
// toggle: an "in" was followed by an "out", and vice versa.
const nextDirection: "in" | "out" = lastEvent?.direction === "in" ? "out" : "in";
// Each South African calendar day starts independently. An unmatched
// earlier-day IN remains an exception; it never becomes today's OUT.
const nextDirection: "in" | "out" =
lastEvent?.direction === "in" && workDate(lastEvent.scannedAt) === workDate(new Date().toISOString())
? "out" : "in";
return NextResponse.json({
employee: {
@ -86,4 +89,4 @@ export async function GET(request: Request) {
lastEvent,
nextDirection,
});
}
}

View file

@ -109,6 +109,22 @@ describe("POST /api/clock/scan", () => {
expect(entriesCreate).not.toHaveBeenCalled();
});
it("clocks IN on a new South African day even when an earlier IN is still open", async () => {
employeesGetFirst.mockResolvedValueOnce(EMPLOYEE);
clockEventsGetFirst.mockResolvedValueOnce({
id: "old-in", direction: "in", scanned_at: "2026-09-25T21:59:00.000Z",
});
clockEventsCreate.mockResolvedValueOnce({ id: "today-in" });
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-25T22:00:00.000Z"));
const res = await post({ employee_no: "MCW058" });
vi.useRealTimers();
expect((await res.json()).direction).toBe("in");
expect(clockEventsCreate).toHaveBeenCalledWith(expect.objectContaining({ direction: "in" }));
expect(entriesCreate).not.toHaveBeenCalled();
expect(entriesUpdate).not.toHaveBeenCalled();
});
it("clocks out and creates a new timesheet_entries row when none exists yet today", async () => {
employeesGetFirst.mockResolvedValueOnce(EMPLOYEE);
clockEventsGetFirst.mockResolvedValueOnce({
@ -166,29 +182,29 @@ describe("POST /api/clock/scan", () => {
expect(entriesCreate).not.toHaveBeenCalled();
});
it("attributes an overnight shift's date_logged to the clock-in date, not the clock-out date", async () => {
it("uses the South African work date for an in and out on the same local day", async () => {
employeesGetFirst.mockResolvedValueOnce(EMPLOYEE);
clockEventsGetFirst.mockResolvedValueOnce({
id: "evt5",
direction: "in",
scanned_at: "2026-09-11T22:00:00.000Z",
scanned_at: "2026-09-11T22:00:00.000Z", // 12 Sep 00:00 SAST
});
entriesGetFirst.mockRejectedValueOnce(new Error("not found"));
entriesCreate.mockResolvedValueOnce({ id: "entry2" });
clockEventsCreate.mockResolvedValueOnce({ id: "evt6" });
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-12T02:00:00.000Z"));
vi.setSystemTime(new Date("2026-09-12T02:00:00.000Z")); // 12 Sep 04:00 SAST
await post({ employee_no: "MCW058" });
vi.useRealTimers();
expect(entriesGetFirst).toHaveBeenCalledWith(
expect.stringContaining('date_logged="2026-09-11T00:00:00.000Z"'),
expect.stringContaining('date_logged="2026-09-12T00:00:00.000Z"'),
);
expect(entriesCreate).toHaveBeenCalledWith(
expect.objectContaining({ date_logged: "2026-09-11T00:00:00.000Z" }),
expect.objectContaining({ date_logged: "2026-09-12T00:00:00.000Z" }),
);
});
});
});

View file

@ -1,20 +1,12 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { dateLogged, workDate } from "@/lib/clock-day";
import type { ClockEventRecord, EmployeeRecord, TimesheetEntryRecord } from "@/lib/pb-types";
function escapePb(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Midnight-UTC for the calendar date of `iso`, matching the
* `date_logged` convention every other timesheet-writing path uses
* (see src/app/api/timesheets/import/route.ts: `new Date(row.date).toISOString()`).
*/
function dateLoggedFor(iso: string): string {
return new Date(iso.slice(0, 10)).toISOString();
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
@ -54,7 +46,10 @@ export async function POST(request: Request) {
lastEvent = null;
}
if (!lastEvent || lastEvent.direction === "out") {
// The QR request contract stays unchanged for already-installed phones.
// Only a same-local-day IN can be closed by today's scan.
if (!lastEvent || lastEvent.direction === "out" ||
workDate(lastEvent.scanned_at) !== workDate(now)) {
await pb.collection("clock_events").create({
employee: employee.id,
site: employee.site,
@ -67,14 +62,14 @@ export async function POST(request: Request) {
const sessionHours = round2(
(new Date(now).getTime() - new Date(lastEvent.scanned_at).getTime()) / 3_600_000,
);
const dateLogged = dateLoggedFor(lastEvent.scanned_at);
const entryDate = dateLogged(workDate(lastEvent.scanned_at));
let entry: TimesheetEntryRecord | null;
try {
entry = await pb
.collection("timesheet_entries")
.getFirstListItem<TimesheetEntryRecord>(
`employee="${employee.id}" && date_logged="${dateLogged}" && timesheet=""`,
`employee="${employee.id}" && date_logged="${entryDate}" && timesheet=""`,
);
} catch {
entry = null;
@ -92,7 +87,7 @@ export async function POST(request: Request) {
const created = await pb.collection("timesheet_entries").create<TimesheetEntryRecord>({
employee: employee.id,
site: employee.site,
date_logged: dateLogged,
date_logged: entryDate,
hours_worked: sessionHours,
});
entryId = created.id;
@ -113,4 +108,4 @@ export async function POST(request: Request) {
sessionHours,
dailyTotalHours,
});
}
}