From 8d728333f96d1027c820c0ef2c187cee95af93fe Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 25 Sep 2026 07:04:45 +0200 Subject: [PATCH] fix(clock): start new local day with an in scan --- .../api/clock/employee-lookup/route.test.ts | 17 ++++++++++- src/app/api/clock/employee-lookup/route.ts | 11 +++++--- src/app/api/clock/scan/route.test.ts | 28 +++++++++++++++---- src/app/api/clock/scan/route.ts | 23 ++++++--------- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/app/api/clock/employee-lookup/route.test.ts b/src/app/api/clock/employee-lookup/route.test.ts index 7732acb..4acc492 100644 --- a/src/app/api/clock/employee-lookup/route.test.ts +++ b/src/app/api/clock/employee-lookup/route.test.ts @@ -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\\""`), ); }); -}); \ No newline at end of file +}); diff --git a/src/app/api/clock/employee-lookup/route.ts b/src/app/api/clock/employee-lookup/route.ts index 5d9c7e5..dd01467 100644 --- a/src/app/api/clock/employee-lookup/route.ts +++ b/src/app/api/clock/employee-lookup/route.ts @@ -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, }); -} \ No newline at end of file +} diff --git a/src/app/api/clock/scan/route.test.ts b/src/app/api/clock/scan/route.test.ts index 68d4424..13cdf9c 100644 --- a/src/app/api/clock/scan/route.test.ts +++ b/src/app/api/clock/scan/route.test.ts @@ -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" }), ); }); -}); \ No newline at end of file +}); diff --git a/src/app/api/clock/scan/route.ts b/src/app/api/clock/scan/route.ts index 96b4be0..fa225eb 100644 --- a/src/app/api/clock/scan/route.ts +++ b/src/app/api/clock/scan/route.ts @@ -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( - `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({ 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, }); -} \ No newline at end of file +}