MEGAINFRA-WEBAPP/docs/clock-daily-exceptions-rollout.md
DEls 4493bbc0db feat(clock): pay against the real work schedule
Mon-Thu 07:00-16:30, Friday 07:00-15:00, last Friday of the month
07:00-12:00 (weekends use the Mon-Thu bands). Each shift end has a
10-minute band either side; arrivals up to 07:10 pay from 07:00.
Lunch now phases in from 5h to 5h30 instead of a 30-minute cliff, so
a normal day pays 9 / 7.5 / 5 hours.

The worker calendar judges each day against that day's expected hours,
so a normal Friday is green, and the worker page shows the schedule.
The recalc script recognises rows already rewritten by the first rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:51:24 +02:00

9.8 KiB
Raw Permalink Blame History

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 fixed work schedule. 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, and the schedule is chosen from the work date of the IN scan.

The schedule is:

Day Paid shift Expected hours
Monday–Thursday 07:00–16:30 9.00
Friday 07:00–15:00 7.50
Last Friday of the month 07:00–12:00 5.00
Saturday / Sunday 07:00–16:30 none (all work is extra)
  • Paid start: an IN at or before 10 minutes past the shift start (any earlier too) pays from the shift start; anything later pays from the actual scan instant (short day).
  • Paid end: an OUT within 10 minutes either side of the shift end (16:20–16:40, 14:50–15:10, 11:50–12:10) pays to the shift end; an earlier exit is a short day, a later one is overtime.
  • Lunch is smooth rather than a cliff: one minute of lunch per minute of span past 5 hours, capped at 30 minutes. So 9.5h span pays 9.00, 8h pays 7.50, 5h and 5h11 both pay 5.00, 5h45 pays 5.25, and anything under 5h is unpaid in full (a rain-shortened 2h50 pays 2.83). Paid hours are rounded to 2 decimals.

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. On a Friday 06:55 to 15:08 is 7.50, 07:00 to 15:11 is 7.68 and 07:00 to 14:49 is 7.32. On the last Friday of the month 06:58 to 12:06 is 5.00, 07:00 to 12:11 is 5.00 (the smooth lunch absorbs the extra minute) and 07:00 to 11:49 is 4.82; working on to 15:00 that day is 7.50.

Because the timesheet matrix reads the stored hours, it needs no change and renders the same paid figures with worked status. The worker calendar does change: it used to compare every day against the employee's single stored hours_per_day_standard. It now compares each day against expectedPaidHours(date), so a 9-hour Friday correctly shows as extra rather than normal, and a 5-hour last Friday is a full day. The calendar day details label the figure "Paid hours" with the day's expected hours and its kind (Mon–Thu / Friday / Last Friday / Weekend), and the worker detail cards show the schedule (9h Mon–Thu, 43.5h standard week) instead of the stored standard hours.

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, where it writes 2026-10-09-recalc-clock-paid-hours-v2.json. An entry is rewritten when its stored hours still match the recomputed raw total or the superseded v1 paid total, so hand-edited rows are skipped and reported rather than overwritten; entries already on the current rule are reported as unchanged, which makes a re-run a no-op. The earlier rule is kept in the script as paid_session_hours_v1 purely to recognise what the first run wrote. Its read-only dry run on 2026-10-09 (before the schedule change) reported 467 rows to change, 29 unchanged and 0 skipped.