feat(clock): store paid hours, not the raw scan span

A scan in up to 07:10 pays from 07:00; a scan out between 16:20 and
16:40 pays to 16:30; a session over five hours loses a 30-minute lunch.
A normal day is therefore exactly 9 hours (green on the calendar and
matrix); late arrivals, early exits and overtime use the real times.
Applied on clock-out, in missed-scan corrections and their preview.
The calendar keeps the raw scan times and labels hours "Paid hours".

Includes a dry-run-first script to recalculate existing QR entries; it
only rewrites rows still equal to their raw scan total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-09 19:01:17 +02:00
parent e66ac520dc
commit 75b178dd33
14 changed files with 560 additions and 15 deletions

View file

@ -41,3 +41,38 @@ If the app release must be rolled back, pause worker scans first. Keep new corre
## Final gate
Do not apply the schema or deploy until this local diff, test/build results, backup, and the existing shared-PIN limitation have been reviewed. Production data remains untouched at this stage.
## Paid hours rule
QR sessions store PAID hours, not the raw elapsed span, because the company
pays and bills a standard 9-hour day. The rule lives in one pure module,
`src/lib/clock-paid-hours.ts`, and both the scan route and the missed-out
correction call it, so a corrected arrival lands on the same day a QR scan
would have recorded. All comparisons are in Africa/Johannesburg.
- Paid start: an `IN` at or before 07:10 local pays from 07:00; anything
later pays from the actual scan instant (short day).
- Paid end: an `OUT` between 16:20 and 16:40 inclusive pays to 16:30; an
earlier exit is a short day, a later one is overtime.
- A span longer than 5 hours loses a 30-minute lunch. Paid hours are
rounded to 2 decimals.
The same bands apply on weekends. Examples: 06:52 to 16:34 is 9.00, 07:10
to 16:40 is 9.00, 07:15 to 16:30 is 8.75, 07:00 to 17:30 is 10.00, 07:00
to 15:00 is 7.50, and a rain-shortened 07:00 to 09:50 is 2.83 because it is
under the lunch threshold.
Because the timesheet matrix and the worker calendar read the stored hours,
this rule needs no change in either report. A paid 9-hour weekday shows as
a normal day in the calendar and renders `09:00` with worked status in the
matrix; the calendar day details now label the figure "Paid hours" while
still showing the raw scan times.
`scripts/migrations/2026-10-09-recalc-clock-paid-hours.py` replays the rule
over existing scans so rows written before the change agree with new ones.
It is dry-run by default and only writes with `--apply` and
`--backup-confirmed` pointing at an existing directory. An entry is rewritten
only when its stored hours still match the recomputed raw total, so
hand-edited rows are skipped and reported rather than overwritten. Its
read-only dry run against production on 2026-10-09 reported 467 rows to
change, 29 unchanged and 0 skipped.

