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>
9.8 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-lookupand/api/clock/scankeep 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-dayINyieldsnextDirection: "in"and creates a fresh rawINevent, with no hours for the old unresolved day.clock_eventsstays the QR audit log. No old event is edited or given a fabricatedOUT.- 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_eventlink. - The additive
clock_missed_out_correctionscollection stores the originalINevent, worker, site, actual out instant, reason, recording instant, and direct timesheet entry. A unique index prevents two corrections for oneIN. All direct browser rules are locked. - The internal
clock_correction_lockscollection 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 nextIN, 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 withT; 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-storeresponse 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)
- Confirm a restorable PocketBase backup and save its local path. Do not remove or rewrite historical QR events.
- Run
scripts/migrations/2026-09-25-add-missed-clock-outs.pywithout flags usingPB_URL,PB_ADMIN_EMAIL, andPB_ADMIN_PASSWORD; it prints a read-only schema diff. Review the two locked collections and unique indexes. - After the deployment decision, run the migration with
--apply --backup-confirmed /absolute/path/to/backup. Read back the collection rules and indexes. - 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.
- 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-dayINand laterOUT; verify the older card remains until corrected. Do not use a live worker badge for a synthetic test. - For each correction, obtain the real leaving date/time and reason from the supervisor. After saving, read back the correction row, unchanged original
INevent, 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
INat 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
OUTwithin 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.