# Verify: Attendance locations · spec 0002 · updated 2026-10-27
_Steps derived from spec 0002 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._

Use a school that already has the 0001 tables. Sign in as admin, director, or backend (or staff with status admin). Open the live app at `https://design-changes.zap.ng/app/admin/`. Unauthorized access and extra school copies are out of this pass; you can check those yourself.

## UI / manual

- [x] Staff attendance nav group shows Locations (`href` `staff_attendance_locations.php`, icon `icon/attendance.png`) and opens the list. Settings is spec 0003. → AC-1
- [x] Empty list: Pattern C hero, skeleton then empty prompt, Add goes to the edit page → AC-2, AC-3
- [x] Add: name, search (runs on button or Enter, not while typing), Use my current location, map, radius slider and number (default 100, range 20 to 2000, step 10). Click drops a draggable marker or moves it. Circle follows marker and radius. Save stores name, lat, lon, radius_m and returns to the list card. Save stays disabled until the response → AC-3, AC-4, AC-11
- [x] First add with no other rows: map tries geolocation once (deny stays silent), else Nigeria 9.0820, 8.6753 zoom 6. A fix uses zoom 16. Second add with two others fits them; with one other, center that point zoom 16. Other places including inactive show as faded circles → AC-3, AC-9
- [x] Address search with `countrycodes=ng` returns up to 5 Nigeria only matches; pick one flies to zoom 16 and drops the marker. Failed search still lets you click the map. A 21st search in 10 minutes returns 429 → AC-8
- [x] Deny geolocation on the button: short message, search and click still work → AC-9
- [x] Three tile errors or a missing Leaflet script show retry, which re-inits the map → AC-11
- [x] List card shows name, radius in meters, toggle, edit, delete. Toggle saves without a full reload. Inactive stays on the list → AC-2, AC-6
- [x] Duplicate name with different letter case is refused (PHP `mb_strtolower`, HTTP 200 error). Empty name is refused. Save without a marker is refused. Radius 19 or 2001 is refused on the server → AC-5, AC-11
- [x] Delete unused row: confirm says it will be removed; row gone. Delete a row that has a day, token, or attempt: confirm says it will only turn off; row stays inactive; toast follows server `mode`. Already inactive used row: confirm says it cannot be removed → AC-7
- [x] Last active place can be turned off → AC-6

## Commands

- [x] `SELECT` the saved row: `name`, `latitude`, `longitude`, `radius_m`, `is_active=1` on create, `created_at` and `updated_at` in Africa/Lagos, no `school_id` column → AC-4, AC-12
- [x] After used delete, the row still exists with `is_active=0`. After unused delete, the id is gone → AC-7

## Value sourcing

- [x] List cards come from `staff_attendance_locations` in the session database, not from another school → AC-2, AC-12
- [x] Marker lat/lon on save is the Leaflet marker, not a typed hidden default → AC-4
- [x] Radius on save is the slider/number value, clamped 20 to 2000 → AC-3, AC-4
- [x] Unique name uses trim plus PHP `mb_strtolower`, not a MySQL UNIQUE key → AC-5
- [x] Referenced flag for confirm wording counts days, tokens, and attempts → AC-7
- [x] Geocode results come from the PHP proxy to Nominatim with `countrycodes=ng` as a hard filter, not from the browser, and only after submit → AC-8
- [x] `created_at` / `updated_at` come from PHP Lagos now (app boot timezone), not MySQL `NOW()`. Toggle and deactivating delete also set `updated_at` → AC-4, AC-6, AC-7
- [x] JSON uses hex flags; CSRF fail is 403; validation is 200 `status=error` → AC-12

## From /develop (2026-10-26)

### Commands added

- [x] Server radius 19 and 2001 are refused (not clamped). UI slider and number stay in 20 to 2000 → AC-3, AC-11
- [x] Hero count equals `locations.length` from the same list payload, not a separate COUNT → AC-2
- [x] Create sets `is_active=1`. Edit save leaves `is_active` unchanged → AC-4
- [x] Toggle POST `is_active` 0 or 1 is written as given, not flipped → AC-6
- [x] Geocode `countrycodes=ng`. Queries under 3 characters do not count toward the 20 per 10 minutes cap → AC-8
- [x] Leaflet 1.9.4 loads from jsDelivr with the SRI hash copied at build. Tiles are `https://tile.openstreetmap.org/{z}/{x}/{y}.png` → AC-11

### Value sourcing added

- [x] CSRF token on the page and in each JSON body comes from `$security->create_csrf_token()`. Page JS replaces the in memory token from every reply → AC-12
- [x] Nav tile is People group, `icon/staff.png`, href `staff_attendance_locations.php` → AC-1
- [x] Hero overline is the constant Staff attendance, title is Locations, school pill is `$school_config['school_name']` → AC-2
- [x] Edit add form: empty name, no marker, radius 100. Edit load: row name, lat, lon, radius → AC-3, AC-4
- [x] Faded circles are every other current school row printed as JSON, including inactive → AC-3, AC-9
- [x] Name stored is POST `name` after trim, max 100 characters. Compare uses `mb_strtolower(..., 'UTF-8')` → AC-5
- [x] Map first add center: browser geolocation zoom 16, else 9.0820, 8.6753 zoom 6. Two or more others: fitBounds padding 40 maxZoom 17. One other: that point zoom 16. Edit: the row zoom 16 → AC-9

### Acceptance criteria coverage

- AC-1 covered by nav tile for an allowed role · AC-2 covered by Pattern C list, count, cards, empty, sort · AC-3 covered by edit chrome, map, radius, others JSON · AC-4 covered by save fields, create active, redirect · AC-5 covered by PHP name unique · AC-6 covered by toggle absolute 0 or 1 · AC-7 covered by delete confirm and `mode` · AC-8 covered by Nominatim proxy and rate cap · AC-9 covered by geolocation and map start · AC-10 extra school copies are out of this pass · AC-11 covered by Leaflet SRI, tile retry, lat/lon/radius refuse · AC-12 covered by session DB, CSRF, JSON hex flags, engine `mysqli` plus `$ctx`
