# lfcs-signon — QR sign-on for ISO documents

Foreman opens `/j/2630` on his phone. It lists the job's sign-on docs (prestart, toolbox,
SWMS, policies) with a QR each. Crew scan with their own phone, or he hands his phone round.
Bloke reads the PDF, scrolls to the bottom, ticks "read and understood", picks his name,
signs with finger, submits. A signed PDF (doc + signature page) lands in the job's Drive
folder and a row lands in the job's register CSV. No Airtable. No login for crew.

## Drive layout (inside every job folder on the shared Drive)

```
<Job folder>/05. Safety & Compliance/Sign-On/
    Docs/        <- PDFs to be signed. Filename carries the rev: "SWMS-Formwork rev C.pdf"
    Signed/      <- 2026-08-17_0705_SWMS-Formwork rev C_Joe Bloggs.pdf   (written by app)
    Register.csv <- one row per sign-on                                    (written by app)
    Crew.txt     <- one name per line, the pick-list. Optional; free text if absent.
    Foreman.txt  <- one line, "Name | 0412 345 678". Who the crew rings on this job. Optional.
```

Change a doc = drop the new rev in `Docs/`, remove the old. Same QR keeps working (QR points
at the doc *slot*, not the file). Old sign-ons stay tied to the old rev in `Register.csv`.

## Sign on / sign off -> the real LFCS timesheet

Bloke scans the sign-on QR on the foreman screen. `/on/2643` — one big button, no password, no
PIN, no GPS. The phone remembers his name (`static/whoami.js`, same `lfcs.name` key Take 5 uses).
Sign off asks one thing: lunch, already set to 0:30, one tap to change. `/week/2643` is **his own
LFCS timesheet**, letterhead and all, filling itself in — never anybody else's week.

```
<Job folder>/05. Safety & Compliance/Sign-On/
    Attendance-Register.csv  <- append-only: timestamp,job,name,event,lunch,note,source

<TIMESHEETS_ROOT>/Crew/<Worker Name>/
    Attendance.csv                          <- the same events, keyed by worker (see below)
    <Worker> Timesheet WE 2026.8.28.xlsx    <- rebuilt on every sign-off
```

`TIMESHEETS_ROOT` defaults to `I:/My Drive/Timesheets`. The .xlsx is the real form — `app/assets/
LFCS-Timesheet.xlsx` is a blank copy of `Rocky Timesheet WE 2026.8.7.xlsx`, filled at rows
12/15/18/21/24/27/30 (Mon..Sun) with the total at F33.

**Why the events are written twice.** The job register is the safety record — the muster, who was
on site. The per-worker file exists because a bloke can be on two jobs in a week and his timesheet
is one sheet; without it the second job's rebuild would wipe the first job's hours. It is derivable
from the job registers if it is ever lost.

**Nothing is ever edited.** Both files are append-only and hours are derived from them every time,
so a rebuild is idempotent. A corrected time has to be a new attributed row, never an overwrite —
the moment a time can be quietly changed the record stops being evidence.

A foreman correction is an `event=adjust` row using the extra columns `day,field,old,new,reason,by`.
The tap it corrects stays exactly where it is; `timesheet.build_week` applies the latest adjustment
per field per day on top, and the corrected cells come out shaded amber with a note naming who and
why — on `/week` and in the .xlsx. A register written before adjustments existed is widened from
seven columns to thirteen on the first append, keeping every row.

## Who this phone is — device binding

Company-wide, beside the jobs rather than inside one, because a bloke's phone is his on every job:

```
<jobs root>/_signon/
    devices.csv  <- append-only: timestamp,token_hash,name,status,by,note
    people.csv   <- one row each:  name,status,role,phone,since,by
```

First visit, he says his name once. The server mints a random 128-bit token, stores only its
SHA-256, and logs a **pending** request. The foreman sees "⏳ Matty — this phone?" on `/j/<job>` and
taps APPROVE or NOT HIM. From then on that phone **is** Matty on `/on`, `/week`, `/take5` and
`/report` — the name box disappears and a typed name cannot override the binding. The token lives in
the phone's `localStorage` (`static/device.js`); the cookie is only how it reaches the server.

Nothing here is ever edited either. A change of mind is a new row and the latest row for a token
wins, same rule as the register.

**Two deliberate escape hatches, so shipping this cannot strand a live site:**

