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:
parent
e66ac520dc
commit
75b178dd33
14 changed files with 560 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
237
scripts/migrations/2026-10-09-recalc-clock-paid-hours.py
Normal file
237
scripts/migrations/2026-10-09-recalc-clock-paid-hours.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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", " ");
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>)}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
81
src/lib/clock-paid-hours.test.ts
Normal file
81
src/lib/clock-paid-hours.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
src/lib/clock-paid-hours.ts
Normal file
53
src/lib/clock-paid-hours.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"}]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue