diff --git a/docs/clock-daily-exceptions-rollout.md b/docs/clock-daily-exceptions-rollout.md index f06e200..b8ba615 100644 --- a/docs/clock-daily-exceptions-rollout.md +++ b/docs/clock-daily-exceptions-rollout.md @@ -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. diff --git a/scripts/migrations/2026-10-09-recalc-clock-paid-hours.py b/scripts/migrations/2026-10-09-recalc-clock-paid-hours.py new file mode 100644 index 0000000..ccd34f4 --- /dev/null +++ b/scripts/migrations/2026-10-09-recalc-clock-paid-hours.py @@ -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) \ No newline at end of file diff --git a/scripts/migrations/test_2026_10_09_recalc_clock_paid_hours.py b/scripts/migrations/test_2026_10_09_recalc_clock_paid_hours.py new file mode 100644 index 0000000..94827a2 --- /dev/null +++ b/scripts/migrations/test_2026_10_09_recalc_clock_paid_hours.py @@ -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() \ 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 9ae25f1..c823448 100644 --- a/src/app/api/clock/scan/route.test.ts +++ b/src/app/api/clock/scan/route.test.ts @@ -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" }), diff --git a/src/app/api/clock/scan/route.ts b/src/app/api/clock/scan/route.ts index b42a7b1..c2be06d 100644 --- a/src/app/api/clock/scan/route.ts +++ b/src/app/api/clock/scan/route.ts @@ -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", " "); diff --git a/src/app/dashboard/workforce/on-site/correction-dialog.tsx b/src/app/dashboard/workforce/on-site/correction-dialog.tsx index 9af05d7..e4ea9e3 100644 --- a/src/app/dashboard/workforce/on-site/correction-dialog.tsx +++ b/src/app/dashboard/workforce/on-site/correction-dialog.tsx @@ -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. } diff --git a/src/components/worker-month-calendar.tsx b/src/components/worker-month-calendar.tsx index 1379b26..9986dbf 100644 --- a/src/components/worker-month-calendar.tsx +++ b/src/components/worker-month-calendar.tsx @@ -73,8 +73,8 @@ export function WorkerMonthCalendar({ employeeId, timesheets, scans, notes, stan
{(Object.keys(labels) as DayStatus[]).map(status=>{labels[status]})}Estimated rain

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.

{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."} Weather: Open-Meteo

- {if(!open)setSelectedDate("");}}>{selected?.date} · {selected && labels[selected.status]}Recorded hours and attendance details for this day.{selected &&
-

Recorded hours: {formatNumber(selected.hours,{maximumFractionDigits:2})}h · Standard weekday: {standardHours>0 ? `${standardHours}h` : "Not set"}

+ {if(!open)setSelectedDate("");}}>{selected?.date} · {selected && labels[selected.status]}Paid hours and attendance details for this day.{selected &&
+

Paid hours: {formatNumber(selected.hours,{maximumFractionDigits:2})}h · Standard weekday: {standardHours>0 ? `${standardHours}h` : "Not set"}

{selected.extraHours>0 &&

Above standard: {formatNumber(selected.extraHours,{maximumFractionDigits:2})}h

} {selected.weekend && selected.hours>0 &&

Weekend work recorded. This flag does not change payroll.

} {selected.issues.map(issue=>

{issue}

)} diff --git a/src/lib/clock-correction.test.ts b/src/lib/clock-correction.test.ts index 2863f9d..dcdb223 100644 --- a/src/lib/clock-correction.test.ts +++ b/src/lib/clock-correction.test.ts @@ -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" }); }); diff --git a/src/lib/clock-correction.ts b/src/lib/clock-correction.ts index 07bab4e..7b6b38a 100644 --- a/src/lib/clock-correction.ts +++ b/src/lib/clock-correction.ts @@ -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)}`) diff --git a/src/lib/clock-day.ts b/src/lib/clock-day.ts index b4523c1..f3e9e7a 100644 --- a/src/lib/clock-day.ts +++ b/src/lib/clock-day.ts @@ -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); diff --git a/src/lib/clock-paid-hours.test.ts b/src/lib/clock-paid-hours.test.ts new file mode 100644 index 0000000..e8b6b0c --- /dev/null +++ b/src/lib/clock-paid-hours.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/lib/clock-paid-hours.ts b/src/lib/clock-paid-hours.ts new file mode 100644 index 0000000..ce5773a --- /dev/null +++ b/src/lib/clock-paid-hours.ts @@ -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]; +} \ No newline at end of file diff --git a/src/lib/timesheet-matrix.test.ts b/src/lib/timesheet-matrix.test.ts index f969b0a..bf7805f 100644 --- a/src/lib/timesheet-matrix.test.ts +++ b/src/lib/timesheet-matrix.test.ts @@ -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"); +}); diff --git a/src/lib/worker-calendar.test.ts b/src/lib/worker-calendar.test.ts index af5d2be..430db3f 100644 --- a/src/lib/worker-calendar.test.ts +++ b/src/lib/worker-calendar.test.ts @@ -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"}]); +});