- Binding only bites for a bloke once he has an approved phone, or is marked inactive. Until then
  the old name-memory keeps working — a site whose foreman has not been told there is anything to
  tap carries on as before.
- `/j/<job>` stays open until the first foreman or admin phone is approved. Otherwise there is no
  phone left that could approve the first phone, including that one. After that, a worker's phone
  gets a plain "Foreman only" page, because that screen shows everyone's hours.

Inactive is one tap and kills every one of his phones; putting him back on restores the phones the
deactivation killed, but never one knocked back with NOT HIM.

Per-job foreman contact is one line in `<Job>/.../Sign-On/Foreman.txt` — `Matt Ryan | 0412 345 678`
— because who you ring is a per-job fact, and a man can be foreman on one job and a hand on another.
Phone falls back to `people.csv` if the line has a name only.

## T&M docket — the money one

Foreman-only. `/tm/<job>/new`: describe the extra work, tap the men, tap their hours (half-hour
chips to ten — no number pad, because 45 typed where 4.5 was meant reads fine on a phone and only
shows up when the claim comes back short), optional plant/materials line. Save, and the head
contractor's rep signs it on the same phone at `/tm/<job>/<id>/sign`.

```
<Job folder>/07 - Site Documents/TM Dockets/
    TM-<job>-<date>-<seq>.pdf   <- branded LFCS docket, written when it is SIGNED
    TM-Register.csv             <- one row per docket, status draft|signed
```

Two steps with a draft in between on purpose: the super is not always standing there when the work
finishes. An unsigned docket stays on the foreman screen in red until it is signed — an unsigned
docket is money on the ground.

**Day Works fills itself in.** Signing writes an `event=dayworks` row against every man named on the
docket, in the job register and his own events file, and rebuilds his week. The Day Works Y/N column
that has always been on the paper form comes out **Y** — on `/week` and in the .xlsx. A draft writes
nothing; only a signature does.

Docket screens are foreman-only including the PDF, because a docket carries other men's hours and
the ids are guessable.

## Materials — a request list, not stock control

Worker: `/materials/<job>` — one box ("what are you short of") and an optional photo. Foreman: three
buttons on `/j/<job>` — got it on site / order it / not needed. **"Order it" only marks it ordered.**
There is no purchasing system behind it and there is not meant to be: the foreman rings the supplier.
The bloke who asked sees the answer on the same screen he asked from.

```
<Job folder>/05. Safety & Compliance/Sign-On/
    Materials-Register.csv   <- id,timestamp,job,name,item,photo,status,by,note
    Materials/               <- the photos
```

## Photo drop

`/photo/<job>` — one 📷 button. Camera opens, shot lands in the job's site diary, named the time and
who took it. Downsized to 1600px server-side, same `_to_jpeg` the incident photos use.

```
<Job folder>/07 - Site Documents/Site Diary/YYYY-MM-DD/HH-MM <Name>.jpg
```

A web page can never reach a camera roll — so the button IS the filing. That is the point: it kills
the WhatsApp-then-file-it-later chore.

## Vehicle prestart — `/plant/<rego>`

Eight items (lights, tyres, glass, fluids, brakes, damage, cab, load restraint), plus three more on
the truck (restraint gear, mass, height). ALL OK is one deliberate tap that answers every line; flip
any to NO and the note box opens, because a NO with no words on it tells the foreman nothing.

**No device gating here on purpose.** A bloke borrows a ute for a run to the yard and still has to
check it; a gate that says "see the office" in a car park at 6am just means the check does not happen.

The fleet is `app/fleet.py` — one dict, one file to edit when a vehicle comes or goes.

```
<jobs root>/_signon/Fleet/
    <REGO>/Plant-Register.csv   <- that vehicle's history + its defect photos
    Defects.csv                 <- the open-defect index every foreman screen reads
```

Written twice for the same reason attendance is: the foreman screen would have to open eight files
on every page load to find out whether anything is wrong. Company plant, not job plant — so a defect
shows on **every** foreman's screen until someone marks it fixed.

## The board — `/board/<job>` and `/board`

Rocky's "make it like a game" idea. Names drag between work areas; one drag writes the diary line,
the pricework rate, the T&M evidence and the live muster at the same time, and nobody types any of
them.

```
<Job folder>/05. Safety & Compliance/Sign-On/
    Board-Register.csv   <- timestamp,job,zone,person,action,detail,by
<jobs root>/_signon/
    Roster.csv           <- date,person,job,by,timestamp,source
```

