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>
104 lines
9.8 KiB
Markdown
104 lines
9.8 KiB
Markdown
# 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.
|