View file

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Rebuild direct (QR clock) timesheet_entries hours as PAID hours.
Clock scans used to store the raw elapsed span (06:52 -> 16:34 = 9.7h).
The company pays a standard 9-hour day, so `src/lib/clock-paid-hours.ts`
now stores paid hours: an IN at or before 07:10 local pays from 07:00, an
OUT between 16:20 and 16:40 inclusive pays to 16:30, and a span past five
hours loses a 30-minute lunch.
This script replays the same rule over the stored scan history so entries
written before the change agree with new ones. It only rewrites an entry
when its current hours still equal the raw recomputed total (within 0.02),
which leaves hand-edited rows alone; those are listed as skipped.
Dry run by default. Only --apply --backup-confirmed PATH (an existing
directory) writes: the old hours of every changed row are written there as
JSON before anything is PATCHed.
Credentials come from PB_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD, loaded
by scripts/pb_env.py from .env.local.
"""
import argparse
from datetime import datetime
import json
import os
from pathlib import Path
import sys
from zoneinfo import ZoneInfo
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from pb_env import PB, superuser_token, http # noqa: E402
ZONE = ZoneInfo("Africa/Johannesburg")
SHIFT_START = "07:00"
START_GRACE_MIN = 10
SHIFT_END = "16:30"
END_GRACE_MIN = 10
LUNCH_MIN = 30
LUNCH_AFTER_HOURS = 5
# Stored hours_worked must still match the raw recomputation this closely
# to be rewritten; beyond that a person edited it by hand.
MATCH_TOLERANCE = 0.02
def _minutes(value):
hour, minute = value.split(":")
return int(hour) * 60 + int(minute)
def _work_date(instant):
return instant.astimezone(ZONE).date()
def _at(date, clock):
hour, minute = clock.split(":")
return datetime(date.year, date.month, date.day, int(hour), int(minute), tzinfo=ZONE)
def _parse(iso):
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
def paid_session_hours(in_iso, out_iso):
"""Python twin of paidSessionHours() in src/lib/clock-paid-hours.ts."""
in_at = _parse(in_iso)
out_at = _parse(out_iso)
in_local = in_at.astimezone(ZONE)
out_local = out_at.astimezone(ZONE)
if in_local.hour * 60 + in_local.minute <= _minutes(SHIFT_START) + START_GRACE_MIN:
start = _at(in_local.date(), SHIFT_START)
else:
start = in_at
end_minutes = out_local.hour * 60 + out_local.minute
if _minutes(SHIFT_END) - END_GRACE_MIN <= end_minutes <= _minutes(SHIFT_END) + END_GRACE_MIN:
end = _at(out_local.date(), SHIFT_END)
else:
end = out_at
span = max(0.0, (end - start).total_seconds() / 60)
paid = span - LUNCH_MIN if span > LUNCH_AFTER_HOURS * 60 else span
return round(paid / 60, 2)
def _raw_session_hours(in_iso, out_iso):
return round((_parse(out_iso) - _parse(in_iso)).total_seconds() / 3600, 2)
def _fetch_all(token, collection, sort=None, page_size=500):
"""Page a whole collection. sort is optional: some collections reject
the system `created` field, and callers that key rows by id do not care."""
rows = []
page = 1
while True:
query = f"?page={page}&perPage={page_size}" + (f"&sort={sort}" if sort else "")
status, body = http("GET", f"/api/collections/{collection}/records{query}", token=token)
if status != 200:
raise RuntimeError(f"could not read {collection} ({status}: {body.get('message')})")
rows.extend(body.get("items", []))
if page >= body.get("totalPages", 1):
return rows
page += 1
def build_sessions(events, corrections):
"""Map timesheet_entry id -> (raw_hours, paid_hours) from scan history."""
totals = {}
def add(entry_id, in_iso, out_iso):
if not entry_id:
return
raw, paid = totals.get(entry_id, (0.0, 0.0))
totals[entry_id] = (
round(raw + _raw_session_hours(in_iso, out_iso), 2),
round(paid + paid_session_hours(in_iso, out_iso), 2),
)
# An IN followed by the next OUT on the same work date. The OUT row
# carries the timesheet_entry it was booked against.
open_in = {}
for event in sorted(events, key=lambda row: row["scanned_at"]):
stamp = event["scanned_at"]
if event["direction"] == "in":
open_in[event["employee"]] = stamp
continue
employee = event["employee"]
start = open_in.pop(employee, None)
if not start or _work_date(_parse(start)) != _work_date(_parse(stamp)):
continue
add(event.get("timesheet_entry"), start, stamp)
# A missed exit has no OUT row: the correction supplies the pair.
by_id = {event["id"]: event for event in events}
for correction in corrections:
clock_in = by_id.get(correction.get("clock_in_event"))
if not clock_in:
continue
start = correction.get("actual_in_at") or clock_in["scanned_at"]
add(correction.get("timesheet_entry"), start, correction["actual_out_at"])
return totals
def plan(token):
events = _fetch_all(token, "clock_events", "scanned_at")
corrections = _fetch_all(token, "clock_missed_out_corrections", "recorded_at")
sessions = build_sessions(events, corrections)
entries = {row["id"]: row for row in _fetch_all(token, "timesheet_entries")}
changed, unchanged, skipped = [], [], []
for entry_id, (raw, paid) in sorted(sessions.items()):
row = entries.get(entry_id)
if not row:
skipped.append((entry_id, "entry could not be read"))
continue
if row.get("timesheet"):
skipped.append((entry_id, "imported timesheet row"))
continue
current = round(float(row.get("hours_worked") or 0), 2)
if abs(current - raw) > MATCH_TOLERANCE:
skipped.append((entry_id, f"stored {current} differs from raw {raw} (hand edited)"))
continue
if abs(current - paid) <= MATCH_TOLERANCE:
unchanged.append((row, paid))
continue
changed.append((row, paid))
return changed, unchanged, skipped
def report(changed, unchanged, skipped):
print(f"to change: {len(changed)}")
print(f"unchanged: {len(unchanged)}")
print(f"skipped: {len(skipped)}")
if skipped:
print("\nskipped rows")
for entry_id, reason in skipped:
print(f" {entry_id}: {reason}")
if changed:
print("\nsample of changes")
print(f"{'employee':<14}{'date':<12}{'current':>9}{'new':>9}")
for row, paid in changed[:20]:
print(f"{str(row.get('employee'))[:13]:<14}{str(row.get('date_logged'))[:10]:<12}"
f"{float(row.get('hours_worked') or 0):>9.2f}{paid:>9.2f}")
if len(changed) > 20:
print(f" ... and {len(changed) - 20} more")
def main():
parser = argparse.ArgumentParser(description="Recalculate clock paid hours")
parser.add_argument("--apply", action="store_true")
parser.add_argument("--backup-confirmed", type=Path)
args = parser.parse_args()
if args.apply and (not args.backup_confirmed or not args.backup_confirmed.is_dir()):
parser.error("--apply requires --backup-confirmed to be an existing directory")
if not (os.environ.get("POCKETBASE_ADMIN_EMAIL") and os.environ.get("POCKETBASE_ADMIN_PASSWORD")):
print(f"No PocketBase admin credentials in the environment or .env.local ({PB}).")
print("Offline plan only. No writes.")
return 0
print(f"Mode: {'APPLY' if args.apply else 'DRY RUN'}")
token = superuser_token()
changed, unchanged, skipped = plan(token)
report(changed, unchanged, skipped)
if not args.apply:
print("\nNo writes.")
return 0
backup = args.backup_confirmed / "2026-10-09-recalc-clock-paid-hours.json"
backup.write_text(json.dumps(
[{"id": row["id"], "employee": row.get("employee"),
"date_logged": row.get("date_logged"),
"hours_worked": row.get("hours_worked")} for row, _ in changed],
indent=2), encoding="utf-8")
print(f"\nbackup written: {backup}")
for row, paid in changed:
code, saved = http("PATCH", f"/api/collections/timesheet_entries/records/{row['id']}",
{"hours_worked": paid}, token=token)
if code != 200:
raise RuntimeError(f"update failed for {row['id']} ({code})")
if round(float(saved.get("hours_worked")), 2) != round(paid, 2):
raise RuntimeError(f"update could not be verified for {row['id']}")
print(f"updated {len(changed)} entries")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except RuntimeError as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,93 @@
"""Pure rule checks; never connects to production."""
import importlib.util
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
import unittest
script = Path(__file__).with_name("2026-10-09-recalc-clock-paid-hours.py")
spec = importlib.util.spec_from_file_location("recalc_clock_paid_hours", script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
def at(local):
"""UTC ISO for a Africa/Johannesburg wall time."""
hour, minute = (int(part) for part in local.split(":"))
return (datetime(2026, 10, 6, hour, minute, tzinfo=ZoneInfo("Africa/Johannesburg"))
.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"))
# The same cases the TypeScript test asserts, so the two ports cannot drift.
CASES = [
("06:52", "16:34", 9.00),
("07:10", "16:40", 9.00),
("06:30", "16:30", 9.00),
("07:15", "16:30", 8.75),
("07:00", "17:30", 10.00),
("07:00", "15:00", 7.50),
("07:00", "14:00", 6.50),
("07:00", "09:50", 2.83),
("06:55", "16:19", 8.82),
("06:49", "16:30", 9.00),
("06:50", "16:30", 9.00),
("07:11", "16:30", 8.82),
("07:00", "16:19", 8.82),
("07:00", "16:20", 9.00),
("07:00", "16:41", 9.18),
("07:00", "12:00", 5.00),
("07:00", "12:01", 4.52),
]
class PaidSessionHoursTests(unittest.TestCase):
def test_matches_the_typescript_examples(self):
for start, end, expected in CASES:
with self.subTest(session=f"{start}-{end}"):
self.assertAlmostEqual(
module.paid_session_hours(at(start), at(end)), expected, places=2)
def test_reads_a_utc_scan_instant_as_sast(self):
self.assertEqual(
module.paid_session_hours("2026-10-06T04:52:00.000Z", "2026-10-06T14:34:00.000Z"),
9.0)
def test_returns_zero_when_the_end_precedes_the_paid_start(self):
self.assertEqual(module.paid_session_hours(at("07:15"), at("07:05")), 0)
class BuildSessionsTests(unittest.TestCase):
def test_pairs_an_in_with_the_next_out_and_sums_paid_hours(self):
events = [
{"id": "e1", "employee": "emp1", "direction": "in",
"scanned_at": "2026-10-06T04:52:00.000Z"},
{"id": "e2", "employee": "emp1", "direction": "out",
"scanned_at": "2026-10-06T14:34:00.000Z", "timesheet_entry": "entry1"},
]
raw, paid = module.build_sessions(events, [])["entry1"]
self.assertAlmostEqual(raw, 9.7, places=2)
self.assertEqual(paid, 9.0)
def test_ignores_an_out_on_a_different_work_date(self):
events = [
{"id": "e1", "employee": "emp1", "direction": "in",
"scanned_at": "2026-10-06T21:00:00.000Z"},
{"id": "e2", "employee": "emp1", "direction": "out",
"scanned_at": "2026-10-06T22:00:00.000Z", "timesheet_entry": "entry1"},
]
self.assertEqual(module.build_sessions(events, []), {})
def test_uses_the_corrected_arrival_when_one_was_recorded(self):
events = [{"id": "e1", "employee": "emp1", "direction": "in",
"scanned_at": "2026-09-23T14:00:00.000Z"}]
corrections = [{"clock_in_event": "e1", "timesheet_entry": "entry1",
"actual_in_at": "2026-09-23T05:00:00.000Z",
"actual_out_at": "2026-09-23T14:00:00.000Z"}]
raw, paid = module.build_sessions(events, corrections)["entry1"]
self.assertAlmostEqual(raw, 9.0, places=2)
self.assertEqual(paid, 8.5)
if __name__ == "__main__":
unittest.main()

View file

@ -59,7 +59,8 @@ describe("POST /api/clock/scan", () => {
const response = await post({ employee_no: "MCW058" });
expect(response.status).toBe(200);
expect((await response.json()).direction).toBe("out");
expect(entriesCreate).toHaveBeenCalledWith(expect.objectContaining({ hours_worked: 8 }));
// 08:00 -> 16:00 SAST raw is 8h; paid drops the 30-minute lunch.
expect(entriesCreate).toHaveBeenCalledWith(expect.objectContaining({ hours_worked: 7.5 }));
} finally { vi.useRealTimers(); }
});
beforeEach(() => {
@ -159,7 +160,7 @@ describe("POST /api/clock/scan", () => {
clockEventsCreate.mockResolvedValueOnce({ id: "evt2" });
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-11T14:30:00.000Z")); // 8.5h after clock-in
vi.setSystemTime(new Date("2026-09-11T14:30:00.000Z")); // 08:00 -> 16:30 SAST
const res = await post({ employee_no: "MCW058" });
const json = await res.json();
@ -167,13 +168,14 @@ describe("POST /api/clock/scan", () => {
vi.useRealTimers();
expect(json.direction).toBe("out");
expect(json.sessionHours).toBe(8.5);
expect(json.dailyTotalHours).toBe(8.5);
// Raw span is 8.5h; paid hours subtract the 30-minute lunch.
expect(json.sessionHours).toBe(8);
expect(json.dailyTotalHours).toBe(8);
expect(entriesCreate).toHaveBeenCalledWith({
employee: EMPLOYEE.id,
site: EMPLOYEE.site,
date_logged: "2026-09-11T00:00:00.000Z",
hours_worked: 8.5,
hours_worked: 8,
});
expect(clockEventsCreate).toHaveBeenCalledWith(
expect.objectContaining({ direction: "out", timesheet_entry: "entry1" }),

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { dateLogged, workDate } from "@/lib/clock-day";
import { paidSessionHours } from "@/lib/clock-paid-hours";
import type { ClockEventRecord, EmployeeRecord, TimesheetEntryRecord } from "@/lib/pb-types";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { isWorkerActive } from "@/lib/worker-status";
@ -66,9 +67,8 @@ export async function POST(request: Request) {
return NextResponse.json({ direction: "in", employeeName: employee.name, at: now });
}
const sessionHours = round2(
(new Date(now).getTime() - new Date(lastEvent.scanned_at).getTime()) / 3_600_000,
);
// The company bills a standard day, so a scan stores PAID hours, not the raw span.
const sessionHours = paidSessionHours(lastEvent.scanned_at, now);
const entryDate = dateLogged(workDate(lastEvent.scanned_at));
const pbEntryDate = entryDate.replace("T", " ");

View file

@ -4,6 +4,7 @@ import * as React from "react";
import { useRouter } from "next/navigation";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { format24HourInput, localOutInstant, localTimeHHmm, workDate } from "@/lib/clock-day";
import { paidSessionHours } from "@/lib/clock-paid-hours";
import type { AttentionRow } from "./on-site-view";
import { formatDecimal } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
@ -37,8 +38,12 @@ function CorrectionForm({ shift, onClose }: {
try {
const start = localOutInstant(`${inDate}T${inTime}`);
const out = localOutInstant(`${outDate}T${outTime}`);
const value = (out.getTime() - start.getTime()) / 3_600_000;
if (Number.isFinite(value) && value >= 0) hours = Math.round(value * 100) / 100;
// A leaving time at or before the arrival is not a session yet; the
// operator still has to correct the entered times.
if (out.getTime() > start.getTime()) {
// Same rule the API stores, so the preview matches the saved value.
hours = paidSessionHours(start.toISOString(), out.toISOString());
}
} catch {
// The form keeps its entered values for correction.
}

View file

@ -73,8 +73,8 @@ export function WorkerMonthCalendar({ employeeId, timesheets, scans, notes, stan
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 text-xs">{(Object.keys(labels) as DayStatus[]).map(status=><span key={status} className="inline-flex items-center gap-1.5"><span className={cn("h-3 w-3 rounded-sm border",colours[status])} />{labels[status]}</span>)}<span className="inline-flex items-center gap-1.5"><CloudRain className="h-3.5 w-3.5" />Estimated rain</span></div>
<p className="mt-3 text-xs text-muted-foreground">Monday–Friday are expected workdays. Weekends stay grey unless hours or clock scans are recorded. Red means check the records, not confirmed absence. Tap a day for details.</p>
<p className="mt-1 text-xs text-muted-foreground">{month>today.slice(0,7) ? "Rain history is available for completed days." : weather?.month!==month ? "Loading rain history…" : weather.error ?? "Rain markers use the site recorded for that day. Missing weather is unknown; rainfall does not confirm why a shift was short."} <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer" className="underline">Weather: Open-Meteo</a></p>
<Dialog open={!!selected} onOpenChange={open=>{if(!open)setSelectedDate("");}}><DialogContent className="sm:max-w-lg"><DialogHeader><DialogTitle>{selected?.date} · {selected && labels[selected.status]}</DialogTitle><DialogDescription>Recorded hours and attendance details for this day.</DialogDescription></DialogHeader>{selected && <div className="space-y-3 text-sm">
<p><strong>Recorded hours:</strong> {formatNumber(selected.hours,{maximumFractionDigits:2})}h · Standard weekday: {standardHours>0 ? `${standardHours}h` : "Not set"}</p>
<Dialog open={!!selected} onOpenChange={open=>{if(!open)setSelectedDate("");}}><DialogContent className="sm:max-w-lg"><DialogHeader><DialogTitle>{selected?.date} · {selected && labels[selected.status]}</DialogTitle><DialogDescription>Paid hours and attendance details for this day.</DialogDescription></DialogHeader>{selected && <div className="space-y-3 text-sm">
<p><strong>Paid hours:</strong> {formatNumber(selected.hours,{maximumFractionDigits:2})}h · Standard weekday: {standardHours>0 ? `${standardHours}h` : "Not set"}</p>
{selected.extraHours>0 && <p>Above standard: {formatNumber(selected.extraHours,{maximumFractionDigits:2})}h</p>}
{selected.weekend && selected.hours>0 && <p>Weekend work recorded. This flag does not change payroll.</p>}
{selected.issues.map(issue=><p key={issue} className="text-red-700 dark:text-red-300">{issue}</p>)}

View file

@ -54,6 +54,7 @@ describe("missed scan-out correction", () => {
const result = await correctMissedClockOut(pb as never, "oldin", {
actualOutLocal: "2026-09-23T17:00", reason: "Supervisor confirmed missed exit scan", now,
});
// 16:00 -> 17:00 SAST is 1h, under the lunch threshold, so paid = raw.
expect(result.sessionHours).toBe(1);
expect(updated).toContainEqual({ collection: "timesheet_entries", id: "entry1", data: { hours_worked: 10.24 } });
expect(entriesGetFullList).toHaveBeenCalledWith(expect.objectContaining({
@ -134,7 +135,8 @@ describe("corrected arrival when the morning scan was missed", () => {
reason: "Nobody scanned him in this morning",
now,
});
expect(result.sessionHours).toBe(9);
// 07:00 -> 16:00 SAST is 9h raw; paid hours subtract the 30-minute lunch.
expect(result.sessionHours).toBe(8.5);
expect(created.find((row) => row.collection === "clock_missed_out_corrections")?.data)
.toMatchObject({ actual_in_at: "2026-09-23T05:00:00.000Z", actual_out_at: "2026-09-23T14:00:00.000Z" });
});

View file

@ -1,6 +1,7 @@
import { createHash, randomUUID } from "node:crypto";
import type PocketBase from "pocketbase";
import { dateLogged, localOutInstant, workDate } from "@/lib/clock-day";
import { paidSessionHours } from "@/lib/clock-paid-hours";
import { unresolvedClockIns } from "@/lib/clock-events";
import type { ClockEventRecord, ClockMissedOutCorrectionRecord, TimesheetEntryRecord } from "@/lib/pb-types";
@ -136,7 +137,9 @@ export async function correctMissedClockOut(
if (entries.length > 1) {
throw new ClockCorrectionError("More than one direct time entry exists for this day. Review it manually.", 409);
}
const sessionHours = round2((actualOut.getTime() - start) / 3_600_000);
// A correction stores PAID hours too, so a corrected arrival lands on the
// same 9-hour day the QR path would have recorded.
const sessionHours = paidSessionHours(new Date(start).toISOString(), actualOut.toISOString());
const existing = entries[0];
const entryId = existing?.id ?? createHash("sha256")
.update(`clock-direct:${clockIn.employee}:${workDate(clockIn.scanned_at)}`)

View file

@ -78,6 +78,20 @@ export function localTimeHHmm(iso: string): string {
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
/** Minutes past local midnight for a scan instant, for shift comparisons. */
export function localMinutesOfDay(iso: string): number {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) throw new Error("Invalid scan instant");
const { hour, minute } = partsAt(date);
return hour * 60 + minute;
}
/** The instant of an "HH:mm" clock time on a given local work date. */
export function localInstant(localWorkDate: string, time: string): Date {
if (!/^\d{2}:\d{2}$/.test(time)) throw new Error("Invalid local time");
return localOutInstant(`${localWorkDate}T${time}`);
}
/** Date-only timesheet convention: UTC midnight for the South African work date. */
export function dateLogged(localWorkDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(localWorkDate);

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { paidSessionHours } from "./clock-paid-hours";
import { localOutInstant } from "./clock-day";
/** Build a UTC ISO instant from a local Africa/Johannesburg wall time. */
function at(local: string): string {
return localOutInstant(local).toISOString();
}
describe("paidSessionHours", () => {
it("pays a full standard day for a normal scan pair", () => {
expect(paidSessionHours(at("2026-10-06T06:52"), at("2026-10-06T16:34"))).toBe(9);
});
it("accepts both edges of the arrival and departure grace bands", () => {
expect(paidSessionHours(at("2026-10-06T07:10"), at("2026-10-06T16:40"))).toBe(9);
expect(paidSessionHours(at("2026-10-06T06:30"), at("2026-10-06T16:30"))).toBe(9);
});
it("charges a short day for a late arrival inside the shift", () => {
expect(paidSessionHours(at("2026-10-06T07:15"), at("2026-10-06T16:30"))).toBe(8.75);
});
it("pays overtime past the departure band", () => {
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T17:30"))).toBe(10);
});
it("pays a shorter Friday and afternoon session", () => {
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T15:00"))).toBe(7.5);
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T14:00"))).toBe(6.5);
});
it("skips lunch for a rain-shortened morning under five hours", () => {
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T09:50"))).toBe(2.83);
});
it("charges lunch when the departure misses the band by a minute", () => {
expect(paidSessionHours(at("2026-10-06T06:55"), at("2026-10-06T16:19"))).toBe(8.82);
});
it("switches the paid start at the 07:10 arrival edge", () => {
expect(paidSessionHours(at("2026-10-06T06:49"), at("2026-10-06T16:30"))).toBe(9);
expect(paidSessionHours(at("2026-10-06T06:50"), at("2026-10-06T16:30"))).toBe(9);
expect(paidSessionHours(at("2026-10-06T07:11"), at("2026-10-06T16:30"))).toBe(8.82);
});
it("switches the paid end at the 16:20 and 16:40 departure edges", () => {
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T16:19"))).toBe(8.82);
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T16:20"))).toBe(9);
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T16:40"))).toBe(9);
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T16:41"))).toBe(9.18);
});
it("charges lunch only when the span exceeds five hours", () => {
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T12:00"))).toBe(5);
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T12:01"))).toBe(4.52);
});
it("applies the same bands on a weekend", () => {
expect(paidSessionHours(at("2026-10-10T06:52"), at("2026-10-10T16:34"))).toBe(9);
expect(paidSessionHours(at("2026-10-10T07:00"), at("2026-10-10T17:30"))).toBe(10);
});
it("treats a corrected arrival like any other scan", () => {
expect(paidSessionHours(at("2026-10-06T07:00"), at("2026-10-06T16:00"))).toBe(8.5);
});
it("reads a UTC scan instant as its South African wall time", () => {
expect(paidSessionHours("2026-10-06T04:52:00.000Z", "2026-10-06T14:34:00.000Z")).toBe(9);
expect(paidSessionHours("2026-10-06T05:15:00.000Z", "2026-10-06T14:30:00.000Z")).toBe(8.75);
});
it("returns zero for an end before the paid start", () => {
expect(paidSessionHours(at("2026-10-06T07:15"), at("2026-10-06T07:05"))).toBe(0);
});
it("rejects an unparseable scan instant", () => {
expect(() => paidSessionHours("not-a-time", at("2026-10-06T16:30"))).toThrow("Invalid scan instant");
expect(() => paidSessionHours(at("2026-10-06T07:00"), "not-a-time")).toThrow("Invalid scan instant");
});
});

View file

@ -0,0 +1,53 @@
import { localInstant, localMinutesOfDay, workDate } from "@/lib/clock-day";
/** Standard paid shift start, Africa/Johannesburg. */
export const SHIFT_START = "07:00";
/** Arrivals up to this many minutes after SHIFT_START still pay from SHIFT_START. */
export const START_GRACE_MIN = 10;
/** Standard paid shift end, Africa/Johannesburg. */
export const SHIFT_END = "16:30";
/** Departures this many minutes either side of SHIFT_END still pay to SHIFT_END. */
export const END_GRACE_MIN = 10;
/** Unpaid lunch taken when the shift runs past LUNCH_AFTER_HOURS. */
export const LUNCH_MIN = 30;
export const LUNCH_AFTER_HOURS = 5;
/**
* Paid hours for one QR session. The company bills a standard 9-hour day, so
* stored hours are the paid span, not the raw clock span:
*
* - paid start: an IN at or before 07:10 local pays from 07:00; a later IN
* pays from the actual scan instant (short day).
* - paid end: an OUT between 16:20 and 16:40 inclusive pays to 16:30; an
* earlier OUT is a short day, a later one is overtime.
* - span is 30 minutes shorter once it runs past 5 hours (lunch).
*
* Applied on weekends too, and to corrected arrivals.
*/
export function paidSessionHours(inIso: string, outIso: string): number {
const inAt = new Date(inIso);
const outAt = new Date(outIso);
if (Number.isNaN(inAt.getTime()) || Number.isNaN(outAt.getTime())) throw new Error("Invalid scan instant");
const [endFrom, endTo] = endWindowMinutes();
const start = localMinutesOfDay(inIso) <= shiftMinutes(SHIFT_START) + START_GRACE_MIN
? localInstant(workDate(inIso), SHIFT_START)
: inAt;
const endMinutes = localMinutesOfDay(outIso);
const end = endMinutes >= endFrom && endMinutes <= endTo
? localInstant(workDate(outIso), SHIFT_END)
: outAt;
const span = Math.max(0, (end.getTime() - start.getTime()) / 60_000);
const paid = span > LUNCH_AFTER_HOURS * 60 ? span - LUNCH_MIN : span;
return Math.round((paid / 60) * 100) / 100;
}
function shiftMinutes(value: string): number {
const [hour, minute] = value.split(":").map(Number);
return hour * 60 + minute;
}
function endWindowMinutes(): [number, number] {
return [shiftMinutes(SHIFT_END) - END_GRACE_MIN, shiftMinutes(SHIFT_END) + END_GRACE_MIN];
}

View file

@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import { buildTimesheetMatrix, matrixCsv, matrixRange } from "./timesheet-matrix";
import { buildTimesheetMatrix, cellLabel, matrixCsv, matrixRange } from "./timesheet-matrix";
const input={start:"2026-10-01",end:"2026-10-07",today:"2026-10-07",sites:[{id:"s1",name:"Site One",project_code:"S1"}],employees:[{id:"e1",name:"Worker One",employee_no:"001",site:"s1"}],entries:[{employee:"e1",site:"s1",date_logged:"2026-10-02",hours_worked:7.5},{employee:"e1",site:"s1",date_logged:"2026-10-02",hours_worked:1.25},{employee:"e1",site:"s1",date_logged:"2026-10-03",hours_worked:4}],notes:[{employee:"e1",note_date:"2026-10-04",end_date:"2026-10-06"}]};
it("sums daily minutes and calendar-week totals without merging sites",()=>{
const result=buildTimesheetMatrix({...input,sites:[...input.sites,{id:"s2",name:"Site Two",project_code:"S2"}],entries:[...input.entries,{employee:"e1",site:"s2",date_logged:"2026-10-02",hours_worked:2}]});
@ -32,3 +32,10 @@ it("identifies days with multiple source entries while retaining their hours",()
expect(matrix.sites[0].workers[0].totalMinutes).toBe(765);
expect(matrixCsv(matrix)).toContain('"Multiple-entry days"');
});
it("renders a paid 9-hour clock day as 09:00 with worked status",()=>{
const matrix=buildTimesheetMatrix({...input,entries:[{employee:"e1",site:"s1",date_logged:"2026-10-05",hours_worked:9}],notes:[]});
const cell=matrix.sites[0].workers[0].cells[4];
expect(cell.minutes).toBe(540);
expect(cellLabel(cell)).toBe("09:00");
expect(cell.status).toBe("worked");
});

View file

@ -45,3 +45,16 @@ it("keeps weekend clock problems visible instead of greying recorded attendance"
const days=workerCalendarDays({...base,scans:[{id:"in",at:"2026-10-03T06:00:00Z",direction:"in" as const}]});
expect(days[2].status).toBe("attention");
});
it("treats a paid 9-hour weekday as normal and a 10-hour weekday as extra",()=>{
const nine=workerCalendarDays({...base,timesheets:[{date:"2026-10-01",hours:9}],scans:[{id:"in",at:"2026-10-01T04:52:00Z",direction:"in" as const},{id:"out",at:"2026-10-01T14:34:00Z",direction:"out" as const}]});
expect(nine.find(d=>d.date==="2026-10-01")?.status).toBe("normal");
expect(nine.find(d=>d.date==="2026-10-01")?.shortDay).toBe(false);
const ten=workerCalendarDays({...base,timesheets:[{date:"2026-10-01",hours:10}]});
expect(ten.find(d=>d.date==="2026-10-01")?.status).toBe("extra");
expect(ten.find(d=>d.date==="2026-10-01")?.extraHours).toBe(1);
});
it("keeps the raw scan times shown next to the paid hours",()=>{
const days=workerCalendarDays({...base,timesheets:[{date:"2026-10-01",hours:9}],scans:[{id:"in",at:"2026-10-01T04:52:00Z",direction:"in" as const},{id:"out",at:"2026-10-01T14:34:00Z",direction:"out" as const}]});
expect(days[0].hours).toBe(9);
expect(days[0].scans.map(s=>({direction:s.direction,at:s.at}))).toEqual([{direction:"in",at:"2026-10-01T04:52:00Z"},{direction:"out",at:"2026-10-01T14:34:00Z"}]);
});