**Append-only, like everything else here.** `app/board.py` derives the state from the rows every
time — there is no board file to edit and no delete button on any surface. That is the whole reason
the board is worth anything: a zone card marked done, with the men and the times already on it, IS
the production record.

**Foreman's board.** Zone cards down the screen. Tap a deck card (SET OUT · PREP · SHUTTER · STEEL ·
INSPECT · POUR · STRIP · CLEAN), give it three words, and that is a work area. ROAMING and DAYWORKS
are always there — dragging a man onto DAYWORKS is the T&M evidence writing itself. Long-press a
zone for its stage ladder (formed → steel → inspected → poured → stripped → cleaned) and its
estimate chips; long-press a name for sent home / moved job / off the tools.

**One hold point, and it does not negotiate:** a zone cannot go to `poured` unless `inspected` was
recorded against it. The row is simply not written and the screen says *"needs inspection first"*.

**Three ways to move a name, because one way is never enough on a site.** Tap the name then tap the
work area; or drag it; or long-press for the buttons. Every gesture only ever opens or submits a
plain `<form>` that is already in the HTML, so a site with no JS still gets every action in one tap.

**Take 5s land on the board.** A Take 5 already says who and what — the foreman drags the card out
of "from the crew" onto a zone, or waves it off. Nothing gets retyped.

**Admin board `/board`** is the same component one altitude up: the columns are the live jobs (any
job folder holding a Sign-On folder — no config list to fall out of step) and a drag writes
**tomorrow's** roster row. Sunday is skipped; Saturdays are worked.

**[POST TO GROUPS]** composes the message somebody already types into WhatsApp every evening and
puts it in a copyable box. It sends nothing, anywhere. If the message ever stops coming off the
board, the board is dead inside a week.

## `/today` — the icon points at the person, not the job

A worker's home-screen icon opens `/today`: the phone knows who he is, the roster says where he is,
and he lands on that job's sign-on. No roster row → one screen of big buttons, and his answer is
itself a record (`source=self`). `/on` carries a "Not at X? tap here" line when it routed him, and
the correction is recorded too (`source=corrected`). `/on` and `/week` show "Tomorrow: HORNSBY" when
someone has rostered him.

## The live site diary — `/diary/<job>`

The Daily Site Report, writing itself while the day happens. Same fourteen sections in the same
order as `lfcs-engine/skills/site-debrief`, assembled from today's registers: labour and hours off
the attendance register, work performed off the board, safety off Register.csv / Take5 / incidents,
plus materials, plant, dayworks and the day's photos. **Sections with nothing in them say "nothing
yet" — nothing is ever invented.** Read-only and foreman-only.

The four boxes no register can know — summary, weather, issues, next day — say **"TBC — say it"** and
carry a mic.

```
<Job folder>/05. Safety & Compliance/Sign-On/
    Diary-Notes.csv   <- timestamp,section,text,by
```

## Voice — Deepgram, stdlib only

Browser `MediaRecorder` → `POST /diary/<job>/voice/<section>` → Deepgram prerecorded (`nova-2`,
`smart_format`) via `urllib` → the transcript is a row in `Diary-Notes.csv`. No SDK, no new
dependency, 30 s timeout.

`DEEPGRAM_API_KEY` in `.env`. No key = the buttons are disabled and the page says "voice
unavailable"; the typed box still works. A failed call says *"couldn't hear that — try again"* on
screen and writes the real Deepgram message to the log — never a traceback, never a bare status code.

Needs a secure context, which the live domain has. On a local test box use `localhost`, not the LAN
IP, or the browser will refuse the microphone.

## Run locally

```
pip install -r requirements.txt
copy .env.example .env      # fill JOBS_ROOT_FOLDER_ID + GOOGLE creds path
uvicorn app.main:app --reload --port 8080
```

Open http://localhost:8080/j/2630

## Deploy (Hermes box)

`deploy/` has a systemd unit + Caddyfile. Needs a public HTTPS URL for QR to work on 4G.
Drive auth: service account JSON, with the shared `Jobs` folder shared to the SA email
(Editor). See `.env.example`.

## Not built yet (v1 gaps, on purpose)

- Offline / no-signal mode
- Photo of the sign-on sheet fallback
- "Who HASN'T signed the current rev" report (data is in Register.csv, report page later)
- Photo receipts (slice 6)
- Offline write queue — device binding gives it somewhere to hang, but nothing queues yet
