Tripi | Enterprise Transport & Logistics (c) 2026 Back to sign in

Version History

Tripi release log · currently running 1.9.291

Version Date / Time Update
v1.9.291 30 Jul 2026 19:45 Autonomous Routing — clock schedules and driver portfolios. (1) SPEC GAP 1 — the planner could only decide WHEN to run one way: shift anchor minus a lead time. It could not express "Monday 09:00, 13:00 and 17:00; Tuesday hourly 08:00 to 16:00; never on a public holiday". New RoutingSchedule (pure, DB-free, 46 unit tests) adds the calendar layer: explicit-times and recurring-interval patterns, 7-bit day masks, effective dates, open-ended suspension, three public-holiday modes, per-run booking cut-offs, and a forward next-run scan (2) New RoutingScheduleRunner turns schedules into real planning runs. Each occurrence is claimed through a UNIQUE occurrence_key, so any number of concurrent cron workers is safe — a duplicate-key error simply means another worker owns that run. Scope resolution (tenant / site / campaign / delivery centre / shift / trip type / service category) narrows the planner windows; the cut-off filters bookings created too late; the run then delegates to the existing RoutePlanner so there is still exactly ONE optimiser code path in the platform (3) A missing site-to-vendor link now raises an allocation exception on route_plan_exceptions with a recommended action, and is never silently reassigned to another vendor (spec 2.6). No occurrence is ever dropped: every one ends as completed / completed_with_exceptions / failed / skipped with a message explaining why (4) Autonomous Routing console at /routing/schedules — next scheduled run, last successful run, last failed run with its reason, bookings awaiting routing, runs in flight, routed today, open exceptions, routes awaiting vendor; schedule register with per-schedule next-fire time and last outcome; full CRUD; Run now; Emergency run (mandatory audited reason, ignores the cut-off by design); public-holiday capture; 30-second live health poll (5) SPEC GAP 2 — driver portfolios did not exist. New DriverPortfolio supports Preferred / Excluded / No Preference across 14 scope types (client, campaign, delivery centre, service category, trip type, district, area, zone, suburb, site, corridor, route group, route, geofenced polygon), each optionally restricted by direction, day mask, time window (including windows that wrap midnight) and effective dates for temporary rules (6) Conflict resolution is explicit: the narrowest matching scope wins, and at equal specificity an EXCLUSION beats a PREFERENCE — fail safe. A driver with no rules is No Preference by definition, so applying this migration changes no existing assignment behaviour until a vendor writes a rule (7) Wired into the live assignment engine: portfolio is now eligibility check 14 in DriverAvailability (excluded drivers leave the automatic pool entirely), and candidates() ranks by portfolio BAND before score, so a preferred driver always precedes a no-preference driver while the existing proximity/fatigue/reliability score still decides inside each band. The candidates_ranked explainability event now records each driver's classification (8) Preference never overrides compliance, availability, working-hour, licence or vehicle-compatibility rules — those are evaluated first and a preferred driver who fails any of them is simply not a candidate. An exclusion can only be overridden through DriverPortfolio::override(): permission (dispatch.manage or vendor.compliance_override), a mandatory reason of at least 10 characters, explicit driver consent, and a full audit row (9) Driver portfolio management screen at /vendors/drivers/{id}/portfolio, grouped into three visually distinct classifications per spec 9.3, with vendor isolation enforced in the controller on every action — a vendor user can never see or edit a driver belonging to another vendor, and the denial itself is audited (10) Scheduler integration: routing schedules fire FIRST on each cron tick, before the dispatch autopilot, so routes created by a scheduled run get a vehicle and driver on the same tick rather than waiting a full cycle (11) Two idempotent per-tenant migrations (migrate_routing_schedules.php, migrate_driver_portfolio.php) creating six tables. Both are inert on arrival. Verified by 46 unit tests covering day masks, both patterns, effective dates, suspension, all three holiday modes, due-detection grace including the 23:55-to-00:05 midnight roll, occurrence-key stability, cut-off arithmetic and next-run scanning.
v1.9.290 24 Jul 2026 04:45 Route Sharing - activation control and honest profile card. (1) FIELD QUESTION: there is no Share button. ROOT CAUSE: the Share-my-live-route send flow (the tracking-screen button + Confirm and send secure link sheet) only renders when RouteShare::active() - tables migrated AND the routeshare.enabled master switch on - and the master switch DEFAULTS OFF by privacy design, with no admin screen anywhere to turn it on. Meanwhile the passenger profile Route Sharing card rendered regardless, inviting setup for a button that could never appear (2) New scripts/routeshare_enable.php: tenant-aware CLI control (status / on / off, per tenant or all) that flips the master switch and reports the full gating chain honestly - migration state, master switch, channels, and the final ACTIVE verdict (3) The passenger profile card now tells the truth when the feature is off: an amber notice explains route sharing has not been switched on by the transport office yet - preferences can still be saved in advance (4) Verified by a new 10-check suite plus full regression across all twelve suites.
v1.9.289 24 Jul 2026 04:00 Availability card moved to Profile - home keeps the live status pill. (1) The full Availability-for-next-trip management card (option grid, reason/expected-time fields, update form) now lives on the PROFILE page above the sign-out block, reclaiming the home duty board. Home carries a compact one-row live pill - Availability for next trip + the status chip - that deep-links to the profile card and updates live off the tp:avail beacon event (2) The trip allocation OFFER sheet was split into its own include-guarded partial (_avail_offer.php) mounted on BOTH home and profile - a dispatch offer surfaces wherever the driver is, with the same accept/decline flow, countdown and vibration alert (3) The home pill still runs restoreAfterReconnect() on render, so the reconnect self-heal from v1.9.288 keeps working from the home screen (4) Verified by 7 new structural checks in the availability suite (22 checks total) plus full regression across all eleven suites.
v1.9.288 24 Jul 2026 03:20 Driver availability - honest defaults; the auto-offline sweep finally runs. (1) FIELD QUESTIONS (01:21 screenshot): why does the home widget ALWAYS say OFFLINE / NOT CONNECTED, and why is that button preselected? ROOT CAUSE: statusOf() fabricated status offline for any driver with no availability row - a connectivity claim the system could not substantiate - and the widget preselected whatever the current status was. Compounding it, sweepAutoOffline() - the only legitimate source of a system offline verdict - had NO CALLER anywhere (2) A driver with no row is now UNSET, labelled Not set yet in neutral grey: a fact, not an alarm. unset is system-only (absent from STATUSES) and the autopilot treats it as cautiously as offline (absent from ALLOCATABLE). A rejected status update now echoes the drivers real current state instead of offline (3) The widget only preselects a status the DRIVER chose: system states render with nothing selected and Update disabled until the driver actively picks (4) sweepAutoOffline() now rides the GPS beacon via sweepIfDue() - throttled to once a minute platform-wide, and gated on the availability memory columns so a driver is never demoted where restoreAfterReconnect() could not bring them back. OFFLINE / NOT CONNECTED is now a real presence verdict that self-heals on reconnect (5) Verified by a new 15-check suite plus full regression across all eleven suites.
v1.9.287 24 Jul 2026 02:30 recalc_trip_times schema fix - direction and operating date are route facts. (1) FIELD ERROR (24 Jul console run): recalc_trip_times.php failed with Unknown column direction - it selected direction and trip_date directly off trips, but on this tenant both live on ROUTES. The v1.9.286 tenant fix is what surfaced this: the script had never actually queried anything before (2) recalc_trip_times.php now joins routes and filters on routes.route_date - schema-safe on every tenant. Verified end-to-end against a scratch database built in the exact production shape (trips WITHOUT direction/trip_date) (3) TripTimeService::compute() hardened the same way: it now LEFT JOINs routes and resolves direction and the operating date as route facts with the trip-level columns as an override where they exist - no silent empty-date shift lookups on any tenant (4) Verified by a new 7-check production-shape suite plus full regression across all ten suites.
v1.9.286 24 Jul 2026 01:50 CRITICAL - 7 legacy CLI scripts silently broken; migrate_offline_sync never ran on the server. (1) FIELD ROOT CAUSE (console paste: migrate_offline_sync.php printed NOTHING, twice): the v1.9.279 tenant-selection fix was applied only to the 6 scripts authored in that round - the legacy scripts were never swept. migrate_offline_sync.php still called Database::tenant() with no session and exited silently, so the offline sync tables NEVER EXISTED in production. Every driver sync flush was answered offline sync tables not migrated - the true origin of the stranded Pending records (2) Swept ALL 126 scripts with a refined detector (Database::tenant() and the GLOBAL DB helpers only): 7 genuinely broken scripts patched with scripts/_tenant_cli.php - migrate_offline_sync, migrate_driver_audit, migrate_driver_portal_access, migrate_vehicle_config, migrate_vendor_routing, migrate_trip_times, recalc_trip_times. The 11 _migrate_runner-based scripts flagged initially were false positives (they enumerate master_database_registry themselves) and were left untouched (3) TESTING GAP CLOSED: the v1.9.279 guard only asserted the 6 named scripts; it now sweeps the ENTIRE scripts directory on every run and fails naming any tenant-touching CLI script without a selector - verified by stripping the selector and watching the guard fail (4) DEPLOY ORDER: php scripts/migrate_offline_sync.php (now actually runs), then php scripts/migrate_sync_reconcile.php, then tap the sync pill on the stranded device - the queued records upload into the freshly-created ledger and the trip leaves Pending Synchronisation.
v1.9.285 24 Jul 2026 01:05 Offline sync reconciliation - the two infinite-retry loops are dead. (1) FIELD ROOT CAUSE (23:15 Sync Centre: two trip.arrive records looping on server retry): TWO server-side retry loops with no exit. Loop A - a sync-ledger claim stuck at received (an attempt that died mid-apply) answered retry forever: the note said will be reconciled but no reconciler existed. Loop B - any exception inside apply() deleted the claim and answered retry, so a deterministic fault (plausibly the UNGUARDED generateWaybill call inside applyEnd, the only closure side-effect without a try/catch) made the device re-send into the same crash for eternity (2) LOOP A FIX: a received claim older than 120 s is ORPHANED - it is now taken over and applied. Every applier is idempotent (already-closed answers duplicate, wrong state answers conflict), so a half-applied earlier attempt can never double-apply. Claims younger than 120 s still answer retry, preserving genuine concurrency protection (3) LOOP B FIX: attempts are counted on the claim row (new attempts + last_error columns via scripts/migrate_sync_reconcile.php, column-guarded for pre-migration tenants); after 5 apply() throws the record is answered failed with the real error - surfaced, never looping silently. The waybill call is now guarded like costing and billing (4) CLIENT SAFETY NET: the device gives up after 15 server retries and surfaces the record as failed - no build combination can loop forever again (5) SELF-HEALING: the stranded field device needs no manual surgery - on the next flush its orphaned claim is taken over and applied (or duplicate if the trip already closed), verification confirms the package and the pill settles to Synced and confirmed (6) Verified by a new 12-check DB-backed reconciliation suite plus the existing 36-check offline-trip integration suite re-run green against the rewritten service.
v1.9.284 24 Jul 2026 00:15 Driver App trip engine - online-direct / offline-queue. (1) ROOT CAUSE OF THE FIELD COMPLAINTS: the offline engine was queue-first for EVERY action, even on a healthy connection - each tap was written to IndexedDB, flushed by a background timer, verified, held for a 1.6 s settle window and only then reloaded. That made Confirm arrival and deliver all feel slow, painted Pending Synchronisation on trips that were never offline, and left a tapped sync pill waiting out an exponential backoff of up to 2 minutes (2) REDESIGN: ONLINE actions now write to the server IMMEDIATELY - one POST with a hard 8 s ceiling, then an instant reload to server truth; the record is still written to the local ledger as synced so the Sync Centre audit trail stays complete. The durable queue engages ONLY on genuine connectivity loss (or if the direct write itself fails mid-flight - nothing is ever lost). Server hiccups no longer flip the engine offline (3) The Trip completed on this device / Pending Synchronisation card and amber chip can now only appear for trips genuinely completed offline; an online completion reads recorded and synchronised and shows the server-rendered closed state at once (4) SYNC PILL FIXED: tapping it resets the backoff, requeues failed records and forces an immediate flush before opening the Sync Centre; Sync now and the reconnect event do the same (5) Verified by a new 19-check suite asserting the online-direct gate, the 8 s ceiling, queue-only-on-offline, all four field complaints, the preserved audit ledger, and clean JS parses.
v1.9.283 23 Jul 2026 23:20 CRITICAL - speeding banner shown while under the limit: platform-wide [hidden] guard. (1) FIELD ROOT CAUSE (22:43 screenshot: banner over a 1 km/h Within-limit card): driver-app.css declared .spd-warn{display:flex}, and ANY author display rule overrides the user-agent [hidden]{display:none} - so warn.hidden=true, set correctly by every v1.9.281 clamp on every payload, had zero visual effect. The banner was unconditionally visible from page load; the warning LOGIC was right, the pixel was not. Identical fault class to the Executive modals fixed in v1.9.280 - but that fix was scoped to one stylesheet instead of the platform (2) FIX: a global [hidden]{display:none !important} guard now heads ALL 22 platform stylesheets, restoring standard hidden-attribute semantics everywhere. Elements shown by removing the attribute are unaffected - the guard only applies while hidden is present (3) The generalised sweep immediately exposed four more LIVE instances never reported: .lg-modal display:flex in login.css AND desktop-login.css (login modals permanently rendered), .authp-success in auth-pages.css, .st-remedy in legal.css - all now guarded (4) TESTING GAP CLOSED: the v1.9.280 cascade analysis only scanned the Executive dashboard against executive.css, which is exactly why .spd-warn escaped. itest_render.py now sweeps EVERY hidden-marked class across all app/Views (128 classes) against EVERY stylesheet and fails on any display rule without a [hidden] guard in that file. Verified by stripping the driver-app guard and watching the sweep fail.
v1.9.282 23 Jul 2026 22:40 Fleet identity vehicle markers - commissioned minibus and MPV artwork across every live map. (1) Two commissioned vehicle renders installed as the fleet identity artwork: the SA-livery high-roof minibus (Quantum class) and the white MPV - processed to transparent, tightly-cropped PNGs at badge (96 px) and panel (256 px) sizes (2) New VehicleArt service resolves every vehicle to its class: the vehicle TYPE NAME decides when it clearly names a class (quantum/minibus/taxi/sprinter to minibus; sedan/mpv/suv/ertiga to mpv), otherwise seating capacity decides (9 or more incl. driver to minibus). Never a dead end - unknown vehicles render as MPV, and a fully unmapped type name leaves a vehicle_type_unmapped audit entry (3) DRIVER APP: the follow-mode marker is now the vehicle itself - the identity render inside a white badge ring. The 3/4-perspective render NEVER rotates; the GPS course is carried by the chevron on the badge ring, which holds its last course while stationary (4) LIVE TRIP MONITOR: the direction-facing top-down map marker is retained; the identity render appears in the vehicle popup header and on every trip card in place of the generic van glyph - both payload branches resolve it server-side (5) PASSENGER APP: the live tracking map shows the allocated vehicle as its identity render instead of the generic bus pin, guarded for trips without an allocated vehicle (6) Verified by a new 33-check suite covering class resolution, capacity arithmetic, unmapped-type audit trail, asset transparency and wiring across all three surfaces.
v1.9.281 22 Jul 2026 20:45 Driver App - speed and speed-limit accuracy, vehicle marker, follow-mode map. (1) SPEED ACCURACY ROOT CAUSE: the beacon took its own getCurrentPosition with enableHighAccuracy:false and maximumAge:15000 - coarse network/Wi-Fi positioning, up to 15 s stale, which carries NO Doppler speed; the server then derived speed from deltas between network positions: under-reading while driving AND phantom spikes (the 106 km/h false banner). On live-trip screens the beacon now feeds from a high-accuracy GPS watch: true Doppler speed, course and capture time on every reading; stale cached speeds are never sent. Server smoothing replaced: flat mean of the last 4 readings (up to a minute of lag) becomes recency-weighted (6 s half-life, 20 s hard window, Doppler favoured) (2) NO WARNING UNDER THE LIMIT - absolute rule enforced in BOTH layers: the server only sets warning when the current smoothed speed is strictly above the limit, and the client banner additionally requires THIS payload speed above THIS payload limit, rewrites its text from the same payload, drops after a 20 s stale watchdog, and can never be resurrected by a bfcache page restore (3) SPEED LIMIT ACCURACY: the tag-only OSM lookup voted across every way within 60 m so an adjacent arterial could outvote the road actually driven, and the 110 m cache cell made wrong answers sticky. New geometry layer: way geometry cached per 440 m tile (one free Overpass call per tile per 30 days, offline-friendly), matched by perpendicular distance (35 m) plus course agreement (55 deg, either digitising direction), ties to the LOWER limit; road name shown with the limit. Applied-limit hysteresis: lower limits apply instantly, higher ones need 2 consecutive candidates. Fully free/open-source: OSM plus Overpass, no Google dependency (4) MAP: the driver position is now a heading-rotated VEHICLE marker matching the Live Trip Monitor, and the map runs in follow mode - vehicle locked to centre at near-max street zoom, panning disabled, pinch/scroll/double-tap zoom kept and zooming AROUND THE CENTRE so any chosen zoom persists while the route leg redraws ahead (5) Schema: trip_speed_readings.candidate_limit_kph via scripts/migrate_speed_intel.php (idempotent, tenant-aware). Verified by a new 25-check suite.
v1.9.280 22 Jul 2026 19:25 Executive dashboard - modal overlay fix and actionable empty state. (1) FIXED: all three modals (KPI definitions, executive action, chart full-screen) rendered on page load and covered the dashboard, making it unusable. Cause: .exd-modal declares display:flex, and ANY author display declaration overrides the user-agent [hidden]{display:none} rule outright, so the hidden attribute the JS toggles had no effect. Added an explicit [hidden] guard ahead of the display rules, covering modals, menus and the tooltip (2) Empty state is now actionable and accurate: it distinguishes no campaigns exist on this tenant (with the shift-schedule campaign backfill instructions) from no campaigns assigned to you, and an executive.admin holder gets a direct Assign campaigns now button plus the exact steps to grant themselves tenant-level access (3) TESTING GAP CLOSED: the fault was invisible to PHP and DOM assertions because it is purely a CSS cascade outcome. New test/itest_render.py renders the REAL view with the REAL stylesheet and adds a deterministic cascade analysis; verified by reintroducing the bug and confirming the test fails.
v1.9.279 22 Jul 2026 18:40 CRITICAL - multi-tenant CLI tenant selection for all Executive/version-control scripts. (1) ROOT CAUSE: tripi is multi-tenant and Database::tenant() resolves the tenant from the session. There is no session in CLI, so it fell through to redirect(/login) - which under CLI prints a header warning (or nothing at all when the script has not yet echoed) and then EXITS. Every script shipped in 1.9.272-1.9.278 relied on it, so they silently did NOTHING on the server: the executive schema, permissions and grants were never created, which is why the super administrator kept getting a 403 (2) New scripts/_tenant_cli.php implements the platform convention already used by 91 other scripts: default tenant 1, or pass an id, --tenant=<id>, or all (re-invokes the script once per active tenant in its own process, passing through the original arguments). An unknown or suspended tenant reports clearly and exits non-zero instead of failing silently (3) Wired into migrate_executive_intelligence.php, migrate_executive_admin_grants.php, migrate_version_control.php, migrate.php, run_executive_rollups.php and diagnose_executive.php. VersionControl::apply() passes the tenant to the migration subprocess it spawns, and VersionController passes Auth::clientId() (4) TESTING GAP CLOSED: the harness stubbed Database::tenant() so it auto-connected, which is exactly what hid this. The stub now models production and six permanent guards assert every shipped CLI script requires the selector. Suites: 100 executive + 35 version control + 25 schedule import.
v1.9.278 22 Jul 2026 17:20 Executive Intelligence installation diagnostic. (1) New scripts/diagnose_executive.php (read-only, follows the platform diagnose_* convention): checks schema, seeded KPI/leakage catalogues, the four executive permissions, which ROLES and which USERS hold executive.view, campaign assignments with effective-date state, and whether executive_daily_facts has been rolled up - then prints the exact next command to run (2) Clarifies the two Executive screens: the pre-existing Exec Dashboard at /reports/executive (Administration > Reports) is a different, older report; the new module is Administration > Executive Intelligence at /executive (3) Reminder surfaced in the diagnostic: RBAC permissions are loaded into the session at login, so anyone already signed in must sign out and back in after a grant.
v1.9.277 22 Jul 2026 16:05 Executive Intelligence - system_admin permission grants. (1) Fixed super administrators receiving a 403 on /executive: the v1.9.272 migration granted executive.view/export/drilldown only to the new Executive role and, unlike every other module migration, never granted them to system_admin (the platform has no super-admin bypass - permissions come strictly from role_permissions) (2) migrate_executive_intelligence.php now grants all four executive permissions to system_admin on fresh installs; new scripts/migrate_executive_admin_grants.php closes the gap on existing databases (idempotent, applied via php scripts/migrate.php run) (3) Data visibility remains governed by executive_campaign_assignments per spec - an administrator without an assignment sees the explicit empty state and creates assignments under /executive/admin. Affected users must sign out and back in, as permissions load into the session at login.
v1.9.276 22 Jul 2026 15:35 version_history.txt repaired and auto-synced. (1) Repaired version_history.txt (rendered live on the sign-in screens): restored the header line the 1.9.270 block had displaced, converted every legacy block entry to the parser's single-line format (the lost 1.9.192-1.9.199 descriptions are back), neutralised stray pipes - 180 entries, zero malformed (2) Backfilled the five missing releases 1.9.271 -> 1.9.275 with full release notes (3) recordRelease() now also syncs version_history.txt: a new release is prepended under the header (parser-safe, idempotent, best-effort) so the file and the app_versions register can never drift again.
v1.9.275 22 Jul 2026 14:20 Platform Version Control - versioning re-implemented as a first-class capability instead of two constants. (1) RELEASE REGISTER (app_versions): every deployed version with build label, title and a JSON changelog, seeded with the recorded history 1.9.270-1.9.275; the RUNNING APP_VERSION is auto-recorded the first time the Version History page sees it, so a deployment can never be invisible (worst case it appears as 'Deployed release - notes not yet captured'). recordRelease() is idempotent and, from this release, ALSO syncs this version_history.txt file so the sign-in modal and the register can never drift apart again. (2) SCHEMA MIGRATIONS LEDGER (schema_migrations): which of the ~86 scripts/migrate_*.php have been applied to THIS database, each with a SHA-256 checksum taken at apply time, captured output, runtime, outcome and the app version that applied it; existing scripts are backfilled as applied (live system) and marked as backfill. CHECKSUM DRIFT - a migration file edited after it was applied - is flagged, never silent. (3) RUNNER (scripts/migrate.php): 'status' shows applied/pending/drifted/failed at a glance; 'run' applies every pending script in order as ledgered subprocesses and STOPS AT THE FIRST FAILURE so nothing executes against a half-migrated schema; 'run --only=<script>' re-applies after a fix (failed -> applied); '--include-drifted' re-runs changed idempotent scripts deliberately. Deploy sequence is now: unzip -> migrate.php status -> migrate.php run. (4) VERSION HISTORY PAGE (/system/versions, settings.view): release timeline with changelogs + the migrations ledger with status chips; settings.manage users can apply a pending/failed/drifted migration from the page (CSRF, audited, output inline). The sidebar version badge now links to it. Verified: 23-check functional suite (register seeding + idempotency, backfill checksums, all five discover() states, runner stop-on-failure and --only re-apply, auto-record) with the executive (99) and schedule-import (25) suites re-run green. Files: app/Services/VersionControl.php (new), app/Controllers/VersionController.php (new), app/Views/system/versions.php (new), scripts/migrate_version_control.php (new), scripts/migrate.php (new), app/Core/Router.php, app/Views/layout/app.php, docs/VERSION_CONTROL.md (new), config.php.
v1.9.274 21 Jul 2026 23:40 Executive Dashboard - spec compliance closure. Treated the re-submitted Executive Transport Intelligence spec as a verification round: audited v1.9.272 against every section and closed the four genuine gaps. (1) INSIGHT CONDITIONS 9 -> 14 of 16: cost-per-passenger above the approved target (dormant until a target is configured on the KPI; verified firing with quantified impact - (actual - target) x journeys), vendor-score deterioration (overall score drop >= 10 points period-on-period, one insight per campaign per period), complaints above the red threshold per 1 000 journeys (min 3), failed vehicle inspections rising (>= 3 and >= 1.5x the previous period, via the campaign's trips), and vendor compliance documents expired or expiring within 30 days on the campaign's serving vendors (vendor_documents). Conditions whose source module is absent degrade silently; the two survey-based conditions stay honestly dormant with the satisfaction KPI's No Data stance. (2) MONTHLY SUMMARY LAYER: executive_kpi_results now also materialises at monthly grain (aggregation_period='month', dated the 1st, ALL KPIs including engine calculations); the rollup job covers every month its range touches plus the current month. (3) CHARTS 11 -> 15: Punctuality/SLA trend, Safety incident trend (incidents/critical/SOS lines), Cost per passenger by campaign, and Savings realised vs opportunity - each with the standard hover tooltip, data-table and full-screen tools. (4) CHART IMAGE EXPORT: every chart card gained a download tool saving the rendered chart as an SVG named with the chart and period. Verified: 99-check suite re-passed plus targeted proofs (84 monthly rows persisted; new conditions fire with quantified impact). Files: app/Services/Executive/InsightEngine.php, KpiEngine.php, app/Controllers/ExecutiveController.php, app/Views/executive/dashboard.php, public/assets/js/executive.js, scripts/run_executive_rollups.php, docs/EXECUTIVE_DASHBOARD.md, config.php.
v1.9.273 21 Jul 2026 23:05 Schedule Import validation - hours displayed during validation + overlap/duplicate hardening. (1) HOURS FRONT AND CENTRE: dedicated right-aligned Hours column in the validation table (formatted 9h / 9h 15m, amber-tinted when suspicious; overnight shifts labelled explicitly) plus a second KPI row - Total Scheduled Hours, Average Shift Length, Employees Scheduled, Overlapping Shifts, Weekly-Hour Breaches. New duration checks: >24h = blocking error (date portion wrong), >16h and <2h warnings, and a rest-day row carrying working hours flagged as a contradiction. (2) OVERLAPS AGAINST THE LIVE SCHEDULE: each row is checked (one batched query, +-1 day window so overnight shifts collide correctly) against ACTIVE shifts already in shift_schedules, not just within the file; the warning names the existing shift's date and times and correctly excludes the shift the row legitimately updates. Overlapping rows get their own chip and filter button. (3) DUPLICATES + ADJACENT: exact in-file duplicates still skip with a pointer to the original row; a second NON-overlapping shift on the same day is an informational split-shift notice. NEW per-employee weekly-hour limit: per ISO week, the file's hours PLUS existing scheduled hours (minus any being replaced) are summed - exceeding the limit warns on the employee's first row for that week (setting wfm.max_week_hours, default 60h). (4) The issues CSV export now carries shift date, start/end and hours per issue. Commit gate unchanged: warnings inform, blocking errors refuse. Verified: 25-check functional suite against MariaDB (allocation of every rule incl. DB-overlap, split-shift, weekly breach 62h30, commit gate + clean commit persisting duration_minutes). Files: app/Services/ShiftImport.php, app/Controllers/ScheduleController.php, app/Views/schedules/import_preview.php, config.php.
v1.9.272 21 Jul 2026 22:35 Executive Transport Intelligence Dashboard - consolidated campaign-aware executive intelligence on live transactional data. (1) CAMPAIGN MASTER: campaigns become a first-class entity backfilled from distinct WFM shift-schedule values; each employee maps to their most recent active campaign (guarded employees.campaign_id); trip costs (costing snapshot -> waybill -> estimate, completed only) are allocated PRO-RATA by transported passenger journeys, with anything unmappable in a visible Unallocated bucket surfaced by the data-quality panel - never hidden. Net = Gross - enforced penalties - employee contributions - recoveries, components stored separately. (2) ACCESS: effective-dated executive_campaign_assignments at campaign/site/business-unit/tenant level with per-grant flags (financials, people, vendor, incidents, supporting records/PII, export); enforcement is at QUERY level - a request naming an unassigned campaign is a hard audited 403, never silently narrowed; trip drill-down verifies scope and passenger-level records additionally require the records flag, audited per view. (3) KPI REGISTRY: 43 seeded KPIs across financial/budget/forecast/operations/SLA/people/customer/vendor/safety/leakage/savings; fact_sum and fact_ratio KPIs are fully data-driven so administrators ADD NEW KPIs from Configure without code; thresholds/targets edited with a mandatory reason, audited old->new; zero denominators show Not Available; passenger satisfaction honestly shows No Data until a survey source exists. Statuses Green/Amber/Red/Critical/Grey, direction-aware, with period-on-period movement. (4) PERFORMANCE: pre-aggregated executive_daily_facts (one row per date x campaign x site x vendor x direction) + executive_kpi_results; hourly rollup (scripts/run_executive_rollups.php) + on-demand 7-day refresh; the dashboard never scans raw ledgers at page load. (5) LEAKAGE ENGINE: 10 effective-dated rules (low occupancy, unused seats, no-shows, excess km, cancelled billable, missing waybills, duplicate invoice lines with the FIRST claim exempt so values are never double-counted, overbilling, unsupported lines); idempotent via UNIQUE (rule, source_key); lifecycle detected -> under review -> confirmed/disputed -> recoverable -> recovered/prevented/accepted/closed, every transition audited; recovered value only reported after the transition. (6) BUDGETS + FORECASTS: versioned monthly campaign budgets (prior versions superseded, never deleted), configurable FY start month, run-rate/committed/seasonal forecast methods with the methodology always shown. SAVINGS REGISTER: approval requires a baseline cost + calculation methodology (API-enforced). (7) INSIGHT ENGINE: quantified per-campaign insights (what/where/when, financial impact, probable cause, recommended action - never 'performance is down'), hash-deduplicated, lifecycle acknowledge/assign/resolve/dismiss. (8) UI: premium Inter/Roboto dashboard - consolidated / campaign-comparison / exceptions-only modes, campaign multi-select + site/vendor/direction/period filters that PERSIST through drill-down (Portfolio -> Campaign -> Site -> Vendor -> Route -> Trip -> supporting records), SVG chart engine (line/area, bars, grouped bars, waterfall, donut, heatmap) with tooltips + data-table + full-screen per chart, vendor scorecard with configurable weights (Settings exec.vendor_weights), risk heatmap, executive action register, saved views, KPI definitions catalogue. (9) EXPORTS: branded PDF executive summary + 7-sheet native XLSX (KPI summary, cost by campaign, vendors, leakage, savings, monthly trend, data-quality statement), every export stamped with scope, period, generation + data-refresh time (Africa/Johannesburg), applied filters and the data-quality status; currency format R1 250 000.00 throughout. (10) ADMIN (executive.admin): assignments, budgets, KPI registry incl. new-KPI creation, leakage rules, savings - all audited. Settings: exec.fy_start_month, exec.forecast_method, exec.ontime_start/arrival_tolerance_min, exec.vendor_weights, exec.safety_weights. CLI bootstrap hotfix: the new migration + rollup scripts load app/Core/bootstrap.php so the autoloader is present. RUN scripts/migrate_executive_intelligence.php then backfill scripts/run_executive_rollups.php --from=<start>; cron hourly. Verified: 99-check integration suite against MariaDB - allocation maths to the cent, KPI accuracy/formatting/statuses, budget versioning, forecasting, all leakage detectors incl. duplicate single-counting + idempotent reruns, insight dedupe + transitions, data-quality classification, vendor scorecards, campaign- and site-level access + effective dates + the hard-403 tamper path, and XLSX/PDF export integrity. Files: scripts/migrate_executive_intelligence.php (new), scripts/run_executive_rollups.php (new), app/Services/Executive/{Access,Facts,KpiEngine,Engine,Budget,Vendors,LeakageEngine,InsightEngine,ExportService}.php (new), app/Controllers/ExecutiveController.php (new), app/Core/Router.php, app/Views/layout/app.php, app/Views/executive/{dashboard,admin}.php (new), public/assets/js/executive.js (new), public/assets/css/executive.css (new), docs/EXECUTIVE_DASHBOARD.md (new), config.php.
v1.9.271 21 Jul 2026 18:05 Offline-first Driver Trip Workflow - the driver app now executes a complete trip without connectivity. Trip-sheet package download with SHA-256 integrity before departure; offline capture of passenger boarding/no-show status, GPS breadcrumbs, incidents and passenger signatures against the manifest; a durable idempotent sync ledger (driver_sync_transactions) guarantees every offline-captured record lands exactly once when connectivity returns; trips remain in a Pending Synchronisation state until the device confirms 100 percent of records uploaded. Files: driver app sync services, DriverAppController, migration scripts/migrate_offline_sync.php, config.php.
v1.9.270 21 Jul 2026 16:30 MAJOR - Boarding verification + trip-time engine (Driver + Passenger App refactor per spec). (1) NEW app/Services/TripTimeService.php: INBOUND schedules are computed BACKWARDS from required site arrival (earliest passenger shift_start from shift_schedules minus the early-arrival allowance) over the committed route_stops sequence using OSRM leg durations inflated by the traffic buffer, plus a flat route buffer and per-passenger boarding dwell - yielding per-stop collection times (persisted to route_stops.planned_eta), driver departure and duty-start (departure minus duty prep). OUTBOUND: driver at site by latest shift_end; boarding window = shift_end + boarding allowance; planned departure at window end; forward per-drop ETAs. OSRM unreachable -> conservative haversine fallback at urban speed, every derived time flagged ESTIMATED. The shift/slot time is NEVER presented as a collection or departure time anywhere. recomputeAndNotify() diffs old vs new stop ETAs and notifies each affected passenger (Notifier in-app+push) beyond the configurable change threshold. (2) PASSENGER APP (tracking): new Boarding Pass card - six-digit boarding_code large-type, scannable QR encoding the SIGNED TPB1 token (same token the driver scanner validates; vendored qrcode-generator, no CDN), live boarding-status chip (planned/boarded/no-show/dropped), and the fact grid now leads with "Scheduled collection/drop-off" from the calculated stop ETA labelled "Calculated from your route and shift" - with "Being planned" when no route is committed yet; driver + vehicle details already present. (3) DRIVER APP (trip): new Route Schedule card - inbound: DUTY START / DEPART / ARRIVE SITE BY plus the per-stop sequence with calculated times and the no-show wait; outbound: SHIFT ENDS / BOARDING window / PLANNED vs ACTUAL DEPART plus per-drop ETAs; Estimated chip when the fallback model produced the times. Drivers never calculate manually. (4) BOARDING PIPELINE HARDENED: NEW driver-scoped POST /driver/trips/{id}/scan-board (ownership in-handler; the desktop trip.update route remains for the console) via extracted TripController::applyScanBoard - closing the same Rbac-teleport class as v1.9.264 before the field found it; the scanner sheet posts the driver route. Duplicate boarding already blocked (409 already-boarded), full Audit + DriverAudit + trip_events trail already present; NOC/admin read the same rows live. (5) EIGHT SETTINGS registered (Trip Execution): inbound_early_min(15), outbound_boarding_min(20), boarding_per_pax_min(2), traffic_buffer_pct(15), route_buffer_min(5), no_show_wait_min(3), notify_lead_min(30), notify_change_min(5), duty_prep_min(15). (6) SCRIPTS: migrate_trip_times.php (idempotent trips.calc_* columns - engine degrades gracefully unmigrated) and recalc_trip_times.php (--date --notify; cron-ready; appears in Ops Console Tools). STAGED NEXT: reminder scheduling at notify_lead_min (needs the cron hook), recompute hooks inside every mutation path (optimiser commit, address change, reassignment - batch script + driver-view lazy compute cover operations meanwhile). php -l clean x9; node --check clean; QR vendor from npm qrcode-generator 2.0.4 (MIT). VERSION / 1.9.269 / 2026-07-21 14:00 SAST FIX - "Unknown column 'd.vehicle_id'" on POST /driver/availability (production log; migrate_driver_round238 correctly reported nothing to do - the failing column was never part of any migration). Root cause: DriverAvailability::availabilityBlockers joined drivers d LEFT JOIN vehicles v ON v.id = d.vehicle_id - but the platform has NO drivers.vehicle_id column anywhere in the schema; vehicle and driver are linked PER TRIP (trips.vehicle_id / trips.driver_id). The query referenced a fictional FK and fataled on every availability POST. Fix with correct platform semantics: the driver's relevant vehicle is their NEXT allocated live trip's vehicle (else most recent live allocation), fetched via trips; vehicle compliance gates (operational status, ban, licence, roadworthy, insurance expiries) apply exactly as before WHEN a vehicle is allocated; when none is allocated yet the vehicle gates are skipped - allocation is dispatch's job, not a precondition for a driver declaring themselves available (the old 'No vehicle is assigned to you' blocker was unreachable dead logic against a nonexistent column). Driver gates (duty, active profile, ban, licence/PDP expiry, GPS, working hours) unchanged. php -l clean; single service file, no migration. VERSION / 1.9.268 / 2026-07-21 13:10 SAST FIX - Speed HUD contradiction: red "You are exceeding the speed limit" banner over an ACTUAL SPEED of 1 km/h with a green "Within limit" chip and a 60 limit. Two-layer fix. SERVER ROOT: a PARKED vehicle's beacons are all duplicate/stationary and route to the statusPayload branch - which never ran the event lifecycle, so the main path's end-on-stationary rule (v1.9.238, smoothed < 10) never executed and an active speeding event from an earlier drive survived indefinitely, keeping a severity in every payload. statusPayload now RETIRES the active event on the spot when the vehicle is demonstrably not speeding (stationary/duplicate stream, smoothed under 10 or absent): status 'ended', real duration, audited with reason 'stationary_dup_branch'. CLIENT HARD RULE (_speedcard.php, mirrors the server principle stated in the v237 fix): the red banner may ONLY accompany a payload whose CURRENT status is 'speeding' - whatever a legacy or edge emitter ever sends, a compliant reading can never banner; a mismatched payload (warning without speeding status) logs itself verbatim to the console with a [TP-SPEED] prefix so any recurrence names its emitter. The audible tone and vibration follow the same rule automatically (they key off the banner flag). php -l clean x3; view + service, no migration; normal reload. VERSION / 1.9.267 / 2026-07-21 12:20 SAST FIX (the last layer - named by the instrument, proven by the field screenshot) - Trip Manifest map threw "Map failed to initialise - applyAttribution is not defined". The four-layer archaeology, complete: (1) stale config gate (fixed 1.9.263); (2) deploy/transport gaps (fixed by atomic rsync + verify greps); (3) suicidal map watchdog (fixed 1.9.265); and now (4) A KIT COLLISION: the driver trip page loads mobile-mapkit.js (line 555, the good self-sufficient kit) and then app.js (line 667, for shared UI helpers) - and app.js DEFINES ITS OWN window.tpMapKit for desktop pages, clobbering the good kit with one whose baseMap calls bare applyAttribution(), a helper only map-attr.js (desktop-only) provides. mobile.js then built the map with the desktop kit and threw. The Map Doctor rendered green because it loads only mobile-mapkit - which is exactly how the collision was isolated. Fixes in app.js, safe in EITHER load order: (a) the applyAttribution call is typeof-guarded, so the desktop kit is safe on any page lacking map-attr.js (desktop behaviour unchanged - map-attr.js is present there); (b) the export becomes window.tpMapKit = window.tpMapKit // {...} - app.js NEVER clobbers an existing kit, so mobile pages keep the mobile kit regardless of include order. node --check clean. Deploy app.js + config; mb_asset cache-busts on reload. Tech-debt note: app.js is included on several mobile views for shared helpers - splitting those helpers out of the desktop bundle is a future cleanliness round, not required for correctness now. VERSION / 1.9.266 / 2026-07-21 11:20 SAST NEW - Map Doctor: on-device diagnostics for the inline map pipeline at GET /driver/map-doctor. Context: the whole pipeline audits clean in the sandbox after 1.9.265 (self-healing deps, discriminating watchdog, hosted-style fallback in mapkit, container height, vendor files in the tree and in the patched zip) - so if the phone still shows a fallback, the failing stage exists only on the device/server combination and must be measured THERE. The doctor runs every stage on the phone and prints a screenshot-ready PASS/FAIL checklist with the exact URL and HTTP status per stage: config echo (style/pmtiles/ osrm values), maplibre-gl.js load, pmtiles.js, protomaps themes, mobile-mapkit.js load + tpMapKit presence, style-JSON fetch with status, LIVE MAP INIT in a real 240px canvas waiting for the load event (25s deadline; first error event captured verbatim), tile completeness via areTilesLoaded() after settle, and an OSRM route probe (HTTP status + route count) against the configured or public router. Auth-only by design - diagnostics must never be blocked by role state. One screenshot of the page names the failing stage with evidence; every failure line carries the URL that failed. php -l clean; two server files, no migration. VERSION / 1.9.265 / 2026-07-21 10:15 SAST FIX (root, proven by the field screenshots) - Trip Manifest map showed "Route overview / Open in Maps" instead of the live road-route map. Three stacked causes across rounds; the final one is the killer the screenshots prove: (1) v1.9.263 removed the stale (pmtiles//style) config gate; (2) this round adds a DEPENDENCY SELF-LOADER - if maplibre / pmtiles / protomaps / mobile-mapkit are missing at init (page include or deploy gap), mobile.js injects them from the server and retries; only a genuine 404 reaches the fallback, which then NAMES the missing file; (3) THE KILLER: the driver map's "health watch" tore the LIVE map down on ANY error event matching style/source/tile/pmtiles/fetch - one flaky tile on mobile data nuked the whole map into the exact card in the screenshots - and a 6-second isStyleLoaded() deadline additionally killed slow-network loads that were succeeding. Rewritten discriminating watch: once the map has LOADED it is never torn down (tile/glyph misses are cosmetic; MapLibre retries); before load, errors log to console with a [TP-MAP] prefix and only a 20s no-load deadline swaps to the card, naming the last real error. The tracking map got the same self-heal and named-cause treatment; the generic "Route overview" label no longer exists anywhere in mobile.js. DEPLOY-GAP IMMUNITY: this round's PATCHED zip additionally carries mobile-mapkit.js and the maplibre/pmtiles/protomaps vendor files, so even patched-only deploys cannot strand the phone. node --check clean; mb_asset cache-busting delivers it on a normal reload. VERSION / 1.9.264 / 2026-07-21 09:10 SAST FIX (final) - Post-verification teleport + Trip Manifest map, both at their true roots. (1) TELEPORT: the face check PASSES (its route has no Rbac gate), then face-verify.js submits the start form - which posted to the DESKTOP route POST /trips/{id}/start guarded by the trip.start permission. When the tenant's driver role lacks that grant (or the account's role is employee-side), Rbac denies BEFORE the controller runs and redirects to role-home - which for an employee-linked driver is /app, the PASSENGER app. Verification succeeds, the very next POST hits the permission wall, teleport. This is the identical failure class v1.9.237-238 fixed for drop-off/close/ arrive, and it gets the identical established fix: NEW driver-scoped route POST /driver/trips/{id}/start -> DriverAppController::startTrip (ownership verified in-handler; no desktop Rbac dependency); TripController::start() extracted into public static applyStart() preserving EVERY gate - startable status, allocation, passed pre-trip inspection, face verification / supervisor override - and honouring the form's back field (/driver/trips/{id}); the start form now posts to the driver-scoped route; desktop start() delegates to the same engine, zero behaviour change on the console. (2) MAP: the stale (dcfg.pmtiles // dcfg.style) gate was already removed in 1.9.263; mb_asset() is confirmed filemtime-cache-busted so the phone picks the fix up on deploy; the two init catch paths now console.error the real exception ([TP-MAP] prefix) so any residual fallback names its cause. With this deploy the manifest renders the inline map with the complete OSRM road route and stop pins. php -l x4 + node --check clean; applyStart verified free of $this. VERSION / 1.9.263 / 2026-07-21 08:05 SAST FIX x2 - (1) Identity check "still redirects to another user app": the 1.9.262 guard named WRITES, but the observed teleport fires on the GET - opening the trip page itself with a non-driver session. GETs no longer teleport either: a full-screen notice names the signed-in account and email ("You are signed in as <name>, which has no driver profile"), with two honest exits - "Sign out & switch account" and "Continue to my passenger app" - plus a [TP-DRIVERCTX] server log line recording user id, email, driver_id=NULL, employee_id and the URI on every occurrence, so one grep answers "who was signed in" with evidence. (2) Driver trip map "still showing Open in Maps" instead of the inline road-route map: trip.php emits TP_MAP and loads every vendor script, but mobile.js gated the inline map behind (dcfg.pmtiles // dcfg.style) - a STALE config dependency, since tpMapKit resolves its own style chain (pmtiles -> hosted fallback). The gate is removed; the inline map with the full OSRM road route now renders whenever maplibre + mapkit are present, and when they are NOT, the fallback card names the precise unmet precondition ("Map engine not loaded (maplibre vendor script)" vs "Map kit not loaded (mobile-mapkit.js missing on server)") so a single screenshot diagnoses a deploy gap. node --check + php -l clean. Deploy atomically (rsync --checksum from the FULL zip) - the fallback card's own text will confirm whether mobile-mapkit.js actually reached production. VERSION / 1.9.262 / 2026-07-21 07:00 SAST FIX - Identity check "redirects to another user app". Diagnosis: the only cross-app redirect in the driver flow is DriverAppController:: ctx() - when the session's user has NO driver_id but HAS an employee_id, every driver route bounces to /app (the passenger app). So the device performing the check is signed in as an account without a driver profile - most plausibly fallout from the force-sign-out testing (the phone was bounced to login and a non-driver account signed in), or the user row's driver_id linkage is missing. The platform defect was the SILENT TELEPORT: a role mismatch mid-flow must name itself. ctx() now distinguishes: GET navigation by a passenger to /driver still lands in their own app (correct and unchanged); any WRITE from the driver app (identity check, availability, status posts) with a non-driver session answers an explicit JSON 401 - 'This device is signed in as <name>, which has no driver profile - sign out, sign in with the driver account, and retry' (and a parallel message when the linked driver row no longer exists). face-verify.js marks its posts with X-Requested-With and already displays server messages verbatim, so the person sees the cause on screen. Environmental check shipped in the notes: verify users.driver_id for the driver account and relink if NULL. php -l + node --check clean. VERSION / 1.9.261 / 2026-07-21 06:05 SAST FIX - Identity check "Unexpected server response (HTTP 404)". Root cause, a two-bug stack finally unmasked: face-verify.js posts to DBASE + '/trips/{id}/face-check', and the driver trip view supplies data-base as PLAIN BASE_PATH - so the client has posted to /trips/{id}/face-check since the face rounds shipped, while the route is registered as /driver/trips/{id}/face-check. Router miss, branded 404, non-JSON. It was never seen before because nginx's 1 MB body limit (fixed in 1.9.260) rejected every identity-check post BEFORE routing ran - the 413 masked the 404 from day one; removing the wall exposed the next defect in the stack, and the 1.9.260 status-bearing error message named it precisely. FIX AT BOTH ENDS: (1) client - face-verify.js now posts to DBASE + '/driver/trips/{id}/face-check' (the only DBASE URL in the file; nothing else consumed the wrong path); (2) server - alias route POST /trips/{id}/face-check -> DriverAppController::faceCheck so any driver phone still holding the pre-fix cached JS works the moment the server deploys (the handler self-guards driver ownership, so the alias adds no exposure). node --check + php -l clean. VERSION / 1.9.260 / 2026-07-21 05:10 SAST FIX - Driver identity check failing with "Unexpected server response". Diagnosis: that message is face-verify.js's fallback when the server answer is NOT JSON. The PHP path was audited clean (every validation branch json_outs; FaceVerifyService fully guarded; safeQuery exists), which leaves the request being killed BEFORE PHP: nginx's DEFAULT client_max_body_size is 1 MB, the check posts a 1280px JPEG frame (captured at quality 0.82) base64-encoded (+33%) which routinely exceeds 1 MB, and nginx rejects with an HTML 413 page - non-JSON - the exact symptom, on every attempt. The handler's own 6 MB guard proves the design expects multi-MB posts; the proxy was killing them at the door. THREE-PART FIX: (1) SERVER CONFIG (the real one, documented in patch notes): client_max_body_size 8m in the tripi.work nginx server block + reload. (2) CLIENT PAYLOAD DISCIPLINE (face-verify.js): frames larger than ~900k base64 chars are re-encoded down (960px q0.78, then 800px q0.7) before posting - comfortably under a 1 MB cap even on unpatched proxies, while the server still accepts up to 6 MB where allowed. (3) HONEST ERRORS: the fallback message now carries the real HTTP status with a plain- language hint - 413 names the upload limit and the setting to raise, 401/403 names the expired session, 5xx points at the server log - a proxy limit can never masquerade as a mystery again. node --check clean. Deploy: file + the nginx one-liner below. VERSION / 1.9.259 / 2026-07-21 04:05 SAST FIX - /bookings returned the branded 500 after 1.9.258. The two approval-context subselects added to the list query hit schema drift on the production booking_approvals table (an app-level SQL exception, branded error page, event logged). Regardless of the precise drift, the defect was architectural: a TOOLTIP ENRICHMENT must never take the bookings list down. The list query is now built via a factory and the enriched form runs inside try/catch - on ANY Throwable the real SQL error is written to the log ([TP-BOOKINGS] prefix, for the schema fix) and the plain pre-1.9.258 list is served instead; the view already null-guards appr_type/appr_reasons, so chips simply render without the type suffix until the drift is corrected. Graceful degradation per the platform convention (feature classes probe/degrade, never fatal). php -l clean. Deploy note: after this lands, /bookings is up either way - then read the [TP-BOOKINGS] log line and send it over so the underlying booking_approvals drift gets its own targeted migration. VERSION / 1.9.258 / 2026-07-21 03:00 SAST NEW - Self-explaining "Eligibility" flags (Bookings + Approvals). Field question: why are bookings flagged Eligibility? Answer, now IN THE UI: a booking is flagged Eligibility when it passes every HARD check but trips one or more SOFT rules in the EligibilityService (FR sections 5/7) - no WFM shift loaded for the day when policy requires one, weekend or public-holiday transport under a require-approval policy, off-policy distance, and similar policy verdicts. Instead of denying, the platform accepts the booking and routes it to Transport as an 'eligibility' approval task; the exact reason(s) were already stored per booking in booking_approvals.comments but never surfaced. Implementation: the bookings list query now carries the latest pending approval's type + comments (two correlated subselects); the PENDING APPROVAL chip becomes "PENDING APPROVAL - Eligibility (i)" with a rich hover card - what the flag means, THIS booking's recorded reason(s) verbatim in an amber callout, and what approve/reject each do (4h due) - help cursor, keyboard focus-within support, flips above the row near the table foot. The Approvals screen gets the explicit Eligibility label, the same hover card, and the recorded reasons visible inline under the badge without hovering. Late-booking and other types get concise cards of their own. php -l clean x4; no migration. VERSION / 1.9.257 / 2026-07-21 02:05 SAST NEW - Map layers panel redesigned (design review implementation). The old flyout mixed a one-shot ACTION (Fit) with four LAYER TOGGLES in identical text-only rows, showed no on/off state whatsoever, ignored the legend's icon vocabulary and had no clickable affordance. The panel is now a titled "MAP LAYERS" control: Fit view sits on top as a bordered action button with its re-frame glyph, separated from the toggle group; each layer row carries its LEGEND-MATCHED icon (dashed blue connector for Site links, blue police dot, red SOS dot, red-wash risk square), the label, a live COUNT BADGE (how many features the layer would place on the map right now - links from vehicles with site coordinates, SOS from active incidents, risk zones from the loaded set, police after station load), and a real SLIDING SWITCH that turns teal when active with a row tint - the menu is now simultaneously the control AND the status display. All existing ids, handlers, aria-pressed wiring and keyboard behaviour are untouched (markup + CSS + a count updater only); hover/pointer per the platform interaction standard. node --check + php -l clean. VERSION / 1.9.256 / 2026-07-21 01:10 SAST NEW + instrumented FIX - Bulk-closure PROCESS MODAL. Field asked for a detailed process display on Proceed, and the same "skipped 212" result recurred after 1.9.255 - a combination that smells like the third deployment gap of the day (the 252 Csrf fix demonstrably missed production once already). Both problems are solved by the same instrument: Proceed now opens a live process-log modal that renders the SERVER'S OWN ANSWER batch by batch, nothing summarised away. Contents: pre-flight (selection size, batch plan, endpoint, UI build, and the closure rules in plain language); per batch - closed / cancelled / skipped counts, a SKIP CENSUS from the server naming why each skip happened (per-status counts with plain-language meanings: already closed, id not found, server error...), and the ENGINE VERSION that answered; an explicit red "Deployment gap detected" row that HALTS the run when the answering engine predates v1.9.255 (or does not echo a version at all) - naming the mismatch instead of skipping silently; honest failure rows on batch errors with exact completed-before-failure totals; and a final summary. Server: bulkClose now returns skipped_statuses (per-status census, including not_found and error) and v = APP_VERSION. If the next Proceed still skips everything, the modal will state the reason in words - either "engine vX predates the fix" (deploy gap) or a census of terminal statuses (data truth). node --check + php -l clean. VERSION / 1.9.255 / 2026-07-21 00:20 SAST FIX - Bulk close skipped an entire 212-trip selection ("Closed 0 - cancelled 0 - skipped 212"). Root cause: the actionable-status sets in bulkClose were ENUMERATED GUESSES - the cancel branch listed planned/assigned/dispatch_ready, but the platform's actual pre-start status is 'scheduled' (the trips.status schema DEFAULT, the most-used status in the codebase) - so every selected trip fell through to skip. Fix by INVERSION instead of enumeration: terminal statuses (completed/closed/cancelled/no_show/superseded - the monitor's own LIVE exclusion set) are skipped; the underway set (in_progress/ delayed/arrived/en_route/boarding) closes with the full waybill/ costing sequence; and ANY other non-terminal status - scheduled, dispatch_ready, dispatched, assigned, planned, and anything added in future - means the trip never ran and is cancelled. Since the monitor only lists non-terminal trips, every selectable row is now actionable by construction. The same wrong sets were fixed in scripts/expire_stale_trips.php (candidate query now NOT IN terminal; live set widened) so the ops expiry tool matches. php -l clean. VERSION / 1.9.254 / 2026-07-20 23:40 SAST FIX/NEW - "Limit 200 trips per bulk closure" on large selections. The server cap stands (each request is one bounded, auditable batch); the CLIENT now absorbs it: selections over 200 are chunked into sequential batches of 200, posted one after another with live progress on the action button ("Closing 400 / 612..."), results aggregated into a single toast ("Closed X - cancelled Y (N batches)"). On a mid-sequence server error or network failure the run stops HONESTLY: the toast reports exactly how much completed before the failure and the snapshot refreshes, so re-selecting covers only the remainder - nothing is double-closed (the server skips already-terminal trips) and nothing is silently lost. Each batch remains individually audited server-side with its own id list. monitor.js only; node --check clean. VERSION / 1.9.253 / 2026-07-20 22:55 SAST FIX - Console flooded with "Expected value to be of type number, but found null instead" (blob-UUID source, evaluate frames) on the Live Monitor. That signature is MapLibre's WORKER rejecting a non-finite value: GeoJSON is serialised across the worker boundary, where NaN becomes null, and the worker then logs once per offending feature per evaluate. Static analysis cleared the obvious candidates (heading/rot is coalesced, risk-zone weight is coalesced, zoom interpolates cannot null), so instead of a fourth guess the boundary itself is now hardened class-wide in monitor.js: fin() guard - vehicle features fall back to the server-supplied fix if animation state is ever non-finite and are skipped outright when no finite fix exists; rot is finite-guarded; site-link lines require all four coordinates finite; risk-zone circles finite-guard lng/lat/radius and drop degenerate polygons. No NaN or null coordinate/numeric prop can reach the worker from any dynamic source, whatever upstream produced it. Plus a named diagnosis path: window.tpMonDebug() in the console returns the exact vehicles / animation keys / zones carrying non-finite values, so if the log ever reappears the offender is identified with data in one line instead of theory. node --check clean; monitor.js only. VERSION / 1.9.252 / 2026-07-20 22:10 SAST FIX - HTTP 500 on bulk-close Proceed. TripController::bulkClose (v1.9.250) calls Csrf::verify(), but TripController's grouped import read {Auth, Rbac, Audit, Notifier, DriverAudit} - no Csrf - so the call resolved to the non-existent App\Controllers\Csrf and fataled before any work. (Every prior TripController route relied on router-level protections; this was the first in-class Csrf::verify.) Fix: Csrf added to the grouped use. No behavioural change - the endpoint now runs its governed sequence exactly as specified in 1.9.250. php -l clean; single file, normal deploy. VERSION / 1.9.251 / 2026-07-20 21:45 SAST FIX - Workspace select-all checkbox did nothing. Ordering bug in the v1.9.250 change delegation: the header checkbox carries the xp-ck class (for shared styling) as well as id=xpAll, and the per-row xp-ck branch was tested FIRST - it swallowed the event (writing a junk 'undefined' selection key) and returned before the select-all branch could run. The xpAll branch is now tested first, and the per-row branch additionally requires a data-ck id so a styling-only checkbox can never pollute the selection map again. Select-all now ticks every currently-listed row (post search/filter), untick clears them, and the "Close N trips..." action reflects the count immediately. node --check clean; monitor.js only, normal reload. VERSION / 1.9.250 / 2026-07-20 21:10 SAST NEW - Bulk closure in the trip workspace. The v1.9.249 table gains a checkbox column with a select-all header checkbox; selections survive the live re-render, self-prune when rows leave the set, and clear on direction switch. When anything is selected a "Close N trips..." action appears; it opens the branded confirmation ("Are you sure you want to close the selected alerts?") with Proceed / CANCEL - Cancel is the DEFAULT focused action and Escape/backdrop also cancel. Proceed ends every selected trip ON THE RECORD via NEW POST /trips/bulk-close (trip.close + CSRF, cap 200): live trips (in_progress/delayed/arrived) get a system arrival + closure - boarded passengers marked dropped (exactly as applyArrive does), never-boarded manifest entries cancelled, actual distance from COALESCE(recorded, GPS-tracked, planned) with the source named in the trip event, costing snapshot, passenger-billing hook and the auto-waybill - identical side-effects to a manual closure; never-started trips (planned/assigned/ dispatch_ready) are cancelled with no waybill; terminal trips are skipped and reported, never touched. Per-trip status audits + trip events plus one batch audit with the full id list; result toast shows closed/cancelled/skipped. "Not to show again" is therefore achieved honestly - the trips are genuinely ended, so the monitor's snapshot excludes them - not by hiding rows. node --check + php -l clean. VERSION / 1.9.249 / 2026-07-20 20:15 SAST NEW - Expanding trip workspace on the Live Monitor. Field finding: the Inbound - Due for Arrival and Outbound - Due for Departure side panels are triage-sized and visually too small for real list work. Each panel header now carries a < control: clicking it expands to a FULL-WIDTH table workspace over the map (the control flips to > / Collapse; Esc also collapses). Workspace: radio selector Inbound / Outbound (real radio inputs, segmented-control styling), a dedicated search box (trip / route / driver / vehicle / vendor / site / status), a status filter populated live from the current direction's set, and a COMPLETE sortable table - Trip, Sched (+minutes-to), Status chip, Route, Site, Driver (+mobile), Vehicle, Vendor, Pax boarded/total (+no-shows), Late, GPS freshness, Actions. Click any column to sort asc/desc. Rows carry the SAME actions as the cards via the existing document-level delegation - View (drawer), Call, Track, SOS link, Close with the distance prompt - and clicking a row opens the drawer. LIVE: the table re-renders with every snapshot and honours the global site/vendor and client filters on top of its own search/filter. No server changes - the workspace is a richer projection of the same snapshot the cards already use. node --check + php -l clean; monitor.js/monitor.css/ index.php cache-busted; normal reload. VERSION / 1.9.248 / 2026-07-20 19:25 SAST FIX - Black, grainy, distorted hand cursor over the Live Monitor map (correct over vehicle markers and off the map). Root cause: MapLibre's default canvas cursor is `grab`, and Chromium's grab/grabbing hands are the browser's own 1x BITMAP assets - at Windows display scaling (125/150%) they upscale into exactly the reported artifact. OS vector cursors (arrow, pointer) never distort, which is why the marker hover (JS sets cursor:pointer) and everything off the map looked correct. Fix in monitor.css: the map canvas now uses the neutral OS arrow over empty map (and during drag) instead of the bitmap grab/grabbing pair; interactive features keep the crisp pointer set by JS on hover; panning behaviour is unchanged. Scoped to #lmMap. Files only, normal reload. VERSION / 1.9.247 / 2026-07-20 18:55 SAST FIX - "3 live" pinned on the map after closing every trip. Diagnosis from the operator's own screenshot: the surviving marker's chip reads NOT CONNECTED - it is an ON-DUTY / LAST-KNOWN driver marker, not a trip marker, so closing trips can never remove it, and the map's "N live" pill counted ALL painted vehicles including disconnected last-known ghosts (retained for monitor.lastknown_retain_min after the final beacon; the reported 3 were test driver sessions beaconing/retained at the same desk). Fixes in monitor.js + monitor view: (1) HONEST COUNT - the pill now counts only vehicles with a FRESH GPS beacon as "live" and shows disconnected markers separately as "N last-known" in amber; the two can never be conflated again. (2) The pill is now a drill-down: clicking it opens the v1.9.243 KPI panel (Stale GPS view when any last-known markers exist - listing them with driver, vendor and a map Locate action - else the Active view), with a tooltip explaining the live/last-known distinction and where the retention is tuned. (3) monitor.lastknown_retain_min registered in Settings -> Monitor (default 120, min 5) so operators can drain ghost markers fast (e.g. 15 min) or keep last positions long for incident work - previously the value existed in code but was not operator-tunable. Clearing the reported 3: force sign-out the test driver sessions from their vehicle popups (v1.9.244-246 flow) and/or lower the retention - the pill drops to genuine live immediately either way. node --check + php -l clean; files only, no migration. VERSION / 1.9.246 / 2026-07-20 18:10 SAST FIX - Illegible dialog text on the Live Monitor (near-white on white). Third and final layer of the monitor-dialog saga: 1.9.244 added ui-alerts.js to the bare layout, 1.9.245 added ui-alerts.css, and the modal then rendered with GHOST TEXT because the framework colours its title/message/buttons with design-system variables (--text, --text-2, --ink-900, --surface, washes...) that only global.css defines - absent on the bare monitor, and worse, any dark theme reusing those names would poison a white dialog with near-white text. Durable fix: ui-alerts.css now defines a SELF-CONTAINED palette scoped to the framework's own roots (.tn-stack, .tn-ov) with the light-theme literal values, so every toast and modal renders identically on ANY page - light or dark, shared or bare layout - with no dependency on which theme sheet the host page loads. On shared-layout pages the scoped values mirror global.css exactly, so nothing changes there. PROVEN adversarially: a Playwright render on a page with hostile dark-theme :root variables computes .tn-mttl to rgb(15,27,45) (#0F1B2D) and .tn-mmsg to rgb(92,107,130) (#5C6B82) - dark, legible, correct. Files only; normal reload (css cache-busted via filemtime). VERSION / 1.9.245 / 2026-07-20 17:40 SAST FIX - Follow-through on 1.9.244: the Force sign-out dialog now FIRED on the Live Monitor but rendered completely unstyled - the modal's tone icon SVG (sized by CSS class) exploded to viewport size and the title, message and Cancel/Sign out buttons rendered as raw text bottom-left. Same class of defect one layer down: the bare monitor layout received the ui-alerts.js include in 1.9.244 but the framework's stylesheet (public/assets/css/ui-alerts.css - .tn-* toast/modal styles) was still absent from the page. app/Views/monitor/index.php now links ui-alerts.css (cache-busted, after monitor.css). Applies to every branded dialog on the monitor: Force sign-out confirm, Close-trip distance prompt, toasts. php -l clean; files only, normal reload. VERSION / 1.9.244 / 2026-07-20 17:05 SAST FIX - "Force sign-out driver" non-responsive on the Live Trip Monitor. Root cause: the monitor is a bare, self-contained layout that loaded maplibre/pmtiles/protomaps/map-attr/monitor.js/noc.js but NEVER ui-alerts.js - window.Notify was undefined on the page, so forceLogout threw silently at Notify.confirm() on the first click: a dead button with a console error nobody sees. The same undefined reference made the monitor's Close-trip dialog dead on this page (both the historical confirm and the v1.9.243 distance prompt). Fixes: (1) ui-alerts.js is now loaded on the monitor page (cache-busted, before monitor.js) so the branded confirm/prompt framework exists; (2) monitor.js resolves the dialog framework AT CALL TIME via NFY() with a native-dialog last resort, so an operator action can never again die silently on a bare layout that misses the include; both call sites (Force sign-out, Close trip) switched. Verified end-to-end path: vehicle popup button -> branded confirm -> POST /users/{id}/force-logout (user.force_logout, stamps users.force_logout_at; the router bounces the driver's session to login on their next action) -> toast. node --check + php -l clean. VERSION / 1.9.243 / 2026-07-20 16:20 SAST NEW + FIX - Live Monitor KPI drill-down. Field report: summary tile says "3 Live" (Active) but there is no way to SEE the three or act on them - a tile was a number, the filtered results lived in side panels far from the tiles, and nothing signalled the tiles were clickable. monitor.js: (1) clicking any KPI tile (Active, Inbound, Outbound, Delayed, Late, Seat Risk, Lone-Pax Risk, SOS, Stale GPS, Not Closed) now opens a LIVE drill-down panel on the right listing every matching trip as a FULL ACTION CARD - the exact tripCard used by the lists, so View (drawer), Call, Track, Relief, Divert and Close all work through the existing document-level delegation with zero new wiring; the panel refreshes with every snapshot, honours the other client filters, closes when the tile is toggled off or filters are cleared, and the Stale GPS view also lists on-duty stale vehicles with a Locate action that flies the map to them. Tiles get cursor:pointer per the platform interaction standard. (2) LATENT BUG exposed and fixed: the monitor's one-click Close posted NO actual_distance_km while the closure engine hard-requires it - every Close from the monitor has been failing server validation. closeTrip now uses a branded prompt requiring the actual distance (prefilled from the planned route distance carried on the card/drawer button, comma-decimal tolerated), posts it with the closure, and reports "Trip closed - waybill generated"; postAction gained optional form fields. Notify. prompt resolution shape (raw string / null) normalised. node --check clean; cache-busted via filemtime; no server-side changes. VERSION / 1.9.242 / 2026-07-20 15:05 SAST FIX - The 120-notice treadmill: bulk closes were correct but the display stayed at 120 forever. Three mechanisms, three fixes. (1) The behaviour engine's upsert explicitly re-fires a NEW alert under a rotated fingerprint after closure - so every sweep tick re-minted notices for the same still-late zombie trips seconds after an operator closed them. NEW re-raise cooldown: an OPERATOR closure is a human judgement; the same condition is suppressed for noc.reraise_cooldown_min (default 240) before it may re-fire. Auto-resolved notices are unaffected (condition genuinely cleared then returned = a new occurrence). (2) The sweep considered every non-terminal trip regardless of age - trips weeks past their slot (one 23016 min behind) are data hygiene, not operations. NEW stale-trip horizon: trips whose scheduled service time is older than noc.stale_trip_horizon_h (default 24, 0=off) leave the sweep entirely, so their open notices AUTO-RESOLVE on the next tick and no new ones mint - the backlog drains itself after deploy without further bulk closing. (3) The feed is LIMIT 120 - the bulk bar now says so explicitly when the page is full, pointing at the horizon setting. Plus the SOURCE cleanup: NEW scripts/expire_stale_trips.php - dry-run by default (--apply --days=N, default 2, cap 2000): in_progress/delayed zombies -> closed with actual distance from COALESCE(existing, GPS-tracked, planned); planned/assigned that never ran -> cancelled; per-trip system_expired event + audit entry; terminal statuses never touched. Two new Settings under Trips. php -l + node --check clean. Deploy: files + optionally run the expiry tool (dry-run first); no migration - the horizon and cooldown activate on the next sweep tick. VERSION / 1.9.241 / 2026-07-20 14:10 SAST NEW - Bulk close for Behaviour Notices (the governed shape promised in the 1.9.239 notes, triggered by a 120-notice backlog making per-alert closure operationally unreasonable). Rail header gains a "Bulk close" toggle (incident.manage only): selection mode overlays a checkbox on every live notice card (tap toggles instead of opening; selections survive the 10s feed re-render and self-prune when a notice leaves the live set), with a bar offering Select all shown / Clear / Close N alerts. The confirm panel applies ONE classification to the whole set - reason pills identical to single close, notes (required for Other), submit disabled-until-reason with the same hint pattern as v1.9.239 - because bulk must not become a bypass: NEW POST /noc/alerts/bulk-close (NocController::bulkClose, incident.manage + CSRF) closes each alert individually with its own immutable BehaviourEngine timeline entry ("Bulk closure - <reason>..."), records one batch Audit entry with the full id list and closed/skipped counts, caps at 200 per request, and SKIPS (never force-closes) any alert whose state changed since selection - skips are reported back in the success toast. Serves both the Live Trip Monitor and the NOC dashboard (shared rail renderer). Verified: node --check + php -l clean; renderer re-render selection persistence and stale-selection pruning implemented in renderRail. VERSION / 1.9.240 / 2026-07-20 13:05 SAST FIX - Alert workspace focus mode. Field finding on the Live Monitor / NOC behaviour-alert panel: v1.9.239's Close flow works, but the workspace stacks the behaviour briefing, a 12+ row context table, the operator actions, three inline forms, quick note, calls and the incident timeline into one tall column - the armed Close alert button sat far below the fold at 100% zoom (the operator had to zoom to 33% to find it). noc.js now runs a FOCUS MODE: opening any form (Close with reason / Log a call / Escalate) collapses everything except the section hosting the form behind a single "Show full alert details" restore bar, scrolls the form to the top of the panel, and pins the form's submit row as a STICKY bar at the bottom of the panel viewport - the whole task fits the screen at any zoom, and the submit button is always visible while the operator fills the reason, explanation and notes. Closing the form or tapping the restore bar brings the briefing/context/timeline back (the open form persists either way); the quick-note block tucks away during focus. Serves both the NOC dashboard and the Live Trip Monitor (shared workspace). node --check clean; cache-busted via filemtime. Also resolves this session's earlier false trail: the "truncated" served noc.js was a characters-vs-bytes counting artifact (multibyte UI glyphs) - production was verified serving the current file byte-for-byte. VERSION / 1.9.239 / 2026-07-20 02:20 SAST FIX - Live Monitor / NOC behaviour-alert closure appeared to do nothing. Field report: "Close with no reason is not performing any action". No such control exists; the flow is Close with reason -> reason pills -> Close alert, and closure reasons are DELIBERATELY mandatory (closures are permanent audit / SLA / vendor-performance records; the fast path is the Resolved - no service impact or Duplicate alert pill, two taps). The real defect: pressing Close alert without a pill rendered the "Select a closure reason first" message at the TOP of the workspace (#nocActMsg) while the button sits at the BOTTOM below the pills and two textareas - the feedback landed off-screen, so the click read as dead. The same blind spot silently swallowed server errors (e.g. a 403 for a user without incident.manage). Fixes in noc.js: (1) validation and ALL doPost feedback now render in a message slot INSIDE the open form, beside the button that was pressed, and scroll into view (fallback to the workspace banner); (2) Close alert starts DISABLED with not-allowed cursor and an explicit hint ("Select a closure reason above to enable...") and arms the moment a reason pill is tapped; (3) belt-and-braces: an unarmed trigger highlights and scrolls the reason pills with an attention pulse; (4) the Log-a-call form gains the same local message slot. Serves both the NOC dashboard and the Live Trip Monitor (which loads the same noc.js workspace). node --check clean; cache-busted via filemtime so browsers pick it up on next load. No server-side changes: the reason-mandatory governance stands. VERSION / 1.9.238 / 2026-07-20 01:35 SAST FIX x4 + hardening - Driver field round 2 + push alert style. (1) AVAILABILITY "keeps defaulting to Offline": sweepAutoOffline() demotes a silent driver to Offline for dispatch truth, but nothing remembered the driver's CHOICE. driver_availability gains last_driver_status/reason/ set_at; setStatus records every driver-made selection; NEW restoreAfterReconnect() puts the last positive state (available / after_current / awaiting_dispatch / on_break) back automatically the moment the app is heard from again - called from the GPS beacon AND the widget render, gates respected, explicit driver Offline/End-of-shift never overridden; migration seeds the memory from current driver-set rows. (2) CLOSE TRIP -> wrong-app 403: POST /trips/{id}/close AND /arrive were desktop console routes gated by trip.close - same disease as the drop-off fix in v1.9.237, sibling routes now covered: shared engines TripController::applyClose()/applyArrive() (generateWaybill made static) + driver-scoped POST /driver/trips/{id}/close and /arrive where trip ownership IS the authorisation; closure gates, costing snapshot, passenger billing and auto-waybill identical from both surfaces; forms repointed. (3) "Actual distance driven" no longer asks cold: the beacon now ACCUMULATES GPS-actual distance per live trip (trips.gps_distance_km + gps_last_lat/lng/at, pair-guarded: 2-180s between fixes, <=200 km/h movement, accuracy <=150 m, relocks add nothing) and both the Close and Arrive sheets PREFILL the field from actuals - editable, with a "prefilled from GPS" hint; existing odometer end-start auto-calc kept. (4) SPEED hardening on top of v1.9.237: an active speeding event now ends IMMEDIATELY when the smoothed speed drops under 10 km/h (parked = the excursion is over) instead of holding the warning through the cool-down window. (5) PUSH alert style: payloads now carry require_interaction (default on) and the service worker renders requireInteraction + silent:false + an alert-grade vibration pattern (sw cache -> tp-shell-v6) to maximise Android heads-up (peek) eligibility; the profile Trip-alerts card now explains, per install type, how to raise the channel to "Pop on screen" - because whether a notification banners OVER other apps is an OS notification-channel decision that web content cannot force, and a centre-screen overlay is not a web platform capability (documented honestly in the notes). NEW scripts/migrate_driver_round238.php (trips GPS columns + availability memory columns, idempotent, seeds memory). NOTE: the v1.9.237 speed-warning and embedded-map fixes are server/page side - if either symptom persists, verify APP_VERSION on the driver profile screen shows 1.9.237+ before field-testing this round. Verified: php -l clean x9, node --check clean x2, all patch anchors grep-verified, set_by ENUM compatibility corrected for the restore path. VERSION / 1.9.237 / 2026-07-19 23:05 SAST FIX x3 + NEW - Driver Trip Manifest field round. (1) FALSE SPEEDING WARNING at 2 km/h in a 60 zone: the "you are exceeding" banner was latched to the open sustained-breach governance event, so it kept showing after the driver slowed or parked; the warning is now gated on the CURRENT reading actually breaching (the governance event still opens/cools/closes unchanged for NOC and reporting). Three jitter guards added so phantom events cannot mint: duplicate/stationary beacons now report the last CLASSIFIED reading instead of "speeding because an event exists"; sub-3-second coordinate deltas no longer produce calculated speeds (jitter amplifier); the first usable pair after a GPS gap/relock is marked low-confidence (a teleport can fabricate any speed); and a hard floor - smoothed speed under 10 km/h can never register a breach. (2) MAP SHOWED "Open in Maps" INSTEAD OF THE ROUTE: window.tpMapKit only existed in the desktop bundle app.js, never loaded on mobile, so the driver trip hero AND passenger live tracking always fell to the fallback card. NEW public/assets/js/mobile-mapkit.js - self-contained kit (pmtiles/protomaps style resolution, baseMap, pins, popup, fit, OSRM base) loaded on driver trip + passenger tracking; the embedded live route map with stops, road-true polyline, distances and ETAs now renders. (3) DROP OFF -> 403 "not available to vendor accounts": every manifest action (Drop off forms AND the Collect/Board/No-show sheets in mobile.js) posted to the DESKTOP console route POST /trips/{id}/passenger/{pid} gated by trip.update, which driver accounts do not hold. NEW driver-scoped route POST /driver/trips/{id}/passenger/{pid} -> DriverAppController:: passengerStatus - ownership of the trip IS the authorisation; the console logic is extracted into a shared engine TripController:: applyPassengerStatus() so OTP verification, no-show records, passenger notifications, trip events, audits and Guardian safe-arrival behave identically from both surfaces. Every action now records WHO/WHEN/WHERE: mobile.js keeps a fresh watchPosition fix and stamps lat/lng/accuracy into each data-geo form at submit; the engine persists status_lat/status_lng/ status_acc_m/status_by on trip_passengers (NEW idempotent scripts/migrate_passenger_status_geo.php; engine probes and skips until run) and appends the geocode to the trip event note. (4) NEW DRIVE MODE: the live trip screen is now a single no-scroll cockpit - map fills all spare height with the speed chip overlaid and distance/drive-time pinned on-map, the next collection/drop-off card with one big confirm button sits beneath, and a dark cockpit bar (Manifest / Scan / SOS / Full view) puts everything else one tap away in a slide-up sheet. Implemented by moving the EXISTING live nodes into a fixed shell (no cloning - map instance, beacon loop and all bindings keep working) with full restore on Full view. NEW app/Views/mobile/driver/_drive_mode.php. Verified: php -l clean x8, node --check clean x2, all 14 patch anchors applied and grep-verified. Deploy: php scripts/migrate_passenger_status_geo.php; phones pick the new JS up on next app open (cache-busted). VERSION / 1.9.236 / 2026-07-19 22:05 SAST NEW - Identity check Tier 2: server-authoritative verification via the tripi-faceid sidecar. A loopback-only Docker container (docker/faceid: FastAPI + InsightFace SCRFD detector + ArcFace recognition, buffalo_l, CPU-only, stateless, optional shared bearer token, 8MB cap) computes the L2-normalised 512-d embedding ON THE SERVER from the actual submitted frame, so no client-computed value is trusted for the verdict - closing Tier 1's residual trust-but-verify gap. PHP remains the single decision authority: NEW app/Services/FaceIdClient.php (tight timeouts, per-request unreachable cache, error taxonomy no_face/bad_image/auth/unreachable); FaceVerifyService gains evaluateFull() - sidecar-first with cosine similarity bands (>=0.40 verified, 0.30-0.40 near_match flagged or strict retry, else mismatch; both thresholds in Settings) and policy fallbacks: prefer mode falls back to Tier 1 on-device recognition when the container is unreachable, require mode holds for review instead; a sidecar "no face in frame" verdict NEVER falls back to the client claim. Enrolment photos now embed automatically at coordinator enrolment time (persistFaceEnrol hook), and NEW scripts/faceid_backfill.php embeds the existing fleet (health-gated, per-driver PASS/SKIP/FAIL, re-runnable; unembedded drivers simply stay on Tier 1 - nothing blocks). NEW scripts/migrate_faceid.php adds driver_face_enrolments.embedding/_model/ _at and driver_face_checks.decision_source/sidecar_sim/sidecar_det/ sidecar_model; faceCheck insert is now dynamically column-probed across all three face migrations. Three new Settings under Trips: sidecar mode (off/prefer/require, default prefer), sidecar match similarity, sidecar near-match floor. Config: FACEID_URL/FACEID_TOKEN via TP_FACEID_* env. Verified: php -l clean across 8 files, py_compile clean; 8 Tier 2 decision tests pass (identical face sim 1.0 -> matched, perturbed -> banded, stranger sim ~0.00 -> mismatch, no_face -> review with no client fallback, prefer-down -> Tier 1, require-down -> review, off -> Tier 1) plus both 1.9.235 suites regression-clean; LIVE integration test of the real FastAPI service in the build sandbox: healthz contract, 401 on bad bearer, 422 on undecodable image, PHP client mapping all confirmed. Deploy: php scripts/migrate_faceid.php; cd docker/faceid && echo "FACEID_TOKEN=$(openssl rand -hex 24)" > .env && docker compose up -d --build (first boot downloads model weights); export TP_FACEID_URL + TP_FACEID_TOKEN to php-fpm; php scripts/faceid_backfill.php. VERSION / 1.9.235 / 2026-07-19 21:05 SAST NEW - Robust driver identity check: face RECOGNITION against the enrolled reference, decided SERVER-SIDE. Replaces the legacy check that (a) failed honest drivers - single 640px frame through the experimental FaceDetector API in fastMode, hopeless in a dark cab at 02:00 - and (b) failed as security - the verdict was the client-posted face_detected flag, which defaulted to PASS on every device without the API (iOS entirely, most Android builds) and was curl-forgeable; the coordinator-enrolled 128-d descriptor (driver_face_enrolments) was never consulted at all. Now: NEW app/Services/FaceVerifyService.php - the enrolled descriptor never leaves the server; the driver app samples up to 8 frames over ~2.4s (TinyFaceDetector + landmarks + recognition descriptor, models already shipped for enrolment), averages the top-3 descriptors, and the server computes the Euclidean distance and decides in configurable bands: <=0.50 verified/matched; 0.50-0.62 near_match (passes flagged for NOC sampling, or retries when strict matching is on); >0.62 mismatch/review. Devices that cannot run the models degrade to evidence mode (photo stored, supervisor confirms via the existing start-override path, which now web-pushes approvers) - NEVER an auto-pass. Liveness challenge per attempt (blink twice / turn head, validated from landmark motion across frames), live brightness meter with "too dark - switch on the cab light" guidance BEFORE capture, torch auto-enable where supported, 1280px capture. After N failed attempts (default 3) the server flags escalate and the app scrolls to + highlights the existing supervisor override button. NEW public/assets/js/face-verify.js owns the flow (legacy block in mobile.js disarmed); face-api + model weights served cache-first from a dedicated SW cache (tp-face-v1, sw.js -> tp-shell-v5) so drivers download ~6MB once and passenger installs never do. NEW scripts/migrate_face_verify.php adds telemetry columns to driver_face_checks (match_result, distance, near_match, frames_sampled, det_score, liveness, challenge, brightness, client_mode, model_version); faceCheck falls back to the legacy insert until it runs. Six new Settings under Trips: identity check method (recognition/evidence/presence rollback), match threshold, near-match ceiling, strict matching, unenrolled-driver policy (pass flagged no_reference by default - rollout-safe), failed-attempts-before-override. Verified: php -l + node --check clean across 8 files; 17 logic self-tests pass (distance exactness, descriptor validation, every decision band, strict/soft behaviour, unenrolled policies, evidence never-auto-pass, presence rollback parity). Deploy: php scripts/migrate_face_verify.php (and migrate_face_recognition.php if not yet run); enrol drivers via the coordinator app. VERSION / 1.9.234 / 2026-07-19 20:20 SAST FIX/DIAG - Web push feature invisible on some deployments (no enable banner, no Profile Trip alerts card). Root cause class: push-register.js is injected only when a VAPID public key exists; if the platform keypair cannot be created lazily at storage/.vapid.json (typically storage/ not writable by the web user on the production Linux host), the entire feature silently absents itself by design - too silently. Two changes: (1) WebPushService key-material failures now write clear [webpush] lines to the PHP error log naming the exact cause (unwritable storage, corrupt key file, openssl failure) while remaining a silent no-op for users; (2) NEW scripts/diagnose_web_push.php - a one-command, read-only verifier that walks the entire gate chain in order: PHP extensions and P-256 capability, storage writability, VAPID key generation and format, signed-URL minting, a live RFC 8291 encrypt->decrypt round-trip on the server's own PHP build, deployed file checks (routes, _head injection, register script, sw.js v4 push handler, both profile cards), and the push_subscriptions table + device subscription counts. Prints PASS/FAIL/WARN per gate with the exact remediation and a verdict; exit code 0/2 for scripting. Also documents the by-design case: the banner only shows while browser permission is UNDECIDED; once previously allowed/blocked, the Profile card is the control surface. Run as the web user to test real permissions: sudo -u www-data php scripts/diagnose_web_push.php. php -l clean; verifier executed end-to-end (all environment/file/crypto gates PASS). VERSION / 1.9.233 / 2026-07-19 19:56 SAST NEW - Web Push notifications for the mobile PWAs (passenger, driver and coordinator). Real OS-level push delivered whether or not the app is open, including the "Your trip is ready" alert carrying the driver's name, the driver's profile picture as the notification icon, the vehicle registration and the pickup time; tapping opens the app on Live Tracking. Implemented as a pure-PHP open-standards stack with zero dependencies (no Composer): VAPID RFC 8292 (ES256 JWT, platform P-256 keypair auto-generated at storage/.vapid.json, 0600), message encryption RFC 8291 with the aes128gcm content coding RFC 8188 (ephemeral ECDH -> HKDF-SHA256 -> AES-128-GCM), so payloads are end-to-end encrypted and unreadable by Google/Mozilla/Apple push relays. NEW app/Services/Push/WebPushService.php (keys, subscription store, encryption, VAPID, dispatch with 3s/6s curl timeouts, endpoint health: 404/410 auto-deactivation + failure-streak cutoff, HMAC-signed expiring driver-photo URLs so the OS can render the icon without a session cookie); NEW app/Controllers/PushController.php (/push/subscribe, /push/unsubscribe, /push/test real self-test push, and public HMAC-verified /push/driver-photo/{id} with Database::tenantDbName() guard before any DB access); NEW public/assets/js/push-register.js (permission flow, silent re-subscribe self-heal on every app open, one-time dismissible "Turn on trip alerts" banner, profile card wiring with live toggle + Send test button + iOS Add-to-Home-Screen hint); NEW scripts/migrate_web_push.php (idempotent push_subscriptions table; Ops Console Tools compatible). Notifier::send now mirrors every in-app notification to the user's registered devices via an optional push-meta parameter (fully guarded no-op until migrated; existing callers unchanged), so transfers, fines, no-shows, drop-off changes and driver offers all push automatically. AutoAssign::notifyTrip enriched with the trip-ready push (urgency high, collapse tag per trip). sw.js bumped to tp-shell-v4 with push + notificationclick handlers using scope-relative URL resolution (works at domain root AND sub-path installs). Verified: php -l clean on 9 files, node --check clean on 2, and a standalone crypto self-test passing encrypt->browser-side-decrypt round-trip, VAPID JWT signature verification and signed-URL mint/verify/tamper/expiry. Deploy: run php scripts/migrate_web_push.php; on iPhone users must Add to Home Screen (iOS 16.4+) before enabling alerts - the profile card surfaces this hint. VERSION / 1.9.232 / 2026-07-07 01:46 SAST FIX - driver app "Update availability" took several seconds instead of being instant. Root cause: PHP session-lock contention. The driver app fires the GPS beacon (POST /driver/whereami) on a continuous interval; PHP's default file sessions hold an exclusive per-request lock until the script ends, and the beacon holds it through heavy work (driver_positions upsert, SpeedMonitor ingest, availability statusOf, pending-offer lookup). The "Update availability" POST uses the same session, so it queued behind the in-flight beacon's lock. Fix (app/Controllers/DriverAppController.php): call session_write_close() right after the session write in whereAmI() (before the heavy DB/analytics work) and in availabilitySet() (after reading geo + CSRF verify), so neither request holds the lock through its DB work. Guarded with session_status() check; a source audit confirms no $_SESSION writes occur after either close point, so no session data is lost. Result: the Update tap returns immediately and the whole driver app is snappier (the frequent beacon no longer serialises the driver's other requests). NOTE: the status still reading "Offline / not connected" is a dispatch GATE blocker (off duty / no vehicle / expired compliance / GPS not shared / reason required), not a bug - with this fix the red "You cannot use this status yet" box now appears instantly and names the gate; resolve it or relax the driveralloc.* setting. php -l clean. One file, no migration. VERSION / 1.9.231 / 2026-07-07 01:12 SAST FIX - mobile /app/login returned 500 / ERR_TOO_MANY_REDIRECTS for sessionless clients (phones) while an authed desktop worked. Root cause: /app/login is a public route with no tenant DB selected, but the mobile login view pulls in mobile/_head.php which unconditionally includes partials/map_attr.php; that emitter called Settings::get() -> Database::tenant(), and with no tenant Database::tenant() does redirect('/login') via exit (not an exception), so map_attr's try/catch could not degrade it -> forced redirect / 500. Desktop /login uses a different view without the mobile shell, so it was unaffected (hence the misleading "mobile vs desktop" appearance - it was really session vs no-session). Fix (one file, app/Views/partials/map_attr.php): resolve the tenant with Database::tenantDbName() (which never connects or redirects) BEFORE reading settings; when no tenant is resolved (public login, pre-tenant, shared tracking links) use SettingsRegistry defaults and never call Settings::get(); when a tenant is present, read live settings exactly as before. /app/login now renders with zero tenant touches. Verified 5/5 unit assertions (pre-tenant never reaches Database::tenant(); returns registry defaults; authed path still reads live settings); php -l clean. No migration, no settings change, no behaviour change for signed-in pages; also hardens all other public/pre-tenant map-bearing pages against the same failure. VERSION / 1.9.230 / 2026-07-06 23:23 SAST VoIP round 5 - cron wiring (g) + error-handling UX polish. Error UX: a transient ICE "disconnected" mid-call no longer kills the call - a "Reconnecting..." banner shows and WebRTC gets a 9s grace window to recover before the call is dropped cleanly; on recovery the banner clears and the call continues. fatal() now routes through a shared releaseMedia() that stops the signaling/state/duration pollers + ring timer, frees the microphone and closes the peer connection, so a call that fails before connecting no longer leaks an open mic or background pollers. teardown() flushes + finalizes the recording before releasing media on every terminal path. Cron: new VoipService::maintenance() (one call per tenant, table-guarded, per-step try/catch) sweeps stale signaling messages + expired tokens, transitions timed-out ringing/pending calls to "missed" (reusing the existing path so SOS-linked criticals auto-escalate the incident even when the caller's device vanished), and purges recordings past voip.recording_retention_days; VoipService::sweep() now returns the purged-row count. New runner scripts/cron_voip_maintenance.php iterates active tenants (same pattern as cron_sla_enforcement.php) and logs per tenant + a total; suggested schedule every 5 minutes. Verified 4/4 maintenance unit + 4/4 browser error-UX; 39/39 engine + 11/11 controller + 10/10 recording + 20/20 client + 12/12 console + 7/7 queue + 4/4 recorder regression; lint clean. No migration, no settings change - add the cron entry and deploy. Next (optional): web push for backgrounded driver/passenger apps (VAPID + RFC 8291), delivered as its own focused round. VERSION / 1.9.229 / 2026-07-06 23:02 SAST VoIP round 4 - call recording (e) + reporting suite (f). Recording: real WebRTC has no media server, so recording is captured client-side on the NOC operator's browser only (the driver/passenger never records). The operator's browser mixes both parties' audio via a Web Audio graph, encodes Opus/WebM and uploads 4s segments while the call is live; each segment is AES-256-GCM encrypted the moment it lands, then concatenated in order and re-encrypted into one file on hang-up (segments deleted). Nothing is stored in the clear; the key lives at storage/.voip.key (0600), outside the web root. Playback is gated on voip.recordings.play, decrypts server-side and streams, and is audited (plays + downloads; downloads high-risk); a tampered file fails the GCM tag and is refused. A live REC indicator + consent disclaimer show during the call, and a Play control appears on recorded calls in the Console history and the incident Communication panel. Retention purge removes recordings older than voip.recording_retention_days. Reporting: a filterable analytics view (date range, call type, status, direction, vendor, SOS-only, recorded-only) with a summary (total, answer rate, connected, missed, failed, escalated, avg answer time, avg duration, total talk time, recorded, fallback, SOS), a calls-by-day chart with the missed portion per bar, breakdowns by type/status/NOC agent/driver/vendor, and CSV export - all from real call data. New idempotent migration scripts/migrate_voip_recording.php (voip_recordings table + storage/.voip.key + storage/voip_recordings). New routes: recording chunk/finalize (participant token), recording play/download (voip.recordings.play), reports/data/export (voip.reports). Verified 10/10 recording crypto (round-trip, encrypted-on-disk, in-order concat, GCM tamper-detect) + 4/4 browser recorder + 9/9 browser reports; 39/39 engine + 11/11 controller + 20/20 client + 12/12 console + 7/7 queue regression; lint clean. Deploy: run the migration per tenant, enable recording + retention in Settings, grant voip.recordings.play / voip.reports. Next: cron wiring + optional web-push. VERSION / 1.9.228 / 2026-07-06 22:35 SAST VoIP round 3 - driver/passenger call screens (c) + SOS voice workflow (d). Driver and passenger apps can now call the NOC in-app via a robust NOC-queue model: a driver/passenger call is addressed to the queue (no single recipient), any NOC agent sees it ring, and the first to accept claims it atomically (claimNocCall; a second agent gets an "answered by another operator" notice). Because the caller has no peer until claimed, the claiming agent creates the WebRTC offer and the caller answers (new should_offer flag drives offer direction). Driver/passenger see only "Tripi NOC"; the NOC sees the caller identity. If no agent answers within the ring timeout, the caller is offered a masked phone fallback to voip.noc_queue_numbers. The shared client + styles now load in the mobile shell for signed-in drivers/passengers when VoIP is enabled; config() is role-aware (capability + nocCallType + queue numbers) and initiate() permits driver->NOC / passenger->NOC governed by the direction settings + app session. SOS workflow: the driver/passenger SOS screen places an SOS-typed call (sos_driver_noc/sos_passenger_noc) linked to the active incident; the NOC incident screen gains a Communication panel (one-click call driver / each passenger / emergency numbers + a live attempt log with masked numbers, status, duration and recording/phone/escalation tags); and an unanswered SOS-linked call auto-escalates the incident (timeline event + escalation-level bump, guarded). Verified 7/7 new queue browser tests + 4/4 incident-panel render tests; 20/20 client + 12/12 console + 39/39 engine + 11/11 controller regression; lint clean. No new migration. Enable under Settings > VoIP & Communication and set the NOC queue numbers; configure self-hosted STUN/TURN for calls beyond a LAN. Next: recording, reporting, cron. VERSION / 1.9.227 / 2026-07-06 22:06 SAST VoIP round 2 - the WebRTC client + call modal and the NOC Communication Console, on the v1.9.226 foundation. New shared client public/assets/js/voip.js runs real browser-native WebRTC: reads capability + self-hosted ICE from /voip/config, initiates a call, captures the mic, builds the RTCPeerConnection and exchanges SDP + ICE over the DB-backed /voip/{id}/signal endpoints (no WebSocket, no third party), discovers incoming calls via /voip/inbox and joins via /voip/{id}/join, mirrors server state via /voip/{id}/state, and falls back to a masked device-dialer call when WebRTC is not possible. Full call modal: outgoing / incoming / in-call / fallback / error, with mute, hold, speaker (where supported), duration timer, status, recording indicator + disclaimer, notes drawer, supervisor / emergency escalation and outcome selector; explicit user-facing error handling. New NOC Communication Console at /voip/console (voip.console): live active trips with real driver / vehicle / vendor / passenger / SOS context and one-click call buttons for driver, each passenger, vendor supervisor and emergency contact, an emergency quick-dial, and a live call-history feed - all from real records. Added VoipService::inbox + joinAsRecipient and the inbox / join / console / console-data endpoints; wired voip.css + voip.js + window.TP_VOIP app-wide for call-capable users and a "Communication Console" sidebar item under Safety & SOS. Inter/Roboto, brand colours, spec status colours, hand pointers, underline-free links, focus rings, disabled cursors. Verified 20/20 real-WebRTC client tests (fake mic) + 12/12 console render/interaction tests; 39/39 engine regression; lint clean. No new migration. Enable under Settings > VoIP & Communication; configure self-hosted STUN/TURN for calls beyond a LAN. Next: driver/passenger call screens, SOS call-workflow wiring, recording, reporting, cron. VERSION / 1.9.226 / 2026-07-05 23:28 SAST VoIP Communication Channel - foundation layer (Part 2, round 1). Self-hosted, no third party: browser-native WebRTC audio (encrypted DTLS-SRTP) with SDP/ICE signaling carried over this PHP + MariaDB stack via HTTPS polling (voip_signal_messages) - no WebSocket daemon, no Node, no Composer, no external service; short-lived per-session tokens (voip_call_tokens) gate signaling; a secure masked device-dialer fallback when WebRTC cannot connect; reliable mobile NAT traversal via the tenant's own coturn (TURN) read from Settings. Delivered: idempotent migration (voip_call_sessions, voip_call_audit, voip_signal_messages, voip_call_tokens; ROW_FORMAT=DYNAMIC) plus six RBAC permissions; a 33-key "VoIP & Communication" settings group (per-direction enablement, recording + retention + notice, masking, timeout/retry, SOS + supervisor + emergency escalation, notifications, NOC queue + emergency + vendor contacts, and the self-hosted STUN/TURN config - nothing hard-coded); VoipService (strict call-session state machine, provenance audit with IP/device/browser/OS/UA, high-risk audit on a missed SOS-linked call, DB-backed signaling, ICE assembly with relay->all downgrade, number masking); and VoipController with six gated routes (config, initiate, state, act, signal push/poll) resolving recipients against real driver/passenger/vendor records with in-app user-id addressability and masked-dialer fallback. 39/39 engine pure-logic tests + 11/11 controller authorisation tests; php -l clean. No MySQL in the build sandbox, so DB methods are lint-verified against proven patterns and run on WAMP/cPanel. Deploy: run php scripts/migrate_voip.php; configure under Administration > Settings > VoIP & Communication; voip.enabled is OFF by default. Remaining rounds: WebRTC client + call modal, NOC console, driver/passenger call screens, SOS workflow wiring, recording, reporting. VERSION / 1.9.225 / 2026-07-05 22:25 SAST Global Map Information / Attribution Behaviour (Part 1 of the map+VoIP brief). Fixes the platform-wide problem of the map information "i" control looking permanently clicked. Root cause confirmed against the vendored MapLibre CSS: the compact attribution is a <details>/<summary> whose (i) button ships with a semi-transparent grey default background that reads as "pressed", and the compact control can render expanded; older per-stylesheet overrides forced a fixed background on top. Delivered a config-driven, not cosmetic, solution: a new Administration > Settings > "Map Display & Attribution" group of 14 versioned/audited settings (enable, display mode, icon + default state, position, theme auto/light/dark, overlap protection, z-index, mobile behaviour, apply-globally/override, provider text + link, reporting); one client-side authority window.tpApplyAttribution in app.js plus a standalone applier (map-attr.js) for the shared-link and vendor-portal surfaces that do not load the engine; master styling in map-attr.css that neutralises the pressed-look, adds hover/expanded/focus states, light/dark themes, overlap insets, z-index and responsive collapsed/compact/hidden behaviour with hand pointers; all five map constructors (baseMap, monitor, shared-link, portal) rewired and the emitter partial injected into the desktop, mobile, monitor, shared-link and portal shells; conflicting rules removed from app.css, monitor.css and portal.css. 20/20 headless-browser checks on a real MapLibre map, 8/8 PHP emitter checks (incl. graceful default fallback + icon-off compliance guard), lint clean. No migration. Part 2 (Integrated VoIP) is deferred pending the media-infrastructure decision - the real backbone will be built behind a provider seam rather than mocked. VERSION / 1.9.224 / 2026-07-05 21:57 SAST Bugfix: SOS live-location pin invisible on driver and passenger apps. Two same-named @keyframes mbPulse existed in mobile.css; the later hold-pad ring version (opacity + transform:scale) globally replaced the pin's box-shadow pulse, and its transform animation overrode MapLibre's inline positioning transform every frame - collapsing the marker to the map corner at ~15% opacity. Renamed the ring animation mbSosPulse; wrapped the three .mb-pin marker builders (locPin, pinEl, tracker pin) so styling and the teardrop rotate live on an inner element while MapLibre owns a plain root - rotation restored, markers permanently immune to root-transform interference. 10/10 pin checks (position at fix, opacity 1, box-shadow pulse, no root animation), offline coordinate-card fallback regression clean, 29/29 SOS suite re-run. CSS+JS only; filemtime asset versioning busts client caches. VERSION / 1.9.223 / 2026-07-05 21:20 SAST Bugfixes. (1) Duty-board 500 below "Your next trip": DriverAvailability:: pendingOfferFor selected non-existent route_stops.addr_suburb/addr_line; pickup/dropoff now resolve via bookings -> passenger_address_versions (pav.suburb/line1) filtered to the route's current version, and the offer fetch is shielded to return null on any schema drift instead of killing the duty board. (2) Blank SOS live-location map (driver AND passenger, GPS state Active): var-hoisting collision in shared mobile.js - the live-trip tracker's var mapEl (#mbMap) overwrote the SOS block's mapEl (#mbLocMap) to null on SOS pages, so neither the map nor the coordinate fallback could mount; tracker variable renamed trkMapEl (8 lines). After the fix the map renders, and with unreachable tiles the health watchdog swaps to the stable coordinate card. mb_asset filemtime versioning busts client caches on deploy. 6/6 offer-query tests, before/after headless repro, 29/29 SOS suite re-run. VERSION / 1.9.222 / 2026-07-05 20:45 SAST Driver SOS mirrored to the Passenger SOS experience: same emergency readiness strip (control room via new GET /driver/ping heartbeat + HEAD fallback, location, network), same press-and-hold pad with live type chip, same six-type grid, operator note and discreet-handling toggle (anonymous flag; copy adapted to passengers/vendor), same live-location card, the passenger-format Emergency contacts card (Transport Control, the driver's personal emergency contact - new drivers.emergency_contact_name/phone via migrate_driver_sos.php, captured on the Master Data driver form with graceful pre-migration fallback - the active trip site's Security Desk resolved live, Emergency Services) and the What-happens-next walkthrough; trip context card in the journey-card format; offline queue-and-retry retained; shared /sos endpoint and hold engine unchanged. 29/29 UI checks. VERSION / 1.9.221 / 2026-07-05 19:52 SAST Passenger Billing: Trip-Cost Emails & Settlement Ledger. Every closed trip (close + inbound arrive) creates per-passenger charges - the costing-snapshot share as trip_cost (zero share = company_paid + settled, with its mandatory event row) plus a configurable out-of-radius levy on excess km - and emails a branded Trip Cost Confirmation (navy/teal, Inter/Roboto, HTML +plain alternative through the SMTP delivery ledger; reference stored on the charge). Applied no-show fines mirror into the ledger and email the No-Show Fine Notice (missed trip, reason, cut-off rule, amount, total due, settlement route, appeal option); waived/cancelled fines waive the mirrored charge. Settlement methods cash/salary_deduction/eft/credit_card/ company_paid/waived/pending; statuses pending/settled/waived/failed/ reversed/disputed; the single settle() path writes passenger_charge_events (prev/new status+method, amount, refs, actor, IP) plus high-risk audit - reason mandatory on waive/reverse/failed, double settlement refused. Passenger Billing console (billing.settle) with KPI strip, filters, inline settlement and per-charge Trail; passenger My Billing screen with balance, status chips, receipts and payment history; Mailer/SmtpClient gain multipart/alternative HTML on both transports; tables passenger_charges + passenger_charge_events; 7 settings under Passenger Billing (master OFF); 2 reports. 45/45 engine + 9/9 MIME + 29/29 UI checks. VERSION / 1.9.220 / 2026-07-05 18:47 SAST Passenger No-Show, Late Cancellation, Fines & Dynamic Rerouting: automatic cut-off sweep (scheduler + driver sheet + console; inbound in-progress only; planned-only so timely cancellations always protect the passenger); the configurable fine lifecycle (pending/applied/waived/appealed/approved_payment/ paid) with NOC review, passenger appeals, per-employee monthly cap and payroll-deduction acceptance; passenger No-Show History screen + cancellation cut-off warning; controlled minimal-disruption rerouting on no_show, late_cancellation, passenger_added/removed, vehicle_transfer and slot_transfer with in-place removal, cheapest insertion, old-vs-new ledger, constraint gates (max delivery/deviation/added time) and pending-NOC approval; route comparison map (grey old / blue new lines; green unchanged, amber resequenced, red removed, purple added stops); driver route-update acknowledgement panel + live cut-off strip; out-of-sync resolver with the four controlled options incl. Declined - Own Transport; No-Show Console + nav; tables passenger_fines, trip_reroute_events, passenger_transfer_events; permission noshow.manage; 21 settings under a new No-Show category (master OFF); 4 reports. 68/68 engine + 38/38 UI checks. VERSION / 1.9.219 / 2026-07-05 04:39 SAST [2026-07-05 04:39 SAST] v1.9.219 - Passenger Active Route Sharing A passenger on an active trip can e-mail a secure live-route link to a trusted person. New Configuration Centre category (18 privacy-first rules), passenger profile Route Sharing section (trusted addresses, driver/vehicle preferences, active links with revocation), Share-my- live-route sheet on the tracking screen with the spec confirmation notice, professional e-mail, and the public /track/{token} page: road route, numbered stop circles (own stop highlighted), live vehicle + speed + ETA, dual-gated driver/vehicle card, monitored-safety panel, stale-GPS notice, 10s auto-refresh. 32-byte tokens stored hash-only; invalid/expired/revoked links all render one generic page (no trip- existence oracle); share and poll rate limits; allow-list payload never carries another passenger's identity; full trip_route_share_audit ledger + new safety report. 56/56 engine + 31/31 UI checks. VERSION / 1.9.218 / 2026-07-05 03:01 SAST [2026-07-05 03:01 SAST] v1.9.218 - SMTP migration hotfix Fixed error 1709 (Index column size too large, 767-byte limit) on COMPACT-row-format InnoDB installations: idx_edl_recipient now uses a 100-char prefix index and all three SMTP module tables are created ROW_FORMAT=DYNAMIC, with an in-place repair of the table left by the failed run. Re-run scripts/migrate_smtp.php - fully idempotent. VERSION / 1.9.217 / 2026-07-05 02:55 SAST [2026-07-05 02:55 SAST] v1.9.217 - SMTP / Email Configuration module Enterprise email configuration under Administration > Email / SMTP: named SMTP configurations (host, port incl. custom, None/SSL/TLS/ STARTTLS, auth, sender identity, bounce envelope, timeout, retry policy, enabled/default) with AES-256-GCM encrypted passwords (storage/.smtp.key, mask-only frontend, secret-free audit). Resolution: tenant default -> legacy settings -> platform constants -> log-only, with an ACTIVE-source banner. Full dependency-free transport with safe credential-free failure mapping. Test Connection (rate-limited, professional test email, smtp_test_logs + last-test state + audit). Delivery ledger queued -> processing -> sent / retrying -> perm_failed with per-config self-managed retries via the job queue and a filterable delivery-log screen. Permissions smtp.view/manage/test/logs. Graceful pre-migration degradation. 38/38 engine tests (incl. a real scripted SMTP server), 23/23 UI checks. VERSION / 1.9.216 / 2026-07-05 02:06 SAST [2026-07-05 02:06 SAST] v1.9.216 - Female Passenger Last-Drop Safety Rule Duty-of-care routing control on manual and autonomous routing: every outbound route runs the Last-Drop Safety Check - detects a female or protected passenger as the final lone drop (grouped stops = company), actively resequences within the detour tolerance and duration ceiling, applies a route-score safety penalty, and when unavoidable opens a formal exception: NOC alert + acknowledgement, passenger awareness responses (comfortable / not comfortable / call me), independent in-app safe-arrival confirmation (driver-only never sufficient) and a timed auto-escalation. Four rule modes incl. hard blocks that hold dispatch. New Settings category (21 rules), route preview safety panel with governed override, passenger banner, neutral driver flag, NOC monitor overlay + alerts, POPIA-masked identities + gated employees.gender, 2 safety reports, scheduler sweep. Inert until migration + enable. 42/42 engine tests, 16/16 UI checks. VERSION / 1.9.215 / 2026-07-05 01:09 SAST [2026-07-05 01:09 SAST] v1.9.215 - Driver Availability & Autonomous Allocation Drivers declare availability for the next trip in the app (9 statuses, reasons, expected time) with hard gates preventing misleading availability. New Configuration Centre category (24 rules). The dispatch autopilot now allocates through a 13-point eligibility matrix, scores and ranks candidates, and OFFERS the trip with a countdown - accept assigns, decline/expiry cascades, missed offers can stand the driver down, exhaustion escalates to NOC once. Live Monitor availability overlay; trip-page allocation decision panel; immutable availability + decision ledgers; 2 new reports; scheduler sweeps. Inert until migration + enable. 42/42 engine tests, 14/14 UI checks. VERSION / 1.9.214 / 2026-07-05 00:42 SAST [2026-07-05 00:42 SAST] v1.9.214 - Fix: Backup Management menu item not visible The nav item was gated solely on backup.view, which exists only after the backup migration runs - pre-migration nothing showed, even for system administrators. Menu and module screen now fall back to settings.manage (same pattern as Dashcam Configuration): admins see the item pre-migration and get the install instructions; after migration backup.view governs. All sensitive actions (configure, create, download, revoke, audit) keep their strict permissions. VERSION / 1.9.213 / 2026-07-05 00:14 SAST [2026-07-05 00:14 SAST] v1.9.213 - Backup Management module (Administration > Backup) Complete enterprise backup workflow: named configurations (type, schedule, retention, expiry, download limits, recipients), PHP-native chunked dump engine through the tenant PDO (no shell, no credential exposure), streaming gzip + AES-256-CTR with HMAC-SHA256 encrypt-then-MAC and HKDF per-backup keys from a 0600 master key, files outside the web root under randomised names, expiring limited-use tokenised download links (hash-only at rest) behind the full validation matrix with one uniform refusal page, every attempt audited with identity/IP/device/browser/OS, audit drawer + CSV export, revocation, scheduler-run due configs and retention sweep (expire links, securely delete past-retention files), notification emails through the existing Mailer with strict content exclusions. Six permissions seeded with download separate from create. Verified: crypto 6/6, service behaviour 16/16, UI harness end to end. VERSION / 1.9.212 / 2026-07-04 23:48 SAST [2026-07-04 23:48 SAST] v1.9.212 - Fix: Settings search/filter row alignment The filter bar's dropdowns sized to their longest option and could not shrink, overflowing the row at laptop widths. One-row flex model: the search field is the only flexing control; the category/status selects are width-capped with min-width:0 and ellipsis; chip label tightened; buttons right-justified. Wrap remains below 880px with half-width selects. Headless-verified single-row containment and justification at eight widths from 920 to 1920px with all six controls present. VERSION / 1.9.211 / 2026-07-04 23:40 SAST [2026-07-04 23:40 SAST] v1.9.211 - Configuration Centre: category-level Save / Cancel Per-row Save buttons removed; each category header gains a live action cluster (N changed / Cancel / Save Category) that appears on the first edit in that category. Category save batches every edited row through its own existing /settings/save form sequentially - validation, permissions, versioning, critical reasons and audit unchanged; one branded confirmation covers critical batches, and a missing critical reason blocks with focus. Cancel restores stored values behind a confirmation. Enter in a field now routes to the category save, with native row submission intercepted so the implicit-submit fallthrough to the Reset button can never fire. Reset-to-default stays per-row; the floating bar remains for cross-category batches (2+ changes) on the same shared engine. Verified end to end headless: counts, chips, isolation between categories, cancel, Enter-save reload, critical block-then-save, zero dirty after reload. VERSION / 1.9.210 / 2026-07-04 23:27 SAST [2026-07-04 23:27 SAST] v1.9.210 - Configuration Centre: compact table-row settings Settings converted from multi-line cards to premium single-row table layout: six aligned columns (setting + key, status pill, live control, last modified + version, modified by, row actions) under a per-category header. Average row height 68px vs ~170px cards. Full descriptions moved into the portal tooltip as its first row - every setting now has the tooltip and nothing is lost. Critical rows slide open an inline reason line when edited; confirm flow, save-all bar, filters, permissions, validation, versioning and audit unchanged. Machine-verified: 173/173 settings rendered, columns aligned, valid critical edit reveals reason + raises the bar, tooltip on top with Description, 1000px stacked reflow proven. VERSION / 1.9.209 / 2026-07-04 23:12 SAST [2026-07-04 23:12 SAST] v1.9.209 - Configuration Centre: enterprise workspace evolution Admin > Settings evolved to the full three-panel premium workspace: five KPI cards with supporting captions (incl. real changed-in-30-days from the version ledger), the grouped category rail, and a new governance/insight panel - baseline-alignment SVG gauge, recent configuration activity (old -> new, who, when, version) and an active-category snapshot with pre-filtered history links, all real data via guarded controller queries. A sticky bottom save bar appears on any edit: Reset restores stored values; Save all posts each dirty card through its own existing /settings/save form sequentially so validation, permissions, versioning, critical reasons + branded confirmation and the audit trail run per setting - no new endpoint, nothing bypassed. Category switches gain a loading shimmer; the v1.9.207 unload guard and v1.9.208 portal popovers are preserved and regression-tested. Machine-verified: all 173 registry settings render; geometry and responsive reflow proven at 1600/1100/800px. VERSION / 1.9.208 / 2026-07-04 22:53 SAST [2026-07-04 22:53 SAST] v1.9.208 - Fix: Settings info popovers clipped behind cards The blue info icon's popover rendered inside its own card, whose stacking context made it impossible to paint above sibling cards and left it exposed to ancestor overflow clipping. Structural fix: every popover is portalled to document.body at init (WeakMap keeps the card association) and positioned fixed from the icon's viewport coordinates with intelligent placement - below by preference, flipped above at the viewport edge, clamped horizontally, repositioned live on scroll and resize, z-index above all page chrome but below the branded dialog framework. Click handling moved to document scope so close and outside-click work from the portal; mobile keeps its bottom-sheet presentation. Headless-verified: paints on top inside an overflow:hidden stack, flips at the bottom edge, follows scroll. VERSION / 1.9.207 / 2026-07-04 22:41 SAST [2026-07-04 22:41 SAST] v1.9.207 - Fix: "Leave site?" prompt when saving a setting The Configuration Centre's unsaved-changes guard fired the browser's native beforeunload dialog against the save navigation itself. Save and Reset submissions (including the critical-setting confirm flow) now raise a submitting flag and clear the card's dirty state, so the prompt never appears on a legitimate save while abandoning genuinely unsaved edits still warns. Verified headless against the real page script in both directions. VERSION / 1.9.206 / 2026-07-04 22:13 SAST [2026-07-04 22:13 SAST] v1.9.206 - Real-time speed monitoring (driver, NOC, Live Monitor) Every driver beacon during an active trip now flows through the new SpeedMonitor engine: validation (accuracy, GPS jumps, impossible acceleration, duplicates, clock skew), device-or-calculated actual speed, rolling-window smoothing and the layered speed-limit resolver (admin rules, site zones, OSM/Overpass, class defaults, statutory display-only). A sustained-breach state machine confirms events above limit+tolerance held for the minimum duration, tracks max/avg/duration and severity (critical by margin, never de-escalating), and clears only after compliant driving. Driver app gains a glanceable speed card with SA-style limit disc and an undismissable warning banner with optional tone and vibration; offline fixes queue on-device and replay idempotently. Confirmed events raise NOC alerts with the existing acknowledge/escalate workflow, colour the Live Monitor with a speeding halo, feed the new Speeding Events report and the trip-detail speed panel, and auto-escalate repeat or critical offenders to vendor compliance. Seventeen new settings under Settings > Speed Monitoring; two new tables via migrate_speed_monitoring.php. BehaviourEngine's single-reading overspeed layer stands down while the engine is active. 19/19 behavioural tests; driver card render DOM-verified headless. VERSION / 1.9.205 / 2026-07-04 10:09 SAST [2026-07-04 10:09 SAST] v1.9.205 - Hotfix: Autopilot Run-now 500 (drivers column) notifyTrip selected d.name; drivers stores first_name/last_name - now CONCAT'd exactly as the dispatch board does. Additionally every sweep stage and per-trip notification is individually try/guarded with 'error' rows in the event ledger, so a single bad statement can never 500 the Run-now action or break the 5-minute scheduler. 11/11 tests. VERSION / 1.9.204 / 2026-07-04 09:41 SAST [2026-07-04 09:41 SAST] v1.9.204 - Professional global dialog system Every native browser alert/confirm/prompt ("localhost says...") replaced platform-wide with the branded Tripi dialog framework: 40 declarative data-confirm conversions, 12 async Notify refactors including the Configuration Centre critical-setting save, new Notify.prompt with required-field validation, a window.alert safety-net shim, the .btn-danger auto-confirm absorbed into one authority (no double dialogs, submitter preserved), and the framework rolled out to the Vendor Portal and mobile shells via a self-contained ui-alerts.css. SOS confirmation is branded and fail-open. 5/5 headless-browser behavioural tests with zero native dialogs fired. VERSION / 1.9.203 / 2026-07-03 23:07 SAST [2026-07-03 23:07 SAST] v1.9.203 - Autonomous Assignment Engine (Dispatch Autopilot) Completes the autonomy chain after route issue: automatic vendor cascade on rejection or acceptance-SLA breach (tried vendors excluded, max-attempts ceiling, escalation when exhausted), smallest compliant vehicle and best compliant driver assigned the moment the vendor accepts, automatic dispatch-ready advance, and deduplicated passenger + requester notifications with vehicle, driver and pickup time. Runs every 5 minutes from the scheduler, instantly on vendor acceptance, and on demand from the new Dispatch Board autopilot panel (live counters and queues from real event rows). Booking detail now shows the full assignment chain with vendor acceptance state, AUTO provenance chips and a Dispatch Readiness row. Optional ALLOC-001 hard gate blocks vehicle/driver assignment before vendor acceptance. Seven settings under Settings > Dispatch Autopilot; engine fully inert until enabled. 11/11 behavioural tests. VERSION / 1.9.202 / 2026-07-03 09:50 SAST [2026-07-03 09:50 SAST] v1.9.202 - Dashcam visible under Admin > Settings Dashcam Configuration added to the Settings page Registries & Tools rail and the Administration sidebar group; all dashcam surfaces dual-gated (dashcam.* OR settings.manage / incident.manage fallbacks) so links and pages work before the migration seeds permissions or a re-login refreshes the session cache. 9/9 gate-matrix tests; DOM- verified rail placement. [2026-07-03 09:43 SAST] v1.9.201 - Settings search and filters on one row Configuration Centre filter bar reworked to a strict single row on desktop (search flexes, selects/chip/buttons fixed-width, nowrap); wrap re-enabled only below 880px. Verified by headless-Chromium DOM measurement at 1440/1180/760px plus screenshot proof. [2026-07-03 02:03 SAST] v1.9.200 - Dashcam Safety Intelligence Module Full dual-camera dashcam build: scope-resolved recording rules with POPIA consent (driver>vehicle>route>vendor>site>global), normal/SOS compression profiles, multi-language distress keyword library (en/af/zu/xh), driver recording engine (honest dual-camera probe, consent gate, IndexedDB offline queue surviving restarts, SHA-256 chunk-resumable verified uploads, keyword/impact/obstruction detection, SOS priority + evidence lock), NOC Dashcam Safety Console (compliance metrics, event workflow with confirm-SOS/false-positive, audited side-by-side playback, lock/ legal hold, export packs with custody manifest), SOS server hook (locks active session evidence, links to the same incident), vendor readiness card (no footage), passenger recording notice + safety-concern channel, retention sweep honouring locks/holds, 8 RBAC permissions, sidebar links. 11/11 service tests; all PHP/JS linted; render harnesses green.
v1.9.199 3 Jul 2026 01:25 Interaction-policy compliance sweep across the full platform. Fixed the one remaining hyperlink-underline violation (Vendor Driver 360 geo link -> dim-on-hover) and added the hand pointer to four clickable non-button elements (optimizer scrim, SOS document tile, SOS lightbox, SOS quick- filter rows). Verified: every page covered by global.css via its main stylesheet; disabled -> not-allowed intact; focus-visible preserved; Inter/Roboto throughout new modules; full-tree underline sweep = 0. (The attached spec document arrived empty - no feature build possible; re-send to implement.) No migration.
v1.9.198 3 Jul 2026 00:47 Geographic data attribution (licence compliance). Every live map now shows the accurate credit line in the MapLibre attribution control: (c) OpenStreetMap contributors, tiles OpenFreeMap/Protomaps, geocoding Nominatim, routing OSRM, speed limits Overpass API. Terms s10 extended with the ODbL attribution. CARTO was removed from the proposed line - Tripi does not use CARTO; OpenFreeMap/Protomaps credited instead. No migration.
v1.9.197 2 Jul 2026 23:50 PERMANENT fix for 'Speed limit: Unresolved'. New always-resolving statutory final layer (60 km/h urban default, admin-tunable, estimated/ low confidence; overspeed alerts deliberately never fire off it). Outages are no longer cached as answers: versioned v2 cell cache with a 120 s outage memo vs 24 h confirmed-empty vs 30 d mapped; migration purges poisoned legacy cells. Transport now prefers cURL with an Overpass endpoint-failover list and captures exact TLS/network errors; GET /monitor/speed-limit?diag=1 exposes every attempt. Off-road fixes widen 60 m -> 250 m to the nearest road class (labelled, low conf). Clients never cache failed lookups and auto-retry every 15 s. 11/11 behavioural tests pass. Run php scripts/migrate_speed_rules.php.
v1.9.196 2 Jul 2026 23:33 Layered speed intelligence - no more dead-end speed messages. Speed limit resolves admin rule -> delivery-centre zone -> OSM posted -> road-class default (estimated, SA-statutory-seeded, admin-tunable) -> carried-forward last value -> professional action message. Actual speed resolves sim hook -> device GPS -> coordinate-delta calculation (EMA, spike rejection) -> 0 km/h stationary -> last valid (60 s). Popup + Vehicle Intelligence show a live six-row panel (limit/source, speed/source, confidence, overspeed status). New overspeed behaviour alerts with budgeted OSM lookups and an append-only speed_events audit trail. New Settings > Speed Rules module: geofence/route/site-zone overrides, effective dates, mandatory reasons, revoke-only history, lowest limit wins on overlap. Migration: php scripts/migrate_speed_rules.php.
v1.9.195 2 Jul 2026 23:10 Vehicle marker popup and Vehicle Intelligence panel now show Speed limit and Actual speed. Actual speed comes from the device GPS beacon (speed_kph, now forwarded independently of heading); the posted limit is resolved from OpenStreetMap maxspeed tags via a new grid-cached server service and GET /monitor/speed-limit (30-day hit / 1-day miss cache, 4 s timeout, mph converted, ties resolve to the lower limit). Driving more than 5 km/h over the limit renders red with OVER LIMIT. Unmapped roads honestly say so; a limit is never guessed. No migration.
v1.9.194 2 Jul 2026 22:44 Live map: vehicle markers now face the real direction of travel like a premium navigation app. Direction priority: device GPS course (driver beacon now persists heading + speed_kph), server bearing from the last two GPS fixes, client movement bearing (6 m jitter floor); stationary vehicles retain their last travel direction. Rotation eases along the shortest arc (~1 s, no spins) and positions glide between polls; the demand-driven ~30 fps loop idles when the fleet is still. Marker is map-aligned so it stays glued to the road when the map rotates. No migration; older tenants degrade to movement bearing automatically.
v1.9.193 2 Jul 2026 21:51 FIX: 500 'Database connection failed ... as tripi_user' when opening the app from a phone/tablet on the same wifi as the WAMP dev machine. The environment auto-detect treated the PC's LAN IP (192.168.x.x) as production and used live DB credentials locally. Detection is now deterministic: Windows/WAMP installs are always local; on Linux, private-LAN IPs (10.x / 192.168.x / 172.16-31.x / ::1), localhost and *.local resolve local while tripi.work and public hosts stay production. TP_ENV still overrides everything. config.php only; no migration.
v1.9.192 2 Jul 2026 09:23 NOC Critical Driver Behaviour Alerts and Incident Management. - Persistent behaviour-alert engine (8 rules: late risk, driver not on duty, off duty on live trip, offline, stationary, route deviation, zero boarding, arrived-unclosed) with severity tiers info/warning/ critical/safety, auto-resolve and throttled scans (+ cron entry point). - Behaviour Notices rail on the Live Monitor with full incident workspace: expected vs actual behaviour, impact, recommended action, acknowledge, notes, call logging WITH audio recording (in-browser or PBX file), valid-reason close (kept on record), escalation to vendor/ Transport Manager/security/emergency/line managers/emergency contacts/ client (safety escalations auto-open a formal SOS incident), immutable timeline and full audit trail. - Vehicle intelligence panel from every map marker: driver photo/duty/ compliance, vendor contact book and SLA, per-passenger addresses, shift, statuses, line manager and emergency contact. - New tables noc_alerts / noc_alert_events / noc_alert_calls / noc_alert_escalations plus 13 seeded noc.* threshold settings (scripts/migrate_noc_behaviour.php, idempotent).
v1.9.191 2 Jul 2026 02:53 Vehicle scanner clarity + checklist polish: scan banner now narrates each real stage with live progress and gives crystal-clear not-found / not-clear messages; the scanning banner only shows while actually working (hidden on success/cancel); Scanned Vehicle Details never prints 'No fields recognised' - it always shows the OCR text read and states whether the reg was matched, not found in the database, or could not be isolated. Checklist item description enlarged to 17.5px bold; camera button relabelled 'Camera - Photo / Video'; recorder timer now clearly labelled 'Recording' (live clip length). No migration.
v1.9.191 2 Jul 2026 02:51 Vehicle scanner + checklist polish: OCR status now reports each real phase in plain language (loading engine / enhancing photo / reading NN% / complete) with explicit not-found, unreadable and scanner-unavailable messages; the scanning strip is hidden unless actually scanning and on camera-cancel. Scanned Vehicle Details no longer shows a false 'No fields recognised' - it always shows the raw OCR text and states clearly when a read registration is not on file in the vehicle database. Checklist item name enlarged to 16px/800 bold; camera button relabelled 'Camera - Photo / Video'; the recorder timer is now labelled 'Recording' so its purpose is clear. No migration.
v1.9.190 2 Jul 2026 02:23 Inspection Checklist rebuilt as a single-open accordion: flat numbered rows (nr + name + Mandatory/Optional), all collapsed with only the first expanded, opening one collapses the others (only one open at a time). Each item now carries Pass/Fail/N-A, per-item Camera (photo+video), Mic (in-app audio recorder with timer + native fallback) and Files capture supporting MULTIPLE attachments each (thumbnail/ icon/inline audio + remove + count badge), plus the comments box. Removed the category sections, the global Evidence card and Collapse-all. Evidence stored per item in compliance_check_evidence; allow-list widened to audio/video/documents (executables still blocked, 50MB cap). Review sheet/close-fix, OCR, face verify and save-draft preserved. No migration.
v1.9.189 2 Jul 2026 01:58 Global interaction policy hardening: removed the 11 remaining explicit text-decoration:underline rules across 8 component stylesheets (app/portal/vendorapp/monitor/auth-pages/desktop-login/legal), replacing each with a non-underline dim-on-hover affordance so links keep a clear hover state while fully honouring the no-underline policy for anchors AND non-anchor elements. global.css remains the single source of truth (imported by every top-level stylesheet). Added hand pointer to the compliance review sheet's clickable dismiss overlay (.cr-scrim). No logic change; teal keyboard-focus rings preserved.
v1.9.188 2 Jul 2026 01:48 Compliance Check UI + vehicle OCR: Driver and Vehicle now stack on separate rows; removed the Fixed/Random type cards (check still recorded as Random audit). Added a comprehensive vehicle scanner on the Vehicle row: camera capture -> grayscale/contrast pre-process -> Tesseract OCR -> SA licence-disc / number-plate parser (registration, make, model, description, VIN, engine, colour, expiry, tare, GVM, control no) -> auto-match + auto-select against the fleet -> scanned-details panel with confidence -> scan summary written to the audit note. Local-first tesseract.min.js with jsDelivr fallback and graceful degradation to manual selection. No migration.
v1.9.187 1 Jul 2026 22:30 FIX 500 on compliance submit: the composed audit note (type line + per-item outcomes + up to 6x250-char commentaries + evidence count) overflowed vehicle_compliance_checks.notes VARCHAR(1000), throwing 'Data too long' under MySQL strict mode (first reached now that the v1.9.186 modal fix unblocked submit). Fix: migration widens notes->TEXT and location_text->VARCHAR(255) (idempotent), AND persistInspection reads the live column type and UTF-8-safely truncates notes/location_text to fit so it cannot 500 even pre-migration. Also removed an mb_substr() dependency from the submit handler, guarded the evidence-path UPDATE and capped MIME to the column width. No functional change to enforcement.
v1.9.186 1 Jul 2026 22:08 FIX Random Compliance Check stuck modal: the review sheet (.cr-sheet, display:flex) out-ranked the hidden attribute (equal specificity, defined later) so it painted over the whole screen on load and the close button could not dismiss it. Added .cr-sheet[hidden]{display:none!important} (plus the same guard for .cc-ev-list/.cc-hasnote/type-counts-filter bars) so hidden always wins; the sheet now opens only on Review & complete and Close/Back/scrim/Escape all dismiss it. The Random Compliance Check entry now opens the Random workflow (type toggle + review header default to Random audit; complianceForm honours ?type=), removing the incorrect Fixed compliance default. No migration.
v1.9.185 1 Jul 2026 21:54 Transport Coordinator Compliance Check FULLY RESOLVED: real multi-file, per-item evidence (photos/videos/documents) with on-device preview, per-file 'relates to' tagging, remove, 50MB + type validation and secure per-attachment metadata (path/name/mime/size/uploaded-by/uploaded-at) stored one row per file. Evidence-required items now gate completion with clear review warnings. Detail view lists every attachment with its linked item, coordinates and device. Whole flow verified end to end (register -> new -> face -> config checklist -> evidence -> review -> immutable submit -> detail). Enforcement unchanged/seed-equivalent. Remaining pieces are manager/admin surfaces (rules-editor UI, correction workflow, server-synced drafts), noted not stubbed.
v1.9.184 1 Jul 2026 21:41 Coordinator Compliance Check phase 2: configurable rules engine + evidence metadata. New compliance_check_items table (per-item category/mandatory/commentary-on-fail/evidence-mode/ N-A-reason/driver+vehicle-impact/SLA-clause), seeded to reproduce today's exact behaviour and now driving the form, validation and enforcement (verified seed-equivalent). New compliance_check_evidence table stores per-attachment path/name/mime/size/uploaded-by/uploaded-at. Checklist regrouped into collapsible category sections; per-item Evidence badges; configurable commentary/N-A-reason enforcement; device-local Save Draft with Resume/Discard; evidence list on the detail view. Idempotent migration adds + seeds both tables. Deferred (noted): rules admin UI, server-side drafts, correction-workflow UI, multi-file/video per-item evidence.
v1.9.183 1 Jul 2026 21:26 Coordinator Compliance Check premium upgrade (phase 1): Fixed vs Random Audit type toggle (stored + shown), live Passed/Failed/N-A/Pending counts, All/Pending/Failed/N-A filter chips, per-item commentary (required on Fail, optional reason on N/A) with indicators, gated Complete button, and a full final review sheet (score ring, warnings, failed/N-A detail, evidence summary, escalation preview, performed-by/date/type metadata) before an immutable submit. Device + GPS captured to the audit trail. Idempotent migration adds check_type + device_info columns. Deferred: rules-config engine, multi-file evidence, server drafts, correction-workflow UI (noted, not stubbed).
v1.9.182 1 Jul 2026 20:56 Coordinator Compliance Check: aligned the checklist to the concept's six items - added Vehicle licence disk valid (Mandatory) and Fire extinguisher present & valid (Optional); retired Tracker/ Dashcam/Panic/Condition from the active list (columns kept for history). Extended enforcement so a failed licence disk raises a vehicle non-compliance. Idempotent migration adds chk_licence_disk / chk_fire_ extinguisher columns. Concept visual design (cards, face-ID banner, tri-state, progress, modal) retained.
v1.9.182 1 Jul 2026 10:02 Transport Coordinator Compliance Check refactored to the approved concept: shield header + info, driver/vehicle cards, live face-ID banner, expandable Pass/Fail/N-A checklist with progress bar, Collapse-all, Mandatory/Optional flags, per-item Fail commentary (compiled into notes) + char count, evidence thumbnail with remove, sticky Complete Inspection. Real CHK_ITEMS (8) and full POST + face-api contract preserved; vehiclesList enriched with vehicle type + seats (graceful fallback) for the subline.
v1.9.181 1 Jul 2026 09:39 Vendor Portal chrome aligned to the Admin desktop: replaced the royal-blue shell with the admin .shell/.sidebar (dark ink) + white brand bar with the real tripi logo, admin grouped accordion nav + version footer, and the admin top bar (live clock + company chip + user menu; no dead SOS/bell/search). portal.css rebuilt to admin tokens/components. Rich Overview (6 KPIs, trips+On-Time, MapLibre map, panels) retained, now admin-styled; page header moved back into content. Isolation unchanged. No migration.
v1.9.180 1 Jul 2026 09:27 Fixed Vendor Portal Compliance 500 (e() given an array). The document-type dropdown iterated Settings::lookups rows (['code'=>,'label'=>]) as strings. Now reads code/label per row for the option value/text, tolerates a plain-string fallback, and skips empty codes. profile.php contactTypes confirmed a flat in-view list (safe). View-only, no migration.
v1.9.179 1 Jul 2026 09:20 Fixed Vendor Portal login 500 (Unknown column 't.on_time'). trips has no on_time/delay columns; Overview now derives On-Time from actual_start_at vs scheduled_start_at (5-min grace) for the today's-trips On-Time cell, the On-Time Performance KPI (30-day % + 7-day delta) and delayed-today map legend. Graceful dashes where no timing data. CSS/logic only, no migration.
v1.9.178 1 Jul 2026 09:12 Desktop login: fixed tiny email/password text. Inputs sized their font in container-query units (.92cqw), which Chrome paints too small for autofilled values until focus re-layout. Changed .lg-field input to font-size clamp(14px,.95cqw,17px) - readable, identical at rest and focused, still scales on large screens. CSS-only, no logic change.
v1.9.177 1 Jul 2026 00:08 Vendor Portal Overview rebuilt as a faithful mirror of the approved design: royal-blue sidebar with the real tripi logo, grouped nav (no 'Soon'; unbuilt items route to nearest live screen), title/date/bell/company topbar. Six real KPIs incl. On-Time Performance (trips.on_time, 30-day + delta) and Open Incidents; filterable My Trips table with On-Time column; REAL MapLibre fleet map plotting the vendor's geocoded sites; billing/fleet/compliance/tasks panels. Graceful degradation, no fabricated data. Vendor isolation unchanged. No migration.
v1.9.176 30 Jun 2026 23:23 Vendor Portal aligned to the Admin Portal: real tripi logo restored (hand-drawn mark removed), vendor shell rebuilt on the admin .shell/.sidebar/.content/.topbar classes (dark ink sidebar, collapsible grouped nav, live SAST clock, user menu), portal.css rebuilt to the admin component language via app.css tokens (cards/tables/KPIs/badges/buttons/forms). All 'Soon'/placeholder nav items removed - only real working vendor routes shown. No dead controls. Server-side vendor isolation unchanged and re-verified (scope() + 65 vendor_id bindings + vendorBoundDenied guard). No migration.
v1.9.175 30 Jun 2026 22:35 Vendor Portal rebuilt to mirror the approved operations-dashboard design. Light workspace + royal-navy sidebar + blue accent. New shell (grouped sidebar with Soon markers for unbuilt sections, date/bell/company topbar) and a dashboard rebuilt to the reference layout (six stat cards, My Trips table, fleet-map panel, billing/fleet/compliance/tasks panels) - all from real vendor- scoped data via a guarded controller extension. Mock-only tiles (on-time %, incidents, live GPS) not faked. All .vp-* classes preserved so every view inherits the look. Scoping unchanged. No migration.
v1.9.174 30 Jun 2026 21:34 Vendor Portal "Operations Command" bold redesign. Rewrote portal.css (v3) around a single thesis - a live transport-operations command centre. Signature: a dark navy->teal command band visualising the day's trip pipeline as a segmented flow, set against a disciplined light workspace with Roboto Mono telemetry labels. Added a compliance SVG ring + breakdown, dispatch-board tables (mono refs, driver id-chips, hover accent), refined tiles/cards/buttons/forms. Rebuilt the dashboard as the command centre. Real data only; vendor scoping unchanged. A standalone browser preview ships alongside. No migration.
v1.9.173 30 Jun 2026 21:05 Vendor Portal premium UI/UX redesign. Rewrote portal.css into a bolder executive design system (deep-navy sidebar with glow, glass topbar with breadcrumbs, premium KPI cards with icons + accents, refined cards/tables/chips/buttons/forms, compliance alert tiles, responsive). Rebuilt the dashboard as an executive overview and added section eyebrows + breadcrumbs across all 15 views. Class vocabulary preserved so every view upgrades. Presentation only; vendor data-scoping unchanged. No migration.
v1.9.172 30 Jun 2026 20:42 Fix - Vendor Portal rendered unstyled. The v1.9.170 /portal->/vendor-portal relocation greedily rewrote the stylesheet link in layout/vendor.php (portal.css -> vendor-portal.css), 404ing the portal CSS so the page lost its layout and the sidebar SVGs rendered full-size. Restored the /assets/css/portal.css link. No other asset path affected; nav route links remain /vendor-portal. One view file, no migration.
v1.9.171 30 Jun 2026 20:27 CRITICAL: vendor accounts could reach internal admin (cross-vendor exposure). Vendor accounts could open /vendors/{id}, /vehicles, /drivers etc. (internal modules) and see all vendors' data, because external vendor roles held internal admin permissions and vendor_manager (an INTERNAL role) had been assigned to a vendor. Fix: Rbac::require() now confines any vendor-bound account (vendor_id>0) to the vendor.portal allowlist - every other permission-gated route returns a controlled 403 + audit. homeFor() sends vendor accounts to /vendor-portal only; vendor_manager->/vendors. Demo script strips the mis-assigned internal role. Internal staff unaffected. Code+docs, no schema migration.
v1.9.170 30 Jun 2026 20:07 Vendor Portal data-isolation hardening (security remediation). Fixed a route collision (GET /portal defined twice) by relocating the whole vendor portal to /vendor-portal/*. Added controlled 403 + audit on every cross-vendor/unknown record access via forbid()/denyRecord() (no existence disclosure); hardened scope() (no-vendor-mapping and suspended/blacklisted -> controlled 403); added 8 defensive vendor_id join constraints; scope comments; and docs/VENDOR_PORTAL_ISOLATION_TEST_MATRIX.md. Portal was already vendor-scoped on every query; this closes the brief's remaining gaps. Code + docs, no schema migration.
v1.9.169 30 Jun 2026 19:39 Edit-field override fix - no inner border on wrapped inputs. v1.9.168 ringed the inner <input> on focus, drawing a faint navy (gray-looking) rectangle inside the login/auth field wrapper. edit-fields.css now forces inputs inside an icon wrapper to be fully borderless/ringless and moves the navy focus to the field container only. Single clean field, no inner border. One CSS file, no migration.
v1.9.168 30 Jun 2026 19:31 Edit-field styling - cache-proof global override. Added public/assets/css/edit-fields.css (new file, loaded last, !important) forcing navy focus + white fields + autofill-white platform-wide, so the no-green/no-gray fix applies even when an older desktop-login.css is cached. Linked last in layout/app, layout/vendor and the auth pages (login, register, forgot/reset password, login_otp). CSS + link tags only, no migration.
v1.9.167 30 Jun 2026 02:20 Demo helper - vendor@demo.local on the desktop portal. scripts/setup_demo_vendor_portal.php: idempotently seeds the vendor.portal permission (granted to vendor_manager + vendor_user) and adds the vendor_manager role to vendor@demo.local (default) so it lands on /portal instead of the mobile /vendor-app. Run: php scripts/setup_demo_vendor_portal.php 1 [email]. Account must re-login for the new role to load. No app code change.
v1.9.166 30 Jun 2026 02:14 Global edit-field styling fix. Removed the green/teal focus border and the gray (incl. browser-autofill) background from edit fields platform-wide. Input focus is now a navy border + subtle navy ring (focus visibility preserved); fields force a white autofill fill and the few gray-base fields are now white. Brand teal unchanged for buttons/accents; disabled fields keep their gray look. Eight stylesheets touched. CSS only, no migration.
v1.9.165 30 Jun 2026 01:44 Vendor Portal - in-portal editing (fleet/drivers/profile). Vendors can add/edit their own vehicles and drivers (descriptive, capability and expiry fields), activate/deactivate their own assets, edit contact + escalation details, and manage org contacts. Governance is preserved: vendor_id, ban_status, compliance_status and vendor status/approval/SLA/ banking are never request-writable; new assets default to inactive + compliance pending (office vets before activation); banned assets stay office-controlled. Every write vendor-scoped + IDOR-checked + audited. 10 controller methods, 12 routes, 3 new form views. No migration.
v1.9.164 30 Jun 2026 01:34 Vendor Portal - formal RBAC. New vendor.portal permission (module=vendor, action=portal). All 18 /portal routes now gated on it instead of null, so the Router enforces Auth -> CSRF -> Rbac::require before the controller's scope() guard (defence in depth). Idempotent, tenant-aware seeding script grants it to vendor_manager + vendor_user. MIGRATION REQUIRED: php scripts/migrate_vendor_portal_perm.php [tenantId/all] - until run per tenant, that tenant's vendor users get 403 on the portal.
v1.9.163 30 Jun 2026 01:24 Desktop Vendor Portal. New /portal workspace giving each vendor secure, isolated access to ONLY their own organisation. Vendor identity is resolved from the session (never the request); a scope() guard runs on every action and every query/detail/write re-checks vendor_id (IDOR closed). Sections: dashboard, trips & offers (accept/reject, allocate own fleet, trip sheet), fleet, drivers, waybills (sign + query), invoices, compliance (upload), reports (+CSV), profile. New VendorPortalController, layout/vendor shell, portal.css, 12 views, vview() helper, 18 routes; vendor_manager now lands on /portal. No migration.
v1.9.162 30 Jun 2026 00:09 Live Monitor + SOS Control Room map overlays. Two client-side map toggles on each of /monitor and /sos/monitor: 'Police stations' (active SAPS stations as blue P markers, click for name/precinct/phone) and 'SOS incidents' (open incident locations as red siren markers). New /monitor/police-stations and /sos/police-stations endpoints; /monitor/api now returns an incidents array. RiskZoneDiversion::activeStations() helper. Police defaults off on both; SOS layer defaults on in the control room. Guarded - empty police_stations shows nothing. The SAPS station list is managed at /risk/stations.
v1.9.161 29 Jun 2026 09:34 Risk Zone alternative-delivery (diversion) workflow. When a vehicle cannot safely enter a configured Risk Zone, an affected passenger is delivered to the nearest police station, the nearest approved alternative address, or returned to the Delivery Centre (sequenced last unless a NOC emergency override). Driver or NOC confirms; the individual record is updated and THIS TRIP ONLY is re-sequenced via an in-progress-safe re-optimiser - the original trip and single waybill are preserved (no duplicate trip/waybill/billing). New police_stations + passenger_risk_prefs + trip_passenger_diversions schema (migrate_risk_diversion.php); RiskZoneDiversion service; RiskZoneController + console/options/report/stations; driver divert sheet; passenger preference screen (governed); waybill diverted-destination display; monitor diversion chip; notifications, audit and reporting. Settings: riskzone.enabled, detect_on_start, return_dc_after_all, pref_requires_approval, passenger_pref_enabled.
v1.9.160 29 Jun 2026 09:10 Vehicle handover / relief vehicle workflow. NOC can dispatch a relief vehicle to an en-route trip stopped by a serious incident (hijacking, breakdown, safety threat, ...). One continuous journey: the original trip and its single waybill are preserved - no duplicate trip, no duplicate waybill, no separate vendor billing unless configured and approved. New trip_handovers + trip_handover_passengers schema (migrate_trip_handover.php); TripHandover service with two-phase dispatch/complete plus cancel; HandoverController + routes + NOC dispatch form + handover register. Surfaced across Incidents (dispatch/complete/cancel panel), Trip monitoring (relief chip + action, guarded), Waybills (handover section + list badge), Passenger status (handover_left), Driver app (relief auto-assignment + banner), audit, notifications and reporting. Settings: handover.enabled, allow_cross_vendor, separate_billing_requires_approval.
v1.9.159 28 Jun 2026 22:47 Book Ad-hoc Transport: site-scoped slots, vendor selection, on-date list fix. Ad-hoc booking now shows only the slots for the coordinator's locked site (slot list + availability check), with a site hint and time-only slot labels. A Vendor dropdown (active vendors, optional) sits after the slot; the chosen vendor is stored on the booking (migrate_booking_vendor.php adds bookings.vendor_id, idempotent, graceful if unrun) and used by the placement engine - fit is restricted to that vendor's vehicles and any additional-vehicle request is directed to them. The 'bookings on this date' list was refactored to a two-row card so the status pill (e.g. Awaiting Routing) no longer overlaps the site/details text.
v1.9.158 28 Jun 2026 22:28 Remove browser-blue field focus; subtle brand-teal focus app-wide (CSS only). Editable fields, inputs, dropdowns, text areas, search boxes and all form controls no longer show the default blue border/outline/focus ring; the focus state is now a subtle brand teal (#07B49B) consistent across forms, modals, inline editing, filters, search, tables and settings, on desktop and mobile. The blue component focus rules (desktop-login .lg-field, auth-pages .authp-*/.fg textarea, mobile .mb inputs, driver-app sheet-card + --d-ring) were recoloured to teal, and a global FORM-FIELD FOCUS POLICY floor was added in global.css. Keyboard :focus-visible accessibility outline preserved; no double rings. No migration.
v1.9.157 28 Jun 2026 22:01 Ad-hoc direction selector, pre-departure cancel, post-routing placement engine. Coordinator ad-hoc booking gains an Inbound/Outbound selector (default Inbound) that filters the slot list. A coordinator can cancel a passenger booking until the passenger has departed in a vehicle; on cancel the affected vehicle's route is re-optimised on its own (never a full re-route) - toggle adhoc.cancel_reoptimise_affected. When an ad-hoc booking is added for an already-routed date/slot/direction, the new AdhocPlacement engine assesses each routed, not-yet-departed vehicle for a constraint-satisfying free seat (capacity, max duration, sequence, distance, approved pickup) and, if one fits, adds the passenger and re-optimises only that vehicle. If none fit, the coordinator is offered the configured fallbacks - next available slot and additional vendor vehicle (with the full-seat liability rule and cost impact) - each an Administration Settings On/Off toggle under 'Ad-hoc & Cancellation'. Operational and cost impact are shown before confirmation. No migration (registry-defaulted settings).
v1.9.156 28 Jun 2026 21:12 Coordinator ad-hoc bookings confirm without approval. An ad-hoc trip is off-roster (often after cut-off) - soft-approval conditions that previously routed the booking to 'pending approval'. As the coordinator authorises the trip at source, the coordinator ad-hoc path now auto-accepts those soft conditions: status awaiting_routing / approval not_required, no approval task or approver notification. Hard eligibility blocks still apply; bypassed reasons are recorded on the create audit entry. Passenger self-service and desktop/admin bulk booking unchanged. No migration.
v1.9.155 28 Jun 2026 20:57 Fix coordinator ad-hoc booking not saving + past/upcoming bookings table. Root cause: createOne() hard-blocked shift-controlled passengers (the default for all staff) with no roster for the date, so off-roster ad-hoc bookings were silently refused. The coordinator ad-hoc path now books off-shift (still policy-checked by EligibilityService -> routes for approval where required) and falls back to the passenger's latest address when no approved one exists; desktop/admin bulk booking is unchanged. UI: the 'View past bookings' button is replaced by an auto-loaded 'Past & upcoming bookings (latest 10)' table below Create booking; each row opens the full booking-detail modal. New GET /app/coordinator/book/list; /book/history retired. No migration.
v1.9.154 28 Jun 2026 20:38 Coordinator ad-hoc booking: passenger context, slot guards, booking modal. Selecting a passenger now shows their approved home address in the passenger card and lists their bookings for the chosen trip date (inbound+outbound); tapping any booking (current or past) opens a full-detail modal. Slot picker disables expired slots (grace coord.book_slot_grace_min, default 60min) and slots already booked for that passenger on that date. Added a 'View past bookings' loader. Backed by read-only GET /app/coordinator/book/context and /book/history (coord.book). No migration.
v1.9.153 28 Jun 2026 20:14 Coordinator/admin ad-hoc booking now notifies the passenger. Bookings made on a passenger's behalf now send the passenger a 'Transport booked for you' notification (direction/date/time, linking to their mobile Bookings) so the trip surfaces on their app - previously only the actor was notified. Applied in the shared createOne() path (coordinator ad-hoc + admin). The trip already lists under the passenger's Upcoming Bookings (queries filter by employee_id); note the passenger must be signed in as the booked employee and have an approved home address.
v1.9.152 28 Jun 2026 19:40 Facial-recognition modal reliability fixes. Camera now starts immediately (model loads in background; scan starts when both ready) instead of waiting on the ~7MB model. Default camera fixed (opens front, the working one; auto-falls back if a camera stalls; scan only on real frames). Accept/Enroll hidden AND disabled until a valid result. X (close) and backdrop now reliably dismiss the modal (visibility controlled directly, not via overridden CSS). Recognition always resolves: driver name on match / 'Not recognised' on no match / 'No face detected' when empty. Manual driver selection remains as fallback.
v1.9.151 28 Jun 2026 19:31 Coordinator 'Lock your location' on the site picker. New checkbox directly above Confirm Site: when ticked the chosen site is saved as the coordinator's default (coordinator_site_locks) and persists across log-outs until changed - next login skips the picker and applies the locked site. Re-opening the picker pre-selects the site and pre-ticks the box; confirming a different site or unticking clears/updates the lock. Unticked = session-only as before. migrate_coordinator_site_lock.php adds the table; all reads/writes guarded.
v1.9.150 28 Jun 2026 19:19 Double-bell fix + profile menu + Random Compliance Check refactor. Suppressed the duplicate CSS bell glyph (bell now shows once). Removed Drivers from the profile menu. Compliance check: the Driver field is now a tap target that opens the facial-recognition modal - live camera auto-scans (identify), front/back camera flip, recognised -> name in bold green + Accept (selects the driver), not recognised -> Not recognised + Enroll Face, plus an always-available manual searchable driver list so the check always completes. Falls back to a plain dropdown when FR disabled.
v1.9.149 28 Jun 2026 18:02 Coordinator app: site-on-logon + bell fix + profile cleanup + account card. Coordinator must set their on-duty site after login (held for session; pre-fills compliance/sobriety site and shows on home + profile with Change). Notification bell now opens a mobile coordinator notifications screen (/app/coordinator/notifications) instead of bouncing to the desktop. Profile menu trimmed to Drivers + Notifications; account card now shows role, on-site location, mobile, email, last sign-in and notification status.
v1.9.148 28 Jun 2026 17:50 Facial recognition auto-scanning camera modal. Tapping Facial recognition opens a modal with a live camera that auto-scans (detect+identify loop). Not recognised -> 'Not recognised' shown in the camera window + an Enroll Face button (opens the enrolment screen). Recognised -> driver name at the bottom of the window in bold green + an Accept button; Accept closes the modal and auto-selects the driver in the dropdown (recorded as identified with the match distance and captured frame). Matching stays server-side; model on-device, no cloud.
v1.9.147 28 Jun 2026 17:08 Corrected facial-recognition workflow. Compliance check now does VERIFY (1:1) when a driver is selected and IDENTIFY (1:N, auto-selects the driver) when none is - matching runs server-side (descriptors never sent to the browser). Enrolment is a separate deliberate screen (/app/coordinator/face/enrol): search an EXISTING driver by ID number, then capture; implicit first-capture enrolment removed. New face/match + face/enrol(+search) endpoints; descriptor-exposing endpoint removed. result enum extended (identified/not_identified) via migration.
v1.9.146 28 Jun 2026 16:49 Driver facial recognition in the coordinator compliance check. On-device 1:1 facial verification (self-hosted face-api.js model under public/assets/face-models + public/assets/js/face-api.min.js) - first capture enrols a driver reference in the POPIA-governed driver_face_enrolments table; later checks compute the euclidean distance to the reference and mark Verified / Not verified against coord.face_threshold (0.55). A mismatch stands the driver down and auto-raises a non-compliance under clause 9.2.2 (driver not authorised). migrate_face_recognition.php adds the table, face_verified/face_distance/face_photo_* columns, coord.face_enrol perm and settings. Graceful photo-only fallback when model/camera unavailable; no cloud calls, no fabricated match.
v1.9.145 28 Jun 2026 14:50 Coordinator mobile screens aligned to passenger design. Home header now shows the notifications bell (unread badge) instead of a logout button; added the passenger-style pax-hello welcome card (greeting + first name + status chip). Profile rebuilt to mirror the passenger profile (mb-me identity card, Account card, clean mb-row menu, white Sign out). Presentation only - all coordinator data and actions remain live.
v1.9.144 28 Jun 2026 14:31 Coordinator mobile app re-shaped to mirror the passenger app. Home: 3 action tiles (Book Ad-hoc Transport, Random Compliance Check, Register Non-Compliance) + Upcoming Compliance Checks feed (today's trips by slot). Bottom menu: Home/Search/Sobriety/Profile. New Search across vehicles/drivers/passengers/waybills with full detail cards. New vehicle/driver compliance-inspection module (migrate_coordinator_ops.php -> vehicle_compliance_checks; 8-point checklist; stand-down + auto-NCR under clause 9.2.2/9.2.4/9.2.5 on failure; VCC-{YYYY}-{SEQ5}). New Book Ad-hoc Transport on behalf via BookingController::createAdhoc (full desk eligibility/address/ shift/cut-off rules). New coord.book/compliance/compliance_view/search perms. Desktop compliance register/detail added. All features gated on coord.* and query live data; no placeholder data.
v1.9.143 28 Jun 2026 10:51 Coordinator mobile nav + profile menu, driver roster, 413 upload fix. Fixed 413 on Submit-for-review by compressing evidence images in-browser (<=1600px JPEG ~0.7) before upload (videos/docs unchanged) in sla_raise + coord_sobriety_raise; documented required server limits (nginx client_max_body_size / Apache LimitRequestBody / PHP upload+post max) for video. New _coord_nav.php bottom bar (Console/Sobriety/Compliance/Profile, coord.console-gated) added to all coordinator + compliance mobile screens. New /app/coordinator/profile (role-based menu: Console, Sobriety Checks, Non-Compliance, Drivers + Notifications + Sign out) and /app/coordinator/drivers (live read-only roster: vendor, duty status, ban status, licence/PDP expiry warnings). JD was not attached - menu derived from SLA 4.3 / FRS 5, live links only; to be mapped exactly on JD receipt.
v1.9.142 28 Jun 2026 02:08 Transport Coordinator role: console + driver sobriety/breathalyzer module (SLA 4.2/4.3). New migrate_coordinator.php (driver_sobriety_checks table + coord.console/coord.sobriety/ coord.sobriety_view perms granted to transport_coordinator/transport_admin/system_admin/manager). Mobile: coordinator console (/app/coordinator) + on-site sobriety capture (driver, method, pass/fail/ refused, BAC reading, GPS, photo); coordinators route to /app/coordinator after login. Desktop: Coordinator Console + Sobriety Register/detail + new nav group. On fail/refused the driver is stood down (duty_status off_duty), managers/NOC notified, and a non-compliance is auto-raised against the vendor under clause 9.2.3 and submitted for review (linked to the check). Shared via CoordinatorController::persistCheck reusing SlaController::persistRaise + SlaRef (SOB-{YYYY}-{SEQ5}). Settings-controlled (stand_down/auto_ncr/clause) with safe defaults; graceful with no settings table. php -l + node --check clean; 7 views render via harness; migration validated (3 perms, 12 grants, 0 column errors). transport@demo.local gets all coordinator features via transport_admin.
v1.9.141 28 Jun 2026 01:32 Simplified Raise Non-Compliance + on-site mobile coordinator flow. Desktop create.php is now dropdown-driven (vendor/category/clause/site/vehicle/driver/passenger-impact/recommended-action), clause selection auto-fills severity + Service Credit + demerits + appeal/evidence as read-only pills, description is the only free text, and evidence accepts multiple images, videos AND documents. New MobileSlaController + mobile views (sla_list/sla_raise/sla_show) let a Transport Coordinator sign in to the mobile app and raise non-compliance on-site with camera/video capture; routes GET /app/sla, GET /app/sla/raise, POST /app/sla, GET /app/sla/{id} gated on sla.view/sla.raise; coordinators (no employee profile) are routed to /app/sla after login instead of being bounced to desktop. Record INSERT + evidence unified in SlaController::persistRaise (used by both surfaces); saveAttachments now shared + video-capable (sla.evidence_allowed_types / sla.evidence_max_file_mb, default 100MB). php -l + node --check clean; all four views render through a stubbed harness. No services touched.
v1.9.140 28 Jun 2026 00:57 Teleperformance SLA seed (scripts/seed_sla_teleperformance.php). Idempotent seed of the signed TP Staff Transport SLA into the module: penalty rule SC-R500 (R500/demerit point, deduct from invoice; SLA 9.2), demerit escalation TP-ESCALATION (warn 10 / remediation 20 / terminate 30, 90-day window; SLA 9.3), 7 categories, the 7 pointed Section-9.2 clauses with points x R500 Service Credits (R1,000-R6,000), and 26 monitored clauses (performance targets pages 9-10 + 3.1/ 9.1 and the Section 12 incident matrix) with no fixed penalty (set at raise time, linked to the rules). Settings appeal=5 / decision=10 working days (SLA 9.4) applied where a settings table exists. Document error (Incident Data Accuracy <=24%) omitted as a template error. Rolling-window thresholds simplified to single accumulation + 90-day expiry (noted). Run after the migration. php -l clean; executed against a schema-validating harness - 33 clauses, 0 column errors.
v1.9.139 28 Jun 2026 00:41 SLA migration hotfix 2. Roles step failed on a tenant whose `roles` table has no `scope` column (insert hardcoded slug,name,scope). Both the roles and permissions inserts are now column-probed via SHOW COLUMNS and built from the columns that exist (slug always; name/scope and module/action/description only if present). Standard tenants behave identically; this tenant's role insert becomes (slug,name) and succeeds. role_permissions grant unchanged. Idempotent; migration now runs to completion. tp_client_demo is non-standard (no settings table, roles without scope) - likely predates the current Template DB.
v1.9.138 28 Jun 2026 00:35 SLA migration hotfix. migrate_sla_penalties.php aborted on tenants without a `settings` table (e.g. tp_client_demo) because a default-settings INSERT was prepared one line before the SHOW TABLES guard - server-side prepare validated the missing table and threw. Removed the stray (dead) prepare; the existing guarded, column-probed seeding is unchanged and now the migration completes cleanly with or without a settings table (Settings::get fallbacks supply defaults). Idempotent - safe to re-run. Module reads work on defaults; persisting /sla/admin settings still needs the platform settings table to exist on the tenant.
v1.9.137 28 Jun 2026 00:26 SLA, Penalties & Demerits module - Phase 5 (scheduler + reminder send). MODULE COMPLETE. SlaEnforcement: one idempotent, feature-probed per-tenant sweep shared by the cron and the scheduler (enforce no-appeal + final notice, lapse undecided appeals, email appeal + decision reminders); inert without the SLA migration. SlaNotices gains remindAppeal (rotates the secure appeal token so the reminder carries a fresh link, expires at the deadline; logs) and remindDecision (emails all sla.review holders + the raising coordinator, in-app notification, logs). cron_sla_enforcement rewritten to iterate all active tenants via Database::useTenant + SlaEnforcement::run. jobs/scheduler.php runs the SLA sweep as the 5th per-tenant job under the tenant context (tenant query now selects client_id). php -l clean; no regressions (Phase 1 40/40, Phase 2 11/11, Phase 3 12/12, Phase 4 9/9). Completes the module v1.9.133->v1.9.137; the Phase 1 migration remains the only (idempotent) migration; no business data seeded.
v1.9.136 28 Jun 2026 00:19 SLA, Penalties & Demerits module - Phase 4 (charts + report suite). SlaChart: dependency-free server-side SVG bar/line/donut (no JS) in the Tripi palette. SlaReports: six parameterised reports in one uniform shape (vendor performance, by month with penalty+demerit trend lines, top non-performing vendors, breaches by category, appeal outcomes, repeat breaches) each with a totals row, filtered by incident date / vendor / category. SlaReportPdf: paginated PDF table renderer on the Phase 3 PDF engine (qpdf-valid). SlaReportController: reporting hub (filters + tabs + charts + table) with CSV (BOM, numeric), Excel (real numeric cells via XlsxWriter) and PDF export, filters preserved, audit + export_logs. Charted dashboard: enforced-penalty-value trend line (6 mo), workflow status-mix donut + legend, breaches-by-vendor bar above the existing KPI strip. New 'SLA Reports' nav item (sla.report). php -l clean; 9 new tests (charts/PDF/XLSX/2 view renders); no regressions (Phase 1 40/40, Phase 2 11/11, Phase 3 12/12). No new migration; reuses sla.report + app.company_name.
v1.9.135 28 Jun 2026 00:07 SLA, Penalties & Demerits module - Phase 3 (PDF notices, email, vendor portal). Dependency-free PDF writer (app/Services/Sla/Pdf.php; qpdf-valid A4, Helvetica, wrap + page-break) since the platform has no Composer/PDF lib. SlaNoticeDoc builds the Notice of Intent and final Penalty/Demerit Notice with every spec field (incl. appeal link, consequence, disclaimer / enforceable statement). SlaToken issues secure appeal tokens (raw in link, only sha256 stored, constant-time match). SlaNotices orchestrates: generate PDF -> pdf_documents + penalty_notices -> mint appeal token -> email each nominated recipient with the PDF attached via Mailer, logging every send to sla_email_logs; enforces 'vendor must have a recipient' and 'PDF failure blocks email'. Public token-secured appeal portal (VendorAppealController + appeal_portal.php): hash+expiry validation, no internal id in the URL (token in query like the password-reset flow), CSRF, required-field + late-appeal + upload type/size validation, advances SlaWorkflow::recordAppealSubmission, notifies manager + coordinator; states form/ thanks/submitted/closed/expired/invalid. Wired approve -> issueIntent (after recipient check), appealReject -> issueFinal, and cron auto-enforce -> issueFinal. Access-controlled PDF/evidence streaming (sla.view) confined to storage/sla with path-traversal guard + nosniff; detail screen links notices to their PDF and evidence to secure download. php -l clean; qpdf + pdfinfo valid; 12 new tests (token x5, portal states x7); no regressions (Phase 1 40/40, Phase 2 11/11). No new migration; optional app.company_name + app.url settings.
v1.9.134 27 Jun 2026 23:44 SLA, Penalties & Demerits module - Phase 2 (desktop web surface). Operational + admin screens on the Phase 1 spine. New permission-gated routes under /sla and /sla/admin. Controllers SlaController (dashboard with KPIs + at-risk queues + top vendors + category breakdown; non-compliance register with search/vendor/status/category/appeal/date filters, sortable columns, pagination; sectioned raise form with clause-driven autofill + evidence upload; record detail with status timeline, notices, appeal, email log and mandatory-reason action modals; submit/approve/reject + appeal accept/reject all via SlaWorkflow with self-approval blocked) and SlaAdminController (settings + full CRUD for SLA categories, clauses, penalty rules, demerit rules and vendor recipients, audit-logged and soft-deleted). Five premium Inter/Roboto views reusing the native design system. New 'SLA & Penalties' nav group. php -l + node --check clean; view-render harness 11/11 (dashboard, register, create, detail x2 states, admin x6 tabs). PDF/email/vendor-portal are Phase 3; the approve/appeal-reject actions already flag the notice/mail seam. No business data seeded - the SLA schedule and rules are entered via /sla/admin.
v1.9.133 27 Jun 2026 23:27 SLA, Penalties & Demerits module - Phase 1 (data + workflow spine). New module built in phases. Phase 1 = the database layer and the workflow engine that every screen, PDF, email and cron job depends on; fully working and unit-tested (40/40), no UI yet. Migration scripts/migrate_sla_penalties.php (idempotent) creates sla_categories, sla_clauses, penalty_rules, demerit_rules, sla_vendor_recipients, non_compliance_records (+attachments, reviews), penalty_notices, pdf_documents, vendor_appeals (+attachments, decisions), sla_email_logs, sla_notification_logs, workflow_status_history and the sla_statuses lookup - with PKs/FKs/indexes/soft-deletes/timestamps/user-tracking/unique refs; seeds the 11-state lifecycle + structural settings; ensures transport_coordinator/transport_manager roles and the sla.* permission set. Services app/Services/Sla/: SlaStatus (transition graph + guards), SlaDeadlines (calendar/business-day appeal + manager-decision deadlines, configurable basis), SlaDemerits (fixed/variable/percentage/escalating penalties + cap/VAT + demerit thresholds), SlaRef (configurable reference numbers), SlaWorkflow (guarded DB transitions writing status history + audit + events). Cron scripts/cron_sla_enforcement.php auto-enforces on appeal expiry, lapses on manager timeout, flags reminders. Concrete SLA business figures are captured via Admin -> SLA Setup, not seeded with invented data (the attached doc is the spec, not a populated SLA schedule). php -l clean across all files. Phases 2-5 (UI, PDF, email, vendor portal, dashboards, reports, scheduler wiring) follow on this spine.
v1.9.132 26 Jun 2026 23:57 Address Lock - map replaces the radar once the lock completes. The radar is now a transient searching visual: on lock the radar and the progressive scanning list collapse away and the live map expands into that same slot, so the locked screen reads Header -> Live map (with 'You are here!' marker) -> Confirmation card -> Continue. Previously the radar stayed and the map appeared as a second element at the bottom; the map element was moved up into the radar's position with smooth collapse/reveal CSS transitions. Searching = radar + scanning list shown, map + card collapsed; locked = radar + list collapsed, map + card revealed. All other behaviour unchanged (gated Continue into the approval workflow, error/low-confidence states, door-number failsafe). View-only change. php -l + node --check clean; DOM order and single-impl/hooks verified.
v1.9.131 26 Jun 2026 23:39 Exhaustive door-number failsafe + premium Address Lock redesign. DOOR FAILSAFE: when no geocoder returns a house number, reverseLookup now runs a full ladder - Nominatim (OSM, building zoom) -> Google (if map.google_geocode_key set) -> Photon (keyless) -> NEW Overpass nearest-building snap. The Overpass step queries OSM for the nearest feature actually tagged with addr:housenumber, searching outward 30 -> 65 -> 120 m, and accepts the nearest number when it is essentially on the building (<= 28 m) or on the same street as the resolved road; street/suburb/city backfill any blanks. The source is then reported distinctly as 'OpenStreetMap building (~N m)' so it no longer silently stays 'OpenStreetMap' with a missing number. New setting map.overpass_base_url (Settings -> Integrations, default https://overpass-api.de; point at a self- host or mirror such as overpass.kumi.systems). Helpers added: geoOverpassDoor, haversineM, streetMatch. PREMIUM REDESIGN: the Address Lock overlay was completely refactored from the busy neon radar into a restrained premium command interface - a clean concentric locator with a thin trailing sweep, crosshair, N/E/S/W ticks and a soft pulsing core that settles into a glowing lock ring + check; deep navy-black glass card with hairline borders, a single restrained teal accent and a subtle top glow; the Country->City->Suburb->Street->Door resolution shown as an elegant hairline list with a status dot per line; a refined status badge; the full address set prominently; a clean bordered stat grid (door/unit, suburb, city, distance to site, accuracy, verified time); a tasteful framed map with a smaller elegant 'You are here!' marker; and a single prominent primary action with a quiet 'Enter manually' secondary. All behaviour preserved (gated Continue driving the real approval workflow, low-confidence confirm-to-continue, in-overlay error/retry). Inter/Roboto, responsive < 380px, honours reduced-motion. Tested: door failsafe 11/11; prior reveal 10/10 + confidence/distance/gating 16/16 still apply; single-impl + hooks verified.
v1.9.130 26 Jun 2026 23:12 Cinematic Address Lock - confirmation card, gated Continue, full state machine. Redesigned the passenger 'My address' lock sequence end to end and wired it into the real workflow. Radar (sweep/rings/grid/pings) -> progressive reveal Country -> City -> Suburb -> Street -> Door -> 'Address locked'. A confirmation card shows the full address, door/unit, suburb, city, distance to the work site, accuracy and a verified timestamp, plus a status badge: Exact door number confirmed / Address verified / Address located / Approximate location - confirmation required. A live MapLibre map (PMTiles/OSRM, lazy-loaded on pin) flies to the position with a pulsing 'You are here!' marker. A gated Continue button (disabled with 'Locating...'/'Verifying...' helper text until locked) submits the existing address form - labelled 'Submit for approval' or 'Confirm & save' - so it respects approval settings and never bypasses them; no approval messaging shows when approval is off. Low-confidence requires an 'I confirm' checkbox before Continue; failed lookups show an in-overlay error with Try-again + Enter-manually. Also removed three stale duplicated <script> blocks that had accreted in the view (double-binding/console noise); the view now has one overlay, one map-config block and one behaviour script. addresses() passes work-site coords for the distance read-out. Inter/Roboto, responsive. Tested 10/10 (reveal) + 16/16 (confidence tiers, distance, Continue gating).
v1.9.129 26 Jun 2026 22:50 Cinematic command-centre 'Address Lock' experience for the passenger app. The GPS pin flow is rebuilt as a premium tactical radar (rotating sweep, pulsing rings, gridlines, N/S/E/W ticks, location pings, scan bars, glassmorphism + neon-teal) with live status microcopy (Acquiring satellite signal -> Validating coordinates -> Street match detected -> Resolving door number -> Address locked) and a genuinely staged reveal: Country -> City -> Suburb -> Street -> Door, each locking with a tick + haptic. Door that cannot be confirmed is flagged and a warning ('Exact door number could not be confirmed') prompts manual entry. On confirmation the dial locks and the full address is shown (e.g. 3 McCoy Road, Parow, Cape Town). Below the radar a live MapLibre map (existing PMTiles/OSRM stack, lazy-loaded on pin) performs a cinematic fly-to onto the position with a premium pulsing 'You are here!' marker and a satellite-grid overlay; honours reduced-motion and degrades gracefully. Inter/Roboto. Reveal + confirmed-state logic tested 10/10.
v1.9.128 26 Jun 2026 22:23 Multi-source reverse geocoding + premium GPS-lock radar. The reverse geocoder now tries OpenStreetMap (Nominatim), then Google (only when map.google_geocode_key is set), then the keyless Photon, stopping as soon as a house number is found and merging blank fields; it reports the source(s) used. New optional settings map.photon_base_url and map.google_geocode_key. The GPS radar was rebuilt as a premium 'address lock' that reliably reveals tiers one by one in order - Country, Province, City, Suburb, Street, Door - each locking with a tick + haptic (amber 'add it yourself' when missing), with a radar dial, scan line, live coordinate/accuracy readout and a source badge, then 'Address locked' and form fill. Fixes the bug where the reveal stopped after the first tier (stepping is now a bulletproof state machine, unit-tested to walk all six tiers).
v1.9.127 26 Jun 2026 22:08 Passenger notifications + GPS address fixes. Bell badge now uses the real unread count (Notifier::unreadCount) instead of the upcoming-bookings count, so it always matches the list; replaced the double-looking bell emoji with a clean SVG bell (99+ cap, hidden at zero). The passenger Notifications screen now emulates the desktop centre: search, All/Unread/Starred filter tabs with live counts, and Mark all as read (header tick + button), reusing the existing notification routes. Reverse geocode returns the full street with door number where OSM has it (recovered from display_name; 6/6 cases) and now also returns the address parts (country, province, city, suburb, street, door). The GPS radar reveals the address piece by piece in order Country -> City -> Suburb -> Street -> Door (tick per found part, 'add it yourself' when a part is missing), then fills the form. Note: SA OSM house-number coverage is patchy; missing door numbers are surfaced for the user to complete rather than dropped silently.
v1.9.126 26 Jun 2026 21:48 Fixed the passenger 'Update address' 500 and improved the GPS pin. Root cause (reproduced on real MySQL): the address-approval columns (reason, request_ip, decision_comment, decided_by, decided_at) and the 'cancelled' status are migration-added, so if the migration was not applied to the tenant DB the app serves, the save INSERT failed with Unknown column 'reason'. Added AddressPolicy::ensureSchema(): idempotent, runs against the current tenant connection, adds missing columns/enum/table, never throws; called from approvalRequired() and at the top of the passenger submit/cancel; submit also wrapped in a crash guard (friendly message, no raw 500). The migration is now optional. GPS: reverse geocode recovers the house number + road from the display name so it returns '3 McCoy Road' not 'McCoy' (6/6 cases). Added a centred animated radar overlay on 'Pin my exact GPS position' that searches and vanishes when the address is found (25s safety timeout, prefers-reduced-motion aware).
v1.9.125 26 Jun 2026 09:56 "Require approval for address changes" is now the master control. Promoted to the first, standalone setting on the Address Change Approval screen, with all other settings grouped beneath it and dimmed/locked (values preserved) when it is off. When OFF, the entire approval workflow is bypassed everywhere (admin desk, passenger app, approver console, notifications, statuses): no limits, cool-down, GPS gate, approvers, departments, approval emails, waiting statuses or steps - the change applies immediately subject only to basic validation (coordinate V03 gate bypassed via approve() mode 'immediate'), stale pendings auto-cancel, and the passenger sees an 'updated immediately, no approval required' message. The console shows an 'Approval is OFF' banner and decide() refuses to act. When ON, behaviour is unchanged. All driven by AddressPolicy::approvalRequired() - no hard-coded rules. Audited (apply_immediate).
v1.9.124 26 Jun 2026 09:33 Passenger App My Address - status, workflow and actions, all driven from Admin Settings (no hard-coded rules) and fully audited. The screen now shows: current approved address; change awaiting approval (with approval-required note and any/all/sequential progress); approved & active; rejected with the reason; change-limit-reached; next-eligible date; and a no-approval-needed immediate-update path. Actions per status: submit, edit pending (resets approver steps + re-notifies), cancel pending (status 'cancelled', does not count to the limit), and view status/rejection reason; further changes blocked on limit/cool-down. New settings address.allow_self_cancel and address.allow_self_edit; rejection/approval reason stored on the row (decision_comment/decided_by/decided_at); approval_status gains 'cancelled'. Reuses the v1.9.123 AddressPolicy. Run migrate_address_approval.php (idempotent).
v1.9.123 26 Jun 2026 09:15 Passenger Address Change Approval. New configurable governance under Settings -> Address Change Approval (built on the existing AddressService, not duplicated): master approval-required toggle (apply-immediately vs Pending); frequency limit with a period unit (days/weeks/months/quarter/year) that blocks over-limit and tells the passenger the next-allowed date; configurable approvers (specific users + department codes) with any/all/sequential modes tracked in a new address_approval_steps table; email-to-approvers with passenger, employee number, current+requested address, time, reason and review link; reason captured on submission; mandatory rejection reason. Approver console gains reporting cards, status/search/department/date-range filters, current->requested view and multi- approver progress. Dedicated premium settings screen (toggles, dropdowns, people/department pickers, click/hover tooltips). New: migrate_address_approval.php, AddressPolicy, settings/address_approval view. Admin-only; fully audited. Run migrate_address_approval.php.
v1.9.122 25 Jun 2026 22:33 Per-site routing vendor resolution. A single tenant-wide default vendor is wrong: vendors are approved per site (vendors.approved_sites) and a site may use different vendors for inbound vs outbound. New VendorResolver resolves the vendor for a (site, direction) with precedence: per-direction nomination -> any-direction nomination -> sole approved vendor for the site -> legacy global default -> per-site exception. New route_plan_site_vendors table holds nominations. RoutePlanner now resolves vendor per window (backward compatible: still falls back to the global default). Seed Lab Routing tab gains a per-site/direction vendor picker (approved vendors first, resolution shown, ambiguous/unapproved flagged) and routes each group with its own vendor, skipping only groups with no resolvable vendor; a switch saves picks as site nominations. New: migrate_routeplan_site_vendors.php, VendorResolver. Audited.
v1.9.121 25 Jun 2026 22:11 Autonomous Routing - Seed Lab: new enterprise testing console that generates controlled, realistic booking demand from live employees/sites/slots to prove the autonomous routing pipeline end to end. Enforces one-per-employee/day/direction/slot (vs existing + within-batch, at preview and commit). Dry-run/commit/revert with reversible seed batches (source_channel='seed'); validation + readiness scoring; scenario biasing (high/low density, capacity stress, edge-of-zone, exceptions); searchable/sortable/paginated booking table + CSV; runs the same routing core the planner uses and compares expected vs actual routes. New: migrate_seed_console.php, SeedGenerator, SeedController, seed/index view, seed-console.js. Audited; route.optimise.
v1.9.120 25 Jun 2026 19:20 Fix: after the v1.9.119 switch refactor the Routing Console "Show only at-risk" toggle (.rc-sw) rendered broken in its ON state - a solid green blob with the knob jammed off the right edge. CAUSE: two old .rc-sw rules in app.css were missed when legacy switch CSS was removed because they sit on their own lines outside the deleted block - .rc-sw.on::after{left:25px} (old knob travel) and .rc-atrisk-inline .rc-sw.on ~ label{color:#e11d48} (old red label). The old design moved the knob via left:25px while the new switches.css moves it via transform:translateX(20px); both applied together so the knob landed at ~45px on a 46px pill and overflowed. FIX: removed the stray .rc-sw.on::after{left:25px} so travel comes only from the unified transform (off knob left, on knob seated at the right within bounds), and dropped the now-mismatched red label colour (switch is green) while keeping a subtle bold-when-active cue; the intentional .rc-atrisk .rc-sw{margin-top:6px} layout rule is retained. A full sweep confirmed app.css, mobile.css and monitor.css now hold no stray switch styling. VERIFICATION: rendered the actual Demand Queue toolbar row (.rc-atrisk-inline + .on toggle) headless in both states - DOM checks confirm width 46px with knob left:3px/transform none when off and left:3px/translateX(20px) when on (knob seated ~23px inside the 46px pill, no overflow). Files: public/assets/css/app.css; config.php.
v1.9.119 25 Jun 2026 19:10 System-wide On/Off switch refactor: every boolean toggle across admin and mobile now shares one premium inward-bevel component - a recessed pill track with inner shadows and a soft highlight, an elevated knob with polished depth, a premium GREEN active state with a subtle glow/highlighted inset, and a calm neutral-grey recessed inactive state (Inter/Roboto). SINGLE SOURCE: new public/assets/css/switches.css defines the whole design once (shared track+knob appearance, on/focus/disabled via grouped selectors and --sw-* tokens; each toggle contributes only geometry) and is loaded last in both layouts (app/Views/layout/app.php and app/Views/mobile/_head.php) so it is the authority on desktop, tablet and mobile. CONSOLIDATED seven previously inconsistent switches (teal/blue/red/crimson on, differing sizes) into the one design: .tp-switch (canonical checkbox), .cc-sw (settings), .vc2-switch (fleet config), .vp-switch (vendor form), .mb-switch (mobile rows), .rc-sw (routing at-risk), and .lm-toggle (map control, compact variant). LEGACY REMOVED so nothing duplicate/conflicting remains: old .tp-switch/.vc2-switch/.rc-sw blocks in app.css, .mb-switch in mobile.css, .lm-toggle in monitor.css, and inline .cc-sw (manage.php) + .vp-switch (vendor_form.php); layout-only rules preserved (.rc-atrisk .rc-sw, .mb-toggle, .vp-switch-row, .lm-mapctl button). NO FUNCTIONAL CHANGE - only styling: every switch keeps its markup, classes, input names/values, role/aria and JS (checkbox, class-driven .on, and aria-pressed mechanisms untouched), so saving, data-binding, validation and events are unaffected; native controls still provide keyboard + screen-reader state, a green focus ring is applied to all, and disabled switches use not-allowed + dimmed. VERIFICATION: php -l clean on all four edited views; brace balance confirmed on every edited CSS file and inline-style view with no orphaned switch selectors left in core stylesheets (only the intentional .rc-atrisk .rc-sw spacing) and all kept layout rules surviving; all seven structures rendered headless in off/on/disabled/focus with DOM checks confirming green on-track, grey off-track and correct knob travel, and the compact .lm-toggle pill/knob aligning over its label. Caveat: verified in isolation and by DOM assertions; exhaustive per-page visual testing of every live screen hosting a switch was not performed in this environment. Files: public/assets/css/switches.css (new); app/Views/layout/app.php; app/Views/mobile/_head.php; public/assets/css/app.css; public/assets/css/mobile.css; public/assets/css/monitor.css; app/Views/settings/manage.php; app/Views/masterdata/vendor_form.php; config.php.
v1.9.118 25 Jun 2026 18:35 Diagnosed and fixed why a passenger-app booking for tomorrow did not appear in Admin Bookings or Routing. NOT a data bug - the booking is created correctly (status awaiting_routing for the chosen date) and the passenger sees it as their next trip; it was hidden because BOTH admin views default their date filter to TODAY: BookingController::index defaults to today and filters WHERE b.trip_date = today (so tomorrow is excluded, with no on-screen cue), and the Routing console filters() uses the same today default (tomorrow demand is counted in demand-on-other-dates and reachable from the Overview demand KPI jump but not in the default list). If a booking needs authorisation it is created pending_approval, in which case Routing correctly hides it until approved while Bookings shows it under Pending - but the dominant reason here is the date default. FIX: the Bookings list keeps its today default but no longer hides future bookings silently - when a single day is in view and there are upcoming bookings on other dates (scope- and tab-aware), a banner now appears above the table ("N upcoming booking(s) on other dates aren't shown by the today filter - next is <date> (M)") with a "View <date>" button that jumps to the next date holding bookings (e.g. tomorrow) and a "Show all dates" button that clears the date filter to list every upcoming booking. Routing already surfaces other-date demand via its Overview demand KPI so no change was needed there; the booking is one click away in both views. Purely additive - no change to booking creation/storage, the default today view contents, scopes, tabs or query semantics; the cue is computed from the same scope/tab filters as the list. VERIFICATION: php -l clean on both changed files; the date-filter resolution the banner links rely on was checked (no date param -> today; date=<day> -> that day; date=all or empty -> all dates) so View-date and Show-all-dates resolve correctly; the banner rendered headless for review. Files: app/Controllers/BookingController.php; app/Views/bookings/index.php; config.php.
v1.9.117 25 Jun 2026 10:44 Fix: the Autonomous Routing tab (added in v1.9.116) could show AMBER on a perfectly healthy console - enabled, migrated, zero exceptions, every eligible booking routed, nothing more due today - purely because no planning run had executed yet today. CAUSE: RoutePlannerConsole::health() had a final rule treating runs-today==0 as a warning (Awaiting first run); with nothing due and nothing to review that is not a call-out, so the amber contradicted the all-clear panel. CHANGE: removed the no-run-yet amber so the tab colours on concrete, visible signals only - RED when enabled-but-not-migrated or the last run failed; AMBER when there are open exceptions to review or the last run completed-with-exceptions; GREEN when enabled, migrated, no failed run and nothing to review (idle or clean); GREY when off. No run yet today is no longer a warning on its own. A genuinely stuck scheduler (work due but never run) is not yet a distinct signal and can be added later with schedule-overdue awareness; it does not affect this fix. VERIFICATION: php -l clean; health() re-tested 9/9 including the exact screenshot scenario (enabled, ready, 0 runs, 0 exceptions, no last run) now returning GREEN, with off/not-migrated/failed/open-exceptions/completed-with-exceptions/clean and failed-beats-exceptions precedence all still correct. Files: app/Services/RoutePlanner/RoutePlannerConsole.php; config.php.
v1.9.116 25 Jun 2026 10:32 Routing Console gains a dedicated Autonomous Routing top tab, positioned immediately after Overview and Filters, with a dynamic status background colour that reflects autonomous-routing health and updates automatically from the latest validation results: GREEN = all checks clear, no action; AMBER = open exceptions to review, last run completed-with-exceptions, or no run yet today (potential call-outs / items requiring review); RED = the engine failed or is not set up (a problem to resolve); plus a neutral GREY when autonomous routing is switched off. A status dot reinforces the colour (red pulses) and an open-exception count badge shows when items need review. HEALTH MODEL: new pure classifier RoutePlannerConsole::health(snapshot) in priority order - off (disabled); bad (enabled-but-tables-missing, or last run failed); warn (open exceptions > 0, or completed_with_exceptions, or no run today yet); ok (enabled, migrated, last run clean, nothing to review) - returning level, label, a one-line detail (tab tooltip) and the open-exception count. TAB CONTENT: the existing autonomous monitor + exception queue partial (_autonomous_monitor.php) now lives inside this tab instead of as a banner above the tabs; when autonomous routing is off and not migrated the tab shows a short not-active panel linking to Settings -> Autonomous Routing. LIVE UPDATE: the colour is server-rendered from the latest snapshot on load, and a new endpoint GET /routing/autonomous/health (route.view) returns the health as JSON which the console polls every 60s to recolour the tab and refresh the count without a reload, so a problem appearing between loads turns the tab red on its own. VERIFICATION: php -l clean on every changed file; the poll JS passes node --check; health() unit-tested 10/10 (off when disabled; bad when not migrated or last run failed; warn for open exceptions, completed-with-exceptions and awaiting-first-run; ok when all clear; failed beats exceptions in precedence; open count and label surfaced for the badge); console structure asserted from the rendered view (Autonomous Routing is the third tab after Overview and Filters, carries rauto-<level>, the #tpane-autonomous pane includes the monitor partial, the poll script and the /routing/autonomous/health route are present, and the old pre-tab include is removed); the three status colours rendered headless for review. Files: app/Services/RoutePlanner/RoutePlannerConsole.php; app/Controllers/RoutingController.php; app/Core/Router.php; app/Views/routing/queue.php; config.php.
v1.9.115 25 Jun 2026 10:05 Risk Zones promoted to a first-class BOOKING-TIME safety geofence, independent of Nightfall. Every booking is now checked against the active risk zones regardless of whether Nightfall is enabled or the time of day: if the passenger address falls inside a zone the booker is WARNED, and if the zone severity is at or above a configurable threshold (default 75%) the booking is PREVENTED so an alternative drop-off can be chosen - 0.25 Potential Risk and 0.50 Limited Danger warn; 0.75 Dangerous and 1.00 No-Go Zone block. NEW SERVICE app/Services/Guardian/RiskZones.php: match(lat,lng) returns the heaviest active zone whose radius covers the point (pure haversine geofence, no darkness/Nightfall gating); assess(lat,lng) returns allow/warn/block with the zone name, severity % and band label, blocking when severity >= the configured threshold and allowing when the guard is off, the point is unset, or no zone covers it; loads guardian_risk_zones once per request and is guarded against the table being absent. ENFORCEMENT: BookingController::store now resolves the passenger approved address WITH coordinates and assesses once per passenger - a severe zone moves them into the existing blocked bucket (zone name + % + choose-an-alternative message) and is audited (risk_zone_block), while a milder zone still books but is surfaced in a new booked-with-a-risk-zone-warning summary line; MobileAppController::bookStore assesses the home address before any day is booked, refusing the whole booking on a severe zone (clear message + audit) and prepending a warning to the confirmation on a milder one; MobileAppController::bookForm passes the assessment to the view. PASSENGER FORM app/Views/mobile/book.php: a severity-coloured banner appears above the route card (orange Drop-off caution for warn, dark-red Drop-off blocked with a Manage addresses link for block); on block the Confirm button is disabled and relabelled, the review-sheet confirm is disabled, and form submission is prevented client-side on top of the authoritative server refusal. GOVERNANCE: two new Safety settings - guardian.zone_booking_guard (bool, default ON; master switch) and guardian.zone_booking_block_pct (int 0-100, default 75; block-vs-warn threshold) - both rendering in the Configuration Command Centre under Safety & Guardian; settings total 121 -> 123, depends_on graph clean. VERIFICATION: php -l clean on every changed file; the mobile block-guard JS passes node --check; RiskZones unit-tested 11/11 with stubbed zones + settings (heaviest overlapping zone wins - No-Go over Dangerous; block at/above threshold; warn below; allow outside all zones; a 75% zone flips block->warn when the threshold is raised to 80; guard-off allows; null coordinates allow; all four band boundaries correct); registry integrity re-checked (123 settings, 18 categories, 0 dangling depends_on, both new keys in the safety category); the warn and block booking banners rendered headless for visual review. The check uses the same guardian_risk_zones table as Nightfall, so it needs scripts/migrate_nightfall_v1.php; with no zones or the table absent the guard simply allows every booking. Files: app/Services/Guardian/RiskZones.php (new); app/Core/SettingsRegistry.php; app/Controllers/BookingController.php; app/Controllers/MobileAppController.php; app/Views/mobile/book.php; config.php.
v1.9.114 25 Jun 2026 09:36 Three operator-requested changes. (1) CONFIGURATION CENTRE opens directly on the navigation console instead of a card landing: GET /settings now redirects to /settings/manage, the sidebar Settings link points straight at /settings/manage, the console hero drops the looping Overview button (Change history stays), and the left rail gains a Registries & Tools group (Lookups & Lists, Vehicle Configuration, Booking Governance, and Risk Zones for settings.manage holders) so the navigation list is the single launcher; no registry/contract/backend change, the old settings/index.php overview is simply no longer rendered. (2) ROUTING CONSOLE: the Issued / Active Plans and Live Routing Geography tabs were disabled whenever there were zero issued/active plans (the tab script ignores clicks on a disabled button); removed the disabled-when-empty gate so both are always selectable - empty data degrades gracefully (the plans table shows its No active plans row and the geography tab shows the base map), no controller change. (3) RISK ZONES rebuilt as an interactive map editor (app/Views/nightfall/zones.php): an operator clicks the live map to drop a zone, a radius circle appears and is sized with a 0.2-15 km slider, and severity is one of four colour-coded bands that also colour the circle and the draggable marker pin - 0.25 Potential Risk (yellow, Caution), 0.50 Limited Danger (orange, Warning), 0.75 Dangerous (red, High Danger), 1.00 No-Go Zone (dark red, Extreme/Prohibited). Existing zones render as colour-coded circles (active in band colour, paused greyed) with a read-only click popup (name, band, uplift %, radius, status); the map auto-frames them on load; the zones table gains a band colour swatch per row while Pause/Activate and Delete stay. Backend unchanged - the form still posts name, lat, lng, radius_km, weight, note to /settings/risk-zones/save (already accepts these); the editor reuses the existing MapLibre GL + PMTiles stack, the shared tpMapKit base-map helper and the global TP_MAP style. Risk Zones still require the Nightfall table and otherwise show the migrate_nightfall_v1.php guard. VERIFICATION: php -l clean on every changed file; the Risk Zones JS passes node --check; rendered headless in Chromium (device_scale_factor=2) with the vendored MapLibre build and an offline style - the map initialised, all three test zones drew as colour-coded circles, clicking the map placed a new graded circle and updated the coordinate readout, the four severity buttons recoloured the circle, and Save stayed disabled until a name was entered (live basemap tiles load from the configured tile server in production; the sandbox has no network to it so the preview backdrop is blank). Files: app/Controllers/SettingsController.php; app/Views/layout/app.php; app/Views/settings/manage.php; app/Views/routing/queue.php; app/Views/nightfall/zones.php; config.php.
v1.9.113 25 Jun 2026 01:59 Administration -> Settings elevated into a flagship Configuration Command Centre - a bold, premium enterprise redesign of the whole module (both the manage console and the overview landing). Presentation only: every backend contract is preserved and still enforced - routes (GET /settings/manage, POST /settings/save, POST /settings/reset), POST fields (key, value, reason, back_qs, CSRF), server-side validation, the critical-reason requirement (Rbac::requireHighRisk), sensitive masking, permission gates (canManage / canSensitive) and audit/versioning. No backend change this round - the per-category counts added in v1.9.112 are reused and the global summary stats are computed in-view; settings still come entirely from the live registry + configuration_settings (no hard-coded or placeholder values). COMMAND CENTRE (app/Views/settings/manage.php): a navy gradient hero with a teal glow and four live, glassy stat tiles (total settings, modified [links to ?state=modified], critical [toggles the critical filter], categories); a sticky premium search/filter bar (search name/key/description/help, category and status selects, a Critical-only chip); a left rail that organises all categories into logical families (Operations, Safety & Workforce, Commercial, Platform & Access, with a robust More fallback) each with an icon tile, count and teal modified-badge, the active category highlighted with a navy fill and teal accent; and a main panel of category sections (gradient icon tile + blurb + count) holding refined setting cards. Cards carry a left status accent (teal when modified), a clean name + Critical/Sensitive badges + the blue i icon, description, a chip-styled key, the current value, a status pill (vN modified with updated-by/date, or default), and premium controls - enterprise On/Off switches for boolean-like settings via a bulletproof hidden-input + checkbox pair (always POSTs 0/1, no JS dependency), styled selects, write-only secret fields and validated numeric/text inputs. Editing UX: Save disabled until a value changes (dirty tracking with a teal ring), inline numeric range/whole-number validation, typed reason + confirm for critical settings, a Saving... state, Reset-to-default with confirm, and a beforeunload unsaved-changes guard - all on top of the unchanged server enforcement. The blue i INFO CARD is exactly to spec: hidden by default; hover previews; click pins it open; stays open until an outside click / the same icon again / the card close button / Escape; only one open at a time; flips to avoid viewport overflow on desktop and becomes a bottom-centred sheet on mobile; content is concrete (What it does, Why it matters, Allowed values, Default, Example, Risk, Depends on, Affects). OVERVIEW LANDING (app/Views/settings/index.php) redesigned to match: the same navy hero + stat tiles, a large search that flows into the console, and a grouped category grid (same families) of premium cards with hover lift, arrow micro-interaction and teal modified accents, plus a Registries & Tools row that preserves the existing Lookups, Vehicle Configuration, Booking Governance and (Rbac-gated) Risk Zones links, and a most-recent-change footer. Brand navy #14284C + teal #07B49B throughout, Inter/Roboto, DM Mono for keys/values, subtle shadows and depth, smooth transitions, pointer cursor on every interactive element, no link underlines, fully responsive (rail -> jump-select, stats reflow, cards stack, no horizontal overflow). VERIFICATION: manage.php, index.php and SettingsController all php -l clean; rendered both screens headless in real Chromium (device_scale_factor=2) with live registry data. Console: balanced markup (18 sections, 3689 divs), hero stats 121/14/13/18, the four rail families + All settings (19 nav items), 18 sections, 121 cards, 21 On/Off switches, zero popovers visible at rest, exactly one shown after clicking an icon, still one after clicking a second icon (one-at-a-time), zero after Escape. Overview: balanced markup, hero stats, all five group labels, 18 category cards + 4 tool cards. Desktop screenshots of both reviewed for flagship quality; mobile reflow confirmed via the responsive rules. Files: app/Views/settings/manage.php; app/Views/settings/index.php; config.php.
v1.9.112 25 Jun 2026 01:33 Administration -> Settings completely refactored into a premium, enterprise-grade configuration console. Pure presentation rewrite of the manage screen (app/Views/settings/manage.php) plus one additive controller change; EVERY backend contract is preserved - the routes (GET /settings/manage, POST /settings/save, POST /settings/reset), the POST fields (key, value, reason, back_qs, CSRF), server-side validation (SettingsRegistry::validate), the critical-reason requirement (Rbac::requireHighRisk), sensitive-credential masking, permission gates (canManage / canSensitive) and the audit/versioning via Settings::set are all untouched and still enforced. Settings remain loaded entirely from the live registry + configuration_settings - no hard-coded or placeholder values. LAYOUT: a sticky left category rail (every category with its icon, a live count, and a teal modified-count badge; current category highlighted) + a main panel of grouped premium setting cards + a premium search/filter bar (search by name/key/description/help, category select, status select [modified / at-default], and a client-side Critical-only filter chip). Collapses cleanly on tablet/mobile: the rail becomes a category jump-select, cards stack, the value/status moves below, no horizontal overflow. CARDS: each setting shows its name, Critical/Sensitive badges, description, the mono key, the current value, and a status pill (vN - modified with updated-by/date, or default). CONTROLS: boolean-like settings (type bool and enum ['0','1']) render as proper On/Off switches using a bulletproof hidden-input + checkbox pair so they always POST 0 or 1 with no JS dependency; other enums render as styled selects; secrets as write-only password fields; numerics/text as validated inputs. EDITING UX: Save is disabled until a control actually changes (dirty tracking), light client-side numeric range/whole-number validation with an inline message, critical settings require a typed reason and a confirm before saving, a Saving... loading state, a Reset-to-default action with confirm, and a beforeunload guard for unsaved changes - all on top of the unchanged server-side enforcement. BLUE i INFO CARD: implemented exactly to spec - hidden by default; hovering the icon previews the card; clicking pins it open and it stays open until the user clicks elsewhere, clicks the same icon again, clicks the card's close button, or presses Escape; only one card is ever open at once (opening another closes the previous); it is a positioned popover that flips to avoid viewport overflow on desktop and becomes a bottom-centred sheet on mobile. Help content is concrete, not filler: What it does, Why it matters, Allowed values (derived from type/options/min/max), Default (derived), Example, Risk if changed, Depends on, and Affects. VISUAL: brand navy #14284C + teal #07B49B, Inter/Roboto, DM Mono for keys/values, consistent inputs/selects/switches, subtle dividers and shadows, smooth transitions, pointer cursor on every interactive element, no link underlines. CONTROLLER: SettingsController::manage() now also passes per-category counts (total + modified) for the rail - a self-contained closure over the existing $stored map; nothing else changed. VERIFICATION: manage.php + SettingsController php -l clean; rendered the console headless in real Chromium (device_scale_factor=2) with live registry data for the Autonomous Routing category and asserted via the DOM - 13 cards, 8 On/Off switches, balanced markup (1 section open/close, 440 divs open/close), zero popovers visible at rest, exactly one shown after clicking an icon (with the real help text), zero after Escape, and the active category highlighted in the rail; re-rendered at 390px wide and confirmed the desktop rail is hidden while the mobile jump-select shows; both desktop and mobile screenshots reviewed for premium quality. Files: app/Views/settings/manage.php (premium console); app/Controllers/SettingsController.php (per-category counts for the rail); config.php.
v1.9.111 25 Jun 2026 01:17 Fix: Administration -> Settings dashboard 500 (Class "Rbac" not found). ROOT CAUSE: the app autoloader only resolves names under the App\ namespace (App\Core\Rbac -> app/Core/Rbac.php); it does not register global class aliases. Views render in the global namespace, so a view may write a bare Rbac:: / Csrf:: only if it ALSO imports the class with a use statement (as queue.php does). app/Views/settings/index.php used a bare Rbac::can('settings.manage') (in the Risk Zones shortcut card) with no import and not fully-qualified, so it resolved to a non-existent global \Rbac and threw "Class Rbac not found" - taking down the whole Settings dashboard. FIX: qualified it to \App\Core\Rbac::can(...), matching the codebase's established pattern for class references in views (manage.php already uses \App\Core\Csrf::field()). A full audit of every view for this exact defect (bare App\Core class reference with neither a use-import nor an FQN) found this was the only genuinely broken instance: the vehicle-config views all carry use App\Core\Csrf, and the one other match (mobile/_head.php Database::) is inside a code comment, not executed. Also re-verified the autonomous-planner service files (which live in a namespace) each import every App\Core class they use (Settings, Database) or reference it fully-qualified (Notifier, Audit) - no similar latent defect there. VERIFICATION: settings/index.php php -l clean; proved \App\Core\Rbac resolves via the autoloader while the bare Rbac does not, and Rbac::can exists; SettingsRegistry integrity clean (121 settings, 18 categories, 0 dangling depends_on, every cross-category key referenced by the routeplan settings resolves); rendered the Settings dashboard end to end with the real autoloader and a settings.manage session - it now renders (9.4 KB, no 500), the previously-fatal Risk Zones branch executes, and the new Autonomous Routing category card shows. Note on the routeplan 'Default routing vendor' setting: it is an integer field because the settings engine only supports static enum option lists (no table-sourced dropdowns); the help text directs the admin to their vendor id. Files: app/Views/settings/index.php; config.php.
v1.9.110 25 Jun 2026 01:00 Autonomous Route Planner - increment 4 of 4 (final): safe re-planning, the corrected issue/hold model, and administrator notifications. This closes the autonomous loop. KEY DISCOVERY corrected here: the existing core (routeGroup) ISSUES DIRECTLY - it creates routes as 'released' plus a 'scheduled' trip - so increment 2's draft assumption was wrong. Rather than change the proven core, the planner now HOLDS issued sheets back when it should not dispatch. (1) ISSUE vs HOLD (spec section 4): after the core runs, the planner computes shouldIssue = auto_issue AND (no exceptions OR hold-draft-on-exception off). If it should issue, the freshly released sheets stay live; otherwise each is demoted to draft via the new CLI-safe RoutingController::autoHoldRoute seam (route 'released'->'draft' and its 'scheduled' trip cancelled - but ONLY while the trip is still scheduled, so a hold can never disturb a started trip). The run ledger now records sheets_issued vs sheets_draft correctly (superseding the increment-2 placeholder where everything was logged as draft). (2) SAFE RE-PLANNING (spec section 7): a new windowState() reports prior finished runs, live routes, and - critically - whether any trip has progressed past 'scheduled' (started). executeWindow now detects a re-plan (prior runs or live routes present), logs it as trigger_type='replan', and refuses to re-plan when a trip has started (the core would otherwise supersede/cancel a live trip) - instead it records an 'already_assigned' exception and skips, NEVER calling the core. It also enforces the re-planning freeze window and attempt ceiling via RoutePlannerSchedule::replanAllowed(). Every automatic change remains audited (routeGroup audits its supersede/cancel; autoHoldRoute audits the demote+trip cancel; the run ledger + exception queue are the planning audit trail). (3) NOTIFICATIONS (spec section 10): a pure notifyEvents() decides which administrator alerts fire from the outcome + settings, and notifyOutcome() fans them to route.view permission holders via the native in-app Notifier (notify_admin_on_complete -> a per-run digest; notify_admin_on_exception -> an attention alert when bookings could not be routed). Driver/passenger-on-issue is wired and settings-gated but is an honest no-op for now: the autonomous path leaves the vendor to allocate the driver and employees are not linked to user accounts, so there is no recipient to resolve at issue time - it activates once driver allocation and passenger-user linkage exist. RoutePlannerSchedule::config() extended with the four notify_* flags; the cron tick now injects autoHoldRoute alongside autoPlanGroup. Still additive: the only controller change is the new autoHoldRoute seam; routeGroup, release() and every existing method are untouched. VERIFICATION: all four touched files php -l clean; executeWindow unit-tested 11/11 (auto_issue on+clean -> issued, 0 held, ledger issued=2/draft=0, notifyIssued fired; auto_issue off -> all held in draft, no issue notification; exception + hold-on-exception -> held; exception + no-hold -> issued; RE-PLAN with a started trip -> skipped + already_assigned, core NOT called; re-plan inside the freeze window -> skipped, core NOT called; prior runs -> trigger_type=replan; first plan -> trigger_type=scheduled); the notify gating unit-tested 6/6 (complete only when clean+flag, both on completed_with_exceptions, exception-only, nothing on failed, nothing with no flags, exception on skipped+flag); new queries confirmed parameterised with NULL-safe <=> matching, the started-trip guard excludes scheduled+dead states, issuedRouteIds targets released routes, and autoHoldRoute only cancels a still-scheduled trip. HONEST SCOPE: the decision/control logic is fully tested; the SQL, the real routeGroup/autoHoldRoute calls and the Notifier run live in production. THE AUTONOMOUS ROUTE PLANNER IS NOW COMPLETE (increments 1-4): configuration + schema + scheduling brain + lock; cron tick + execution; console monitor + exception queue; and now safe re-planning + issue/hold + notifications. Files: app/Services/RoutePlanner/RoutePlannerSchedule.php (config notify flags); app/Controllers/RoutingController.php (new autoHoldRoute seam only); app/Services/RoutePlanner/RoutePlanner.php (executeWindow re-plan safety + issue/hold + notifications); scripts/run_route_planner.php (inject autoHoldRoute); config.php.
v1.9.109 25 Jun 2026 00:47 Autonomous Route Planner - increment 3 of N: the Routing Console becomes the autonomous monitor + exception queue (spec sections 5, 6, 9). The console now shows, at a glance, whether the planner has run, when it ran, what it produced and what still needs a human - turning it from a manual launch pad into a monitoring-and-exception surface. Additive and read-only: a new partial rendered above the existing console; the manual planning deck below is untouched and remains available for overrides. (1) App\Services\RoutePlanner\RoutePlannerConsole - a read-only, fully-guarded monitor feed. snapshot() returns today's rollup (runs / processed / assigned / sheets / draft / issued / exceptions from route_plan_runs), the last run, the open-exception count, and the next scheduled run; openExceptions() and recentRuns() feed the queue and history; nextRun()/pickNext() compute the soonest still-upcoming plan point from the day's demand windows and the configured leads; statusLabel/statusTone/reasonLabel give human labels and chip tones. Every read is wrapped - if the planner tables are absent the feed reports ready=false and the console simply hides the panel, so the page works before the migration. (2) app/Views/routing/_autonomous_monitor.php - the monitor partial: an ENABLED/OFF header, a five-cell KPI strip (processed, assigned, trip sheets with issued/draft split, open exceptions in red when >0, runs today), a last-run chip + next-scheduled line, a two-column body with the EXCEPTION QUEUE (passenger, booking ref, direction/site/slot window, reason chip, recommended action - section 6) and RECENT RUNS history (section 9), and a footer linking to Settings with a note that manual planning remains available. Brand navy/teal, Inter, scoped .rpm-* CSS; graceful empty states (no exceptions -> green all-clear; enabled-but-not-migrated -> a one-line migrate hint). (3) RoutingController::queue() now builds the snapshot (guarded try/catch) and passes 'rp' to the view; queue.php includes the partial directly under the page header. No existing query, KPI, filter, tab or planning form was changed. VERIFICATION: all four touched/new files php -l clean; the next-run and label logic unit-tested 6/6 (pickNext selects the earliest UPCOMING plan point and skips past ones, computes plan_at = anchor - lead correctly in SAST, returns null when all points have passed, honours the outbound lead; status tones green/amber/red; reason labels human-readable); the partial rendered headless in real Chromium (device_scale_factor=2) with representative data and asserted via the DOM - ENABLED badge, KPI values 58/52/9/3/4, the open-exception KPI shown red, three exception rows, and the correct status/reason chips (Completed with exceptions / Address not geocoded / Missing address / Could not optimise / Completed / Failed); screenshot reviewed for layout. The SQL reads run live in production (no DB engine in the sandbox). SEQUENCED NEXT - increment 4 (final): safe automatic re-planning (never touch a started trip or an active manifest; cap attempts; audit every change), a CLI-safe auto-issue path so clean plans can issue without the request-bound release(), and the notifications (admin on complete/exception, driver/passenger on issue). Files: app/Services/RoutePlanner/RoutePlannerConsole.php (new); app/Views/routing/_autonomous_monitor.php (new); app/Controllers/RoutingController.php (queue() wiring only); app/Views/routing/queue.php (one include); config.php.
v1.9.108 25 Jun 2026 00:31 Autonomous Route Planner - increment 2 of N: the cron tick + execution wiring. Makes the planner actually run: at the scheduled lead time it plans the day's due windows by delegating to the EXISTING, proven routing core - it does NOT reimplement optimisation, clustering, capacity or timing. Still additive: the only change to existing code is one new public seam method on RoutingController; no existing method was altered. (1) RoutingController::autoPlanGroup(slotId, date, vendorId) - a thin, CLI-safe public wrapper over the private routeGroup core (confirmed flash/redirect-free), so the scheduled tick and the web app share one planning path. (2) App\Services\RoutePlanner\RoutePlanner - the run orchestrator. runDue(now,trigger) loads config, discovers the day's demand windows (distinct slot/direction/site with awaiting_routing bookings + each window's anchor = trip_date + slot_time), asks RoutePlannerSchedule which are due, and for each due window takes a RoutePlannerLock, runs it, and releases - so repeated ticks never double-plan. executeWindow() opens an audit run, snapshots the eligible awaiting_routing bookings, calls the core, then captures every booking STILL awaiting_routing afterwards as an actionable exception (the core marks placed bookings 'routed', so leftovers are exactly the unplaced ones - a non-invasive exception source that needed no change to the core's contract), writes the run's counts and final status, and closes. Status is derived: completed / completed_with_exceptions / failed (core failed wholesale or no vendor) / skipped (nothing eligible). classifyUnroutable() mirrors the core's own unroutable test - inactive employment -> other; no pickup address -> missing_address; address present but not geocoded -> invalid_geocode; eligible but unplaced -> optimise_failed - each with a recommended corrective action (section 6). (3) New setting routeplan.default_vendor_id (default 0) with info-icon meta: the active vendor the unattended planner books against; 0 raises an incomplete_config exception rather than guessing a fleet. (4) scripts/run_route_planner.php - the cron tick (mirrors run_report_subscriptions.php): iterates active tenants, switches tenant DB, instantiates RoutingController + RoutePlanner and calls runDue in each tenant's local time; one bad tenant is logged and skipped, never wedging the run. Suggested cron: every minute. DELIBERATELY DEFERRED: auto-issue. The existing release() path flashes/redirects (not CLI-safe), so this increment plans into DRAFT sheets only (sheets_created = sheets_draft, sheets_issued = 0); a CLI-safe issue path + the notifications come in increment 4. VERIFICATION: all four touched/new files php -l clean; the 13th routeplan setting loads and the autoPlanGroup seam is present; the orchestrator's full control flow unit-tested 8/8 against an in-memory DB subclass + a stub core (clean->completed with correct ledger counts; partial->completed_with_exceptions with the right per-booking exception; no-vendor->failed+incomplete_config with the core NOT called; no-eligible->skipped with the core NOT called; core-failure->failed+optimise_failed; classifyUnroutable derivation across all four reason branches); new queries confirmed parameterised (IN-clauses via placeholder array_fill, all values bound) and every referenced column cross-checked against the proven core queries; run_uid is 32 hex chars matching CHAR(32). HONEST SCOPE: the orchestration logic is fully tested; the real routeGroup call and the SQL run live in production (no DB engine in the sandbox). SEQUENCED NEXT: (3) Routing Console -> autonomous monitor + exception queue (last/next run, processed/assigned/draft counts, unassigned, warnings, history) reading route_plan_runs / route_plan_exceptions; (4) safe re-planning (never touch started trips / active manifests, audit every change), CLI-safe auto-issue, and notifications. Files: app/Core/SettingsRegistry.php; app/Controllers/RoutingController.php (one new public seam, existing methods unchanged); app/Services/RoutePlanner/RoutePlanner.php (new); scripts/run_route_planner.php (new); config.php.
v1.9.107 25 Jun 2026 00:19 Autonomous Route Planner - increment 1 of N: the self-running spine (configuration + schema + scheduling brain + run lock). Begins transforming the Routing Console from a manual planner into an autonomous daily route-planning command centre per the spec. This increment is PURELY ADDITIVE - it touches nothing in the existing manual routing path (RoutingController unchanged) and the planner stays dormant until routeplan.enabled is switched on (default off). Delivered four foundations the rest of the build sits on. (1) CONFIGURATION: a new 'Autonomous Routing' settings category + 12 routeplan.* settings, each with full info-icon meta - enabled (master switch, default off), inbound_lead_min (120) / outbound_lead_min (90) (how far before shift start/end planning fires), replan_enabled / replan_window_min / max_replan_attempts, auto_issue (default off) / hold_draft_on_exception (default on), and the four notify_* switches. The optimiser constraints the spec lists (max route duration, occupancy target, detour, inbound grace, booking cut-off) are NOT duplicated - they already exist under Route Planning / Utilisation Optimisation / Booking and are cross-referenced via depends_on. (2) SCHEMA: idempotent migration scripts/migrate_route_planner.php creating route_plan_runs (the per-run audit ledger, section 9), route_plan_exceptions (the actionable unroutable-booking queue with a 15-value reason_code enumeration, section 6) and route_plan_locks (the per-window mutex, section 11). (3) SCHEDULING BRAIN: App\Services\RoutePlanner\RoutePlannerSchedule - a pure, DB-free decision engine. dueWindows() returns exactly the windows due to plan NOW (now inside [anchor - lead, anchor), where the inbound anchor is shift start and the outbound anchor is shift end); windowKey() gives the stable lock/ledger key; replanAllowed() enforces the re-plan freeze window and attempt ceiling. (4) RUN LOCK: App\Services\RoutePlanner\RoutePlannerLock - the route_plan_locks PRIMARY KEY on window_key IS the mutex (a competing INSERT fails atomically), with TTL reclaim so a crashed run never wedges a window and owner-checked release so no run steals another's lock. This is the section-11 'no duplicate trip sheets if the scheduler runs more than once' guarantee. VERIFICATION: all four files php -l clean; the 12 settings load with 0 malformed meta and the category present, default safe-off; the migration's 3 CREATE TABLE statements are structurally sound (parens balanced, PRIMARY KEYs present) and the lock/ledger queries are parameterised (injection-safe); RoutePlannerSchedule unit-tested 12/12 (disabled->none; inbound/outbound due only inside the lead window and not past the anchor; correct per-direction lead; window_key format; replan allowed before freeze, blocked inside the window / at max attempts / when disabled); RoutePlannerLock mutex contract tested 6/6 against an in-memory simulation of the unique-key lock (same window cannot be acquired twice, distinct windows independent, non-owner release is a no-op, owner release frees it, expired holder reclaimed via TTL). HONEST SCOPE: this ships the spine only and is fully testable because it is pure logic + structurally-verified SQL; the DB-executed parts run live in production. SEQUENCED NEXT INCREMENTS: (2) the cron tick entrypoint (scripts/run_route_planner.php, mirroring run_report_subscriptions.php) that iterates active tenants, computes due windows, locks, and runs the EXISTING planner core (routeGroup/routeAll/release) for the eligible bookings - selecting eligible bookings, capturing exceptions, writing the run ledger, optionally auto-issuing; (3) Routing Console redesign into the autonomous monitor + exception queue (last/next run, counts, unassigned, warnings, history); (4) safe re-planning rules (do not touch started trips / active manifests), notifications, and the manual-override path with CSRF. Files: app/Core/SettingsRegistry.php; scripts/migrate_route_planner.php (new); app/Services/RoutePlanner/RoutePlannerSchedule.php (new); app/Services/RoutePlanner/RoutePlannerLock.php (new); config.php.
v1.9.106 24 Jun 2026 23:40 Live load-shedding (grid) stage indicator on the NOC monitor. Increment 24. Investigation finding first: the Nightfall arrival-risk engine is ALREADY implemented as Guardian's RiskSurface (computed astronomical darkness via SolarClock, the operator-set load-shedding stage, and the guardian_risk_zones incident-density surface), already wired through NightfallController, the zones view, settings and StrategicSignals - so no module needed building and nothing was duplicated. The real gap was operational VISIBILITY: RiskSurface consumes the grid stage for scoring, but the control room - the people running night routes who most need it - could not see it; it lived only in a setting. Surfaced it live. (1) MonitorController::api() adds summary.grid_stage: when Nightfall is on (guardian.nightfall = 1) it exposes the clamped 0..8 guardian.grid_stage, and null when Nightfall is off so the indicator stays hidden. No new schema, no scoring on the monitor - it only reads the existing setting. (2) The monitor header renders a grid badge (hidden via a class toggle, avoiding the [hidden]-vs-inline-display specificity trap) whose dot uses currentColor so one colour set drives both text and dot. (3) monitor.js updateGrid() shows the badge only at stage >= 1 and colour-grades it - amber (1-3), orange (4-5), red (6-8) - updating on every poll so an operator changing the stage sees it reflected without a reload. This completes the operational visibility of all three Nightfall factors on the monitor: incident zones (overlay, v1.9.102), per-trip lone-passenger exposure (v1.9.105) and now grid state. VERIFICATION: MonitorController.php + monitor/index.php php -l clean; monitor.js node --check clean. grid_stage summary logic unit-tested - off->null, on->clamped 0..8 (high clamps to 8, negative to 0) - 5/5. The badge tested in real headless Chromium - hidden at null and at stage 0, visible and amber at stage 2 with the 'Grid: Stage 2' label, orange at stage 5, red at stage 7, and hidden again when Nightfall goes off - 6/6. HONEST SCOPE: api() reads the live setting; with no DB in the sandbox the summary value is proven via the logic test and the badge via the render test rather than an end-to-end snapshot. A NOTE on the earlier-aborted approach: a redundant grid-state schedule table + service was started and immediately abandoned once RiskSurface was found - no duplicate files were shipped. Files: app/Controllers/MonitorController.php, app/Views/monitor/index.php, public/assets/js/monitor.js, config.php.
v1.9.105 24 Jun 2026 23:24 Guardian lone-passenger route risk surfaced on the live NOC monitor. Increment 23 - completes the operational-surfacing follow-on identified back in v1.9.99 (ShiftGuard seat-risk shipped v1.9.100, the Nightfall precinct overlay v1.9.102; this is the remaining Guardian/route-risk piece). The control room could see WHERE the risk corridors are (precinct overlay) but not WHICH active trips are currently carrying lone-passenger exposure. Added a 'Lone-Pax Risk' KPI tile that flags active trips whose route version has a lone-passenger leg at or above the configured threshold. (1) MonitorController::api() now selects t.route_version_no and, when Guardian is enabled, batch-reads the exposures Guardian ALREADY computed and stored at optimisation time (guardian_route_exposure) for the live (route_id, version_no) set in a single injection-safe parameterised query - no scoring is done on the monitor. Trips whose stored lone_leg_min is at or above guardian.lone_leg_risk_threshold (default 12 minutes, read via Guardian's own config) are tagged guardian_risk = 1 on both the inbound and outbound cards, and a guardian_risk summary counter is set. Fully guarded: Guardian off, the exposure table absent, or any query error simply flags nothing. (2) monitor.js gains the guardianrisk filter predicate (shared by both panels via predicate()) and maps the kGuardianRisk counter from summary.guardian_risk. (3) The monitor view renders the 'Lone-Pax Risk' KPI tile - identical .lm-kpi markup to the existing tiles - only when Guardian::enabled(), so control rooms not using Guardian see no change; clicking it filters both panels to the flagged trips. VERIFICATION: MonitorController.php + monitor/index.php php -l clean; monitor.js node --check clean. Flagging logic unit-tested - threshold boundary inclusive (12>=12 flags, 5<12 does not), null lone_leg ignored, trip with no exposure ignored, counter equals the flagged count, and the (route_id,version_no) pair de-duplication plus the parameterised IN-clause placeholder and flattened-param construction verified injection-safe - 7/7. JS guardianrisk predicate tested - flagged card passes, un-flagged hidden, inert under other filters - 3/3. Tile conditional render verified through the actual lm-kpis fragment - absent when Guardian off, present with #kGuardianRisk when on, and sitting beside Seat Risk when both modules are enabled. Cursor parity with sibling tiles is guaranteed by identical markup (global button rule). HONEST SCOPE: api() runs against the live database; with no DB in the sandbox the exposure join + card tagging are proven via the logic unit test and the structurally-verified parameterised query rather than an end-to-end snapshot - same honesty as the v1.9.100 seat-risk tile. No alerts were added (deliberately, to avoid flooding the alerts panel on every night route); the tile + filter is the surface. Files: app/Controllers/MonitorController.php, public/assets/js/monitor.js, app/Views/monitor/index.php, config.php.
v1.9.104 24 Jun 2026 23:15 Short-TTL per-tenant cache for the strategic dashboard signals. Increment 22. The StrategicSignals tile strip runs on both the executive and the operations (landing) dashboards, and for a tenant with predictive modules enabled each render re-runs the ShiftGuard/Commute/Roster probes. Added a focused, fail-safe cache so repeat dashboard loads do not re-run those probes within a 60-second window. Design (deliberately conservative for a multi-tenant SaaS): the cache is keyed on the SAME client_id the app already uses to select the tenant database, so it inherits the application's existing tenant boundary rather than inventing a new, weaker one - each tenant has its own file (storage/cache/strategic_signals_<id>.json). When no positive tenant id resolves, the cache is BYPASSED entirely (no read, no write) and the caller runs live - a missing tenant key can therefore never serve one tenant's tiles to another, it only ever degrades to a cache miss. Only the aggregate tile array (counts, labels, links) is stored - no per-person data - so it is POPIA-safe at rest. It is a pure optimisation: every filesystem path is guarded, writes are atomic (temp file + rename so readers never see a torn file), and any error - stale, corrupt, unwritable, no tenant - behaves as a miss and runs the probe live, so the cache can never break or stale-lock a dashboard. New Auth::clientId() accessor exposes the bound tenant id; StrategicSignals::collect() is now a thin cache wrapper around the renamed private probe(). The NOC monitor is deliberately NOT cached - it polls its own live endpoint. VERIFICATION: Auth.php + SignalCache.php + StrategicSignals.php php -l clean. SignalCache unit-tested against the real storage path with throwaway tenant ids - 10/10: warm round-trip; tenant isolation (tenant 2 cannot see tenant 1's tiles, each sees only its own); bypass on clientId 0 (writes no file, returns live); 60s staleness expiry; corrupt-file -> miss with no crash; empty tiles cached as a hit; and a payload check confirming no per-person fields are written. collect() wrapper tested end to end - 5/5: bypass runs probe() live and writes nothing; first tenant call warms the file; the warm call provably serves the cached file (verified by poisoning it with a sentinel) rather than re-probing. HONEST SCOPE: the benefit only accrues to tenants who have enabled at least one predictive module (all three default off, so the default config is unaffected and carries zero new cost); cache hit/miss and isolation are proven at the unit level against the real filesystem, but true cross-process multi-tenant behaviour under load is not reproducible in the sandbox - the no-tenant-bypass design is what makes that untestable path structurally safe. Files: app/Core/Auth.php, app/Services/Strategic/SignalCache.php (new), app/Services/Strategic/StrategicSignals.php, storage/cache/.gitkeep (new), config.php.
v1.9.103 24 Jun 2026 23:07 Commute burden surfaced on Roster recommendations. Increment 21 - second net-new feature, tying two strategic modules together. Transport-Aware Rostering proposes whole-vehicle consolidations purely on cost/occupancy; the planner had no view of the human-impact dimension - whether a proposed re-slot moves agents who are already carrying heavy commute burden. Joined the two modules ADDITIVELY: RosterController::board() now builds a commute-burden lookup (employee_id -> band, CBS) from CommuteBurden::assess() and annotates each recommendation with how many of its moved agents are high-burden and their average burden. RosterAdvisor's ranking, savings and the consolidation algorithm are completely untouched - this is decision context for the human, not a change to the optimiser's objective. The join is gated on the Commute module being enabled AND the viewer holding report.view (burden is wellbeing data, normally report-gated), and is fully guarded so a disabled module, an absent table or a query error simply leaves recommendations un-annotated. The roster board renders a chip per recommendation - crimson '<n> high-burden . avg <x>%' when any moved agent is high-burden, grey '0 high-burden' otherwise - shown only when burden data is available for that cohort. Agents not present in the commute assessment (e.g. too few trips to score) are skipped, not counted. VERIFICATION: RosterController.php + roster/board.php php -l clean; the annotation logic unit-tested (2 of 3 agents scored with one unscored skipped; high count and average computed correctly - 0.7/0.2 -> 45%; recommendations whose agents are all unscored, and recommendations with no agents, both correctly marked unavailable) - 4/4; and the chip render-tested in headless Chromium across the high-burden (crimson, '2 high-burden, avg 58%'), no-high (grey '0 high-burden') and unavailable (no chip) cases, with the status chip unaffected - 5/5. HONEST SCOPE: the lookup and counts run live against the database; with no DB in the sandbox the data path is proven via the unit test and the fixtured render. Files: app/Controllers/RosterController.php, app/Views/roster/board.php, config.php.
v1.9.102 24 Jun 2026 23:01 Nightfall risk-precinct overlay on the live NOC map. Increment 20 - first net-new feature beyond the completion/hardening work. The Guardian/Nightfall risk precincts (guardian_risk_zones) were only visible on the Risk Zones admin screen; the control room had no spatial view of where the elevated-risk corridors are. Added a toggleable risk-precinct overlay to the live monitor map. (1) MonitorController::index() now loads the active precincts once at page open via a guarded riskZones() helper (read-only; returns empty when the guardian_risk_zones table is absent) and passes them to the view - they are static config, so they are NOT re-fetched on every poll. (2) The monitor view emits the zones into window.TP_MONITOR.riskZones (name, lat, lng, radius_km, weight as floats) and renders a 'Risk zones' map toggle plus a legend swatch, both only when at least one zone exists. (3) monitor.js builds a GeoJSON polygon per precinct (a 48-point circle ring sized in kilometres, with longitude widened by latitude so the ring stays circular on the ground) and adds three MapLibre layers beneath the vehicle markers - a weight-graduated fill (amber->red via a data-driven interpolate on weight), a dashed outline, and a name label - all hidden until the operator toggles them, mirroring the existing 'Site links' toggle. VERIFICATION: MonitorController.php + monitor/index.php php -l clean; monitor.js node --check clean; the circle-geometry and FeatureCollection builders unit-tested in node - 2 features, 49-point closed rings, north offset matching 2 km exactly (0.01809 deg), longitude offset correctly wider at -34 deg latitude, weight/name carried, and the radius-0 fallback to 0.5 km - 7/7; the riskZones() guard verified (absent table -> 0, present -> rows); and the conditional UI render-checked (toggle + legend present with zones, absent without). HONEST SCOPE: the GeoJSON, the MapLibre layer specs and the toggle wiring are verified, but the actual WebGL paint cannot be exercised headlessly without a tile/GL context - that is the limit of sandbox testing for a map feature and should be eyeballed once on a real monitor. The overlay is read-only, off by default, and invisible to tenants with no risk zones configured. Files: app/Controllers/MonitorController.php, app/Views/monitor/index.php, public/assets/js/monitor.js, config.php.
v1.9.101 24 Jun 2026 22:44 Strategic signals shared + surfaced on the Operations Dashboard. Increment 19. The strategic-signals probe introduced for the executive dashboard (v1.9.99) was a private method on ReportController; the home Operations Dashboard - the landing page operators and managers actually use - had no awareness of the predictive modules. Extracted the probe into a single source of truth and surfaced it on both. (1) New App\Services\Strategic\StrategicSignals::collect() holds the one copy of the probe (ShiftGuard at-risk inbound, Commute high-burden agents, Roster consolidations + Rand saving, Nightfall active risk precincts), each tile gated by its board's view permission and each probe individually try/caught so a disabled module, an absent table or a query error simply omits or zeroes that tile. Commute is now explicitly gated on report.view (previously it relied on the exec dashboard already being report.view-gated) so it is safe to call from any surface. (2) New shared partial app/Views/partials/_strategic_signals.php carries the strip markup and self-contained (un-prefixed) styles, rendering nothing when there are no signals. (3) ReportController now delegates to the service and includes the partial; its three module imports and the private method are removed. (4) DashboardController::index() collects the signals and the operations dashboard includes the same partial directly beneath the operational KPI strip. VERIFICATION: all six touched/new files php -l clean; ReportController no longer references the module services or the old private method; the partial is included by exactly the exec and dashboard views; StrategicSignals::collect() unit-tested directly across four scenarios - all enabled with data (4 tiles, correct metrics, alert flags, R-formatted saving), all disabled (0), report.view denied (Commute omitted, ShiftGuard kept), and probe failure with the risk-zones table absent (that tile omitted, the rest intact) - 10/10; and the shared partial rendered in a generic (non-exec) page in real headless Chromium - 4 tiles, alert headline crimson #c0392b, navy normal headline, border-radius, cursor pointer, underline removed, and the strip correctly absent (zero output) when signals are empty. No behavioural change to the executive dashboard's appearance; the operations dashboard gains the strip only when a module qualifies for the viewer. HONEST SCOPE: tile metrics run live against the production database; with no DB in the sandbox the data path is proven via the contract-level unit test and the degraded/empty render. Files: app/Services/Strategic/StrategicSignals.php (new), app/Views/partials/_strategic_signals.php (new), app/Controllers/ReportController.php, app/Views/reports/executive.php, app/Controllers/DashboardController.php, app/Views/dashboard/index.php, config.php.
v1.9.100 24 Jun 2026 22:29 Seat Risk KPI tile on the NOC monitor. Increment 18 - completing the operational surfacing of ShiftGuard. Context: ShiftGuard at-risk inbound trips were already raised as alerts in the control-room alerts panel by MonitorController::api(), and api() already maintained a summary.seat_risk counter - but unlike every other summary counter it had no KPI tile, and there was no way to filter the board to those trips. Completed the loop: (1) api() now collects the at-risk trip_ids while building its seat-risk alerts and tags the matching inbound cards with seat_risk = 1 (guarded by the existing SeatConfidence::enabled() check and try/catch, so a disabled module or absent feed simply tags nothing); (2) monitor.js gains the trip predicate case (kpiFilter === 'seatrisk' -> show only seat_risk cards) and maps the kSeatRisk counter from summary.seat_risk (defaulting to 0); (3) the monitor view renders a 'Seat Risk' KPI tile - identical .lm-kpi markup to the eight existing tiles - only when ShiftGuard is enabled, so control rooms not using it see no change. Clicking the tile filters the inbound list (and, consistent with the other non-stale KPI filters, focuses off the vehicle markers) to exactly the at-risk trips. VERIFICATION: MonitorController.php and monitor/index.php php -l clean; monitor.js node --check clean; the card-marking logic unit-tested (only the matching inbound card tagged, non-matches untouched, a stray at-risk id not in the inbound set ignored); the filter predicate verified (keeps only seat_risk cards; no-filter keeps all); and the tile render-tested in headless Chromium - present with the #kSeatRisk counter and 'Seat Risk' label when enabled, absent when disabled. Cursor parity with the sibling tiles is guaranteed by identical .lm-kpi markup (no per-tile cursor rule; all inherit the global button rule). No change to the existing alerts, counters, map or any other KPI. HONEST SCOPE: api() runs against the live database; with no DB in the sandbox the tagging and counter are proven via the unit/predicate tests and the enabled/disabled render rather than an end-to-end snapshot. With this, ShiftGuard is surfaced both on the executive dashboard (v1.9.99) and operationally on the NOC monitor. Files: app/Controllers/MonitorController.php, public/assets/js/monitor.js, app/Views/monitor/index.php, config.php.
v1.9.99 24 Jun 2026 22:04 Strategic signals on the Executive Dashboard. Increment 17 - beginning the module-output integration surface (the predictive modules previously lived only on their own pages; nothing surfaced them where leadership looks). Added a guarded 'Strategic signals' strip to the executive dashboard between the performance summary and the trend grid. It renders one headline tile per ENABLED predictive module, each linking to its board: Seat Assurance (ShiftGuard) shows at-risk upcoming inbound trips out of those assessed; Attrition Risk (Commute) shows high-burden agents and average burden; Rostering (Roster) shows available whole-vehicle consolidations and their Rand saving; Risk Zones (Nightfall) shows active risk precincts when any are configured. A new private ReportController::strategicSignals() probes each module through its existing service contract (SeatConfidence::assessUpcoming, CommuteBurden::assess, RosterAdvisor::analyze, and an active-zone count over guardian_risk_zones). Every probe is individually wrapped so a disabled module, an absent table or a query error simply omits or zeroes that tile without affecting the rest of the dashboard, and each tile is additionally gated by its board's own view permission (trip.view / report.view / route.view / settings.view) so no unreachable link is shown. When no module qualifies the strip is omitted entirely, leaving existing dashboards unchanged. VERIFICATION: ReportController.php and executive.php php -l clean; the real strategicSignals() method was exercised by reflection across four scenarios - all enabled with data (4 correctly shaped tiles, alert flag on at-risk, R-formatted saving, average-burden percentage), all disabled (0 tiles), permission denied (tiles omitted), and probe failure with the risk-zones table absent (that tile omitted, the rest unaffected) - 11/11; and the real executive.php rendered the strip in headless Chromium, 8/8 - four tiles, correct headlines, alert styling, cursor pointer, underline removed per the global rules, and the strip correctly absent when signals are empty. No change to KPIs, trends, save handlers or any module. HONEST SCOPE: the tile metrics run live against the production database; with no DB in the sandbox the data path is proven only via the contract-level reflection test and the empty/degraded render. The live NOC monitor integration (operational surfacing of ShiftGuard at-risk trips and Guardian/Nightfall route risk) remains a natural follow-on. Files: app/Controllers/ReportController.php, app/Views/reports/executive.php, config.php.
v1.9.98 24 Jun 2026 21:49 Strategic-module wiring audit + two consistency fixes. Increment 16. With the UI refactor closed out, audited the wiring of the five strategic modules now present in the tree - ShiftGuard (Seat Assurance), Guardian, Nightfall (Risk Zones), Commute (Attrition Risk) and Roster (Rostering). Findings: all five are present and sound - controllers, services, views and migrations all php -l clean, and every migration calls migrate_runner() so it is runnable from CLI or the Ops Console. Routing and navigation are wired: ShiftGuard at /shiftguard, Commute at /commute, Roster at /roster, Nightfall as the /settings/risk-zones precinct manager, and Guardian correctly service-only (the optimiser re-ranker, no route). Two consistency gaps were found and fixed. (1) The Settings hub (settings/index.php) listed cards for Manage, Lookups, Vehicle Config and Booking Governance but omitted Risk Zones, even though it is a settings sub-tool reachable from the left nav; added a Risk Zones accent card gated identically to its nav link (settings.manage) with a graceful active-zone count over guardian_risk_zones that degrades to 0 when the table is absent. (2) The Roster nav link was gated on route.optimise while its board route GET /roster requires only route.view - meaning a route.view user could open the page but never saw the link; aligned the nav gate to route.view, matching the board route and the established ShiftGuard/Commute pattern (nav visibility equals the read-route gate; the write action /roster/decide remains route.optimise). No access was granted that the route did not already permit. VERIFICATION: both changed views php -l clean; the Risk Zones card rendered through the actual settings/index.php in real headless Chromium, 6/6 - card present, titled Risk Zones, describes Nightfall, graceful 0-count with the table absent, cursor pointer and underline removed per the global interaction rules. No behavioural change to any module, save handler or migration. Files: app/Views/settings/index.php, app/Views/layout/app.php, config.php.
v1.9.97 24 Jun 2026 21:32 Outstanding items closed out (consolidation release). Increment 15 - completing the three finishable workstreams of the enterprise refactor program in one pass. (A) SETTINGS INFO-ICONS TO 100 PERCENT: authored genuine, operationally specific meta for the remaining 70 settings, taking coverage from 38/108 to 108/108 with 0 malformed. The new content covers the full ShiftGuard seat-confidence model (priors, bands, buffer steepness, unknown-delivery), the Commute-to-Attrition / Commute Strain Index weights and bands, the Transport-Aware Rostering loop, Guardian and the Nightfall darkness/grid uplifts, the remaining monitor / routing / optimiser / booking / address knobs, SMTP and the two write-only secrets (with value-free examples), the report permission gates and waybill prefix. 22 settings now carry a Risk row and 35 carry depends_on cross-references. Pure data through the unchanged v1.9.91 render path. (B) MODAL OPEN/CLOSE 100 PERCENT ON THE SHARED CONTROLLER: swept every remaining pure open/close inline handler across the app to the declarative controller - 3 static openers to data-modal-open and 11 close buttons to data-modal-close across 7 more views (vehicle_config, _vehicle_config_modals, sos/monitor, employees, vendors, _vendor_import, _employee_import), plus the Upload-first-document anchor and the guarded vendorapp import button. There are now 0 inline dialog onclick handlers anywhere in the app; the only inline close calls left are window.close() and the OPTZ panel object, which are not dialogs. Every modal app-wide - tp-modal, vp-modal and the bespoke vehicle-config / import dialogs - opens and closes through the one controller; no modal styling, markup, form, action, CSRF or enctype was changed. (C) DEAD SWITCH CSS PRUNED: removed the legacy .switch3d and .usr-toggle rule sets (superseded by .tp-switch in v1.9.89, 0 markup references remaining), 2851 bytes, via a brace-aware, comment-aware CSS parser that drops a rule only when its real selector references the dead class. VERIFICATION: all 10 changed PHP files php -l clean; 108/108 meta coverage, 0 malformed, with enum (guardian.mode), float (shiftguard.prior_strength) and secret (smtp.pass) entries rendered through the actual manage.php info-icon fragment in real headless Chromium; 0 residual inline dialog onclick app-wide, with the bespoke vehicle-config Make modal verified opening/closing via the controller with its .vcfg-makemodal styling intact; and the pruned stylesheet validated in Chromium - tp-switch base (inline-flex), checked teal gradient, knob slide and .sm variant all intact, .btn-primary uncorrupted, braces balanced (3852/3852). HONEST SCOPE: all UI wiring, render and CSS changes are verified end to end; the underlying settings save handlers and modal form POST handlers are unchanged (same actions, CSRF, fields, enctype) and were not re-exercised here as the sandbox has no database. Files: app/Core/SettingsRegistry.php, app/Views/settings/vehicle_config.php, app/Views/settings/_vehicle_config_modals.php, app/Views/sos/monitor.php, app/Views/masterdata/employees.php, app/Views/masterdata/vendors.php, app/Views/masterdata/_vendor_import.php, app/Views/masterdata/_employee_import.php, app/Views/vendors/tabs/documents.php, app/Views/vendorapp/mgr.php, public/assets/css/app.css, config.php.
v1.9.96 24 Jun 2026 21:14 Settings info-icon coverage extended (+16). Increment 14 of the enterprise refactor program. Continuing the v1.9.91 info-icon metadata rollout with another batch of genuine, operationally specific 'meta', taking coverage from 22/108 to 38/108. Pure data + content change - the render path (the blue i button and the What it does / Impact / Example / Risk / Depends on / Affects popover) is unchanged. New entries span five clusters: NOC monitoring (monitor.gps_stale_sec, monitor.driver_live_alert, monitor.driver_live_lead_min, monitor.inbound_warn_min, monitor.outbound_warn_min, monitor.post_close_min), utilisation thresholds (optim.low_util_pct, optim.severe_util_pct, optim.cost_per_km), booking windows (booking.max_advance_days, booking.late_auth_sla_hours), address GPS governance (address.require_gps_pin, address.max_gps_accuracy_m, address.auto_approve_gps - the last carrying a Risk row for removing reviewer sign-off), and routing/geocoding endpoints (map.osrm_base_url, map.nominatim_base_url). depends_on is wired across the natural pairs (driver-live alert <-> lead window, low-util <-> target-util, GPS pin <-> accuracy <-> auto-approve). Verified: SettingsRegistry.php php -l clean; all 16 new blocks structurally complete (help+impact+example+affects), 0 malformed; and a representative bool setting (address.auto_approve_gps) rendered through the actual manage.php info-icon fragment in real headless Chromium, 7/7 - button renders, help / Risk / Depends-on (mono) / Affects rows all surface, click toggles the popover open. Cumulative info-icon coverage is now just over a third of the 108-setting catalogue, concentrated on the operationally significant knobs. Files: app/Core/SettingsRegistry.php, config.php.
v1.9.95 24 Jun 2026 21:11 Settings info-icon coverage extended (+14). Increment 13 of the enterprise refactor program. The info-icon metadata mechanism shipped in v1.9.91 (optional 'meta' key per setting -> a blue i button beside the label opening a role=note popover with What it does / Impact / Example / Risk / Depends on / Affects); this increment adds genuine, operationally specific meta to 14 more high-value settings, taking coverage from 8/108 to 22/108. New entries: route.max_duration_min, route.avg_speed_kmh, route.site_radius_km (routing ceilings and planning assumptions), noshow.grace_minutes, driver.stop_proximity_m, driver.face_check_required (trip execution and driver verification), vendor.accept_sla_min, incident.ack_sla_min, sos.response_sla_min, sos.media_retention_days (the SLA and evidence-governance settings, with POPIA/legal-hold framing on retention), optim.merge_radius_km, optim.max_detour_min (clubbing constraints), guardian.lone_leg_risk_threshold (Guardian flag), notify.email_enabled (mail master switch). Every entry has a concrete What it does, Impact and Example; safety/operational ones carry a Risk row; depends_on is wired where there is a real dependency (face check -> validity window, ack SLA -> auto-escalate, merge radius -> max detour, max detour -> route ceiling, lone-leg threshold -> guardian mode, email -> smtp.host). Pure data + content change: no controller, view, CSS or JS was touched - the render path is exactly the v1.9.91 mechanism, which already passed its own tests. Verified: SettingsRegistry.php php -l clean; all 14 new meta blocks structurally complete (help+impact+example+affects present), 0 malformed; and one new entry (route.max_duration_min) rendered through the actual manage.php info-icon fragment in real headless Chromium, 11/11 - button renders, popover hidden initially with aria-expanded=false, all five content rows surface the new text, click toggles open, Escape closes. Remaining settings without meta can be filled the same way, batch by batch. Files: app/Core/SettingsRegistry.php, config.php.
v1.9.94 24 Jun 2026 20:58 Modal open/close harmonised across the Vendor module. Increment 12 of the enterprise refactor program. The vendor tab modals already use the mature .vp-modalform shell (dialog.modal:has(.vp-modalform)), so they are not a styling gap - the only non-standard part was inline onclick open/close. Converted all 30 pure open/close handlers across the 8 vendor tab views (assignments, contacts, fleet, documents, subcontractors, overview, driver_360, drivers) to the shared declarative controller: 4 simple import openers (vehImport, docModal, subImport, drvImport) now use data-modal-open, and 26 X / Cancel buttons now use data-modal-close. The JS-function openers that populate fields for edit (ctOpen, vehOpen, subOpen, drvOpen, asgOpen, addrOpen and the 12 edit/open handlers) were deliberately left untouched - they carry logic beyond opening. No styling, markup structure, form, action, CSRF or enctype was changed; only the open/close mechanism is now uniform with the rest of the app. Verified against the actual shipped fleet import modal (PHP-rendered) in real headless Chromium, 9/9: opens via data-modal-open; is a true :modal; the .vp-modalform styling is intact (dialog padding still 0); multipart enctype preserved; required file input preserved; CSRF preserved; form action preserved; closes via both the X and the Cancel button (data-modal-close). All 8 vendor tab views php -l clean; 0 residual pure open/close onclick; 12 field-populating openers preserved. HONEST SCOPE: the open/close wiring is render-verified and the modal bodies/forms are byte-for-byte unchanged; the import/edit POST handlers are untouched and not exercisable here without a DB. Every modal in the app now opens and closes through the one shared controller. Modules harmonised so far: Sites, Departments, SOS, Schedules (full tp-modal shell) + the Vendor module (declarative open/close on the existing vp-modal shell). Files: app/Views/vendors/tabs/assignments.php, app/Views/vendors/tabs/contacts.php, app/Views/vendors/tabs/fleet.php, app/Views/vendors/tabs/documents.php, app/Views/vendors/tabs/subcontractors.php, app/Views/vendors/tabs/overview.php, app/Views/vendors/tabs/driver_360.php, app/Views/vendors/tabs/drivers.php, config.php.
v1.9.93 24 Jun 2026 20:53 Modal standard adoption: SOS + Schedules. Increment 11 of the enterprise refactor program - continuing the tp-modal rollout. Migrated two more plain dialogs onto the standard shell: the SOS register Raise an incident manually form (sos/register.php #newinc) and the Schedules Import WFM shift schedule upload (schedules/index.php #wfmImport). Both were ad-hoc <dialog class="modal"> with a bare <h2> and inline onclick show/close; they now use the titled tp-modal shell (teal-accent header + subtitle, scrollable body) and the shared declarative controller (data-modal-open on the openers, data-modal-close on the X and Cancel). Each form is fully preserved: the incident form keeps its POST /incidents action, Csrf::field(), category / severity selects and the required summary field; the import form keeps its POST /schedules/import/preview action, enctype=multipart/form-data, the required .csv file input, the template download link and Csrf::field(). Native <dialog> supplies role=dialog, aria-modal, focus trap and ESC; aria-labelledby is wired to each title. Verified against the actual shipped markup (PHP-rendered with stubs) in real headless Chromium, 13/13: newinc opens via data-modal-open, is a true :modal, aria-labelledby resolves, CSRF preserved, /incidents action preserved, required summary field present, closes via X and via Cancel; wfmImport opens, multipart enctype preserved, the required file input preserved, CSRF preserved, closes via X. Both views php -l clean; no residual newinc/wfmImport inline onclick. HONEST SCOPE: the shells, open/close and form wiring are render-verified; both POST handlers (incident create, schedule import preview) are unchanged - same actions, CSRF, fields, enctype - with their existing server-side validation / permission / audit, but cannot be exercised here without a DB. Running total of modules on the standard: Sites Import, Departments (catModal + deptHelp), SOS new-incident, Schedules import. Files: app/Views/sos/register.php, app/Views/schedules/index.php, config.php.
v1.9.92 24 Jun 2026 20:47 Modal standard adoption: Departments. Increment 10 of the enterprise refactor program - rolling the v1.9.90 tp-modal standard out to a real module. Migrated both plain dialogs on the Departments screen onto the standard shell: the Add Category modal (a genuine add form) and the How Departments Work help modal. Both were previously ad-hoc <dialog class="modal"> blocks with bare modal-head markup and inline onclick show/close; they now use the titled tp-modal shell (brand-teal accent header with subtitle, scrollable body, footer) and the shared declarative controller (data-modal-open on the openers, data-modal-close on the X / Cancel / Got it buttons), with the help modal opted into backdrop-click dismiss. The Add Category form is fully preserved - same POST action (/departments/category), same Csrf::field(), same code/name/colour fields - and its submit now lives in the standard footer wired to the form via form="catCreateForm". Native <dialog> continues to supply role=dialog, aria-modal, focus trapping and ESC; aria-labelledby is wired to each title. The separate Department side-panel editor and its deptEdit/deptReset JS were untouched (they target a different panel). Verified end to end against the actual shipped markup (PHP-rendered with stubs) in real headless Chromium, 12/12: catModal opens via data-modal-open; is a true :modal (focus trap + aria-modal); aria-labelledby resolves; CSRF field preserved in the form; footer submit targets the form id; form action preserved; all 8 colour options preserved; closes via the X; deptHelp opens, carries the backdrop-dismiss opt-in, closes on backdrop click and on Got it. departments.php php -l clean; no residual catModal/deptHelp inline onclick. HONEST SCOPE: the modal shell, open/close and form wiring are render-verified; the category create POST itself is unchanged (same action + CSRF + fields) and its server-side handler/permission/audit are as before, but cannot be exercised here without a DB. This is the second reference adoption (after Sites Import); remaining modules adopt the standard the same way, module by module. Files: app/Views/masterdata/departments.php, config.php.
v1.9.91 24 Jun 2026 20:38 Settings info-icon metadata. Increment 9 of the enterprise refactor program (UI layer) - the last UI item in the brief. Each setting can now carry an optional 'meta' block in SettingsRegistry (help / impact / example / risk / depends_on / affects); when present, the Manage Settings screen shows a small blue i beside the label that opens an accessible popover laying out those fields. Mechanism: 'meta' is just another key on the registry definition, so it flows through the existing controller untouched ($rows[$key] = $m + [...]) - no controller change. The icon and panel render only when meta exists, so settings without it are unchanged. Populated a genuine, non-filler representative set of 8 settings across 4 categories with real operational guidance: app.poll_interval_sec, booking.cutoff_minutes, optim.target_util_pct, address.max_requests, address.window_days, address.cooldown_days, security.session_timeout_min and security.password_min_length (the address and security pairs cross-reference each other via depends_on). The popover is an accessible disclosure: a real <button> with aria-expanded and aria-label, a role=note panel toggled via the hidden attribute, only one open at a time, closing on Escape or an outside click; the i is brand-blue with a teal focus-visible ring (consistent with the v1.9.88 focus work) and the Risk row is crimson. Verified across two headless Chromium runs, 11 behaviours: i-button renders, panel hidden by default, click opens (aria-expanded=true), all sections render in order (What it does / Impact / Example / Risk / Depends on / Affects), Risk label crimson, opening another panel closes the first, depends_on + affects shown when present, Escape closes, outside click closes. Registry + view php -l clean; meta loads correctly for all 8 (verified via SettingsRegistry::all()); app.css brace-balanced (3882/3882). HONEST SCOPE: the icon/popover behaviour and CSS are fully render-tested and the 8 seeded settings carry real content; the remaining settings simply have no i until meta is added to them (a content task, done incrementally). This completes the brief's UI-layer items (focus redesign, unified switch, modal standard, settings info-icons). Files: app/Core/SettingsRegistry.php, app/Views/settings/manage.php, public/assets/css/app.css, config.php.
v1.9.90 24 Jun 2026 20:26 Standardised modal foundation. Increment 8 of the enterprise refactor program (UI layer). Delivered a single reusable modal standard so add / edit forms are consistent, titled, responsive, accessible and CSRF-ready, replacing the ad-hoc mix of inline-onclick dialogs and bespoke side panels. Three parts: (1) CSS - a .tp-modal shell on the native <dialog class="modal">: titled header with a brand-teal accent bar, optional subtitle, scrollable body, optional footer, lg/sm sizes and a mobile breakpoint; padding handled by the sections so the shell is clean. (2) Helpers (app/Core/helpers.php) - tp_modal_open(id, title, opts) / tp_modal_close(opts) echo the standard shell; opts support subtitle, size (lg/sm), dismiss (backdrop) and a footer slot; the header wires aria-labelledby to the title and the close button is declarative; the caller puts the form + Csrf::field() in the body. (3) JS (app.js) - one delegated controller wiring data-modal-open="id" (opens via showModal), data-modal-close (closes the enclosing dialog) and opt-in data-modal-dismiss (backdrop-click close); native <dialog> supplies role=dialog, aria-modal, focus trapping and ESC, so those come for free and existing inline showModal()/close() modals keep working unchanged (the controller is purely additive). Reference adoption: the Sites > Import dialog migrated to the standard (declarative open/close, backdrop dismiss, titled shell) as the first consumer. Verified end to end: helper output 8/8 (tp-modal + size class, dismiss attr, aria-labelledby + title, subtitle, declarative close, body+footer structure, footer submit targets the body form, no dismiss/footer when not requested); real headless Chromium 11/11 (starts closed; opens via data-modal-open; reports :modal so focus is trapped and aria-modal set; aria-labelledby resolves; focus moves inside; CSRF field present; closes via data-modal-close; ESC closes; backdrop click closes). app.js node --check clean; helpers + sites view php -l clean; app.css brace-balanced (3868/3868). HONEST SCOPE: this ships the standard component + behaviour and one reference adoption, fully render-tested; migrating each module's add/edit form into a tp_modal (with its existing per-controller CSRF/permission/audit already enforced server-side) is the follow-on adoption work, done module-by-module so nothing breaks. Next: Settings info-icon metadata (the blue i with help/impact/example/risk in SettingsRegistry). Files: app/Core/helpers.php, public/assets/css/app.css, public/assets/js/app.js, app/Views/sites/index.php, config.php.
v1.9.89 24 Jun 2026 20:18 Unified On/Off switch (.tp-switch). Increment 7 of the enterprise refactor program (UI layer). Introduced one canonical switch component replacing the divergent legacy toggles. .tp-switch is a checkbox-backed switch that shares the Routing Console .rc-sw / mobile .mb-switch design language: a pill track with a sliding knob, smooth transitions, brand-teal (#07B49B) when on, a teal focus-visible ring (consistent with the v1.9.88 focus redesign), an .sm compact size and a --alert (crimson) variant for danger/at-risk toggles. Accessibility is native: the input carries role=switch so the browser keeps aria-checked in sync with :checked and provides keyboard, focus and disabled handling for free, and the label gives a pointer cursor (not-allowed when disabled). Migrated every divergent usage to it: settings/manage.php (the per-setting On/Off toggle, previously the crimson .switch3d), users/index.php (MFA-required toggle and the per-module .sm toggles, previously the blue .usr-toggle), and routing/queue.php (the Show Radius / Show Legend map-overlay toggles, plus the related closest('.switch3d') selector updated to .tp-switch). mobile/sos.php was intentionally left as-is: its .mb-toggle row already uses .mb-switch, which is the same reference family as .rc-sw, so it is already consistent. Verified in a real headless Chromium render, 8/8: OFF knob at rest with a plain track; ON knob slides +20px with the teal gradient; SM knob slides +16px; DISABLED label cursor is not-allowed; ALERT shows the crimson gradient; role=switch present with the native checked state reflected. All three migrated views php -l clean; no residual switch3d / usr-toggle classes remain in them; app.css brace-balanced (3850/3850). The legacy .switch3d / .usr-toggle CSS rules were left in place as harmless dead styles (no remaining markup references them) to avoid any risk from markup that might be injected elsewhere; they can be pruned in a later housekeeping pass. HONEST SCOPE: the switch is fully render-verified; the broader settings / users / routing pages were not re-rendered end to end (heavy data graphs) but the migrated markup is php -l clean and structurally confirmed. Next: view/add/edit modal standardisation, then Settings info-icon metadata. Files: public/assets/css/app.css, app/Views/settings/manage.php, app/Views/users/index.php, app/Views/routing/queue.php, config.php.
v1.9.88 24 Jun 2026 20:04 Global form focus redesign. Increment 6 of the enterprise refactor program (UI layer). Replaced the harsh blue focus treatment on form fields with a calm, on-brand teal one across the app, matching the already-shipped New Booking pattern. In app.css the shared --focus glow token was retuned from blue rgba(29,91,214,.28) to a soft teal rgba(7,180,155,.25), and brand --teal (#07B49B) + --teal-wash tokens were added (previously undefined in app.css); because most focus rules reference var(--focus), the softer glow propagates app-wide in one change. The core field rule now reads input/select/textarea:focus -> teal border + soft teal glow (textarea added so multi-line fields match), and three further blue focus borders were switched to teal (.site-row / .usr-row focus-visible outlines, .vc2-search input, which also gains the glow). The base input/select rule already carries transition:border-color/box-shadow .12s, so focus animates smoothly. The support widget styles were brought in line too: support-admin.css (.spa-fg fields) and support.css (.tps-fab focus ring, .tps-input and .tps-fl textarea/select focus) now use literal teal values so they hold regardless of which root stylesheet is loaded. Accessibility preserved: global.css keyboard :focus-visible keeps its 2px solid #07B49B outline with offset, and the global :focus-visible glow now reads teal - keyboard focus stays clearly visible. The intentional crimson accent outlines on tabs / segmented controls / interactive rows (Probis accent) were deliberately left unchanged; only the harsh blue form focus was targeted, and --blue itself (link colour, badges) was not touched. Verified: zero harsh-blue focus rules remain across all stylesheets; brace balance OK in all three edited files (app 3829/3829, support-admin 69/69, support 90/90); new teal tokens and the input/select/textarea:focus rule present; smooth transition retained; keyboard teal outline intact; driver/mobile/vendor stylesheets confirmed already free of blue field focus. HONEST SCOPE: CSS-only change verified structurally (token swap, selector coverage, brace balance, a11y outline) rather than pixel-rendered; the change is a surgical blue->teal focus swap. Next: the unified .tp-switch toggle, then view/add/edit modal standardisation and Settings info-icon metadata. Files: public/assets/css/app.css, public/assets/css/support-admin.css, public/assets/css/support.css, config.php.
v1.9.87 24 Jun 2026 19:56 Routing Console occupancy slider. Increment 5 of the enterprise refactor program. A server-side Max Occupancy filter on the Issued / Active Plans table: range 1-100%, console default 50%, keeping only plans whose seat occupancy (passenger_count / suggested_capacity * 100, divide-by-zero safe) is at or below the chosen level; at 100% the filter is inert and every plan shows (including any over-subscribed ones). Implemented in RoutingController::filters() so it applies uniformly to the active-plans COUNT, the paginated LIST and the console map (which derives from the page) - the demand queue uses a separate bookingFilters() and is untouched, plan() does not use filters() so manual planning is unaffected, and Export Trip Sheets filters by the explicit selected route IDs (the occupancy-filtered rows on screen) so the export already matches the view. The value is captured via int_param (default 50), clamped to 1..100. queue.php: a branded range slider in the filter deck with a live percentage readout (DM Mono chip, teal) and helper text; the value is threaded through the shared query-string helper so it PERSISTS across pagination, per-page changes and Apply (omitted at 100% to keep URLs clean); Reset (?date=) returns to the 50% default; the active-filter badge counts occupancy when explicitly applied below 100; each plan row now carries a data-util attribute and a data-low flag, with an explicit LOW badge and a soft row tint when occupancy < 50%. app.css: brand-styled slider (teal->navy track, navy thumb with focus-visible ring, pointer cursor) and the LOW badge / low-row tint. Verified: filters() 9/9 (default 50; clause present with suggested_capacity > 0 and the correct ROUND formula; explicit 30; inert at 100; clamps 500->100, 0->1, -5->1; param order matches clause order with occupancy appended last); view markers all present (slider input, live readout, f-bound value, qs occupancy, data-util, LOW badge, badge counter); CSS slider + LOW rules present and brace-balanced; qs persistence 3/3 (30 carried across pages, default 50 carried, 100 omitted). All PHP php -l clean. HONEST SCOPE: no DB driver in sandbox, so the filters() SQL clause is structurally verified and parameterised (injection-safe) and the full view was not end-to-end rendered (its whole data graph is heavy); filters() logic, the qs persistence expression and all markup markers ARE tested. Next: global focus-state CSS, then the unified .tp-switch, modal standardisation and Settings info-icons. Files: app/Controllers/RoutingController.php, app/Views/routing/queue.php, public/assets/css/app.css, config.php.
v1.9.86 24 Jun 2026 19:47 Cost & occupancy reports off the costing snapshot. Increment 4 of the enterprise refactor program. Four new reports added to the existing report engine (registry-driven, so they inherit the runner, CSV/XLSX export, entity deep-links, accurate full-set totals, column chooser, saved views and scheduled subscriptions for free), all under the existing Financial & Cost category (permission-gated via report.perm_financial), all ZAR, all based on trip_costing_snapshots: (1) Trip Costing (Completed/Closed) - per-trip detail: pricing model, capacity, transported, no-shows, empty seats, occupancy %, expected amount, cost per transported passenger, standard cost per available seat, empty-seat cost; deep-links trip_ref->/trips and route_ref->/routes; filters vendor/pricing_model/site; summary trips/total expected/passengers/avg occupancy/empty-seat cost. (2) Low-Occupancy Trips & Capacity Leakage - closed trips ranked by lowest occupancy first with empty seats and empty-seat cost (clubbing/leakage surfacing). (3) Vendor Cost Performance - per-vendor spend, trips, avg occupancy, blended cost per passenger and empty-seat cost (GROUP BY vendor). (4) Pricing Model Outcomes - the same economics grouped by pricing model (fixed_trip vs per_passenger vs per_km vs hybrid). Reports 3-4 use build closures with SQL GROUP BY; cost-per-passenger is blended as SUM(spend)/NULLIF(SUM(transported),0). All four are flagged guarded => true so they degrade to empty rows (never fatal) until the trip_costing migration runs and snapshots exist - matching the Guardian register pattern; filter option lookups are already try/caught by the runner. Verified: 16/16 - registration (4 under financial), Trip Costing detail (costing columns, _trip_id hidden from display yet present on the row for deep-linking, ZAR money columns, summary incl. Total expected R 850.00), low-occupancy capacity-leakage summary, vendor rollup (rows + columns, total spend R 7,000.00), pricing-model rollup, and 4/4 graceful degradation when the snapshot table is absent. ReportRegistry php -l clean. HONEST SCOPE: no DB driver in sandbox, so the report SQL is structurally exercised through the runner with stubbed rows (shape, columns, summaries, ZAR formatting, aggregation, degradation all tested) but not engine-executed; the queries are standard joins/GROUP BY on the snapshot table. Next: Routing Console Occupancy % slider (server-side), then the global UI items (focus CSS, unified switch, modal standardisation, Settings info-icons). Files: app/Services/Reporting/ReportRegistry.php, config.php.
v1.9.85 24 Jun 2026 19:37 Waybill costing integration. Increment 3 of the enterprise refactor program. The trip costing snapshot is now the single source of truth for the waybill amount and surfaces the full cost breakdown on the waybill views, with the min_charge / included_km rate-card refinements switched on in lockstep so invoice reconciliation stays exact. TripCostingService::compute() now applies the included_km allowance (per_km nets off: base + unit*max(0,distance-included)) and the min_charge floor (every model); with the migration defaults (included_km=0, min_charge=0) the price is byte-identical to before, so unconfigured rate cards are unaffected. TripController::generateWaybill() now READS expected_amount from the costing snapshot created at closure (single source of truth - waybill and snapshot can no longer diverge); if no snapshot exists it falls back to the costing engine directly (TripCostingService::compute) using the rate card in force on the service date and the route's service_line (previously hard-coded 'staff'), so the same min_charge/included_km logic applies everywhere. WaybillController show()/printView() load the snapshot via a graceful costingFor() (degrades to null when the table is absent); the index list LEFT JOINs the snapshot ONLY when the table exists (probe-guarded) for an Occupancy column with a low-occupancy highlight. waybills/show.php gains a Trip Costing card (pricing model + rate-card version, vehicle capacity, transported, empty seats, occupancy, expected amount, cost per transported pax, standard cost per seat, empty-seat cost, and passenger-recoverable/company-subsidy when a share is configured); waybills/print.php gains a matching costing summary block. CURRENCY FIX: replaced the stray rupee symbol with Rand on the waybill Expected Amount tile and across waybills/index.php (4) and billing/ratecards.php (1) - all monetary values are now ZAR. Verified: compute() allowances 6/6 (included_km nets off, distance-below-allowance -> base only, min_charge floors up, above-floor unchanged, defaults 0/0 -> identical R350, brief examples still hold); print render 3/3 (costing model + rate-card version, ZAR breakdown with no rupee, degrades cleanly without a snapshot); show costing card + index occupancy present; no rupee remains in any view; all PHP php -l clean. HONEST SCOPE: no DB driver in sandbox, so generateWaybill/show/index SQL are php -l clean and built on existing patterns but not engine-executed here; the compute() engine + the print/degradation rendering ARE tested. Existing waybills keep their stored amounts; only new closures use the snapshot-driven figure. Next: cost/occupancy reports off the snapshot table, then the Routing Console Occupancy % slider. Files: app/Services/TripCostingService.php, app/Controllers/TripController.php, app/Controllers/WaybillController.php, app/Views/waybills/show.php, app/Views/waybills/print.php, app/Views/waybills/index.php, app/Views/billing/ratecards.php, config.php.
v1.9.84 24 Jun 2026 19:29 Trip-costing engine. Increment 2 of the enterprise refactor program. On trip closure the system now computes and stores an auditable per-trip costing snapshot from live data (trip, route, vehicle, manifest, effective rate card). New app/Services/TripCostingService: a PURE compute() holding all four pricing models, exhaustively unit-tested (24/24): fixed_trip (vendor price = fixed amount), per_passenger (base + unit*transported), per_km (base + unit*distance), hybrid (documented + audit-flagged fallback - distance-based when a unit rate exists, else flat). For every model it derives, with divide-by-zero safety: occupancy %, empty seats, ACTUAL cost per transported passenger (price/transported - burdened head cost) and STANDARD cost per available seat (price/CAPACITY - the seat rate), plus empty-seat cost valued separately at the seat rate. Per the brief, unused seats are recognised as empty-seat cost and are NEVER loaded onto each transported passenger's seat price (standard seat = price/capacity, not price/transported - verified). Passenger-recoverable and company-subsidy amounts are split from a settings-driven cost.passenger_share_pct (default 0, no fabrication). snapshot() loads the trip/route/vehicle/manifest, resolves capacity (actual vehicle seats -> vehicle-type default -> route suggested -> booked) and the rate card effective on the route date (mirroring generateWaybill's resolution, by route.service_line), runs compute(), and idempotently upserts (UNIQUE on trip_id) into the new trip_costing_snapshots table with a full calculation_json + audit. RECONCILIATION-SAFE: vendor_trip_price/expected_amount uses the SAME formula as TripController::generateWaybill() so the snapshot and waybill never disagree; min_charge/included_km are recorded in calculation_json but NOT yet applied to price - they will be switched on in lockstep with the waybill upgrade (next increment) so invoice reconciliation stays exact. Wired into BOTH closure paths (close() outbound + arrive() inbound), non-blocking (try/catch - a costing failure logs but never blocks closure or waybill; the snapshot is idempotent and backfillable). New idempotent migration scripts/migrate_trip_costing.php (+ client_schema.sql) with indexes on trip(unique)/route/vendor/calculated_at/occupancy_pct/pricing_model. New scripts/backfill_trip_costing.php costs already-closed trips lacking a snapshot (idempotent). Verified: compute() 24/24 incl. every brief worked example (R500/10/8 -> R62.50 head, R50 seat, R100 empty) and all four models, divide-by-zero, share split, model normalisation, reconciliation parity; upsertSql idempotent shape (24 cols, trip_id/created_at excluded from UPDATE); all PHP php -l clean; snapshot wired before generateWaybill in both paths. HONEST SCOPE: no DB driver in sandbox, so snapshot()/migration/backfill SQL are php -l clean + built on existing patterns but not engine-executed here; the entire pricing engine (compute) IS exhaustively unit-tested. >> DEPLOYMENT: run php scripts/migrate_trip_costing.php, then optionally php scripts/backfill_trip_costing.php to cost history. Next: waybill costing integration (surface occupancy/empty-seat/cost-per-passenger + switch on min_charge/included_km in lockstep), then cost/occupancy reports off the snapshot table. Files: app/Services/TripCostingService.php (new), scripts/migrate_trip_costing.php (new), scripts/backfill_trip_costing.php (new), app/Controllers/TripController.php, database/client_schema.sql, config.php.
v1.9.83 24 Jun 2026 04:24 Trip-completion correctness sweep (closed vs completed). Increment 1 of the enterprise refactor program. The trip lifecycle closes trips with status='closed' (TripController::close() and arrive() both set 'closed'), but reporting counted only status='completed' - so completed trips, passengers transported and every derived figure (cost per passenger, occupancy, no-show impact) were under-reported. Corrected every trip-completion predicate to IN ('closed','completed') for backwards compatibility: ReportRunner::executiveKpis (Trips completed + Passengers transported, the latter now also tied to closed/completed trips), ReportRunner::executiveTrends (completed series), ExecRollups::daySql (computeDay completed + pax snapshots - so materialised rollups agree with live), the Trip Register report summary in ReportRegistry (Completed count), and IncidentController's average-completion-time metric. The bookings report 'Completed' card is intentionally left on booking status 'completed' (bookings complete; they do not close). Verified: all four files php -l clean; ExecRollups read-back parity intact (computeDay still returns 8 metrics, KPI/trend shapes unchanged) with the corrected predicates confirmed present; runner carries 3 corrected predicates (completed-kpi, completed-trend, pax-kpi); no stale t.status="completed" remains in reporting/incident code. No DB driver in sandbox so SQL is verified structurally, not engine-executed; the predicate change is standard SQL. This is the foundation the trip-costing engine and cost-per-passenger reports build on. Next increments (sequenced): trip-costing engine (TripCostingService + trip_costing_snapshots migration, all four pricing models) and closure wiring; waybill costing integration; cost/occupancy reports; Routing Console Occupancy % slider (server-side); global focus-state CSS; unified .tp-switch; view/add/edit modal standardisation; Settings info-icon metadata. Files: app/Services/Reporting/ReportRunner.php, app/Services/Reporting/ExecRollups.php, app/Services/Reporting/ReportRegistry.php, app/Controllers/IncidentController.php, config.php.
v1.9.82 24 Jun 2026 04:15 Reports enhancements - Batch 4b of 4 (Scale: materialised executive rollups). Completes Batch 4 and the reports enhancement program. The executive dashboard's KPIs and trends are all additive daily metrics, so instead of scanning raw tables (bookings/trips/waybills/incidents/no-shows) across a wide range on every view, we precompute one snapshot row per day and read those back. New tenant table report_exec_rollups (day PK + 8 metric columns: bookings, completed, cancelled, noshows, pax, incidents, critical, waybill_value) - idempotent migration scripts/migrate_exec_rollups.php + added to client_schema.sql. New ExecRollups service: computeDay() (per-day source queries mirroring executiveKpis), upsertSql()/upsert() (INSERT ... ON DUPLICATE KEY UPDATE, idempotent re-materialise), expectedDays()/covers() (range fully covered?), and read-back that reproduces the EXACT live shape - kpisFromRollups() returns the same 8 KPI cards (labels/tones/ZAR formatting) as ReportRunner::executiveKpis(), and trendsFromRollups() returns the same {period,v} series with period labels (daily/weekly ISO year-week/monthly/quarterly/annual) matching ReportRunner::bucketExpr(). New cron worker scripts/run_exec_rollups.php materialises a rolling window ending today (default 35 days, CLI override) per tenant, upserting each day - today's row is refreshed every run (<=1 cron interval stale) and past days catch late-arriving data within the backfill window. Controller executive() now PREFERS rollups when ready AND the range is fully covered, else falls back to LIVE (correctness over speed - a single missing day sends the whole range live); the chosen source is audited and a subtle 'fast' badge shows on the dashboard when served from rollups. Verified: ExecRollups all green - upsertSql idempotent shape + value binding order; computeDay runs all 8 source queries; expectedDays/covers fallback decision (full->rollups, one day short->live, table absent->live); kpisFromRollups parity (same 8 labels/order, count + ZAR formatting, tone rules); periodLabel parity across all five grains incl. ISO week and quarter; trendsFromRollups grouping/summing into the live series shape; exec view 4/4 (fast badge only when source=rollups, KPIs render from rollup data, no badge live). (Five initial 'failures' were harness artifacts: 9 VALUES() refs not 8 incl. computed_at, a stub COALESCE branch collision, and float-vs-int strict comparison on summed series - production code re-confirmed correct.) HONEST SCOPE: no DB driver in our sandbox, so the cron worker and the per-day source SQL are php -l clean and built on the existing Database patterns but are not engine-executed here; the read-back shape parity, coverage logic and period labels ARE unit-tested. >> DEPLOYMENT: run php scripts/migrate_exec_rollups.php, then schedule the worker in cron (hourly): 0 * * * * php /var/www/tripi.work/scripts/run_exec_rollups.php. Until the first run, the dashboard simply serves live. This closes the reports enhancement program (Batches 1-4). Files: app/Services/Reporting/ExecRollups.php (new), scripts/migrate_exec_rollups.php (new), scripts/run_exec_rollups.php (new), app/Controllers/ReportController.php, app/Views/reports/executive.php, database/client_schema.sql, config.php.
v1.9.81 24 Jun 2026 04:08 Reports enhancements - Batch 4a of 4 (Scale: accurate full-set totals + entity deep links). Batch 4 is split: this ships the two user-facing scale wins; materialised executive rollups (4b) follow as their own tested step (table + cron, infra). (1) ACCURATE TOTALS BEYOND THE DISPLAY CAP: on-screen reports cap at 5,000 rows, so the old row count and money figures only reflected the displayed sample. ReportRunner now computes the true COUNT and money SUMs over the FULL filtered set via a COUNT(*)+SUM aggregate that wraps the report's base query as a derived table (new pure aggregateSql() + totals()); the runner result carries total_rows + money_totals. The aggregate only fires when a result is actually capped - uncapped runs total from the rows already in hand (sumMoney), so there's no extra query in the common case. The runner now also strips hidden helper columns (any alias starting with '_') from the display column list while keeping them on each row. The grid header shows the true total ('4,821 rows' not '5,000+'), and when capped a banner reads 'Showing first 5,000 - totals reflect all 4,821; export for the complete set' with the full money totals shown as chips. (2) ENTITY DEEP LINKS: report rows now drill through to the entity. New ReportLinks service maps a display column (booking_ref/route_ref/route/trip_ref/waybill_no/incident_ref) to its detail route using a hidden id column carried on the row; a link is only rendered when the row has the id AND the viewer holds the target route's permission (booking.view/route.view/etc.), so we never link to a page the user would be denied. The relevant report selects gained hidden id columns (b.id AS _booking_id, r.id AS _route_id, t.id AS _trip_id, w.id AS _waybill_id, i.id AS _incident_id) - and because the runner filters '_'-prefixed aliases out of the display column list, these never appear in the grid or in CSV/XLSX exports. Links honour the global no-underline + hand-pointer policy. Verified: ReportRunner 18/18 - aggregateSql structure (COUNT + one SUM per valid money alias, trailing ORDER BY stripped, base wrapped as derived table with placeholders passed through unchanged) and injection-safety (money aliases validated against a strict pattern; a 'bad; DROP' alias is rejected); run() hides '_' columns yet keeps the id on the row, attaches total_rows from the aggregate when capped and from the rows when not (aggregate NOT issued uncapped); sumMoney ignores non-numerics; ReportLinks permission-gated (no link without id / for unmapped column / for non-numeric id / without permission). View 8/8 - cells linkify to /bookings/42 etc., hidden id never rendered, denied permission falls back to plain text, capped banner + ZAR money totals, uncapped shows a plain count; full regression (batches 1/2/3 features) intact and coexisting with deep links. HONEST SCOPE: there is no DB driver in our sandbox, so the aggregate SQL is verified structurally + for injection-safety but is not engine-executed here; it is standard COUNT/SUM over a derived table and runs on MySQL/MariaDB. Remaining: B4b materialised executive rollups (precompute exec KPIs/trends into a table on a schedule, read with live fallback). Files: app/Services/Reporting/ReportRunner.php, app/Services/Reporting/ReportLinks.php (new), app/Services/Reporting/ReportRegistry.php, app/Views/reports/index.php, config.php.
v1.9.80 24 Jun 2026 03:52 Reports enhancements - Batch 3b of 4 (Scheduled email subscriptions). Completes Batch 3. Users can schedule any report to be exported and emailed on a cadence, straight from the runner. A subscription stores the report's filters/sort/comparison plus a RELATIVE coverage window (so a weekly email always covers 'the last week', never a frozen range), a cadence (daily/weekly/monthly + send hour, UTC), recipients and a format (CSV or XLSX). New tenant table report_subscriptions (idempotent migration scripts/migrate_report_subscriptions.php + added to client_schema.sql). New ReportSubscriptions service with the scheduling math: computeNextRun() (daily/weekly/monthly, strictly-after, hour/day-of-week/day-of-month, month + year rollover, dom clamped to 28), dateWindow() (yesterday/last_7/last_30/prev_month/mtd/ytd), parseRecipients() (split+validate+unique), clean()/runPlan(), CRUD + due() + owner-only toggle/delete. Mailer gained attachment support: a new pure buildData() builds the SMTP DATA payload - text/plain when no attachments (legacy shape preserved) or multipart/mixed with base64 parts - and sendWithAttachments(); the no-attachment path is byte-equivalent to before, so existing auth emails are unaffected. New cron worker scripts/run_report_subscriptions.php iterates active tenants, finds due subscriptions, runs the report uncapped for the resolved window, exports it and emails the file to each recipient, then stamps the next run; one bad subscription is logged and rescheduled so it never wedges the run. New routes (CSRF-verified) POST /reports/subscriptions/{save,delete,toggle}; controller actions with report-access checks + audit; runner panel to create/pause/resume/delete subscriptions (degrades gracefully - hidden until the migration runs). Verified: Mailer buildData 12/12 (multipart structure + base64 attachment round-trips to original bytes; text-only preserved); ReportSubscriptions all green - schedule math across daily/weekly/monthly boundaries incl. year rollover and dom clamp, all six windows, recipient parsing, clean/runPlan, create/list/due/toggle/delete (caught + fixed a real frequency-default bug); view 17/17 (panel, per-sub toggle/delete + CSRF, coverage-window/frequency/day/hour/format/recipients form, current-filter capture, freq->day toggle JS, hidden when not ready, no window for nodate registers, batches 1/2/3a regression intact). HONEST SCOPE: the cron worker + actual SMTP send cannot run in our DB/SMTP-less sandbox - they are php -l clean and built on the existing Database/Queue/Mailer patterns; the MIME assembly and all scheduling logic ARE unit-tested. >> DEPLOYMENT: run php scripts/migrate_report_subscriptions.php, then schedule the worker in cron, e.g. hourly: 0 * * * * php /var/www/tripi.work/scripts/run_report_subscriptions.php. Remaining: B4 SQL-side aggregation (lifts the display cap) + materialised exec rollups + entity deep links. Files: scripts/migrate_report_subscriptions.php (new), scripts/run_report_subscriptions.php (new), app/Services/Reporting/ReportSubscriptions.php (new), app/Core/Mailer.php, app/Core/Router.php, app/Controllers/ReportController.php, app/Views/reports/index.php, database/client_schema.sql, config.php.
v1.9.79 24 Jun 2026 03:39 Reports enhancements - Batch 3a of 4 (Saved Views). Batch 3 is split: this ships saved views; the scheduled/emailed subscriptions half (3b) follows as its own tested round because it needs the job queue + a mail path wired in. Saved views: users can name and re-open a report's exact slice - its filters (incl. multi-value), date range, comparison flag and sort - from a panel on the runner. Views are personal by default or can be shared with the whole tenant (shown with a star). New tenant table report_saved_views (idempotent migration scripts/migrate_report_saved_views.php + added to client_schema.sql for fresh installs). New SavedViews service: ready() probe for graceful degradation (panel simply stays hidden until the migration runs), forUser() (own + shared, per report), create(), owner-only delete(), clean() (whitelists persisted params), applyParams() (rebuilds the runnable query). New routes POST /reports/views/save and /reports/views/delete (CSRF-verified by the router). Controller saveView()/deleteView() with report-access checks + audit; the save form captures the current applied state as hidden inputs (f[key][] for multi-value filters, from/to, compare, sort/dir) so 'Save current view' snapshots exactly what is on screen. Verified: SavedViews 14/14 (clean whitelist, applyParams, degradation, create/list/delete, per-report+per-owner scoping, owner-only delete); view all green (panel + CSRF token, apply links rebuilt from stored params, owner-only delete control, hidden-param capture, hidden when not ready; batches 1+2 regression intact). The migration DDL is standard idempotent CREATE TABLE IF NOT EXISTS - not runnable in our DB-less sandbox, but php -l clean and pattern-matched to existing migrations. Remaining: B3b scheduled/emailed report subscriptions (job queue + mail); B4 SQL-side aggregation (lifts the display cap) + materialised exec rollups + entity deep links. Files: scripts/migrate_report_saved_views.php (new), app/Services/Reporting/SavedViews.php (new), app/Core/Router.php, app/Controllers/ReportController.php, app/Views/reports/index.php, database/client_schema.sql, config.php.
v1.9.78 24 Jun 2026 03:31 Reports enhancements - Batch 2 of 4 (View & Export). True .xlsx export: when the server has ZipArchive, Excel exports are now real OOXML .xlsx (styled title, navy header band, numeric cells typed as numbers, Rand kept as 'R#,###.##' strings), with the previous SpreadsheetML .xls used as an automatic fallback when ZipArchive is absent. The .xlsx XML parts are emitted by a pure xlsxParts() method that was validated by zipping the exact PHP output and opening it with openpyxl (workbook opens; title/money/number/multi-value-filter all correct). Also fixed a real bug exposed by Batch 1 multi-select: the export/print 'Filters:' header rendered multi-value filters as 'Array' - now joined as 'key = a/b' (ReportExporter + print.php). Column chooser: a 'Columns' dropdown on the runner lets users show/hide individual table columns; the choice persists per report in localStorage (key rptcols:<code>). Header and data cells carry data-col indices; the detail/expand row is unaffected. Per-category report permissions (settings-driven, DEFAULT-OPEN - no lockout on deploy): two new Settings -> Security entries, report.perm_financial and report.perm_governance. Blank (default) = every report viewer sees those categories exactly as before; set to a permission code to require it. New ReportAccess service enforces this in the catalogue (restricted categories are hidden) and on run/export/print (restricted reports are denied and redirected, so they cannot be reached by URL either). Verified: ReportAccess 12/12 (default-open, configured restriction, granted access, category map); xlsx parts validated via openpyxl + .xls fallback + array-filter header; views 12/12 (catalogue gating hides the 6 financial+governance reports, column chooser control + data-col + localStorage, slice & compare regression intact). Honest note: ZipArchive::addFromString packing is the one piece not executable in our sandbox (standard lib) - the XML it packs is openpyxl-validated. Remaining: B3 saved views (new table) + scheduled/emailed subscriptions (job runner + mail); B4 SQL-side aggregation (lifts the display cap) + materialised exec rollups + entity deep links. Files: app/Services/Reporting/ReportExporter.php, app/Services/Reporting/ReportAccess.php (new), app/Core/SettingsRegistry.php, app/Controllers/ReportController.php, app/Views/reports/index.php, app/Views/reports/print.php, config.php.
v1.9.77 24 Jun 2026 03:16 Reports enhancements - Batch 1 of 4 (Slice & Compare). The nine requested enhancements are being delivered as tested batches rather than one drop; this is batch 1. Multi-select filters: every report filter can now take multiple values. cleanFilters() accepts arrays, assemble() emits a parameterised IN (?,?,...) for multi-value and = ? for single - injection-safe (values bound, never concatenated; verified with malicious array values). The runner UI replaces single dropdowns with a checkbox dropdown per filter (button shows 'n selected'); chart drill-down (single value) still works unchanged. Date-range presets: Today / 7 days / 30 days / This month / This quarter / YTD quick-select chips on both the catalogue executive strip and the runner, computed from today; the active range is highlighted; presets preserve the report code and current filters (including multi-value). Period-over-period comparison: an opt-in 'Compare to previous period' toggle (dated reports only) computes the same report's summary for the equal-length window immediately before the selected range and shows a delta chip on each summary card (direction arrow + percent + 'vs prev'). Deltas are informational - not coloured good/bad - since 'up' means different things per metric. New runner helpers: priorWindow(), deltas(), datePresets(). Honest scope: comparison runs the prior window uncapped to keep its summary accurate (opt-in, so the extra query is deliberate). Remaining batches: B2 column chooser + true .xlsx (where ZipArchive present) + per-category permissions; B3 saved views (new table) + scheduled/emailed subscriptions (job runner + mail); B4 SQL-side aggregation (lifts the display cap) + materialised exec rollups + entity deep links. Files: app/Services/Reporting/ReportRunner.php (array filters + IN + priorWindow + deltas + datePresets), app/Controllers/ReportController.php (comparison + presets), app/Views/reports/index.php (multi-select dropdowns, preset bar, compare toggle, delta chips, CSS/JS), config.php.
v1.9.76 24 Jun 2026 02:59 Reports Phase 4 (final roadmap round): server-side pagination + sorting, a safety fetch-cap, and a dedicated Executive Dashboard with period-trend packs. Runner: run() takes a row cap - on-screen and print use a 5,000-row display cap (result carries a 'capped' flag and the UI warns + points to export), while exports run uncapped for the complete set. New paginate() sorts and page-slices server-side; sorting is applied in PHP against the result's own column whitelist so no user input reaches SQL (injection-safe), per_page is floored/capped (10..500), and page numbers are clamped. Crucially, summary cards and charts are still computed over the FULL filtered set, so paging/sorting never distort the analytics. Runner time-series engine: bucketExpr() (daily/weekly/monthly/quarterly/annual SQL period buckets; grain validated, column trusted) and executiveTrends() producing guarded count/value series for bookings, completed trips, no-shows and waybill value. ReportChart: fromSeries() + a new 'timeline' SVG (vertical bars with a period axis, value labels, Rand-aware). Runner view: sortable column headers (click to sort/toggle, preserving filters), a pager (Prev/Next, Showing A-B of N, Page x of y), and the large-result capped notice. New Executive Dashboard (GET /reports/executive, report.view, nav 'Exec Dashboard'): the live KPI strip plus four period-trend charts with a daily..annual granularity selector and date range - delivering both the dedicated executive page and the time-series packs. Honest scope: pagination/sort operate over the filtered result (display-capped at 5,000; narrow filters or export for more); trends are SQL-grouped time series rendered as dependency-free SVG (consistent with Phase 3's no-CDN approach). This completes the planned Reports roadmap (engine -> wider catalogue -> charts/drill-down -> pagination/exec dashboard/time-series). Files: ReportRunner.php (cap + paginate + bucketExpr + executiveTrends), ReportChart.php (fromSeries + timeline), ReportController.php (cap usage, pagination, executive action), app/Core/Router.php (+/reports/executive), app/Views/layout/app.php (+nav), app/Views/reports/index.php (sortable headers + pager + capped notice), app/Views/reports/executive.php (new), config.php.
v1.9.75 24 Jun 2026 02:39 Reports Phase 3: charts + drill-down. New ReportChart service computes a chart series from the SAME result rows the table shows (so chart and table can never disagree) and renders it as dependency-free inline SVG - bar or donut - with no JavaScript charting library and no CDN (works offline / behind a locked-down network). 17 of the 19 reports gained a chart spec in their registry definition (the two guarded Guardian registers are left chart-less as they are usually empty); bar/donut, count or money-sum, brand-coloured. Drill-down, two ways: (1) the chart legend is clickable - each segment re-runs the report filtered to that value (the drill filter is merged into the current query and validated by the engine like any other filter), shown only where the chart's dimension maps to a real declared filter; (2) every table row expands in place to a full-record detail panel (all columns as label/value), pure vanilla JS, no extra query. The print/PDF document renders the same chart as a static SVG + legend. Honest scope: charts are server-side SVG by design (no Chart.js/CDN dependency in this no-build stack); drill is legend-to-filter and row-expand (no deep links into entity screens this round, to avoid guessing per-entity routes). Still deferred to Phase 4: server-side pagination + sorting for very large ranges, a dedicated executive dashboard page, and executive time-series (daily/weekly/monthly/quarterly/annual) packs. Files: app/Services/Reporting/ReportChart.php (new), ReportRegistry.php (+17 chart specs), ReportRunner.php (attach chart to result), app/Views/reports/index.php (chart panel + clickable drill legend + expandable row detail + JS/CSS), app/Views/reports/print.php (static chart), config.php.
v1.9.74 24 Jun 2026 02:19 Reports Phase 2: ten new live-data reports added to the engine, plus three new categories (Utilisation & Capacity, Driver & Vehicle, Audit & Governance) - catalogue now 19 reports across 8 categories. New reports: Passenger/Employee Register, Site/Delivery-Centre Register (operational); Route Performance & Utilisation (seat utilisation = passengers/capacity, distance, duration, estimated cost); Driver Register & Compliance and Vehicle Register & Compliance (licence/PDP/roadworthy/insurance expiry flagged EXPIRED / EXPIRING-30d, ban state); Attendance & Boarding (boarded/no-show/dropped, boarding rate); Cost per Trip & Passenger (expected amount with derived cost/passenger); Audit Trail (audit_logs with high-risk flag + IP); User Sign-in Activity (login_audit); Passenger Address Governance (approval state + geocode confidence, POPIA-relevant). Each has filters, summary analytics, CSV/Excel/Print export and audit like the rest - they are pure registry definitions, no new controller or view code. Engine: added a 'nodate' capability so master registers (employees, drivers, vehicles, sites) list the full current set rather than being filtered by a created-date; the runner omits the date predicate and the runner UI hides the From/To inputs for those reports. Dated reports are unchanged (backward-compatible assembly). Every column was verified against client_schema.sql before definition. Honest scope notes: there is no shift-schedule table in the schema (shifts exist only via transport slots / the WFM feed), so no shift-roster report was fabricated; cost reporting is derived from waybills/routes since there are no dedicated cost tables; driver/vehicle compliance is computed from the expiry columns on those records. Still deferred to later phases: client-side charts, row-level drill-down, server-side pagination/sorting, a dedicated executive dashboard page, and executive time-series packs. Files: app/Services/Reporting/ReportRegistry.php (+3 categories, +10 reports), app/Services/Reporting/ReportRunner.php (nodate assembly + result flag), app/Views/reports/index.php (hide date inputs for registers), config.php.
v1.9.73 24 Jun 2026 02:09 Administration -> Reports, Round 1: rebuilt on a reusable reporting engine. New app/Services/Reporting: ReportRegistry (categorised, declarative report catalogue - Executive / Operational / Financial / Vendor / Safety), ReportRunner (injection-safe query assembly from the trusted registry with every value bound as a parameter; per-report filters; summary analytics; live filter-option sourcing; cross-platform executive KPIs) and ReportExporter (CSV + Excel SpreadsheetML, each with a professional header: title, period, applied filters, generated-by/at, summary totals, and Rand formatting on money columns). ReportController is now a thin layer over the engine: categorised catalogue + executive KPI strip, on-screen runner with filter panel + summary cards + sticky-header table + empty/loading states, format-aware export (CSV/Excel) with checksum + export_logs + audit, and a print-friendly /reports/print document for PDF. The 9 existing registers were migrated onto the engine and enriched with filters and analytics; no duplicated per-report query logic remains. report.view / report.export permissions retained; Guardian registers stay guarded. Honest scope: this is the foundation plus first slice. Excel export is SpreadsheetML .xls (opens natively in Excel/LibreOffice) because the stack has no ZipArchive/Composer; PDF is a professionally-styled print view (browser -> Save as PDF). Deferred to later phases: client-side charts, row-level drill-down, server-side pagination/sorting for very large ranges, a dedicated executive dashboard page, and the wider report catalogue (passenger/route/driver/vehicle/attendance/utilisation/cost/compliance/audit/user-activity/shift, plus daily-weekly-monthly-quarterly-annual exec packs). Files: app/Services/Reporting/{ReportRegistry,ReportRunner,ReportExporter}.php (new), app/Controllers/ReportController.php, app/Views/reports/index.php, app/Views/reports/print.php (new), app/Core/Router.php (+/reports/print), config.php.
v1.9.72 24 Jun 2026 01:46 Refactored the global interaction policy into a single source of truth. The no-underline / hand-pointer / disabled-not-allowed / focus-visible rules (introduced per-file in v1.9.71) now live ONCE in public/assets/css/global.css, and each top-level stylesheet pulls it in via @import url('global.css') as its first line. The duplicated TRIPI-GLOBAL-INTERACTION-POLICY block was removed from all 12 stylesheets. Coverage is unchanged - those 12 stylesheets already blanket every surface (admin/ops, login & auth, vendor portal, NOC monitor, driver & mobile, support, booking, legal), so importing from them reaches every layout without editing ~20 separate head blocks. Behaviour is identical; the policy is now edited in one place. Note: global.css is a NEW file - it must be deployed alongside the stylesheets. On the admin shell app.css is cache-busted by filemtime; on other surfaces do one hard refresh after deploy. Future edits to the policy that need cache-busting can append a version to the @import URL. Files: public/assets/css/global.css (new), public/assets/css/{app,auth-pages,desktop-login,login,legal,vendorapp,monitor,driver-app,mobile,support-admin,support,new-booking}.css, config.php.
v1.9.71 24 Jun 2026 01:40 Global interaction policy applied application-wide (standing UI preference). Every hyperlink now has its underline removed in all states (default/hover/focus/active/visited, enforced with !important so it overrides browser and framework defaults), and every clickable / interactive element shows a hand pointer (a, button, input buttons, label[for], select, summary, and ARIA roles button/link/tab/menuitem/option, plus [data-clickable]/.clickable). Disabled controls (button/input/select/textarea :disabled, [disabled], [aria-disabled=true], .disabled) use a not-allowed cursor and never present as clickable. Keyboard focus visibility is preserved with a brand-teal :focus-visible outline so removing underlines does not harm accessibility. Applied as a single self-contained policy block appended to every top-level stylesheet so the rule holds on every surface (admin/ops, login & auth, vendor portal, NOC monitor, driver & mobile, support, booking, legal) and cannot be lost by a partial deploy. Files: public/assets/css/{app,auth-pages,desktop-login,login,legal,vendorapp,monitor,driver-app,mobile,support-admin,support,new-booking}.css, config.php.
v1.9.70 24 Jun 2026 01:20 HOTFIX - Commute-to-Attrition board (Attrition Risk) returned HTTP 500. Cause: the view render helper does extract(\$data, EXTR_SKIP) and its own parameter is named \$data; the Commute board controller passed its payload under a key literally named 'data', which therefore collided with that parameter and was SKIPPED on extraction, leaving the view's \$data as the outer payload array (no 'totals'/'agents' keys) and fataling on array_slice(null). Fix: renamed the payload key from 'data' to 'cd' in CommuteController::board() and the matching reads in app/Views/commute/board.php. No logic change; the board now renders in the off state and with data. Also audited the other modules' view payloads (Nightfall, ShiftGuard, Roster, Commute HR) for any key colliding with the render parameters (view/data/bare) - none found; this was the only occurrence. Files: app/Controllers/CommuteController.php, app/Views/commute/board.php, config.php.
v1.9.69 24 Jun 2026 00:22 Transport-Aware Rostering Loop (roadmap 4 of 4 - ROADMAP COMPLETE). Closes the loop between transport and workforce management: instead of routing around a fixed roster, itripi reads the routes the roster produces and proposes small, costed shift changes that let whole vehicles be removed. (1) RosterAdvisor engine (app/Services/Roster/RosterAdvisor.php): the lever is the shift SLOT. For each site / direction / day, slots are the cohorts that travel together; when a sparse slot's entire cohort fits into the spare seats of an adjacent slot AND the shift move stays inside the roster tolerance, that slot's vehicles can be dropped. findEliminations() is a pure, deterministic greedy that orders donor slots to maximise vehicles dropped (then cost, then emptiness), packs each donor into the nearest in-tolerance receiver with room, and tracks remaining spare so the same seats are never double-booked. Produces plain-English proposals: 'Move the 22:00 cohort (4 agents) to 21:30 (-30 min); drop 1 vehicle; save R 1,180.' (2) analyze() aggregates real forward routes (passenger_count, suggested_capacity falling back to a default, estimated_cost) into slots, attaches each cohort's agents from the current route version, runs findEliminations per group, and rolls up totals (recommendations, vehicles droppable, Rand saving, agents to re-slot). (3) WFM push - GATED and never faked (app/Services/Roster/WfmGateway.php): the single outbound integration point. With no WFM endpoint configured it is inert (sent=false, 'WFM endpoint not configured'); even with an endpoint set it does not pretend to transmit until a real contract is implemented there. Accepting a recommendation attempts the push through this gateway and records the honest result. (4) Recommendation board (app/Controllers/RosterController.php + app/Views/roster/board.php, nav 'Rostering', route.view): proposals computed live and grouped by site/direction/day, each readable as a costed shift move with drop/saving; accept or dismiss (route.optimise) persists the decision and snapshot; a clear notice that the automated WFM push is dormant until an endpoint is wired. (5) roster_recommendations ledger (scripts/migrate_roster_v1.php, idempotent, all tenants): the proposal, the reviewer's decision (open/accepted/dismissed) and the WFM-push trail, keyed one-per-donor-slot-per-day. (6) 9 new Settings -> Transport-Aware Rostering controls (new category): roster.enabled (default OFF), horizon_days, shift_tolerance_min, sparse_occupancy, default_capacity, marginal_pct (receiver-detour discount), min_saving, wfm_endpoint, wfm_auth_token. OFF by default (zero overhead, zero regression). Recommendations are computed entirely from real route/cost data; the engine is advisory and never changes a roster or a route by itself, and the outbound push to WFM stays dormant until the client wires their endpoint - no fabricated transmissions or sample data. Currency is Rand. PHASE 2 (deferred, flagged): the real WFM HTTP contract behind WfmGateway::push() once the endpoint exists. Verified: ~26 assertions - slotMinutes parsing; findEliminations across every case (two-half-empty merge to one vehicle; multi-van sparse slot fully absorbed to drop two vehicles via the max-vehicle ordering; out-of-tolerance suppressed; neighbour-full suppressed; well-utilised slots untouched; greedy conflict-free with no double-booked seats; costlier van dropped on ties; min-saving filter); analyze aggregation (multi-route slot sums, stale-version stops excluded, grouping, totals) and graceful empty/absent-table paths; WfmGateway honestly inert both unconfigured and configured; and the recommendation shape carrying every field the board and decision snapshot read. With this release the four-module strategic differentiation suite is complete: Nightfall (v1.9.66), ShiftGuard / Seat Assurance (v1.9.67), Commute-to-Attrition (v1.9.68) and Transport-Aware Rostering (v1.9.69). Files: app/Services/Roster/RosterAdvisor.php (new), app/Services/Roster/WfmGateway.php (new), scripts/migrate_roster_v1.php (new), app/Core/SettingsRegistry.php, app/Controllers/RosterController.php (new), app/Views/roster/board.php (new), app/Core/Router.php, app/Views/layout/app.php, config.php.
v1.9.68 24 Jun 2026 00:03 Commute-to-Attrition Engine (roadmap 3 of 4). Reframes staff transport as a RETENTION lever by pairing a computable Commute Burden Score with an HR-gated attrition-cost model. (1) CommuteBurden engine (app/Services/Commute/CommuteBurden.php): a per-agent Commute Burden Score (CBS, 0..1) blended from three components, all from data the platform already holds - personal ride time (route_stops pickup/drop ETA vs the route's site-arrival, capped), unsociable-hour exposure (a smooth curve over scheduled_start_at peaking in the small hours and zero in comfortable daytime), and average lone-riding minutes (Guardian's guardian_route_exposure). Missing components renormalise the remaining weights, so the score is robust when Guardian has not run. Banded low/medium/high. (2) Attrition-cost fusion - GATED on an HR feed and never fabricated: when HR supplies a per-agent flight risk (probability of leaving in the period) and replacement cost, the engine models burden as lifting flight risk (uplift = 1 + sensitivity x CBS), takes the SHARE of flight risk attributable to the commute (flight_risk x (1 - 1/uplift)), and multiplies by replacement cost to give an expected, avoidable attrition cost in Rand. With no HR row the layer reports available=false - burden still shows, cost does not. A default replacement cost can stand in when HR gives risk but no cost. (3) Management board (app/Controllers/CommuteController.php + app/Views/commute/board.php, nav 'Attrition Risk', report.view): agents ranked by attributable cost then burden, with per-site rollups and totals; a clear notice when the attrition layer is dormant for want of HR data. (4) HR Attrition Inputs admin (app/Views/commute/hr.php, settings.manage): enter / update per-agent flight risk + replacement cost by employee number (upsert, audited), or feed them from an HR system - this is what unlocks the cost layer. (5) hr_attrition_inputs table (scripts/migrate_commute_v1.php, idempotent, all tenants, FK to employees). (6) 14 new Settings -> Commute-to-Attrition controls (new category): commute.enabled (default OFF), window_days, min_trips, ride_cap_min, lone_cap_min, w_ride/w_unsociable/w_lone, unsociable_peak_hour/half_width, band_high/low, attrition_sensitivity, default_replacement_cost. OFF by default (zero overhead and zero regression). The burden half runs on real platform data alone (trip_passengers, trips, routes, route_stops; + Guardian where present); the attrition half stays dormant until HR data is supplied - no mock or sample HR numbers are ever used. Currency is Rand. PHASE 2 (deferred, flagged): feeding the per-agent attrition cost into the route optimiser so routing minimises transport cost + predicted attrition cost (the seam is defined; the optimiser is not rewired in this release as the attrition signal is HR-gated), and a bulk HR import. Verified: ~32 assertions - unsociable-hour curve (daytime zero, midnight peak, monotonic, wraps), burden blend with partial-component renormalisation and band cut-offs and cap clamping, GATED attribution (no flight risk -> available=false with all nulls; full path uplift/attributable-risk/cost; risk-without-cost; default-cost fallback; CBS 0 -> zero cost; monotonic in burden), and assess aggregation/ranking/site-rollups/totals with the HR-dormant path proven (burden computed for all agents, attrition unavailable when the feed is missing). Files: app/Services/Commute/CommuteBurden.php (new), scripts/migrate_commute_v1.php (new), app/Core/SettingsRegistry.php, app/Controllers/CommuteController.php (new), app/Views/commute/board.php (new), app/Views/commute/hr.php (new), app/Core/Router.php, app/Views/layout/app.php, config.php.
v1.9.67 23 Jun 2026 23:47 ShiftGuard / Seat Assurance - per-passenger Seat Confidence (roadmap 2 of 4). Predicts the probability each passenger on an upcoming INBOUND trip badges in on time, as P(boards) x P(delivered on time/boards) = reliability x delivery. (1) SeatConfidence engine (app/Services/ShiftGuard/SeatConfidence.php): reliability is the passenger's historical board rate (boarded vs counted no-shows, where a no_show whose dispute was DISMISSED does not count against them), Bayesian-smoothed toward a population prior so a passenger with little history sits near the mean, not 0 or 1. delivery is a logistic of the on-time BUFFER = shift_start - (trip start + route duration), using actual_start_at once the trip has started so a late departure erodes the buffer automatically; when the shift time or route duration is unknown, delivery falls back to a neutral constant and the score rides on reliability rather than guessing. Confidence is banded high/medium/low; already-boarded passengers resolve to 1.0 and no-shows to 0.0. (2) Live Seat-Assurance board (app/Controllers/ShiftGuardController.php + app/Views/shiftguard/board.php, nav 'Seat Assurance', trip.view): every upcoming inbound trip in the window with its expected on-time count, on-time pct, at-risk passengers and a per-passenger drill-down (confidence, buffer, reliability + history depth). Read-only; never blocks a booking. (3) Live monitor alert (app/Controllers/MonitorController.php): a trip whose expected on-time badging falls below the threshold raises a seat_risk alert on the NOC board - the forward-intervention trigger (re-sequence / standby / hand-off). (4) 10 new Settings -> ShiftGuard controls (new category): shiftguard.enabled (default OFF), window_hours, route_alert_pct, band_high/low, target_buffer_min, buffer_steepness, prior_mean, prior_strength, unknown_delivery. (5) shiftguard_assessments table (scripts/migrate_shiftguard_v1.php, idempotent, all tenants) - the governance/audit + at-risk-register schema, ready for the phase-2 scheduler sweep. Ships on data the platform already holds (trip_passengers, no_show_records, shift_schedules, trips, routes) - a WFM/badging-history feed would sharpen reliability but is NOT required. OFF by default (zero overhead and zero regression when off: the monitor block and board are skipped). Graceful degradation: no history -> population prior; no shift record or route duration -> reliability-only (neutral delivery); shift_schedules table absent on a tenant -> automatic reliability-only fallback. Inbound only (badging is a shift-start concept); nothing is seeded or fabricated. PHASE 2 (deferred, flagged): the scheduler sweep that persists shiftguard_assessments snapshots + emits signals to client WFM (the WFM endpoint does not yet exist, so that emission is inert regardless) + the historical at-risk register report. Verified: ~37 assertions - reliability Bayesian smoothing, delivery logistic, banding, buffer arithmetic incl. late-start erosion, batched reliability read + graceful degradation, rollup math, and the end-to-end at-risk -> below_threshold -> alert path (at-risk trip 16 pct flags, healthy trip 83 pct clears, disabled-by-default skips). Files: app/Services/ShiftGuard/SeatConfidence.php (new), scripts/migrate_shiftguard_v1.php (new), app/Core/SettingsRegistry.php, app/Controllers/ShiftGuardController.php (new), app/Views/shiftguard/board.php (new), app/Controllers/MonitorController.php, app/Core/Router.php, app/Views/layout/app.php, config.php.
v1.9.66 23 Jun 2026 23:26 Nightfall - darkness-aware corridor risk (roadmap 1 of 4). Activates the corridor (C) term Guardian shipped neutral in v1.9.63, turning it into a darkness-GATED aggravator: C = 1 + darkness * (baseline + grid + zone) * weight, floored at 1.0 and capped at 3.0. (1) SolarClock - a pure astronomical model computing the sun's elevation for a leg's place and time (NOAA low-precision solar position) and a 0..1 darkness factor (0 daylight, 1 at astronomical night, ramped through twilight). Validated against published Cape Town almanac data (winter/summer solstice noon elevations; both solstice sunset times within 0.7deg of the horizon; correct Southern-vs-Northern hemisphere seasonality). The world-first insight: a drop at 18:30 scores darkness 0.52 in June vs 0.0 in December. (2) RiskSurface rewrite - corridorRisk(lat,lng,time) fuses computed darkness with the operator-set load-shedding stage (guardian.grid_stage) and any client-defined risk zones whose radius covers the leg midpoint (guardian_risk_zones, heaviest weight wins, haversine geometry). Darkness gates everything: neutral 1.0 in daylight or when Nightfall is off. (3) RiskScorer - one-line change to evaluate the corridor term at the slot time. No formula change. (4) guardian_risk_zones table (scripts/migrate_nightfall_v1.php, idempotent, all tenants) + a Risk Zones admin (NightfallController + view + routes + nav, settings.manage) to add/pause/delete zones - client-entered real data, nothing seeded. (5) Five new Settings -> Safety controls: guardian.nightfall (default OFF), nightfall_weight, darkness_uplift_pct, grid_stage, grid_uplift_pct. OFF BY DEFAULT so the engine is byte-for-byte identical to v1.9.65 unless an operator opts in; darkness + grid work immediately from settings, zones once the migration is run. All inputs real (computed darkness, operator stage, client zones); absent data degrades to neutral. Verified: 17 solar-almanac assertions + 13 corridor-fusion/geometry/cap assertions + 8 integration-regression assertions (v1.9.63 planning engine intact when off; Nightfall amplifies when on) - 38 total, all green. Files: app/Services/Guardian/SolarClock.php (new), RiskSurface.php (rewrite), RiskScorer.php, scripts/migrate_nightfall_v1.php (new), app/Core/SettingsRegistry.php, app/Controllers/NightfallController.php (new), app/Views/nightfall/zones.php (new), app/Core/Router.php, app/Views/layout/app.php, config.php.
v1.9.65 23 Jun 2026 22:54 Guardian - governance reporting: surfaces the risk + safe-arrival data the v1.9.63/64 engine already captures into the governed report catalogue (FRS §27). Two new read-only registers, exportable to CSV with the existing checksum + export-log + audit trail: (1) GUARDIAN ARRIVAL-RISK EXPOSURE - per route version, the lone-passenger exposure Guardian scored, whether a safer order was re-sequenced (and by optimiser or Guardian), the achieved exposure reduction % and km trade, the lone passenger, lone-leg minutes and total exposure. (2) SAFE-ARRIVAL VERIFICATION - every closed-loop watch with passenger, drop time, status (pending/confirmed/escalated/cancelled), confirmation method, time-to-confirm, window and the linked escalation incident - the governance view of confirmation rates and timeouts. Both read the real guardian_route_exposure / arrival_verifications tables (no fabricated data) and are GRACEFULLY GUARDED: a tenant that has not run the Guardian migration sees the reports in the catalogue but they return an empty range rather than erroring. NO new schema, routes or views - the report framework (ReportController catalogue + execute(); the generic reports/index view; /reports/run + /reports/export) renders and exports them automatically. Verified: php -l + a reflection harness over the guarded degradation (empty on absent tables) and row pass-through. Files: app/Controllers/ReportController.php, config.php.
v1.9.64 23 Jun 2026 22:26 Guardian - Arrival-Risk Engine (v2, closed-loop safe-arrival): the execution-side counterpart to the v1.9.63 planning engine. Once the at-risk passenger is actually dropped at home, the trip is not treated as safely complete until a verified safe-arrival confirmation lands - from the passenger ("I'm home"), the driver (watched them go in), or a controller - OR a timeout fires and Guardian autonomously raises a welfare-check incident in the SOS console. SCOPE: outbound drops only (inbound delivers to a staffed site); a watch is opened only for the LONE passenger (the last drop - no boarded passenger remains) and any PROTECTED passenger (consent/policy vulnerability > 1.0), keeping escalation high-signal. (1) SERVICE (new): App\Services\Guardian\Arrival - onDrop (opens the watch + nudges the passenger app), confirm (passenger_tap/driver_confirm/controller, asserts the actor owns the watch), pendingForPassenger/pendingForTrip (UI), and sweep (per-tenant escalation, runs on the scheduler's own PDO). (2) DROP HOOK: TripController::passengerStatus opens the watch the instant an outbound passenger is set dropped; arrivalConfirm action for the driver/controller. (3) PASSENGER: an "Are you home safe?" card on the mobile home (counts down the window; turns urgent when overdue) posting to POST /app/arrival/{id}/safe, plus an in-app nudge on drop. (4) DRIVER: a "Confirm safe arrival" list on the driver trip screen (per-passenger Home safe button) posting to POST /trips/{id}/arrival/{vid}/confirm. (5) ESCALATION: jobs/scheduler.php gains a 4th sweep - overdue unconfirmed watches escalate into ONE incident per trip per wave (category safety, severity high), watches are marked escalated + linked to the incident, a trip_event is written, and incident operators are notified once. (6) MONITOR: the NOC live snapshot gains arrival_pending / arrival_escalated counters and alerts (awaiting confirmation with minutes-left, overdue, and SOS-raised), guarded by table presence. (7) SETTINGS: guardian.arrival_confirm (on/off, default on) gates the loop; the existing guardian.arrival_window_min sets the confirmation window. NO NEW SCHEMA - the arrival_verifications ledger was already created by migrate_guardian_v1.php in v1.9.63. GRACEFUL: every layer is inert and silent unless the Guardian migration is applied, mode is not off, and arrival confirm is on; un-migrated tenants are byte-for-byte unchanged. Verified: php -l on all changed files + a functional harness over the seed-decision (lone/protected/skip), the confirm actor-assert, and the sweep escalation grouping. Files: app/Services/Guardian/Arrival.php (new), app/Controllers/TripController.php, MobileAppController.php, DriverAppController.php, MonitorController.php, jobs/scheduler.php, app/Core/Router.php, app/Core/SettingsRegistry.php, app/Views/mobile/home.php, app/Views/mobile/driver/trip.php, public/assets/css/mobile.css, config.php.
v1.9.63 23 Jun 2026 21:30 Guardian - Arrival-Risk Engine (v1, planning side): per-leg lone-passenger risk scoring + constrained re-sequencing wired into the live route optimiser. The safety differentiator no incumbent models: on a drop route the LAST passenger rides alone into the highest-risk corridor at the worst hour; on a pickup route the FIRST passenger rides alone. Guardian scores every leg of each optimiser cluster as LegRisk = exposure(leg minutes) x occupancy(sole occupant dominates) x vulnerability(consent/policy-based, never inferred) x temporal(night curve, month-end uplift) x corridor(neutral 1.0 in v1, pluggable), isolates the single lone-passenger leg that defines the route's real exposure, and proposes a safer feasible drop order. (1) SERVICES (new, framework-agnostic, no Composer): App\Services\Guardian\RiskScorer (pure, unit-tested scoring + lone-leg identification), Resequencer (constrained local search that lowers lone exposure within a detour tolerance and NEVER breaches the duration ceiling - the exact sibling of RouteOptimizer::specialNeedsGuard), RiskSurface (the pluggable corridor term, neutral in v1), and a Guardian facade centralising mode/capability/graceful-degradation. (2) HOOK: RoutingController::routeGroup runs Guardian immediately AFTER the CVRP optimiser and BEFORE persistence (post-TSP, pre-finalise) - 'enforce' mode applies the safer order at build time within tolerance; 'advise' mode records the opportunity for an operator to apply. Per route version it writes the exposure headline + per-leg breakdown. (3) APPLY ACTION: POST /routes/{id}/guardian-apply re-sequences an existing route to the safer order with full versioning (route_versions bump), trip re-anchor, trip_events, audit and exposure re-record - the governance path of a re-optimisation. (4) PREVIEW: the Route Detail screen gains an 'Arrival Risk - Guardian' panel (navy+teal, risk band chip, the lone passenger + Protected badge, lone-leg duration, and an Apply safer sequence action showing the achievable lone-exposure cut, who becomes lone, and the km cost). (5) SETTINGS: new 'Safety & Guardian' category - guardian.mode (off/advise/enforce, default advise), detour tolerance %, lone-leg risk threshold, safe-arrival window. (6) SCHEMA (migration scripts/migrate_guardian_v1.php, idempotent, all tenants): passenger_protection_profiles (consent/policy vulnerability weights), guardian_route_exposure, guardian_leg_scores, and arrival_verifications (closed-loop safe-arrival ledger - groundwork). GRACEFUL: fully inert and routing byte-for-byte unchanged until the migration is run and mode is set; un-migrated tenants degrade silently (capability probe, like Notifier). RUN scripts/migrate_guardian_v1.php after deploy, then set Settings -> Safety & Guardian -> mode. NEXT ROUND: closed-loop arrival capture (driver/passenger safe-arrival confirmation) + timeout escalation into the SOS console + live-monitor arrival-pending surface (arrival_verifications table is laid for it). Verified: php -l on all changed files + a 22-assertion functional harness over the scoring/re-sequencing core. Files: app/Services/Guardian/{RiskScorer,Resequencer,RiskSurface,Guardian}.php (new), scripts/migrate_guardian_v1.php (new), app/Controllers/RoutingController.php, app/Core/Router.php, app/Core/SettingsRegistry.php, app/Views/routing/show.php, public/assets/css/app.css, config.php.
v1.9.62 23 Jun 2026 21:04 Mobile New Booking — proper Monday-Sunday weekly schedule with week navigation (replaces the flat day list). (1) WEEKLY CALENDAR: the day picker is now a paged single-week view, Mon-Sun, for both shift and non-shift passengers. Each day row shows day name, date, status/direction and the relevant time (shift = auto-assigned slot chip; non-shift = a departure-time select). (2) NAVIGATION: prev/next week controls bounded to the current week and up to 2 weeks forward (offsets 0,1,2). Cannot navigate before the current week (prev disabled at week 0); cannot go beyond +2 weeks (next disabled at week 2). The active range is labelled, e.g. 'Mon 23 Jun - Sun 29 Jun', with a This week / Next week / In N weeks tag. (3) CURRENT-WEEK RULES: the week starts Monday; days earlier than today are shown for context but disabled and unselectable (greyed, locked); booking is allowed from today onward only. Days beyond the booking horizon show 'Outside window' and are disabled. (4) VALIDATION (front + back): the frontend enforces nav bounds and disables past/out-of-window days; the server (bookStore) now hard-caps the bookable horizon to min(advance-days, current week + 2 forward weeks) and rejects any past or out-of-window date in the posted set. (5) NEW MODULE booking-week.js: a shared, mode-agnostic pager that shows one .bk-week block at a time, updates the range label and enforces the bounds; shift weeks are server-rendered, non-shift weeks are built by mobile.js, both paged by this module. (6) LIVE DATA ONLY: weeks are derived from APP_TZ today + the booking.max_advance_days setting; shift rows use the real shift_schedules-resolved slots; non-shift rows use the live transport_slots with cut-off handling; eligibility/availability remain enforced in bookStore. Verified headless for shift + non-shift: 3 weeks, nav bounds, past-day disabling, today selection, week range labels, auto-include of shift days, and the server horizon cap. Files: app/Controllers/MobileAppController.php, app/Views/mobile/book.php, public/assets/js/booking-week.js (new), mobile.js, booking-shift.js, public/assets/css/mobile.css, config.php.
v1.9.61 23 Jun 2026 17:20 Mobile New Booking — fix Route/Destination address alignment (text wrapping one character per line). The destination address was rendering vertically (one letter per line) because the Route used a 2-column CSS grid (16px dot + 1fr text) and grid auto-placement pushed the second stop's text into the 16px dot column, collapsing it. Rebuilt the route as an explicit flex timeline: each stop is its own flex row (.bk-stop = dot + text) with the text block set to flex:1; min-width:0 so it always takes the full available width and wraps normally; the dashed connector sits between the stops, indented under the dot. Also shortened the From/To labels ('Destination · Work' / 'Pickup · Work' instead of '... Work site') to avoid awkward wrapping. Verified headless: the full address now renders on a single line at full card width for both inbound and outbound. Files: app/Views/mobile/book.php, public/assets/css/mobile.css, public/assets/js/booking-map.js, config.php.
v1.9.60 22 Jun 2026 23:29 Mobile passenger New Booking — Route & Destination cards fixed end-to-end. The destination was showing only a bare name-only dropdown (and shift passengers saw just the site name) because the booking form loaded no site address data and the To block was replaced by a <select>. Fixed across controller, view, JS and CSS (not just the visible screen). (1) DATA: MobileAppController::bookForm now loads full site address fields (code, client, address_line1/2, suburb, city) and passes a rich per-site object (name, code, formatted address, suburb/city) to the view. (2) ROUTE CARD: rebuilt with explicit From (origin) and To (destination) stop blocks that ALWAYS show a primary line (Home / site name) plus the full readable address (street, suburb, city) — for both inbound and outbound, shift and non-shift. A direction pill and per-direction labels were added. (3) DESTINATION SELECTOR: where a choice exists (non-shift + 2+ sites) the dropdown is now a clearly-labelled premium field with meaningful option labels (name (code) — suburb, city); the selected destination's full address is shown in the To block (never dropdown-only). Single-destination/shift passengers get a fixed, labelled address (hidden site input). The selector label flips Destination/Pickup with direction. (4) DIRECTION LOGIC: inbound = Home -> site; outbound = site -> Home; From/To, summary (tsPickup/tsDest) and the review sheet all swap correctly and show addresses. booking-map.js now owns the route text and publishes window.MB_ROUTE so the shift and non-shift review sheets render identical address detail. (5) MISSING DATA: no approved home address shows a professional inline notice with a link to Addresses and disables Confirm; a site with no address shows 'Address not on file for this site' rather than blank; no raw IDs/undefined/placeholders. (6) ROBUSTNESS: the route text/labels/summary now render even when the map engine/tiles are unavailable (map decoupled from the cards). Server-side bookStore now also rejects a missing/invalid destination site with a friendly message. (7) UI: premium mobile styling for the stop blocks, address sub-lines, selector field and missing-address notice (Inter/Roboto, navy+teal). Files: app/Controllers/MobileAppController.php, app/Views/mobile/book.php, public/assets/js/booking-map.js, booking-shift.js, mobile.js, public/assets/css/mobile.css, config.php.
v1.9.59 22 Jun 2026 23:00 Driver-flow workflow controls — not-live alert, face-check override, QR boarding, trip-sheet naming. (1) ARTIFACT NAMING: the allocation-time / driver-facing document is now consistently the Trip Sheet / Dispatch Slip (driver 'Accept trip assignment'; the journey list is the 'Trip sheet'); the billing waybill stays post-trip ('prepared'). Canonical WORKFLOW.md added. (2) DRIVER-NOT-LIVE ALERT: the live monitor now flags allocated trips departing within a configurable lead window whose driver has not gone on duty or has no live GPS beacon, raising a 'driver_offline' alert and notifying dispatch/NOC once per trip (deduped via a trip_events marker). New settings monitor.driver_live_alert (on/off) and monitor.driver_live_lead_min (default 20). (3) FACE-CHECK OVERRIDE: when a driver's pre-trip identity (face) check cannot pass, they can Request a supervisor override; an Administrator/controller approves or rejects it on the trip screen (audited). Start is unlocked by a verified face OR an approved override. New trip_start_overrides table (migration scripts/migrate_trip_start_overrides.php; client_schema.sql updated) + endpoints POST /driver/trips/{id}/override-request and POST /trips/{id}/override/{oid}/decide. (4) QR BOARDING: passengers' boarding pass now shows a signed QR (vendored qrcode.js); the driver can Scan boarding QR (native BarcodeDetector) to board the exact passenger as status_method='qr'. Token binds tp_id+trip_id+boarding_code under APP_KEY (forgery/replay rejected). New endpoint POST /trips/{id}/scan-board. RUN scripts/migrate_trip_start_overrides.php after deploy. Files: app/Controllers/TripController.php, DriverAppController.php, MonitorController.php; app/Core/Router.php, SettingsRegistry.php; app/Views/mobile/driver/trip.php, portal/home.php, trips/show.php; public/assets/js/vendor/qrcode.js; database/client_schema.sql; scripts/migrate_trip_start_overrides.php; WORKFLOW.md; config.php.
v1.9.58 22 Jun 2026 21:48 Admin New Booking - lock days with no roster shift for shift-controlled passengers. Previously a shift-controlled passenger with no WFM shift on a given day fell through to a manual slot dropdown, letting that day be selected and amended. Now, for shift-controlled passengers, any day without an active (non-rest) shift record is a LOCKED 'no shift' day in the booking grid: it cannot be selected or amended (no toggle, no slot dropdown), is excluded from the Weekdays quick-pick and from the booking total, and is styled with a striped, greyed card and a 'no shift' flag (new legend entry). The rule is evaluated per day across the whole batch - if ANY selected shift-controlled passenger lacks a shift that day, the day locks (a missing roster means there is no shared slot to ride). Non-shift-controlled passengers are unaffected and still get the manual slot dropdown; mixed groups (a shifted passenger + a non-shift passenger) still resolve to the shared shift slot. Defense in depth: BookingController::createOne now also rejects any (passenger, day) where a shift-controlled passenger has no rostered shift, so a hand-crafted POST cannot bypass the UI lock (reported as 'blocked' in the result summary). Files: app/Controllers/BookingController.php, app/Views/bookings/form.php, public/assets/css/new-booking.css, config.php.
v1.9.57 22 Jun 2026 21:18 Admin New Booking - premium refactor: multi-passenger batch + full advance-window day grid. COMPLETE redesign of the admin New Booking screen (new dedicated stylesheet public/assets/css/new-booking.css; navy #14284C + teal #07B49B). (1) Passenger picker is now a STATIC-sized dialog (980 x 774, fixed) with an internal scroll region, so the window no longer grows or shrinks as the result list changes; you can add MORE THAN ONE staff member, each shown as a removable chip, and every passenger gets a copy of the booking. Adding a passenger is gated by shift compatibility (the 'shifts end the same' rule): the plan endpoint checks all selected passengers share a delivery centre and resolve to the SAME slot per day - if not, the add is refused with a reason and a compatibility banner explains why, so only ride-together passengers are batched. (2) Booking days now render as a GRID of day cards spanning exactly the Maximum-advance-booking window (Settings -> Booking Rules; default 14) - one card per day from today. Shift-controlled passengers' days are pre-populated with the matched time (pickup before shift start inbound / departure after shift end outbound) and locked; non-shift days show the available-slots dropdown; days where passengers' shifts diverge are flagged 'mismatch' and disabled. Quick actions (Select shift days / Weekdays / Clear), a live sticky summary (passengers x days = bookings) and a disabled-until-valid Create button. On submit each passenger x day runs the full chain (BR-001 eligibility + address, BR-004 duplicate, BR-002 SAST cut-off, Schedule-Rules) with one consolidated result. Premium polish: tactile day-card hover lift + filled selected state, TODAY badge, gradient header divider, gradient CTA, refined scrollbars. Files: app/Views/bookings/form.php, public/assets/css/new-booking.css, app/Controllers/BookingController.php (bookingPlan/activeBookings accept employee_ids; store loops passengers x days), app/Core/Router.php, config.php.
v1.9.56 22 Jun 2026 20:33 Admin New Booking — multi-day booking + active-bookings table + shift-aware per-day slots. (1) Multi-day: the form now books one or more days in a single submission, enforcing the booking.max_advance_days setting (Settings -> Booking Rules) as the date-picker max and as a server-side window check; each day creates its own booking and runs the full validation chain (BR-001 eligibility + approved address, BR-004 duplicate block, BR-002 cut-off in SAST, real-time Schedule-Rules eligibility), with a single consolidated result summary (queued / routed for approval / skipped duplicate / blocked / invalid). (2) Below the Passenger card, a new Active Bookings table lists the passenger's current and upcoming trips (ref, date, direction, slot, service, delivery centre, status) via /bookings/active-list, refreshing on passenger select. (3) Per-day slots: shift-controlled passengers are auto-matched to the nearest slot to their roster - pickup at/before shift start for inbound, departure at/after shift end for outbound - shown as a locked auto-assigned block; passengers with no roster shift (or not shift-controlled) get a manual slot dropdown per day, with a manual-fallback dropdown when no slot matches the shift. New endpoints BookingController::bookingPlan + activeBookings and routes GET /bookings/plan, GET /bookings/active-list. Files: app/Controllers/BookingController.php, app/Core/Router.php, app/Views/bookings/form.php, config.php.
v1.9.55 22 Jun 2026 18:50 Passenger Booking — premium route-map + shift-lock polish. Enhanced the booking screen toward the reference design: larger teal auto-assigned time blocks on each scheduled day, connecting timeline lines between Home/Work in the per-day route, a more premium map container (soft shadow, framed legend, pulsing markers) and tighter spacing/typography in the navy+teal scheme. Confirms the already-built capabilities: an interactive map below the Route section drawing the real OSRM road route (not a straight line) with a teal pickup marker and a blue destination marker, reacting to direction/site and feeding distance+duration into the Trip Summary; and shift-schedule handling where shift-controlled passengers get dates+times auto-populated from their roster with manual time selection disabled and clearly flagged, falling back to the manual slot picker when no valid shift exists — all working for both Inbound and Outbound. Files: app/Views/mobile/book.php, public/assets/css/mobile.css (+ supporting booking-map.js, booking-shift.js, MobileAppController shiftPlan in the patch for clean deployment).
v1.9.54 22 Jun 2026 17:27 Notifications: detail modal + favourites/star, unread-only bell, unified mobile actions. (1) Clicking a notification row opens a branded detail modal with the full message, a meta grid (received/status/type/channel) and all action options (favourite, open, mark read, dismiss, delete); each row also carries a star toggle and a 'Starred' filter tab lets users review favourites later. New starred_at/starred_by columns (migration extended, idempotent; client_schema updated) + Notifier::hasStar()/hasColumn() gating so star degrades gracefully if absent. (2) The bell dropdown now lists ONLY unread active items — anything read or dismissed disappears on next bell open. (3) Both mobile surfaces (passenger AND driver) now share one action partial with the identical option set — Mark read / Favourite / Dismiss / Delete + read-state chip and star indicator; mobile no longer bulk-marks-all-read on open, and a mobile-aware redirect returns no-JS submits to the originating list. Star action is user-scoped, CSRF-verified and audited (notification.star/unstar). RE-RUN scripts/migrate_notifications_lifecycle.php to add the star columns.
v1.9.53 22 Jun 2026 16:19 Notifications fail-safe hardening — the v1.9.52 feature requires the notification lifecycle migration; until it is run, the columns dismissed_at/deleted_at/read_by are absent and the feed, the Notifications screen and the mobile/driver lists raised a raw 500 (SQLSTATE 42S22 Unknown column) — which also violated the no-raw-SQL-to-users rule. Added Notifier::hasLifecycle() (cached SHOW COLUMNS probe). NotificationController index/feed/markRead/dismiss/delete/readAll, plus the desktop view and the mobile + driver controllers, now branch on it: with the migration they run the full lifecycle; without it they degrade to plain read/unread (dismiss/delete hidden and returning a friendly 'update not applied yet' message instead of erroring). No screen 500s or leaks SQL in either state. RUN scripts/migrate_notifications_lifecycle.php (or the equivalent ALTER) to unlock dismiss/delete/search-by-state.
v1.9.52 22 Jun 2026 13:56 Notifications system overhaul — full read/dismiss/delete lifecycle, accurate live bell count, and a searchable Notifications centre. DB: new migration adds read_by, dismissed_at, dismissed_by, deleted_at, deleted_by and a generated is_read column plus active/created indexes (client_schema.sql updated for new tenants). New NotificationController provides index (search by title/message/type, filter tabs All/Unread/Read/Dismissed/Deleted, date range, pagination), a JSON feed for the live bell, and per-item markRead/dismiss/delete + readAll — every action user-scoped (no cross-user access), CSRF-verified, audited, with graceful professional errors (no raw SQL/PHP). Notifier::unreadCount now counts only ACTIVE unread (excludes dismissed/deleted) and is wrapped so it degrades gracefully if the migration has not run (prevents site-wide breakage). New bell dropdown + live polling updater (public/assets/js/notifications.js): badge updates without reload, recent items with inline actions, AJAX actions on the full screen update the row + count instantly using the branded Notify framework (delete confirms via the branded modal). Notifications screen rebuilt with search/filters/per-item actions/read metadata (read_at + who) and pagination, progressively enhanced (works without JS). Mobile + driver notification lists now exclude dismissed/deleted and record read_by. Branded CSS for the bell dropdown and the centre.
v1.9.51 22 Jun 2026 13:25 Routing Console — fixed PHP warning 'Undefined array key demand_here' in routing/queue.php (the Demand Queue tab count). The view accessed $kpi['demand_here'] directly, but an older deployed RoutingController does not set that key. The view now reads $kpi['demand_here'] ?? array_sum(array_column($groups,'demand')) — the same computation the controller uses — so the tab count is correct and warning-free regardless of controller version. All other $kpi accesses in the view were already either always-present keys or empty()-guarded. View-only change.
v1.9.50 22 Jun 2026 01:52 Routing Console — removed all native browser dialogs (the 'localhost says...' popups) and replaced them with a centralised, branded notification framework. New public/assets/js/ui-alerts.js exposes a shared window.Notify API (success/warn/error/info toasts, a confirm modal returning a Promise, and an info/acknowledge modal) plus declarative hooks (data-confirm on forms/links, data-toast on buttons); it is loaded globally from the layout so the whole app can use one professional system. Toasts and modals are built from the app's own tokens (Inter/Roboto, crimson/slate palette, card surfaces, shadows, .btn/.btn-primary/.btn-danger), are fully responsive (toasts dock to the bottom on mobile, modal buttons stack), and are accessible (ARIA live/roles, focus trap, ESC/Enter, backdrop-cancel, reduced-motion). Converted every native dialog on the Routing Console: the three Auto Route vendor-selection guards and the 'Merge Groups' explainer in routing/queue.php, and the delete-plan confirm in routing/show.php. No more raw alert()/confirm()/prompt(), localhost wording, or developer-facing text reaches end users.
v1.9.49 22 Jun 2026 00:51 Trip Live Monitor — connected-driver marker hardening + diagnostic rewrite. Controller: a connected, beaconing driver whose LIVE trip cannot be plotted (its route/transport_slot/site row is incomplete, so the trip query's inner joins drop it) was invisible on BOTH the trip-marker path AND the connected-driver path, because the latter excluded EVERY driver on a live trip. The standalone layer now excludes only drivers who actually received a trip marker this snapshot, so such a driver still surfaces as a connected-driver marker (vehicle optional). The existing trip-marker beacon fallback (trip_events GPS or the driver_positions beacon, freshest wins) is unchanged. Diagnostic (scripts/diagnose_monitor_drivers.php): rewritten to mirror the CURRENT controller — trip markers now draw from trip_events OR the device beacon (it previously checked trip_events only and falsely reported a fresh, beaconing trip driver as 'no GPS'); connected-driver markers show every duty status (green/amber/grey) with the vehicle optional (it previously required available duty + a paired vehicle); and it uses the real stale/retention settings instead of a phantom 30-minute window. No schema change.
v1.9.48 21 Jun 2026 23:04 Trip Live Monitor: connected-driver marker now visible on load. The marker was always being placed on the map, but the view stayed at the configured site centre, so an off-site connected driver rendered outside the viewport and looked missing. Wired the one-time auto-fit (a 'firstFitDone' flag was declared in v1.9.47 but never used and the call site held a dead variable): paintVehicles() now frames live vehicles via fitBounds the first time a snapshot carries them, then leaves the map under the controller's control (Fit button / per-trip Track re-frame on demand). Status colours, last-known retention and z-order from v1.9.47 are unchanged. JS-only change.
v1.9.47 21 Jun 2026 22:37 Trip Live Monitor vehicle-marker fix. Markers now appear for any connected driver and are coloured by DRIVER STATUS: green=On duty, amber=On pause, red=Not connected, grey=Off duty (SOS overrides to a red pulse). Root causes fixed: trip-bound markers only read trip_events GPS (now fall back to the driver_positions beacon, freshest fix wins); the standalone layer only showed 'available' drivers via an INNER JOIN to a vehicle (now includes on_break/off_duty and shows even with no vehicle); colour logic was trip-urgency not duty status (on-duty showed blue, stale showed purple). Adds a single driverMarker() resolver, a new legend, and matching JS/CSS. No schema change.
v1.9.46 21 Jun 2026 20:16 Fix + placement for the Booking Governance engine. (1) Fixes a 500 on the rules page caused by passing an int default to get_param() (typed ?string) — switched the four affected reads to int_param(). (2) Removes the duplicate top-level 'Booking Rules' nav item, which also collided by name with the existing Settings 'Booking Rules' (cut-offs) category. (3) The Schedule-Rules engine now lives under Settings as a 'Booking Governance' accent card (Administration -> Settings -> Booking Governance) and its page heading was renamed to match.
v1.9.45 21 Jun 2026 19:51 Real-time Booking Eligibility + approval routing (Phase 3). New App\Services\EligibilityService consumes the Schedule-Rules resolver at booking time and is wired into BOTH booking paths (admin BookingController::store and passenger MobileAppController::bookStore). Checks employee active/eligible, direction allowed, self-service, WFM-shift presence, slot-to-shift alignment (inbound before start / outbound after end, overnight-aware, overtime handling), booking-window, per-day/week/period limits, home-to-site radius (auto/block via haversine), approved/pending address, weekend/public-holiday and require-approval. Hard fails BLOCK with clear user-facing messages; soft fails route to a new 'eligibility' approval task (handled by the existing ApprovalController). Adds a live eligibility preview on the admin New Booking form (green/amber/red). No schema change.
v1.9.44 21 Jun 2026 19:30 Schedule Rules / Booking Governance engine (Phase 2). New admin area Administration -> Booking Rules: configurable scoped policies (global/campaign/site/department/employee) with priority + conflict resolution (most-specific-wins, layered per-field), effective dating, activate/deactivate, full rule version history + audit, and a live 'resolve policy' tester showing which rule sets each field. 22 policy fields cover directions, self-service, WFM-shift enforcement, slot alignment, overtime, per-day/week/period limits, booking/cancel/amend cut-offs, lead/wait/grace, radius auto/block thresholds, public-holiday/weekend handling and address gating. Adds schedule_rules + schedule_rule_versions and seeds an editable Global Default Policy (run scripts/migrate_schedule_rules.php). Also fixes the Phase 1 schedule-import dialog styling.
v1.9.43 21 Jun 2026 18:11 WFM Shift Schedule Import (Phase 1 of the booking-governance engine). New admin module under Master Data → Shift Schedules: validate-before-commit CSV import of WFM rosters with a coverage overview, downloadable template, full per-row validation against the HR master (employee exists, active-for-date, overnight handling, duplicate/overlap detection, HR↔WFM site/campaign consistency), blocking-errors-vs-warnings-vs-info, filter/search, CSV issue export, commit gated until zero blocking errors, and import history. Adds the shift_schedules + shift_import_batches tables and employees.campaign / id_number (run scripts/migrate_shift_schedules.php).
v1.9.42 21 Jun 2026 17:15 Passenger app: removed the Live Tracking tile from the Home quick-actions (leaving a clean Book Transport / My Bookings / SOS row; tracking remains in the bottom nav and on the next-trip card), and the "Review your booking" confirmation now opens as a centred modal instead of a bottom sheet.
v1.9.41 21 Jun 2026 17:03 Added a complete, production-ready PWA install experience: real 192/512 and maskable PNG app icons generated from the brand mark, a full web-app manifest, an upgraded versioned service worker (offline shell, network-first so live data is never stale, old-cache cleanup), a branded Android "Install app" banner using beforeinstallprompt, and a polished iOS "Add to Home Screen" instruction sheet — all standalone-aware and dismissible. Also replaced the Passenger home's flat emoji quick-action icons with the Driver app's premium 3D (ico3d) SVG icons so both apps match.
v1.9.40 21 Jun 2026 16:11 Passenger mobile app elevated onto the Driver app's premium design system — the shared mobile shell now loads the same design layer (typography, headers, cards, buttons, status chips, nav, alerts, empty states) and the branded animated Tripi loader app-wide, so both apps look and behave as one platform. Passenger page headers updated to the standard titles/subtitles, bottom-nav icons replaced with crisp SVGs, a premium welcome hero added to Home, and passenger addresses now render in full (street number, suburb and city).
v1.9.39 21 Jun 2026 15:34 Driver trip screen refactored to a map-first layout — the live map now leads the page with the trip direction, reference and status overlaid and a distance / drive-time / stops bar, followed by a compact info strip (site, vehicle, scheduled time, passenger count), progress and GPS. The Close Trip and Arrive completion dialogs now open as centred modals instead of bottom sheets.
v1.9.38 21 Jun 2026 12:00 Guided trip execution for drivers: live identity (face) check required before Start; map now routes from the driver's live position to the next drop-off (outbound) or collection (inbound) with road distance and ETA; a prominent next-stop action card switches between Collect / Drop off per stop and turns green on arrival, ending in Close trip / Arrive; passenger names shown as initials only (POPIA); every step records actual date and time; GPS ping cadence, stop-arrival radius and the identity-check requirement are now Settings.
v1.9.37 21 Jun 2026 11:21 Fixed the driver Start Trip (and board / no-show / stop / arrive) actions redirecting to a doubled URL and 404-ing on sub-folder installs; the return link no longer repeats the base path, so redirects resolve whether the app runs at the domain root or under a sub-path.
v1.9.36 21 Jun 2026 10:59 Added an in-app Version History: a maintained release log on both the desktop and mobile sign-in screens that opens in a modal and renders as a clean, perfectly aligned table.
v1.9.35 21 Jun 2026 10:44 Restored the driver route map, which had been falling back to a plain "Open in Maps" link, by initialising the map engine before the code that draws the route; fixed the pre-trip inspection silently failing to save by replacing fragile native validation on hidden controls with explicit, server-backed checks.
v1.9.34 21 Jun 2026 01:46 Full professional rebuild of the driver Trip Manifest and Pre-Trip Inspection: OSRM road-routed map with live distance, drive-time and per-stop drive-time; vehicle compliance strip (licence, roadworthy, insurance, PDP); grouped safety-critical checks with live defect blocking.
v1.9.33 21 Jun 2026 01:28 Fixed the driver "Accept assignment" 500 error, caused by an unjoined table alias in the trip-detail manifest query.
v1.9.32 21 Jun 2026 01:19 Reconciled the Routing Console "demand awaiting routing" headline count with the filtered queue, so the number and the visible list always agree, with a jump link to demand sitting on other dates or directions.
v1.9.31 21 Jun 2026 Off-duty drivers now show a correctly greyed live status instead of an active "live" indicator.
v1.9.26 20 Jun 2026 Standardised every driver home-screen icon onto a single 3D component — brushed-silver rim, glossy radial face — for a consistent, premium finish.
v1.9.25 20 Jun 2026 Driver app navigation, loader and header refactor: dedicated Active route and screen, reusable page-loading overlay, consistent compact headers with status chips, and larger, more reliable bottom-nav tap targets.
v1.9.24 20 Jun 2026 Aligned the van illustration's tyres flush to the driver header's live date/time baseline.