THIS FILE is the portable breakdown β the one that travels. It is what docs.appolis.app renders (the hub renders markdown at build time, so a published page can no longer lag the doc), what git diffs, and what anyone without an app login can read. Keep it current: it is the only version of this document the outside world ever sees.
/breakdownin the running app is a LIVE TWIN, not a mirror β it cannot be published. It fetches/api/config,/api/stats,/api/productsand/api/dealersat runtime and its theme + term-rule editors write back (config/app-config.json/ theterm_rulestable), so it is always true inside the app and worthless outside it: hosted anywhere else those relative fetches hit the wrong origin and the page renders as an empty shell whose write-back buttons POST into the void. That is exactly what happened when it was copied to docs.appolis.app; the published copy has been pulled anddocs-hub/build.jsnow carries a β DELIBERATELY NOT COPIED note next to it. Two ways back onto the hub, both fine: (a) this markdown in the hub'srenderslist β the chosen route, and the prerequisite is met; or (b) a static snapshot export that bakes the API values in at build time (not built; see Β§9). Do not addweb/breakdown.htmlto the hub'scopieslist.
Same rule as Flip CMS: this document is updated on every app change.
What it is: customer-service command center for AWS Motor Club (AWS = Addendum & Warranty Solutions) vehicle protection products: TireGuard β tire repair/replacement program; GlassGuard β windshield protection; more products to come. β οΈ GlassGuard naming exception: GlassGuard is windshield protection everywhere, now and going forward. The only exception is the existing Mercedes-Benz of South Orlando book, where an employee incorrectly applied the GlassGuard name to a touchscreen radio screen protector and AWS had to honour it. So the 1,846 imported GlassGuard registrations are that touchscreen product; every GlassGuard sold from here on is a windshield. Do not "correct" the historical rows, and do not let this exception leak into product copy, decks or new dealer setups. Replaces the Google AppSheet + spreadsheet-pipeline setup: instead of dealership sheets being copy-pasted through staging spreadsheets into a master sheet, dealership data goes straight into a real database and every derived number (term, expiry, dealer profit) is computed by code.
Status: v0.7.1 β LIVE ON CLOUDFLARE at aws-motor-club.pages.dev (deployed 2026-08-17, migration first, then code). Real data: 8,136 customers, 10,733 registrations β 10,717 live plus 16 recorded as cancelled β after the Dec-2025 β Jul-2026 statement backfill (1,141 added, 144 repaired) and the 2026-08-18 AppSheet enrichment: 1,331 VIN-less legacy rows matched back to their AppSheet source (999 by contract number), every recovered VIN decoded to year/make/model, December-2025 cleared of its 123 double-counted placeholders, and 570 duplicate customer records merged so each person holds ALL their products on one profile. Remaining known specks: 8 rows whose AppSheet match was ambiguous, 4 VINs NHTSA cannot decode, 44 vehicles whose two products carry different surnames (family members or resales β left for human review). The live app is behind per-person login (Β§1.5): approved people only, each with their own password. Default theme is the β¨ New look (gloss-black shield, v0.5.0); β© Classic is one click away in the sidebar foot. Every registration carries a decoded year/make/model (v0.5.3). 30 claims ($9,831.10 paid β the AppSheet claims history landed 2026-08-18: 11 new claims imported, 12 legacy claims corrected for a doubling bug in the old master workbooks where the same amount sat in two columns, and 12 claims now correctly showing in progress per the dealer), 1 dealership (Mercedes-Benz of South Orlando), 14 F&I managers, 19 note activities carried over from AppSheet.
Version is single-sourced here.APP_VERSIONinweb/index.html(the badge + refresh prompt),package.json, this Status line, the top of Β§10, and the deck (web/features.html) must all read the same number. They drifted five ways once (badge v0.5.2 / Status v0.5.0 / changelog v0.5.3 / package.json 0.4.0 / deck v0.4.1) β if you bump one, bump all five. Two things only land after a command: the badge reaches users afternpm run deploy, and the hub copy of the doc + deck only refreshes afternode build.js && npx wrangler deployinF:\Claude Code\docs-hub.
Brand palette (from the AWS logo): gloss black #101214/#17191c, silver #9ba1a6, bright green #63b32e (accents/active states), white cards.
cd "F:\Claude Code\AWS Motor Club"; npm start β http://localhost:8788 (zero deps, needs Node β₯22.5 for node:sqlite; Node 24.18 at C:\Program Files\nodejs\node.exe, not on PATH in some shells β use full path)npm test (72 tests, node:test)data/aws-motor-club.db (WAL). AWS_DB env overrides; seed only loads into an empty DB, so real data is never overwritten.aws-app in F:\Claude Code\.claude\launch.json (port 8788; Flip CMS owns 8787).server/lib/dbwrap.js (wrapNodeSqlite locally / wrapD1 in prod); server/api.js is runtime-agnostic (no node imports) and shared by server/server.js (node) and server/worker.js (fetch handler + APP_KEY cookie gate; app config lives in the app_config D1 table there, in config/app-config.json locally β injected via handleApi hooks).npm run deploy (gate on green npm test first) = esbuild-bundle server/worker.js β web/_worker.js (Pages advanced mode; excluded from static upload) + wrangler pages deploy --branch main. Bindings (D1 + APP_KEY) come from wrangler.jsonc (pages_build_output_dir form)./ or /breakdown paths in worker.js β Pages pretty-URLs 308-redirect .html paths, which loops against rewrites; env.ASSETS.fetch(req) pass-through is correct.aws-motor-club id 4b687ecd-7c0b-41f1-94ca-ec7719d8381d (ENAM), account tyler@spartanstudios.com (shared account is fine; only the URL needed separating). Data refresh: npm run export-data β npx wrangler d1 execute aws-motor-club --remote --file deploy/out/schema.sql -y then --file deploy/out/data.sql -y (data.sql is idempotent: defers FKs, wipes childrenβparents, re-inserts).npx needs node on PATH in this shell: $env:Path = "C:\Program Files\nodejs;$env:Path".The app used to sit behind ONE shared device key: anyone holding it was in, and the app could never tell who did what. That is gone. Access is now per person, and the account list is a closed allowlist β there is no self-registration anywhere in the app, so a stranger with a valid-looking email cannot create anything.
users table, seeded from ALLOWLIST in server/db.js: tyler@spartanstudios.com (Tyler Bradford) and matt@awsmotorclub.com (Matt Dunkle), both role admin. Adding a third person = add a row there AND run the same INSERT ... ON CONFLICT against prod D1. Seeding never touches an existing password_hash, so re-running it cannot wipe someone's password.password_hash = NULL ("unclaimed"); the first successful login for that email stores whatever password was typed, permanently. Nobody β including us β sets it for them.MAX_WORKERS_PBKDF2_ITERATIONS in server/lib/auth.js pins it and a regression test asserts stored iterations never exceed it. More work factor cannot come from raising this number.Password1 while blocking strong passphrases.HttpOnly; SameSite=Lax; Secure cookie (aws_sess), 30 days. Only the SHA-256 of the token is stored, so a database leak cannot be replayed as a login. Signing out deletes the row; changing your password signs out every OTHER device.isOpenPath in server/lib/auth.js) leaves exactly the login surface open β /login, /api/auth/login, /api/auth/me, the icon and the manifest. Everything else, including /breakdown, /features and every /api/* route, needs a session: HTML redirects to /login, API returns 401.admin and sees exactly the same app (Tyler's call, 2026-07-26). The role column exists for a future ladder, but nothing reads it today except the reset permission.#/account, reached by clicking your name in the sidebar) does two things: change your own password (needs the current one; signs out every other device) and reset a teammate's when they're locked out. A reset clears their password and kills their sessions, so their next sign-in runs the same claim flow they used originally. You cannot reset yourself β a hijacked session could otherwise lock the real owner out β and a reset can never create an account.npx wrangler d1 execute aws-motor-club --remote -y --command "UPDATE users SET password_hash=NULL WHERE email='someone@example.com'". There is deliberately no standing bypass key.ID_SECRET and GOOGLE_PLACES_KEY are encrypted Pages secrets; APP_KEY and ACCESS_KEY.txt were deleted outright. Set or rotate one from bash (never PowerShell β it appends CRLF and the value silently never matches): printf 'value' | npx wrangler pages secret put NAME --project-name aws-motor-club.npm start binds to 127.0.0.1 only and runs the same login gate. It previously listened on every interface with no auth at all, publishing the whole member database to the LAN. HOST=0.0.0.0 still allows a deliberate device test and prints a warning.Term length comes from what the product cost the DEALER (term_rules table):
| Product | Dealer cost | Term |
|---|---|---|
| TireGuard | $299 | 3 years |
| TireGuard | $199 | 1 year |
| GlassGuard | any | 1 year (product default, no price rules) |
expires_on = deal_date + term_years. Coverage is active through the expiry date itself, expired the day after. Feb-29 deals roll to Mar-1.term_source: rule (price matched), default (product has no price rules β normal for GlassGuard), unmatched (product HAS rules but the price matched none β UI shows a "verify" badge).dealer_products table, editable on the dealer page): what we charge that dealer per unit. The dealer sells at whatever price they choose; dealer profit = sale price β dealer cost. New registrations default their dealer cost from dealer_products when left blank (and term follows from it). Import backfilled MBSO: TireGuard $299, GlassGuard $185 (dominant observed costs).fixDateSwap() in deploy/lib/xlsx-lite.js corrects them at import; zero future deal dates remain.server/db.js)products (code, name, default_term_years) + term_rules (product, dealer_cost_cents β term_years) β new products/prices are rows, not code.dealerships (name, city/state, phone, contact_name)customers (first/last name, phone, email, address/city/state/zip, lat/lng) β dealership registration often arrives with LAST NAME + VIN only; full contact info is usually captured at claim time. UI treats missing fields as normal.registrations β customer Γ product Γ vehicle: deal_date, vin, year/make/model, fi_manager, dealer_cost_cents, sale_price_cents, dealer_profit_cents (derived), term_years / term_source / expires_on (derived via insertRegistration()).claims β registration_id, status pipeline new β in_progress β awaiting_customer β approved β closed_paid | closed_denied, issue, resolution, opened_at/closed_at. Status changes auto-log to activity.claim_items β the "what was actually done" ledger: action (repair/replace/roadside/reimburse/other), description, tire_position (LF/RF/LR/RR/SPARE), amount_cents, vendor, date. Claim payout = SUM(items).activity β timestamped notes/calls/texts/emails per customer and/or claim (replaces the AppSheet notes blob).roadside_vendors β vendor directory: phones, city/state/zip, lat/lng, coverage radius, services, our 1β5 rating, times_used, notes, and category: 'ranger' (on-location operator who goes TO the customer β Tyler's definition of a Road Ranger) vs 'shop' (fixed drop-off location). The two categories are separate sections in the UI.server/api.js)GET /api/search?q= β one box searches first/last name, phone, email, VIN, address/city/state/zip, dealership name, make, model, year, contract #.GET /api/customers?q&sort&dir&coverage&product&claims&page&per β the full list: server-side sort (name/city/regs/last_deal/next_expiry/open_claims/ coverage), filters, pagination, totals.GET/POST/PATCH /api/customers/:id, POST /api/customers β card = customer + registrations (+coverage) + claims (+items, payouts) + activity + totals.POST /api/registrations β derives term/expiry/profit on insert; dealer cost defaults from dealer_products when omitted.GET /api/dealers/:id/report?group=month|quarter|year|manager|product|manager_product &from&to&products&managers β the report engine behind the dealer drill-down.POST /api/dealers/:id/pricing β upsert per-product dealer cost.GET /api/roadside/discover?customer_id|city,state,zip|lat,lng[&radius][&kind=shops|mobile] β auto-discovery: Nominatim geocode (customer coords persisted back) + distance-ranked results with in-directory flags. kind=shops = tire/repair shops (Overpass tags). kind=mobile = Road Rangers, TWO engines merged: (a) Overpass tag pull (tyres/car_repair/towing tag + craft/car_parts/ truck_repair/office=company for mobile) filtered by MOBILE_RE in code β Overpass case-insensitive name regexes are unusably slow, don't reinstate; (b) bounded Nominatim free-text ("mobile tire", "roadside assistance", "towing", "mobile mechanic", sequential w/ 400ms gaps per usage policy), gated by MOBILE_STRICT_RE to keep fuzzy junk out. Duplicate names merge, filling missing phone/website across sources. Default radius is 20 mi for BOTH kinds (user report 2026-07-31; was 60 mobile / 40 shops β a wide default buried the closest help, which is the whole point when someone is stranded). The picker still offers 20/40/60/100 for rural gaps. kumi.systems Overpass mirror fallback. GET/POST /api/roadside take category (ranger|shop); near filters by it.GET /api/comms β recent conversation threads. POST /api/activity takes direction (in/out).GET /api/claims[?open=1], POST /api/claims, PATCH /api/claims/:id (status/issue/resolution), POST /api/claims/:id/items.POST /api/activity β log note/call/text/email.GET /api/dealers, POST /api/dealerships, GET /api/dealers/:id/report?granularity=month|year β Program Overview data.GET/POST/PATCH /api/roadside, GET /api/roadside/near?customer_id=|city=&state=&zip= β haversine when both sides have lat/lng, else stateβcityβzip-closeness fallback (server/lib/geo.js).GET /api/products, GET /api/stats (dashboard).web/index.html, vanilla JS, hash-routed)mobile mode (towing/mobile/roadside operators). OSM under-maps one-man mobile outfits, so Google Maps hunt links + Tyler's phone contacts are first-class sources here.shops mode) + its own directory (category='shop'). Cross-links between the two sections preserve the customer/location being searched.&view=map).server/lib/places.js: Text Search (New) with rankPreference: DISTANCE (surfaces small independents over prominent chains) + per-kind query sets (rangers: mobile tire repair / towing service / roadside assistance / mobile mechanic; shops: tire shop / used tires / tire repair shop). Returns verified phone, website, hours, rating + review count (Enterprise SKU fields β ~1k free calls/mo, then ~3.5Β’/call; ~4-7 calls per discovery click). Key lookup (lazy, no restart needed): local = GOOGLE_PLACES_KEY env or config/secrets.json.googlePlacesKey; cloud = Pages secret GOOGLE_PLACES_KEY (npx wrangler pages secret put GOOGLE_PLACES_KEY --project-name aws-motor-club). Without a key everything falls back to OSM and the UI says so. GOTCHA: pipe the secret via bash printf (PowerShell piping appends CRLF β "API key not valid" 400s); worker also trims the value defensively.rangerAccept / shopAccept (places.js): NEGATIVE_RE kills computer/phone/appliance/car-wash/mobile-home/etc. (Tyler saw a mobile computer repair shop in Road Rangers); rangers additionally require a towing type/tag or an on-location vehicle-service name; shops require tire/auto signals. Gate cases are unit-tested β extend the tests when adding patterns.deploy/decode-vins.js backfills year/make/model from the VIN via the free NHTSA vPIC batch API (50 VINs per POST, polite gaps). It only touches rows missing BOTH year and make, so re-runs never clobber hand-entered vehicles, and one decode fans out to every registration sharing that VIN. All 9,715 registrations now carry a vehicle.Both master lists (intake/*.xlsx, sheet "MASTER For APP") imported by deploy/import-masters.js using deploy/lib/xlsx-lite.js (zero-dep xlsx reader; needs the file expanded as a zip first). Columns are located by header title, not position. Actual layout (identical in both files): Dealership Β· Deal Date Β· Deal Number Β· VIN Β· F&I Manager Β· Customer Name (one full-name column, ALL CAPS, sometimes company or bare last name) Β· Dealer Cost Β· Sale Price Β· Dealer Profit Β· Term Β· "TG Customer #" (β contract_no) Β· Status Β· Date of Last Claim or Service Β· Customer Email/Phone/Address Β· Notes Β· 1stβ5th Service or Claim Amount Β· Total Amount Paid Β· Claim In Progress (Β· Front/Rear Tires, TireGuard only, empty).
Import rules that matter:
server/lib/names.js (companies kept verbatim).term_source='sheet').claim_items; "Claim In Progress" β status in_progress (3 open). Notes β activity.node deploy/import-masters.js --tire <dir> --glass <dir> --wipe (refuses to touch a non-empty db without --wipe). node deploy/check-import.js prints a sanity report.Known source-data quirks (imported faithfully, worth cleaning over time): junk contact cells (e.g. an email of "51"), a handful of future deal dates (e.g. 2026-12-01), GlassGuard dealer costs vary ($185 dominant, $399/$599 strays β all 1-yr by product default, so harmless).
Done items are deleted, not left sitting here looking open. Shipped and gone: real-data import (v0.3), the Cloudflare Pages + D1 port (v0.4/v0.4.1), geocoding (v0.4/v0.4.3), VIN decode and the AppSheet notes import (v0.5.3), and the styled breakdown mirror β superseded by the live /breakdown twin.
POST /api/auth/password endpoint is built and tested but has no UI yet), and per-role permissions β both accounts are admin today, so the role column exists but nothing reads it. Also: this app still has no Appolis ID identity, so it cannot join the suite's signed identity envelope until it does./api/config, /api/stats, /api/products and /api/dealers once and bakes the values into a flat HTML file, so the rendered breakdown could be published without the live twin's runtime fetches. Not built β the hub renders this markdown instead, which covers the need. Only worth building if a stakeholder needs the twin's exact rendered look outside the app.Newest first. Every entry is ONE source line on purpose β the hub's renderer is line-based and a wrapped bullet shreds the list (see the note at the top of this file).
Renumbering note (reconciled 2026-07-26, todo_1086): two releases had been given numbers that were already taken.v0.5(2026-07-04, mobile build-out) collided withv0.5.0(2026-07-17, the New Look), andv0.5.1was held by both the 2026-07-05 Google Places release and the 2026-07-17 bug-button sweep. The 2026-07-17 numbers are baked into shipped code (web/index.htmlcomments read "NEW LOOK (v0.5.0, todo_682)") and into board todos 682/727/731, so they kept their numbers; the two earlier releases moved down into the v0.4.x line, where they also sit correctly by date.v0.5βv0.4.4and Google Placesv0.5.1βv0.4.5. No release was deleted and none changed content.
#/imports screen: pick the .xlsx the dealer sends, see exactly what it will do to every row, then commit. Nothing is written by the preview, and the commit re-reads the file server-side rather than trusting anything the browser reports. Built on a new zero-dependency .xlsx reader (server/lib/xlsx.js) that runs in the Worker as well as locally β the existing deploy/lib/xlsx-lite.js needs a pre-expanded archive on disk and can only ever serve the import scripts. Eight months of real statements (Dec-2025 β Jul-2026, 1,921 rows) drove every rule: day-first dates repaired (workbooks saved under a day-first locale turned days 1-12 into real date cells with month and day transposed while days 13-31 stayed text β the December and January sheets are both affected and were silently wrong); cancelled deals recognised in all five shapes the dealers use (a FLAT CXL banner, an UNWIND/CANCEL heading, a per-row note column, a negative cost, and an accounting $(185.00)) and stored as registrations.unwound so they exist as history but are excluded from every count, report and coverage total; columns matched by header name because the layout moves month to month (June drops FI-WIP, July's GlassGuard sheet carries a spare column) and because the June GlassGuard sheet is headed TGRGCOST/TGRGSALE β trusting that would file a month of GlassGuard as TireGuard. The dealer's own cheque total is used as an independent audit: our arithmetic must reproduce it to the penny (live cost above the line, minus refunds above it) and the import refuses to write when it does not. That check earned its place immediately β it caught a live March deal being deleted because the customer's surname is CANCELLIERI and the cancellation pattern was unanchored. Re-uploading a statement is a no-op, a corrected sheet shows its changes for approval rather than duplicating, a blank F&I manager never erases one already recorded, and a re-bought vehicle gets a second registration instead of overwriting the first (deal number is the identity β a VIN can carry several deals over time). 68/68 tests, 18 of them new. Two further defects were caught by generating the production SQL and reading it before running it, rather than importing blind: a statement row only has to carry what the dealer printed, and a December refund line names just a VIN and a clawback amount β writing its blanks over the matched registration would have erased that deal's sold date, sale price and expiry, so every field a statement may omit is now COALESCEd and only the cancellation flag is written unconditionally; and dealer_profit_cents / expires_on, although derived, are now COMPARED, because production held rows whose profit was never computed (NULL, so February and March reported $0.00 profit against real six-figure sales) and a re-upload previously skipped them as "unchanged". Deployed 2026-08-17 in the required order β deploy/migrate-v0.7.0.sql against D1 first, then the code β followed by the Dec-2025 β Jul-2026 backfill: 1,141 registrations added, 144 repaired, 502 customers created, 16 cancellations recorded, all eight months reconciling to the figure the importer predicted, with 0 unmatched terms and 0 future-dated deals. 69/69 tests.error code: 1101 while every invalid one returned a clean 400. Node has no such cap, so all 49 tests passed. The verification gap was mine: to avoid claiming Tyler's account I had only ever tested prod with deliberately-invalid passwords, which never reach the hashing call. Fixes: iterations pinned to 100k behind MAX_WORKERS_PBKDF2_ITERATIONS with a regression test asserting stored iterations never exceed the ceiling; the login page now distinguishes a server fault from a bad password instead of blaming your connection; and password rules dropped composition requirements for length-only per NIST SP 800-63B (the "must contain a number" rule was also rejecting reasonable passphrases). New verification standard: prove the real hashing path in prod with a TEMPORARY account, then delete it β done here, then removed, leaving both real accounts untouched and unclaimed. 50/50 tests.#/account (click your name in the sidebar): change your own password (requires the current one, signs out every other device) and β since no email provider exists yet for a "forgot password" link β any admin can re-arm a locked-out teammate so their next sign-in sets a new password. Guards: reset needs a session, refuses self-targeting, rejects unknown emails, and can never create an account; the team list is session-gated and never ships salts or hashes to the browser. Also added the house-standard styled askConfirm() (this app was still missing it) so the reset confirmation is not a bare browser dialog. Tyler confirmed Matt has the same access level as Tyler β both admin, same app. 49/49 tests.APP_KEY + ID_SECRET came out of wrangler.jsonc (ID_SECRET is an encrypted Pages secret, APP_KEY and ACCESS_KEY.txt deleted as dead); and wrangler.jsonc left .gitignore since it no longer holds credentials. Sidebar shows who is signed in with a sign-out. 46/46 tests (12 new auth tests incl. the allowlist being un-registerable and a weak claim leaving the account unclaimed). Verified on prod: old key cookie now 401, both accounts still unclaimed and waiting for their owners.intake/AWS MANUAL INPUT LIST FOR APP.xlsx, tab "MASTER For APP" β 10,135 rows, combined TG+GG with a product column); new deploy/import-appsheet-notes.js matches notes by contract # β VIN and dedupes by body, so re-runs are safe. Result: the AppSheet universe holds 17 notes total β all 17 matched, 6 were new (11 already imported from the masters), 0 unmatched; prod D1 synced idempotently (19 note activities live). No photos are embedded in the export. The "missing notes" perception was mostly the missing VEHICLES (fixed in this same release β item 1 below) β the notes data was always this small. (1) VEHICLES EXIST NOW: every one of the 9,715 imported registrations lacked year/make/model (the sheets only carried VINs) β new deploy/decode-vins.js backfills them via the free NHTSA vPIC batch API (50 VINs/POST, polite gaps, only touches rows missing BOTH year+make so re-runs never clobber hand-entered vehicles; one decode fans out to every registration sharing the VIN). vehicleStr() lights up across the app automatically. (2) junk contact cells: 17 junk emails (the "29"/"51" artifacts) nulled in data, and the UI now renders phone/email only when they look real (okPhone/okEmail β customers list, card header, comms; the edit form still shows raw values so junk stays correctable). (3) NOTES ROOT-CAUSED (the "notes not imported" half of todo_682): the import was NOT at fault β both master workbooks contain only ~12 notes across every tab, and all imported. The real per-customer notes/photos live ONLY in AppSheet's backing data β Tyler exports it (board todo_685), then a matcher-importer lands them as activity. Stale finding for ops: 2 of the 3 imported "in progress" claims date to 2022/2023 β close or resolve in-app. 34/34 tests.server/lib/places.js: Text Search (New), DISTANCE-ranked, per-kind queries incl "used tires" for indie shops; results carry rating/review count (shown in list + map popups). rangerAccept/shopAccept gate ALL engines β mobile computer repair etc. can no longer appear (Tyler's report). Key plumbing local (env/secrets.json) + cloud (Pages secret), lazy-read, graceful OSM-only fallback with "not configured" surfaced in UI. Key landed the same day and was verified live: rangers came back 40/40 with a phone number, and indie tire shops (Sandlake Tire Shop 4.8β
, 1.1 mi) now surface ahead of the chains. 34/34 tests. (Renumbered from v0.5.1 β see the note above.).hide-sm (customers/claims/comms/dealers/vendors/discovery keep the essentials; anything still wide scrolls inside .panel { overflow-x:auto }); 16px inputs (kills iOS focus-zoom), β₯40px touch targets, 2-col stat cards, 62vh map, scrollable dialogs, safe-area insets. PWA basics: manifest + SVG icon + theme-color β "Add to Home Screen" runs standalone. Discovered phones are now tel: links. Desktop layout untouched (all scoped to the media query). Verified at 375Γ812: zero horizontal overflow on every page, drawer + map + card OK. (Renumbered from v0.5 β see the note above.)kind=mobile: towing tag + mobile/roadside-named, filtered in code β Overpass ,i name regexes are unusably slow, don't reinstate them); new Tire Shops tab holds the fixed drop-off shops. roadside_vendors.category (ranger|shop) separates the directories (remote D1 ALTERed). Overpass mirror fallback added. 32/32 tests.dealer_products per-dealership pricing (backfilled MBSO TG $299 / GG $185; registration default + editable panel). Date-swap fix: future deal dates were month/day transpositions β corrected at import (0 remain). 31/31 tests.registrations.contract_no (searchable) + deal_number, activity.direction; idempotent addColumn migrations; nullable deal_date. 27/27 tests. Verified live on real data in preview.F:\Claude Code\AWS Motor Club, db file data/aws-motor-club.db, env override AWS_DB, launch entry aws-app. Import plan rewritten: read ALL master-list column headers and carry claim notes/payout history over (Β§7).