MEGAINFRA-WEBAPP/docs/clock-daily-exceptions-rollout.md
DEls 75b178dd33 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>
2026-10-09 19:01:17 +02:00

8.4 KiB

MEGA Clock: new-day scans and missed scan-outs

Agreed operator behavior

The installed phone app keeps its existing SITE MANAGER ACCESS PIN page, Person scanner, confirmation screen, and API request shape. Workers do not sign in. A scan after midnight in Africa/Johannesburg starts a new IN for that local day, even if an earlier day's IN has no matching OUT. The older scan remains unresolved; the new day's later scan closes only the new day's attendance.

The on-site dashboard has a fourth Needs attention column. It shows unmatched earlier-day IN scans with no seven-day expiry. A worker can be both on site today and in Needs attention for an older day. Clicking a card asks for the actual leaving date, leaving time, and a reason. The original QR event is retained. A correction creates a separate audit row and updates or creates the direct time total for the original local day once.

There is no new login screen. The manual correction route checks the existing shared PIN against server APP_PIN and requires a same-origin request. The PIN is currently displayed on the app's login page and does not identify an individual operator; this is the current app's shared-PIN trust level. A future main-app login should replace this with a named user and site authorization before relying on individual accountability.

Implementation boundaries

  • /api/clock/employee-lookup and /api/clock/scan keep the request and response fields used by installed phones. They compare the last scan's South African local date with today's local date. A prior-day IN yields nextDirection: "in" and creates a fresh raw IN event, with no hours for the old unresolved day.
  • clock_events stays the QR audit log. No old event is edited or given a fabricated OUT.
  • The dashboard derives unresolved scans from the full event history, independently of the seven-day Not arrived roster. Corrections are excluded by their unique clock_in_event link.
  • The additive clock_missed_out_corrections collection stores the original IN event, worker, site, actual out instant, reason, recording instant, and direct timesheet entry. A unique index prevents two corrections for one IN. All direct browser rules are locked.
  • The internal clock_correction_locks collection serializes corrections for one worker. A stale lock must be reviewed and removed by an administrator after verifying the correction and time entry; it is never silently stolen.
  • A correction rejects a missing reason, leaving time before the original IN, a future time, a time overlapping the next IN, an already-resolved event, or duplicate direct time rows for the same employee/day. An imported sheet row is never updated. The direct time row and audit correction are written together in a PocketBase batch. The production batch setting was read-only checked as enabled (50 requests, 30-second timeout) on 2026-09-25.
  • The exact PocketBase date filter uses a space (YYYY-MM-DD 00:00:00.000Z) even though app code writes ISO with T; this avoids missing an existing direct row. The timesheet's stored date remains midnight UTC for the local work date.
  • The production service worker normally caches API GET responses using NetworkFirst. The new build gives employee lookup a NetworkOnly rule and a no-store response so yesterday's confirmation is not reused when the network is unavailable. Scan POST already requires a network connection.

Read-only evidence and local verification

At the last read-only check, production had 119 QR events: 63 IN and 56 OUT. The seven unresolved 23 September badges were MCW025, MCW034, MCW070, MCW127, MCW177, SUB00109, and SUB0098. The local dashboard preview showed seven Needs attention cards and zero on-site rows before any new-day scans. The local lookup endpoint returned in for all seven. Of the seven, only SUB00109 had an existing direct 23 September time row; it was unique. No live schema, event, time, or correction record was written during these checks.

The prior shift-backfill prototype found nine historical anomalies outside the immediate seven-case flow. This design avoids a historical backfill. Corrections for any employee/day with duplicate direct entries fail closed for manual review.

Tests cover the South African midnight boundary, old unmatched IN followed by today's IN, direct-row date matching, correction overlap, duplicate correction, imported-sheet preservation, and the worker appearing in today's state and Needs attention at once. On 2026-09-25 the complete Vitest suite passed (121 files, 1,372 tests), the production build passed, TypeScript passed, and the migration's two Python tests passed. The generated service worker contains the NetworkOnly employee-lookup rule before the default runtime routes. The migration's offline dry run reported the two locked collections and made no writes.

Production sequence (pending review)

  1. Confirm a restorable PocketBase backup and save its local path. Do not remove or rewrite historical QR events.
  2. Run scripts/migrations/2026-09-25-add-missed-clock-outs.py without flags using PB_URL, PB_ADMIN_EMAIL, and PB_ADMIN_PASSWORD; it prints a read-only schema diff. Review the two locked collections and unique indexes.
  3. After the deployment decision, run the migration with --apply --backup-confirmed /absolute/path/to/backup. Read back the collection rules and indexes.
  4. Deploy the app build as one server release. No phone app reinstall or new login is required. Open the installed PWA online so the updated service worker activates; verify the lookup reaches the network.
  5. Before any test write, read back all seven lookups: each must say in. The dashboard must show seven Needs attention cards, with none falsely shown as On site now. Observe a real worker's new-day IN and later OUT; verify the older card remains until corrected. Do not use a live worker badge for a synthetic test.
  6. For each correction, obtain the real leaving date/time and reason from the supervisor. After saving, read back the correction row, unchanged original IN event, and direct time entry for the original local date. Never guess a leaving time.

If the app release must be rolled back, pause worker scans first. Keep new correction rows and original QR events. Reconcile any new-day IN scans written after cutover before running the old scan server again, because the old server would treat an unmatched earlier-day IN as the next OUT.

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.