From e3fefd54193db73beaaf88cb12b65e80b8d3917c Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 12 Sep 2026 00:30:54 +0200 Subject: [PATCH] docs(clock): add implementation plan for MEGA Clock timeclock PWA Co-Authored-By: Claude Sonnet 5 --- .../2026-09-12-mega-clock-timeclock-pwa.md | 1670 +++++++++++++++++ 1 file changed, 1670 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-mega-clock-timeclock-pwa.md diff --git a/docs/superpowers/plans/2026-09-12-mega-clock-timeclock-pwa.md b/docs/superpowers/plans/2026-09-12-mega-clock-timeclock-pwa.md new file mode 100644 index 0000000..e94633d --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-mega-clock-timeclock-pwa.md @@ -0,0 +1,1670 @@ +# MEGA Clock — standalone QR timeclock PWA Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `/clock`, a standalone PWA (installable separately from "MEGA OPS") that scans a worker's existing QR badge to clock them in/out, writing directly into the existing `timesheet_entries` data as a "Direct (QR)" entry. + +**Architecture:** A new top-level route (`src/app/clock/`, sibling to `dashboard/` and `login/`) with its own minimal layout (just the shared PIN ``, no sidebar) and its own web manifest, mirroring the existing `/dashboard/chat` split-PWA pattern. A new `POST /api/clock/scan` route holds all the clock-in/out decision logic against a new `clock_events` PocketBase collection (raw audit log) and the existing `timesheet_entries` collection (derived daily totals). The camera/QR-decode loop is a client component using the native `BarcodeDetector` API where available, falling back to the `jsqr` library. + +**Tech Stack:** Next.js 16 App Router, TypeScript, PocketBase (via `pbAdmin()`), `jsqr` (new dependency), vitest for the route/logic tests. + +**Spec:** `docs/superpowers/specs/2026-09-11-mega-clock-timeclock-pwa-design.md` + +**Note on scope vs. the spec:** while tracing how the PIN gate actually redirects (`AuthGate` → `/login` → hardcoded `router.replace("/dashboard")`), this plan found that reusing the existing PIN gate as designed would silently bounce a `/clock` user into the full dashboard after they unlock, instead of back to `/clock`. Task 4/5 fix that (a small, necessary correction to make the spec's "reuse the existing PIN gate" decision actually work for a non-dashboard route — not a scope change). + +--- + +## File Structure + +| File | Change | +| --- | --- | +| `package.json` | Add `jsqr` | +| `src/lib/pb-types.ts` | Add `ClockEventRecord` | +| `scripts/pb-bootstrap-schema.py` | Add `clock_events` to `DEFS` + `ORDER` | +| `scripts/pb-relax-rules.py` | Add `CLOCK_EVENTS` to `RULES` | +| `scripts/migrations/2026-09-12-add-clock-events.py` | **Create** — live-PB migration for the new collection | +| `src/lib/safe-redirect.ts` | **Create** — pure helper resolving a safe post-login redirect target | +| `src/lib/safe-redirect.test.ts` | **Create** | +| `src/components/auth-gate.tsx` | **Modify** — redirect to `/login?redirect=` instead of a bare `/login` | +| `src/app/login/page.tsx` | **Modify** — honor `?redirect=`, default to `/dashboard` | +| `src/app/api/clock/scan/route.ts` | **Create** | +| `src/app/api/clock/scan/route.test.ts` | **Create** | +| `src/types/barcode-detector.d.ts` | **Create** — ambient type for the native `BarcodeDetector` API (absent from `lib.dom.d.ts`) | +| `src/app/globals.css` | Add `.clock-footer` (iOS safe-area) | +| `src/app/clock/layout.tsx` | **Create** | +| `src/app/clock/person-scanner.tsx` | **Create** | +| `src/app/clock/clock-view.tsx` | **Create** | +| `src/app/clock/page.tsx` | **Create** | +| `public/clock.webmanifest` | **Create** | +| `scripts/build-icons.mjs` | Add clock icon generators + apple-icon derivation | +| `public/icons/clock-icon-192.png`, `clock-icon-512.png`, `clock-maskable-512.png`, `src/app/clock/apple-icon.png` | **Generated** by running the script above | + +--- + +### Task 1: Add the `jsqr` dependency + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the package** + +```bash +npm install jsqr +``` + +- [ ] **Step 2: Verify it landed as a runtime dependency** + +```bash +grep -n '"jsqr"' package.json +``` +Expected: one line under `"dependencies"`. + +- [ ] **Step 3: Check whether it ships its own TypeScript types** + +```bash +npm run typecheck +``` +Expected: PASS. If it fails with `Could not find a declaration file for module 'jsqr'`, create this file before moving on: + +```typescript +// src/types/jsqr.d.ts +declare module "jsqr" { + interface QRCode { + data: string; + location: unknown; + } + export default function jsQR( + data: Uint8ClampedArray, + width: number, + height: number, + options?: { + inversionAttempts?: "dontInvert" | "onlyInvert" | "attemptBoth" | "invertFirst"; + }, + ): QRCode | null; +} +``` + +Then re-run `npm run typecheck` and confirm it passes. + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json src/types/jsqr.d.ts +git commit -m "chore(clock): add jsqr dependency for QR decoding" +``` +(Drop `src/types/jsqr.d.ts` from the `git add` if Step 3 didn't need it.) + +--- + +### Task 2: `ClockEventRecord` type + +**Files:** +- Modify: `src/lib/pb-types.ts` + +- [ ] **Step 1: Add the interface** + +Add this immediately after the `TimesheetEntryRecord` interface (which ends around line 214, right before `FuelLogRecord`): + +```typescript +/** + * One scan event — the raw audit log behind the MEGA Clock PWA. + * Independent of the derived daily totals in `timesheet_entries`: every + * "in" and every "out" gets its own row here, in scan order. + */ +export interface ClockEventRecord extends RecordModel { + collectionName: "clock_events"; + employee: string; + site: string; + direction: "in" | "out"; + scanned_at: string; + /** Set only on "out" events — the timesheet_entries row this scan created/updated. */ + timesheet_entry?: string; + expand?: { + employee?: EmployeeRecord; + site?: SiteRecord; + timesheet_entry?: TimesheetEntryRecord; + }; +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/pb-types.ts +git commit -m "feat(clock): add ClockEventRecord type" +``` + +--- + +### Task 3: PocketBase schema — `clock_events` collection + +**Files:** +- Modify: `scripts/pb-bootstrap-schema.py` +- Modify: `scripts/pb-relax-rules.py` +- Create: `scripts/migrations/2026-09-12-add-clock-events.py` + +This task touches the live, shared PocketBase instance (`pb-bootstrap-schema.py` hardcodes `https://pb.mega.infra.apphub.co.za` — there is no separate local/dev instance). **Steps 3 and 4 below require your PocketBase admin credentials and your explicit go-ahead before running with `--apply`** — don't run those two automatically. + +- [ ] **Step 1: Add `clock_events` to `scripts/pb-bootstrap-schema.py`** + +Insert this block immediately after the `DEFS["timesheet_entries"] = {...}` block (right before `DEFS["fuel_sheets"] = {...}`): + +```python +DEFS["clock_events"] = { + "name": "CLOCK_EVENTS", "type": "base", + "fields": [ + field("employee", "relation", required=True, + collectionId="__EMPLOYEES_ID__", cascadeDelete=False, maxSelect=1), + field("site", "relation", required=True, + collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1), + field("direction", "select", required=True, + values=["in", "out"], maxSelect=1), + field("scanned_at", "date", required=True), + # Optional: set only on "out" events, pointing at the + # timesheet_entries row that scan created/updated. + field("timesheet_entry", "relation", required=False, + collectionId="__TIMESHEET_ENTRIES_ID__", cascadeDelete=False, maxSelect=1), + ], + "indexes": [ + "CREATE INDEX `idx_clock_events_emp_scanned` ON `clock_events` (`employee`, `scanned_at`)", + ], +} +``` + +Then add `"clock_events",` to the `ORDER` list, right after `"timesheet_entries",` (it depends on `employees`, `sites`, and `timesheet_entries`, all of which are created by that point): + +```python +ORDER = [ + "sites", + "employees", + "suppliers", + "purchase_orders", + "purchase_order_items", + "fixed_costs", + "vehicles", + "fuel_prices", + "timesheets", + "timesheet_entries", + "clock_events", + "fuel_sheets", + "fuel_logs", + "plant_hours_sheets", + "plant_hours_log", + "income_claims", + "delivery_notes", + "delivery_note_items", +] +``` + +- [ ] **Step 2: Add `CLOCK_EVENTS` to `scripts/pb-relax-rules.py`** + +Add this line to the `RULES` dict, after the `"TIMESHEET_ENTRIES"` entry: + +```python + "CLOCK_EVENTS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), +``` + +Also update the module docstring's collection count from 16 to 17: + +```python +"""Relax API rules on the 17 MEGA INFRASTRUCTURE collections. +``` + +- [ ] **Step 3: Write the migration script** + +Create `scripts/migrations/2026-09-12-add-clock-events.py`: + +```python +#!/usr/bin/env python3 +""" +Create the `clock_events` collection — the raw audit log behind the +MEGA Clock timekeeping PWA. +See docs/superpowers/specs/2026-09-11-mega-clock-timeclock-pwa-design.md. + + PB_URL=... PB_ADMIN_EMAIL=... PB_ADMIN_PASSWORD=... \\ + python3 scripts/migrations/2026-09-12-add-clock-events.py [--apply] + +DRY RUN BY DEFAULT. + +Every scan (in or out) writes one row here, independent of the derived +daily totals in `timesheet_entries`. `employee`/`site`/`timesheet_entry` +are all cascadeDelete=False, same posture as every other relation in +this schema: deleting the parent must not silently destroy the audit +trail that references it. + +Created LOCKED (superuser-only on every rule), matching a fresh +`pb-bootstrap-schema.py` run — run `pb-relax-rules.py` afterward (it now +includes CLOCK_EVENTS) to open list/view/create to any authenticated +user, same as every sibling collection. + +Idempotent: skips if the collection already exists. +""" +import json +import os +import sys +import urllib.error +import urllib.request + +APPLY = "--apply" in sys.argv + +LOCKED = { + "listRule": None, "viewRule": None, + "createRule": None, "updateRule": None, "deleteRule": None, +} + + +def field(name, type_, **kw): + f = {"name": name, "type": type_} + f.update(kw) + return f + + +def clock_events_fields(employees_id, sites_id, timesheet_entries_id): + return [ + field("employee", "relation", required=True, + collectionId=employees_id, cascadeDelete=False, maxSelect=1, minSelect=1), + field("site", "relation", required=True, + collectionId=sites_id, cascadeDelete=False, maxSelect=1, minSelect=1), + field("direction", "select", required=True, + values=["in", "out"], maxSelect=1), + field("scanned_at", "date", required=True), + field("timesheet_entry", "relation", required=False, + collectionId=timesheet_entries_id, cascadeDelete=False, maxSelect=1), + ] + + +INDEXES = [ + "CREATE INDEX `idx_clock_events_emp_scanned` ON `clock_events` (`employee`, `scanned_at`)", +] + + +def admin_auth(base_url): + body = json.dumps({ + "identity": os.environ["PB_ADMIN_EMAIL"], + "password": os.environ["PB_ADMIN_PASSWORD"], + }).encode() + req = urllib.request.Request( + f"{base_url}/api/collections/_superusers/auth-with-password", + data=body, headers={"Content-Type": "application/json"}, method="POST", + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read())["token"] + + +def api(base_url, token, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + f"{base_url}{path}", data=data, method=method, + headers={"Authorization": token, "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as resp: + txt = resp.read().decode() + return json.loads(txt) if txt else None + except urllib.error.HTTPError as e: + if e.code == 404 and method == "GET": + return None + raise RuntimeError( + f"{method} {path}: {e.code} {e.read().decode() if e.fp else ''}" + ) from e + + +def verify(collection, employees_id, sites_id, timesheet_entries_id): + problems = [] + fields = {f["name"]: f for f in collection["fields"]} + expected = {f["name"] for f in clock_events_fields(employees_id, sites_id, timesheet_entries_id)} + missing = sorted(expected - set(fields)) + if missing: + problems.append(f"missing fields: {', '.join(missing)}") + + for rel_name, expected_id in ( + ("employee", employees_id), + ("site", sites_id), + ("timesheet_entry", timesheet_entries_id), + ): + f = fields.get(rel_name) + if f is None: + continue + if f.get("cascadeDelete") is not False: + problems.append(f"{rel_name}.cascadeDelete is {f.get('cascadeDelete')!r}, must be False") + if f.get("collectionId") != expected_id: + problems.append( + f"{rel_name}.collectionId is {f.get('collectionId')!r}, expected {expected_id!r}" + ) + + direction = fields.get("direction") + if direction is None or set(direction.get("values") or []) != {"in", "out"}: + got = direction.get("values") if direction else None + problems.append(f"direction.values is {got!r}, expected ['in', 'out']") + + for rule, want in LOCKED.items(): + if collection.get(rule) != want: + problems.append(f"{rule} is {collection.get(rule)!r}, expected {want!r}") + + landed = collection.get("indexes") or [] + for want in INDEXES: + if want not in landed: + problems.append(f"index missing: {want}") + + return problems + + +def main(): + base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/") + token = admin_auth(base_url) + print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}") + + existing = api(base_url, token, "GET", "/api/collections/clock_events") + if existing is not None: + print(f"\nSKIP clock_events: already exists (id={existing['id']})") + return 0 + + ids = {} + for name in ("employees", "sites", "timesheet_entries"): + col = api(base_url, token, "GET", f"/api/collections/{name}") + if col is None: + print(f"\nABORT: collection `{name}` not found - cannot build the relation") + return 1 + ids[name] = col["id"] + print(f"{name} collection id: {ids[name]}") + + print("\nCREATE clock_events") + if not APPLY: + print("\nDRY RUN. Re-run with --apply to create.") + return 0 + + created = api( + base_url, token, "POST", "/api/collections", + { + "name": "CLOCK_EVENTS", + "type": "base", + "fields": clock_events_fields(ids["employees"], ids["sites"], ids["timesheet_entries"]), + **LOCKED, + "indexes": INDEXES, + }, + ) + if not created or not created.get("id"): + print(" FAILED: create returned no collection") + return 1 + print(f" created id={created['id']}") + + after = api(base_url, token, "GET", f"/api/collections/{created['id']}") + if after is None: + print(f" FAILED: collection id={created['id']} vanished after create") + return 1 + problems = verify(after, ids["employees"], ids["sites"], ids["timesheet_entries"]) + if problems: + for p in problems: + print(f" FAILED: {p}") + return 1 + + print(" OK cascadeDelete=False on all relations, LOCKED rules, index present") + print("\nNow run scripts/pb-relax-rules.py to open list/view/create to authenticated users.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyError as e: + print(f"ERROR: missing environment variable {e}", file=sys.stderr) + sys.exit(2) +``` + +- [ ] **Step 4: Dry-run it, review the output with the user, then apply (requires PB admin credentials — ask the user to supply them or run this step themselves)** + +```bash +PB_URL=https://pb.mega.infra.apphub.co.za \ +PB_ADMIN_EMAIL= \ +PB_ADMIN_PASSWORD= \ +python3 scripts/migrations/2026-09-12-add-clock-events.py +``` +Expected: `DRY RUN` output showing `sites`/`employees`/`timesheet_entries` ids resolved and `CREATE clock_events` printed, no errors. Only after confirming this looks right, re-run with `--apply` appended, and expect the final `OK` line plus the `pb-relax-rules.py` reminder. + +- [ ] **Step 5: Run `pb-relax-rules.py` to open the new collection's rules to authenticated users** + +```bash +python3 scripts/pb-relax-rules.py +``` +Expected: a `✓ CLOCK_EVENTS updated` line among the output. + +- [ ] **Step 6: Commit the script changes** + +```bash +git add scripts/pb-bootstrap-schema.py scripts/pb-relax-rules.py scripts/migrations/2026-09-12-add-clock-events.py +git commit -m "feat(clock): add clock_events PocketBase collection" +``` + +--- + +### Task 4: `safe-redirect` helper (TDD) + +Isolated as a pure function (no `window` access inside it) specifically so it's unit-testable — the `AuthGate`/`login` wiring in Task 5 is what actually reads `window.location`. + +**Files:** +- Create: `src/lib/safe-redirect.ts` +- Test: `src/lib/safe-redirect.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/lib/safe-redirect.test.ts +import { describe, expect, it } from "vitest"; +import { resolveRedirectTarget } from "./safe-redirect"; + +describe("resolveRedirectTarget", () => { + it("returns the redirect param when it's a same-origin path", () => { + expect(resolveRedirectTarget("?redirect=%2Fclock")).toBe("/clock"); + }); + + it("falls back to /dashboard when there is no redirect param", () => { + expect(resolveRedirectTarget("")).toBe("/dashboard"); + }); + + it("falls back to /dashboard for a protocol-relative redirect (open-redirect guard)", () => { + expect(resolveRedirectTarget("?redirect=%2F%2Fevil.com")).toBe("/dashboard"); + }); + + it("falls back to /dashboard for a redirect that isn't a relative path", () => { + expect(resolveRedirectTarget("?redirect=https://evil.com")).toBe("/dashboard"); + }); + + it("honors a custom fallback", () => { + expect(resolveRedirectTarget("", "/login")).toBe("/login"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/lib/safe-redirect.test.ts +``` +Expected: FAIL — `Cannot find module './safe-redirect'`. + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/lib/safe-redirect.ts +/** + * Resolves where to send the user after they unlock the PIN gate. + * `search` is the raw `location.search` string (e.g. "?redirect=%2Fclock"). + * Only a same-origin relative path is honored — anything else (an + * absolute URL, a protocol-relative "//host" string, or a missing + * param) falls back to `fallback`. + */ +export function resolveRedirectTarget(search: string, fallback = "/dashboard"): string { + const raw = new URLSearchParams(search).get("redirect"); + if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return fallback; + return raw; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/lib/safe-redirect.test.ts +``` +Expected: PASS (5/5). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/safe-redirect.ts src/lib/safe-redirect.test.ts +git commit -m "feat(auth): add safe-redirect helper for post-PIN navigation" +``` + +--- + +### Task 5: Wire `safe-redirect` into the PIN gate + +**Files:** +- Modify: `src/components/auth-gate.tsx` +- Modify: `src/app/login/page.tsx` + +- [ ] **Step 1: Make `AuthGate` redirect to `/login` with the current path** + +Replace the full contents of `src/components/auth-gate.tsx` with: + +```tsx +"use client"; + +import * as React from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; +import { Loader2 } from "lucide-react"; + +export function AuthGate({ children }: { children: React.ReactNode }) { + const { authenticated } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + React.useEffect(() => { + if (mounted && !authenticated) { + router.replace(`/login?redirect=${encodeURIComponent(pathname)}`); + } + }, [mounted, authenticated, router, pathname]); + + if (!mounted) { + return ( +
+ +
+ ); + } + + if (!authenticated) { + return ( +
+ +
+ ); + } + + return <>{children}; +} +``` + +(Only change from today: `usePathname` import + the `redirect` query param on the `router.replace` call.) + +- [ ] **Step 2: Make the login page honor `?redirect=`** + +In `src/app/login/page.tsx`, add the import and helper, then use it in both places that currently hardcode `"/dashboard"`. + +Add to the imports (top of file): + +```typescript +import { resolveRedirectTarget } from "@/lib/safe-redirect"; +``` + +Replace the first `router.replace("/dashboard")` (inside `press`, the auto-submit branch around line 36): + +```typescript + if (next === expectedPin) { + unlock(); + router.replace(resolveRedirectTarget(window.location.search)); + } else { +``` + +Replace the second `router.replace("/dashboard")` (inside the `Enter`-key handler around line 60): + +```typescript + if (pin === expectedPin) { + unlock(); + router.replace(resolveRedirectTarget(window.location.search)); + } else if (pin.length > 0) { +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 4: Manual check** + +Run the dev server (`npm run dev`), visit `/dashboard/workforce` while logged out — confirm it redirects to `/login`, and after entering the PIN it lands back on `/dashboard/workforce`, not `/dashboard`. (Full `/clock`-specific verification happens in Task 14, once that route exists.) + +- [ ] **Step 5: Commit** + +```bash +git add src/components/auth-gate.tsx src/app/login/page.tsx +git commit -m "fix(auth): return to the originating page after PIN unlock" +``` + +--- + +### Task 6: `POST /api/clock/scan` (TDD) + +This is the core logic: look up the badge, decide in vs. out, write `clock_events` and (on out) `timesheet_entries`. + +**Files:** +- Create: `src/app/api/clock/scan/route.ts` +- Test: `src/app/api/clock/scan/route.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// src/app/api/clock/scan/route.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const employeesGetFirst = vi.fn(); +const clockEventsGetFirst = vi.fn(); +const clockEventsCreate = vi.fn(); +const entriesGetFirst = vi.fn(); +const entriesCreate = vi.fn(); +const entriesUpdate = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name === "employees") return { getFirstListItem: employeesGetFirst }; + if (name === "clock_events") + return { getFirstListItem: clockEventsGetFirst, create: clockEventsCreate }; + if (name === "timesheet_entries") + return { + getFirstListItem: entriesGetFirst, + create: entriesCreate, + update: entriesUpdate, + }; + throw new Error(`unexpected collection ${name}`); + }, + })), +})); + +import { POST } from "./route"; + +const EMPLOYEE = { + id: "emp00000000001", + name: "Jane Doe", + employee_no: "MCW058", + site: "site0000000001", +}; + +function post(body: Record) { + return POST( + new Request("http://x", { method: "POST", body: JSON.stringify(body) }), + ); +} + +describe("POST /api/clock/scan", () => { + beforeEach(() => { + employeesGetFirst.mockReset(); + clockEventsGetFirst.mockReset(); + clockEventsCreate.mockReset(); + entriesGetFirst.mockReset(); + entriesCreate.mockReset(); + entriesUpdate.mockReset(); + }); + + it("returns 400 for non-JSON body", async () => { + const res = await POST(new Request("http://x", { method: "POST", body: "not-json{" })); + expect(res.status).toBe(400); + expect(employeesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 400 when employee_no is missing", async () => { + const res = await post({}); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "employee_no is required" }); + expect(employeesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 404 for an unknown badge", async () => { + employeesGetFirst.mockRejectedValueOnce(new Error("not found")); + const res = await post({ employee_no: "NOPE" }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Unknown badge" }); + expect(clockEventsCreate).not.toHaveBeenCalled(); + }); + + it("clocks in when there is no prior event for this employee", async () => { + employeesGetFirst.mockResolvedValueOnce(EMPLOYEE); + clockEventsGetFirst.mockRejectedValueOnce(new Error("not found")); + clockEventsCreate.mockResolvedValueOnce({ id: "evt1" }); + + const res = await post({ employee_no: "MCW058" }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.direction).toBe("in"); + expect(json.employeeName).toBe("Jane Doe"); + expect(clockEventsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + employee: EMPLOYEE.id, + site: EMPLOYEE.site, + direction: "in", + }), + ); + expect(clockEventsGetFirst).toHaveBeenCalledWith( + expect.stringContaining(EMPLOYEE.id), + expect.objectContaining({ sort: "-scanned_at" }), + ); + expect(entriesCreate).not.toHaveBeenCalled(); + }); + + it("clocks in when the most recent event was an out", async () => { + employeesGetFirst.mockResolvedValueOnce(EMPLOYEE); + clockEventsGetFirst.mockResolvedValueOnce({ + id: "evt0", + direction: "out", + scanned_at: "2026-09-10T08:00:00.000Z", + }); + clockEventsCreate.mockResolvedValueOnce({ id: "evt1" }); + + const res = await post({ employee_no: "MCW058" }); + const json = await res.json(); + expect(json.direction).toBe("in"); + expect(entriesCreate).not.toHaveBeenCalled(); + }); + + it("clocks out and creates a new timesheet_entries row when none exists yet today", async () => { + employeesGetFirst.mockResolvedValueOnce(EMPLOYEE); + clockEventsGetFirst.mockResolvedValueOnce({ + id: "evt1", + direction: "in", + scanned_at: "2026-09-11T06:00:00.000Z", + }); + entriesGetFirst.mockRejectedValueOnce(new Error("not found")); + entriesCreate.mockResolvedValueOnce({ id: "entry1" }); + clockEventsCreate.mockResolvedValueOnce({ id: "evt2" }); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-11T14:30:00.000Z")); // 8.5h after clock-in + + const res = await post({ employee_no: "MCW058" }); + const json = await res.json(); + + vi.useRealTimers(); + + expect(json.direction).toBe("out"); + expect(json.sessionHours).toBe(8.5); + expect(json.dailyTotalHours).toBe(8.5); + expect(entriesCreate).toHaveBeenCalledWith({ + employee: EMPLOYEE.id, + site: EMPLOYEE.site, + date_logged: "2026-09-11T00:00:00.000Z", + hours_worked: 8.5, + }); + expect(clockEventsCreate).toHaveBeenCalledWith( + expect.objectContaining({ direction: "out", timesheet_entry: "entry1" }), + ); + }); + + it("clocks out and accumulates onto an existing same-day direct entry", async () => { + employeesGetFirst.mockResolvedValueOnce(EMPLOYEE); + clockEventsGetFirst.mockResolvedValueOnce({ + id: "evt3", + direction: "in", + scanned_at: "2026-09-11T13:00:00.000Z", + }); + entriesGetFirst.mockResolvedValueOnce({ id: "entry1", hours_worked: 4 }); + entriesUpdate.mockResolvedValueOnce({ id: "entry1" }); + clockEventsCreate.mockResolvedValueOnce({ id: "evt4" }); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-11T14:00:00.000Z")); // 1h after clock-in + + const res = await post({ employee_no: "MCW058" }); + const json = await res.json(); + + vi.useRealTimers(); + + expect(json.dailyTotalHours).toBe(5); + expect(entriesUpdate).toHaveBeenCalledWith("entry1", { hours_worked: 5 }); + expect(entriesCreate).not.toHaveBeenCalled(); + }); + + it("attributes an overnight shift's date_logged to the clock-in date, not the clock-out date", async () => { + employeesGetFirst.mockResolvedValueOnce(EMPLOYEE); + clockEventsGetFirst.mockResolvedValueOnce({ + id: "evt5", + direction: "in", + scanned_at: "2026-09-11T22:00:00.000Z", + }); + entriesGetFirst.mockRejectedValueOnce(new Error("not found")); + entriesCreate.mockResolvedValueOnce({ id: "entry2" }); + clockEventsCreate.mockResolvedValueOnce({ id: "evt6" }); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-12T02:00:00.000Z")); + + await post({ employee_no: "MCW058" }); + + vi.useRealTimers(); + + expect(entriesGetFirst).toHaveBeenCalledWith( + expect.stringContaining('date_logged="2026-09-11T00:00:00.000Z"'), + ); + expect(entriesCreate).toHaveBeenCalledWith( + expect.objectContaining({ date_logged: "2026-09-11T00:00:00.000Z" }), + ); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/app/api/clock/scan/route.test.ts +``` +Expected: FAIL — `Cannot find module './route'`. + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/app/api/clock/scan/route.ts +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import type { ClockEventRecord, EmployeeRecord, TimesheetEntryRecord } from "@/lib/pb-types"; + +function escapePb(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Midnight-UTC for the calendar date of `iso`, matching the + * `date_logged` convention every other timesheet-writing path uses + * (see src/app/api/timesheets/import/route.ts: `new Date(row.date).toISOString()`). + */ +function dateLoggedFor(iso: string): string { + return new Date(iso.slice(0, 10)).toISOString(); +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +export async function POST(request: Request) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + const employeeNo = typeof body.employee_no === "string" ? body.employee_no.trim() : ""; + if (!employeeNo) { + return NextResponse.json({ error: "employee_no is required" }, { status: 400 }); + } + + const pb = await pbAdmin(); + + let employee: EmployeeRecord; + try { + employee = await pb + .collection("employees") + .getFirstListItem(`employee_no="${escapePb(employeeNo)}"`); + } catch { + return NextResponse.json({ error: "Unknown badge" }, { status: 404 }); + } + + const now = new Date().toISOString(); + + let lastEvent: ClockEventRecord | null; + try { + lastEvent = await pb + .collection("clock_events") + .getFirstListItem(`employee="${employee.id}"`, { sort: "-scanned_at" }); + } catch { + lastEvent = null; + } + + if (!lastEvent || lastEvent.direction === "out") { + await pb.collection("clock_events").create({ + employee: employee.id, + site: employee.site, + direction: "in", + scanned_at: now, + }); + 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, + ); + const dateLogged = dateLoggedFor(lastEvent.scanned_at); + + let entry: TimesheetEntryRecord | null; + try { + entry = await pb + .collection("timesheet_entries") + .getFirstListItem( + `employee="${employee.id}" && date_logged="${dateLogged}" && timesheet=""`, + ); + } catch { + entry = null; + } + + let dailyTotalHours: number; + let entryId: string; + if (entry) { + dailyTotalHours = round2(entry.hours_worked + sessionHours); + const updated = await pb + .collection("timesheet_entries") + .update(entry.id, { hours_worked: dailyTotalHours }); + entryId = updated.id; + } else { + const created = await pb.collection("timesheet_entries").create({ + employee: employee.id, + site: employee.site, + date_logged: dateLogged, + hours_worked: sessionHours, + }); + entryId = created.id; + dailyTotalHours = sessionHours; + } + + await pb.collection("clock_events").create({ + employee: employee.id, + site: employee.site, + direction: "out", + scanned_at: now, + timesheet_entry: entryId, + }); + + return NextResponse.json({ + direction: "out", + employeeName: employee.name, + sessionHours, + dailyTotalHours, + }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/app/api/clock/scan/route.test.ts +``` +Expected: PASS (9/9). + +- [ ] **Step 5: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/api/clock/scan/route.ts src/app/api/clock/scan/route.test.ts +git commit -m "feat(clock): add POST /api/clock/scan clock-in/out endpoint" +``` + +--- + +### Task 7: `BarcodeDetector` ambient type + +`lib.dom.d.ts` doesn't include the (Chrome-only, experimental) `BarcodeDetector` Web API, so referencing `window.BarcodeDetector` needs a local ambient declaration. + +**Files:** +- Create: `src/types/barcode-detector.d.ts` + +- [ ] **Step 1: Write the declaration** + +```typescript +// src/types/barcode-detector.d.ts +export {}; + +declare global { + interface BarcodeDetectorOptions { + formats?: string[]; + } + + interface DetectedBarcode { + rawValue: string; + } + + class BarcodeDetector { + constructor(options?: BarcodeDetectorOptions); + detect(source: CanvasImageSource): Promise; + } + + interface Window { + BarcodeDetector?: typeof BarcodeDetector; + } +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/types/barcode-detector.d.ts +git commit -m "chore(clock): add ambient BarcodeDetector type" +``` + +--- + +### Task 8: `.clock-footer` safe-area CSS + +**Files:** +- Modify: `src/app/globals.css` + +- [ ] **Step 1: Append the utility class** + +Add at the end of `src/app/globals.css`: + +```css + +/* MEGA Clock's footer tab bar sits flush with the bottom edge in iOS + standalone mode, where the home-indicator area needs real padding — + nothing in this file already accounts for env(safe-area-inset-*). */ +.clock-footer { + padding-bottom: env(safe-area-inset-bottom); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/app/globals.css +git commit -m "feat(clock): add safe-area padding utility for the footer nav" +``` + +--- + +### Task 9: `src/app/clock/layout.tsx` + +**Files:** +- Create: `src/app/clock/layout.tsx` + +- [ ] **Step 1: Write the layout** + +```tsx +import { AuthGate } from "@/components/auth-gate"; + +export default function ClockLayout({ children }: { children: React.ReactNode }) { + return {children}; +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS (this won't render meaningfully yet — `src/app/clock/page.tsx` doesn't exist until Task 12 — but it must compile). + +- [ ] **Step 3: Commit** + +```bash +git add src/app/clock/layout.tsx +git commit -m "feat(clock): add /clock layout (PIN gate, no sidebar)" +``` + +--- + +### Task 10: `PersonScanner` — camera + QR decode loop + +**Files:** +- Create: `src/app/clock/person-scanner.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +"use client"; + +import * as React from "react"; +import jsQR from "jsqr"; +import { cn } from "@/lib/utils"; + +const SCAN_COOLDOWN_MS = 3000; +const BANNER_MS = 2500; + +type ScanResult = + | { kind: "in"; employeeName: string } + | { kind: "out"; employeeName: string; sessionHours: number; dailyTotalHours: number } + | { kind: "error"; message: string }; + +export function PersonScanner() { + const videoRef = React.useRef(null); + const canvasRef = React.useRef(null); + const lastCodeRef = React.useRef<{ value: string; at: number } | null>(null); + const busyRef = React.useRef(false); + const detectorRef = React.useRef(null); + const [result, setResult] = React.useState(null); + const [cameraError, setCameraError] = React.useState(null); + + const handleDecoded = React.useCallback((value: string) => { + const now = Date.now(); + const last = lastCodeRef.current; + if (last && last.value === value && now - last.at < SCAN_COOLDOWN_MS) return; + if (busyRef.current) return; + lastCodeRef.current = { value, at: now }; + busyRef.current = true; + + fetch("/api/clock/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ employee_no: value }), + }) + .then(async (res) => { + const responseBody = await res.json(); + if (!res.ok) { + setResult({ kind: "error", message: responseBody.error ?? "Scan failed" }); + return; + } + if (responseBody.direction === "in") { + setResult({ kind: "in", employeeName: responseBody.employeeName }); + } else { + setResult({ + kind: "out", + employeeName: responseBody.employeeName, + sessionHours: responseBody.sessionHours, + dailyTotalHours: responseBody.dailyTotalHours, + }); + } + }) + .catch(() => { + setResult({ kind: "error", message: "Couldn't reach the server — try again" }); + }) + .finally(() => { + busyRef.current = false; + setTimeout(() => setResult(null), BANNER_MS); + }); + }, []); + + React.useEffect(() => { + let stream: MediaStream | null = null; + let rafId = 0; + let cancelled = false; + + async function tick() { + if (cancelled) return; + const video = videoRef.current; + const canvas = canvasRef.current; + if (video && canvas && video.readyState >= video.HAVE_CURRENT_DATA) { + const ctx = canvas.getContext("2d"); + if (ctx) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + let value: string | null = null; + if (detectorRef.current) { + try { + const codes = await detectorRef.current.detect(canvas); + value = codes[0]?.rawValue ?? null; + } catch { + value = null; + } + } else { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const code = jsQR(imageData.data, imageData.width, imageData.height); + value = code?.data ?? null; + } + if (value) handleDecoded(value); + } + } + rafId = requestAnimationFrame(tick); + } + + async function start() { + if (!navigator.mediaDevices?.getUserMedia) { + setCameraError("Camera not available in this browser."); + return; + } + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + } catch { + setCameraError("Camera access denied or unavailable."); + return; + } + if (cancelled) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + if (window.BarcodeDetector) { + detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] }); + } + tick(); + } + + start(); + + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + stream?.getTracks().forEach((t) => t.stop()); + }; + }, [handleDecoded]); + + return ( +
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/clock/person-scanner.tsx +git commit -m "feat(clock): add PersonScanner camera + QR decode component" +``` + +--- + +### Task 11: `ClockView` — header, tabs, footer + +**Files:** +- Create: `src/app/clock/clock-view.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { PersonScanner } from "./person-scanner"; + +type Tab = "person" | "fleet"; + +export function ClockView() { + const [tab, setTab] = React.useState("person"); + const [now, setNow] = React.useState(() => new Date()); + + React.useEffect(() => { + const id = setInterval(() => setNow(new Date()), 30_000); + return () => clearInterval(id); + }, []); + + return ( +
+
+ MEGA Clock + + {now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+ +
+ {tab === "person" ? : } +
+ +
+ setTab("person")} /> + setTab("fleet")} /> +
+
+ ); +} + +function TabButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function FleetComingSoon() { + return ( +
+

Fleet scanning is coming soon

+

+ Vehicle QR scanning for plant hours isn't wired up yet — check back later. +

+
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/clock/clock-view.tsx +git commit -m "feat(clock): add ClockView with Person/Fleet footer tabs" +``` + +--- + +### Task 12: `/clock` page + manifest + +**Files:** +- Create: `src/app/clock/page.tsx` +- Create: `public/clock.webmanifest` + +- [ ] **Step 1: Write the manifest** + +```json +{ + "name": "MEGA Clock", + "short_name": "Clock", + "description": "Scan a worker's badge to clock in or out.", + "theme_color": "#fdb13c", + "background_color": "#e8ebed", + "display": "standalone", + "orientation": "portrait", + "scope": "/clock", + "start_url": "/clock", + "id": "/clock", + "icons": [ + { "src": "/icons/clock-icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/clock-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/clock-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} +``` + +- [ ] **Step 2: Write the page** + +```tsx +// src/app/clock/page.tsx +import type { Metadata } from "next"; +import { ClockView } from "./clock-view"; + +export const metadata: Metadata = { + title: "Clock · MEGA INFRASTRUCTURE", + manifest: "/clock.webmanifest", + appleWebApp: { + capable: true, + title: "Clock", + statusBarStyle: "default", + }, +}; + +export default function ClockPage() { + return ; +} +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/clock/page.tsx public/clock.webmanifest +git commit -m "feat(clock): add /clock page with its own installable manifest" +``` + +--- + +### Task 13: Clock icon set + +**Files:** +- Modify: `scripts/build-icons.mjs` +- Generated: `public/icons/clock-icon-192.png`, `public/icons/clock-icon-512.png`, `public/icons/clock-maskable-512.png`, `src/app/clock/apple-icon.png` + +- [ ] **Step 1: Add the clock icon SVG generators** + +In `scripts/build-icons.mjs`, add these two functions right after `chatMaskableSvg(...)` (before `async function renderSvg(...)`): + +```javascript +// Non-maskable clock icon (192 and 512). Transparent background. +// Layout: orange clock ring with tick marks, cube mark as the centre +// hub, "CLOCK" wordmark underneath — same brand DNA as the dashboard +// and chat icons. +function clockSvg(size, fontPx) { + const cx = 256; + const cy = 190; + const r = 130; + + const cubeSize = 90; + const cubeX = cx - cubeSize / 2; + const cubeY = cy - cubeSize / 2; + + const wordY = 420; + + const ticks = []; + for (let i = 0; i < 12; i++) { + const angle = (i * Math.PI * 2) / 12; + const inner = r - 14; + const outer = r - 2; + const x1 = cx + inner * Math.sin(angle); + const y1 = cy - inner * Math.cos(angle); + const x2 = cx + outer * Math.sin(angle); + const y2 = cy - outer * Math.cos(angle); + ticks.push( + ``, + ); + } + + return ` + + + + + + + ${ticks.join("\n ")} + + + + + + + + + + + CLOCK + `; +} + +// Maskable variant — orange fills the whole canvas. Content lives +// inside the centre ~40% safe-zone (~205x205 inside a 512 canvas). +function clockMaskableSvg(size, fontPx) { + const scale = 205 / 512; + const offset = (512 - 205) / 2; + + const cx = offset + 102; + const cy = offset + 75; + const r = 55 * (scale / scale); // keep in the 205-wide safe zone + + const cubeSize = 38; + const cubeX = cx - cubeSize / 2; + const cubeY = cy - cubeSize / 2; + + const wordY = offset + 175; + + const ticks = []; + for (let i = 0; i < 12; i++) { + const angle = (i * Math.PI * 2) / 12; + const inner = r - 6; + const outer = r - 1; + const x1 = cx + inner * Math.sin(angle); + const y1 = cy - inner * Math.cos(angle); + const x2 = cx + outer * Math.sin(angle); + const y2 = cy - outer * Math.cos(angle); + ticks.push( + ``, + ); + } + + return ` + + + + + + + + + + ${ticks.join("\n ")} + + + + + + + + + + + CLOCK + `; +} +``` + +- [ ] **Step 2: Render the new icons and derive the apple-icon** + +Add these lines to the "Main" section at the bottom of `scripts/build-icons.mjs`, after the existing chat renders and before the dashboard apple-icon render: + +```javascript +await renderSvg(clockSvg(512, 80), path.join(ROOT, "public/icons/clock-icon-512.png"), 512); +await renderSvg(clockSvg(192, 30), path.join(ROOT, "public/icons/clock-icon-192.png"), 192); +await renderSvg(clockMaskableSvg(512, 38), path.join(ROOT, "public/icons/clock-maskable-512.png"), 512); + +await renderAppleIcon( + path.join(ROOT, "public/icons/clock-icon-512.png"), + path.join(ROOT, "src/app/clock/apple-icon.png"), +); +``` + +- [ ] **Step 3: Run the script** + +```bash +node scripts/build-icons.mjs +``` +Expected: log lines for `clock-icon-512.png`, `clock-icon-192.png`, `clock-maskable-512.png`, and `src/app/clock/apple-icon.png`, each with a non-zero file size, ending in `All icons rendered.` + +- [ ] **Step 4: Confirm the files exist** + +```bash +ls -la public/icons/clock-icon-192.png public/icons/clock-icon-512.png public/icons/clock-maskable-512.png src/app/clock/apple-icon.png +``` +Expected: all four files listed with non-zero sizes. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build-icons.mjs public/icons/clock-icon-192.png public/icons/clock-icon-512.png public/icons/clock-maskable-512.png src/app/clock/apple-icon.png +git commit -m "feat(clock): add MEGA Clock icon set" +``` + +--- + +### Task 14: Full verification pass + +**Files:** none (verification only) + +- [ ] **Step 1: Full typecheck** + +```bash +npm run typecheck +``` +Expected: PASS, zero errors. + +- [ ] **Step 2: Full test suite** + +```bash +npm run test:run +``` +Expected: PASS, including all `src/app/api/clock/scan/route.test.ts` and `src/lib/safe-redirect.test.ts` cases, and no regressions elsewhere. + +- [ ] **Step 3: Lint** + +```bash +npm run lint +``` +Expected: PASS (or only pre-existing warnings unrelated to the new files). + +- [ ] **Step 4: Manual browser walkthrough** + +Start the dev server and open `/clock` in a real browser (camera permission prompts and `getUserMedia` can't be driven headlessly, so this step is manual — consistent with the spec's testing section): + +1. While logged out, visiting `/clock` redirects to `/login`; entering the PIN lands back on `/clock` (not `/dashboard`) — confirms Task 5's fix. +2. `/clock` renders full-screen with no sidebar, a "MEGA Clock" header with a live clock, and a footer with **Person** / **Fleet** buttons. +3. The browser prompts for camera permission; once granted, the live camera feed fills the main area. +4. Tapping **Fleet** shows the "Fleet scanning is coming soon" placeholder and stops showing the camera view; tapping **Person** returns to the camera. +5. Confirm `/clock` does **not** appear anywhere in the `/dashboard` sidebar. + +- [ ] **Step 5: End-to-end scan check via curl (needs a real employee row and the dev server running)** + +Find a real `employee_no` from the `employees` collection (e.g. via the Workers page), then: + +```bash +curl -s -X POST http://localhost:6004/api/clock/scan \ + -H "Content-Type: application/json" \ + -d '{"employee_no":""}' +``` +Expected first call: `{"direction":"in","employeeName":"...","at":"..."}`. Run the same command again: +Expected second call: `{"direction":"out","employeeName":"...","sessionHours":...,"dailyTotalHours":...}`. Then open `/dashboard/workforce/timesheets`, filter Source = "Direct (QR)", and confirm a new row appeared for that employee with the matching hours. + +- [ ] **Step 6: Final commit (only if any fixes were needed during verification)** + +```bash +git add -A +git commit -m "fix(clock): address issues found during verification" +``` +(Skip this step entirely if verification passed with no changes.)