fix(clock): start new local day with an in scan
This commit is contained in:
parent
29edf2e5aa
commit
8d728333f9
4 changed files with 54 additions and 25 deletions
|
|
@ -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\\""`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue