Compare commits

...

37 commits
v0.1.1 ... main

Author SHA1 Message Date
718c72dde1 v0.6.4: refresh the map's GPS every 30 s while it's on screen
All checks were successful
build-apk / build (push) Successful in 18m52s
A single fix at mount went stale the moment you walked a block, which is
most of the time you'd be looking at the map.

useLocation takes { intervalMs, active }: it polls only while active,
reads immediately on becoming active (re-opening the tab is when the
held fix is most stale), asks for permission once rather than every
poll, and stops polling on a hard denial. The last-known-position
shortcut now runs only before there is anything to show — on a later
poll it is usually older than what we hold, so it would make the dot
jump backwards. It also returns updatedAt.

MapScreen gates the poll on screen focus AND app foreground; focus
alone isn't enough, since a backgrounded app stays "focused" on its
last tab and Android won't hand location to one that isn't in front.

"Park here" / "My location" took nativeFix.current ?? coords, so the
MapLibre dot's cached fix won unconditionally and never expired — a
fresher polled fix could never get past it. Both now use bestFix(),
which takes whichever source is under 45 s old (a poll and a half, so
an on-schedule fix never reads as stale), forces a read if neither is,
and falls back to a stale fix rather than nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE83epHK8WbkppNszG3A2
2026-09-01 22:11:20 +00:00
4217e88338 v0.6.3: hide the paid city lots from users without a ParkSmarter account
All checks were successful
build-apk / build (push) Successful in 9m54s
The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.

Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.

- Paid lots are filtered out of the overlay when signed out, so they are
  neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
  account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
  turns out to take payment another way. Null means "use the category
  default", so an unset value can't be confused with an explicit false.

The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 06:02:23 +00:00
6af3dad762 v0.6.2: show and manage on-phone sessions offline and signed out
All checks were successful
build-apk / build (push) Successful in 9m58s
A city-map timer started without signing in did not appear under Sessions.

The tab returned "Sign in to see your sessions" before rendering anything, so
a local session could never show; and even signed in it only ever listed
ParkSmarter's sessions. A local timer is the one kind that has no server copy,
which made it the one kind the screen could not display.

- Sessions now leads with "Tracking on this phone": the live countdown with
  +1 hour and End, working with no account and no network, because that is the
  only place the session exists.
- Recent local sessions are kept in a small on-device history (50 max). Without
  it a local session vanished the instant it ended — there is no server to ask.
  Paid ParkSmarter sessions are excluded so they don't appear twice.
- The ParkSmarter half is layered on top when signed in and can fail
  independently: offline it reports that and keeps the local half visible,
  rather than the whole tab going blank. It also no longer leaves an unhandled
  rejection when the fetch throws (it had try/finally but no catch).
- The card's second button follows the notification's rule: +1 hour for a local
  timer, Extend -> purchase screen for a bought session, since only one of those
  can honestly add time.

History is written in endActiveParking() before the record is dropped, which
also covers expiry — syncActiveParking() routes a lapsed session through the
same call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 05:39:14 +00:00
7fc92d24e4 v0.6.1: stop Anonymous Mode bouncing to sign-in on every foreground
All checks were successful
build-apk / build (push) Successful in 9m55s
Parking without an account showed "Your session expired — please sign in
again." repeatedly and kicked the user back to the login screen.

The 401 hook on the API client is global: it fires on every unauthorized
response whether or not the caller caught the error. Anonymous Mode has no
token, so any ParkSmarter call 401s, and two of them run automatically —
discoverPaidSession() on open and every foreground, and the map's
lastSessionLot() on every mount. Each one flipped the status to signedOut;
re-entering anonymous mode ran them again, hence "over and over".

Both call sites already caught their errors, which is why this hid: the
bounce came from the client hook, not from the catch.

- A 401 in Anonymous Mode is no longer treated as a session expiry. There is
  no session to expire, and bouncing to sign-in makes the mode pointless.
  This also covers the Sessions/Favorites/Scan tabs, which 401 the same way.
- authBus carries the mode to the non-React modules that need it.
- discoverPaidSession() and lastSessionLot() are skipped entirely when
  anonymous rather than fired and discarded.
- "Last lot" now says it needs an account instead of silently doing nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 04:19:12 +00:00
148e1635d3 v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking"
map as a georeferenced overlay, and lets you track your time on any of its
areas without ever touching the ParkSmarter/IPS API.

Georeferencing (tools/citymap/)
- The PDF carries no geo metadata, so the page->WebMercator affine is
  recovered by fitting the drawing to OSM street centrelines.
- pdftocairo writes stroked street segments with per-path matrix()
  transforms in local coords while filled lots are absolute; both are
  handled. The five legend swatches share the real geometry's colours and
  are identified by stroke-width and position, then dropped.
- 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a
  mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because
  they are angled bays along the old rail corridor, on no named road at all.
- Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows
  the same jog at the fitted scale, which independently confirms the fit.

App
- Map tab: "City map" layer in the legend's colours, tappable.
- "Park here" pins the car from GPS and auto-detects the containing area
  (40 m snap). With no fix it asks you to tap the spot instead, so the pin
  never depends on GPS working.
- The pin lives in its own storage key, not inside the session: pinning the
  car without starting a timer must survive backing out of the screen.
- Durations cap at the posted limit — a 2-hour space is not offered a
  4-hour timer. Lots and no-limit spots get the long options.
- Reuses the existing foreground-service countdown. The second notification
  button reads "+1 hr" for a city area rather than "Extend": there is
  nothing to buy, so it edits the local timer and says so.
- Account -> Align city map: nudge/scale/rotate the whole overlay against a
  live GPS fix. Save-on-phone needs no admin token, since the person who can
  see the misalignment is the one standing on the street.

Server
- parking_areas + map_overlay tables, public read, admin replace-all. The
  areas come from one source document, so replacement is wholesale rather
  than an upsert.

Dropped geometryCenter from the geo module: on the real data it returns a
point in the water for the crescent City Beach lot and mid-block for
L-shaped runs. Nothing used it.

Tests: 8 geometry tests in app/, 5 area/overlay tests in server/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 03:28:18 +00:00
ad55559f55 v0.5.0: persistent parking notification — foreground service, End/Extend
All checks were successful
build-apk / build (push) Successful in 28m55s
The ongoing countdown now appears whenever a car is parked, paid or free,
and sticks around the way ntfy's does on GrapheneOS.

Why it wasn't showing at all on a paid park: StartSessionScreen carried its
own stricter copy of parseApiTime that demanded MM-DD-YYYY + AM/PM. The
dual-format fix from deb78bf only landed in sessionStatus.ts, so when the
API returned the other format the parser returned null and the notification
was simply never posted. There is now one parser (api/parseTime.ts), and the
failure logs instead of going silent.

Persistence: a plain notify() was never enough — Android 14+ lets the user
swipe an ongoing notification away, and nothing brought it back after a
reboot. BbpSessionService is a real foreground service (type specialUse) that
owns the notification, plus a BOOT_COMPLETED receiver to restore it; specialUse
is one of the types Android 14/15 still allow to start from BOOT_COMPLETED.
The service's life is exactly the session's life: it stops itself, removing
the notification, on End, at expiry, or when there's no session to show.

Buttons: paid sessions previously got no actions at all (only free check-ins
did). Both now get End and Extend/Pay. End stops tracking — honest about the
fact that ParkSmarter has no stop-session endpoint, so bought time keeps
running at the meter. Extend opens the purchase screen for that exact zone.

The active session (with its Zone) is persisted locally, so the countdown and
Extend survive reboot, offline, and Anonymous Mode instead of depending on a
round-trip. sessionStatus.ts + features/checkin collapse into one owner,
features/session/activeParking — one record, one notification.

Verified: :bbp-notify:compileDebugKotlin passes, and the merged app manifest
carries the service (specialUse + FGS subtype property), both receivers, and
the FOREGROUND_SERVICE/SPECIAL_USE/RECEIVE_BOOT_COMPLETED permissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:10:54 +00:00
796674bca4 v0.4.1: check-in — one tap for a labeled zone, picker only when unlabeled
All checks were successful
build-apk / build (push) Successful in 32m30s
A zone labeled free_2h/3h/4h checks in straight at that limit (no prompt); an
unlabeled zone shows the 2h/3h/4h picker; pay-immediate zones show no check-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 22:10:42 +00:00
4a5660e7e1 v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone
Some checks failed
build-apk / build (push) Failing after 1h56m52s
Anonymous Mode — "Park without signing in" on the login screen (with a popup of
what works vs needs a login). Anonymous users browse parking areas from our
mirror, see labels, and start free check-in timers; paying, sessions, and
account screens prompt to sign in. AuthContext gains an 'anonymous' status +
enterAnonymous/requireLogin.

Zone mirror — server gains a `zones` table + public GET /api/zones and admin
POST /api/zones/sync. Signed-in admins push the zones they pull (authed) from
ParkSmarter after each map search, so anonymous users can read areas without a
ParkSmarter login. Map/Scan read the mirror when anonymous.

Also: the free "Check in" button is now always available with a 2h/3h/4h picker
(no longer gated on a prior label) — fixes "couldn't start a timer on a free
zone". CORS probe confirmed ParkSmarter allows any origin but only Content-Type,
so a future PWA can't auth to it — the mirror is what makes anonymous browsing
(and a PWA) possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 21:38:32 +00:00
463facbe5a v0.3.0: local free check-in with actionable countdown notification (Phase C)
All checks were successful
build-apk / build (push) Successful in 40m22s
For a zone labeled free_2h/3h/4h, "Check in (free · Xh)" starts a local, API-free
countdown to the free limit — no ParkSmarter call. The bbp-notify module gains a
showCheckin() that posts the ticking chronometer with "End" / "Pay" buttons, a
BbpActionReceiver (declared in the module's new AndroidManifest.xml) that records
the tap in SharedPreferences and, for Pay, relaunches the app; consumePendingAction()
is drained on foreground by useCheckinSync() — End clears the check-in, Pay hands
off to StartSession for the stored zone. A pre-expiry alert reuses the existing
reminder lead-minutes. Starting a paid session supersedes an active check-in.

Also: MeterDetail shows the free/pay-immediate label and (admins) labeling chips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:23:58 +00:00
eeaf09ea0e app: zone-labels client + admin labeling UX (Phase B) + default map = downtown Sandpoint
- zoneLabels client (cached fetch wrapper) hitting bigbrainparking.mowden.top;
  adminStore keeps the admin password in secure-store.
- Admin screen (verify + save password) reachable from Account → Admin.
- MeterDetail shows a label badge and, for admins, 2h/3h/4h/Pay-now/Clear
  labeling controls that PUT/DELETE to the API.
- app.json extra.zoneLabelsApiUrl.
- Map default view is now downtown Sandpoint (48.2766,-116.5533 @ z14) instead
  of the continental-US fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:18:35 +00:00
c14081d85f server: zone-labels API (Fastify + SQLite) — Phase A
New server/ service classifying zones as free_2h/3h/4h street vs pay_immediate
lots. Public reads; writes require the admin bearer token (timing-safe compare).
@fastify/rate-limit (120/min global, 20/min writes), manual IP denylist +
auto-block on repeated auth failures. SQLite via better-sqlite3. Dockerfile +
compose (loopback-only, mem/cpu capped) + nginx block + README. 9 tests pass.

Deployed live at https://bigbrainparking.mowden.top (behind nginx + certbot).
Not an npm workspace — kept out of the app/CI install to avoid the native dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:09:03 +00:00
2434879804 docs: RE report + correct the privacy writeup from live analysis
Adds OFFICIAL_APP_REVERSE_ENGINEERING.md — a technical teardown (methodology,
auth model, device-ID, GPS, Instabug) with mermaid diagrams and the mitmproxy
capture timeline.

Corrects OFFICIAL_APP_PRIVACY.md, which was wrong on two counts:
- IMEI: the app reads no IMEI; it sends the Android SSAID to /api/Device in a
  field misleadingly named IMEINumber.
- Trackers: Segment/Amplitude/Firebase-Analytics/Sentry/ad-ID are all ABSENT;
  the real telemetry SDK is Instabug (session replay, screenshots, network logs).
Also folds in the confirmed GPS auto-send (with the GrapheneOS caveat).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:11:26 +00:00
2bdb59b49a v0.2.6: fix kiosk-code scan resolution + add zone QR asset
All checks were successful
build-apk / build (push) Successful in 30m18s
ScanScreen.lookupCode tried getMetersByScannerCode first and let its thrown
error-envelope abort the whole lookup, so the getMetersByZoneName fallback never
ran; it also sent ZoneName in the scanned case (the API is case-sensitive — the
official app lowercases). Confirmed via live mitmproxy capture of the official
app: a scanned code resolves through ScannerCode, and ZoneName=dl (lowercase)
returns the zone.

Now tries ScannerCode -> ZoneName -> zonename(lowercased) -> TerminalSerNo, each
independent and non-fatal; only reports "Lookup failed" if every attempt errored.

Adds docs/zone-qr/ with the DL-Sandpoint QR (encodes ScannerCode "DL", works in
both BigBrainParking and the official app) and a how-to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:08:49 +00:00
deb78bf952 v0.2.5: parse live-session date format so the countdown actually posts
All checks were successful
build-apk / build (push) Successful in 36m28s
The active-session endpoint (/api/ParkingSession) returns EndTime as
"MM/DD/YYYY HH:MM:SS" (slashes, 24h, seconds), but parseApiTime only matched
the past-session "MM-DD-YYYY hh:mm AM/PM" form. Hermes' Date() can't parse
either, so live sessions parsed to null, got filtered out, and
refreshSessionStatus cleared the notification instead of posting it (log
showed "active sessions raw" but never "show native=").

parseApiTime now accepts both: - or / separators, optional seconds, optional
AM/PM (24h when absent). Added a "[STATUS] parsed N future-end session(s)" log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:49:34 +00:00
2b973348bc v0.2.4: fix countdown notification not appearing + wrong time-remaining
All checks were successful
build-apk / build (push) Successful in 28m23s
Countdown notification "nothing appears" on session start:
- The small icon was the adaptive launcher mipmap (applicationInfo.icon),
  which Android 13+/GrapheneOS rejects as an invalid notification small icon
  and drops the post silently. Ship a proper white-on-transparent vector
  small icon (bbp_stat_parking) inside the module and use it.
- showCountdown now returns a diagnostic string (posted / notifications
  disabled / exception) instead of swallowing failures; JS logs the branch,
  permission result, and native outcome to the in-app diagnostics log.
- Log the raw active-session payload in refreshSessionStatus (element shape
  was previously unconfirmed).

Time remaining showed "7694 min":
- API's TimeRemaining is SECONDS, not minutes. Format it as hours+minutes
  in SessionDetail; annotate the type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:55:45 +00:00
9323bbd6c6 chore: untrack + gitignore local module gradle build output
All checks were successful
build-apk / build (push) Successful in 10m58s
Accidentally committed modules/bbp-notify/android/build/ from a local compile
check; remove it and ignore it going forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:05:09 -07:00
d0f31d5278 v0.2.3: true ticking countdown via a tiny native module (no deps)
Some checks failed
build-apk / build (push) Has been cancelled
Replaces the static status notification with a native Android chronometer that
counts DOWN to the session end — the system ticks it every second with zero app
CPU/battery, visible without opening the app.

- modules/bbp-notify: a ~50-line local Expo module using only NotificationCompat
  (setWhen + setUsesChronometer + setChronometerCountDown + ongoing). Zero
  third-party dependencies, no Firebase — fits the de-Googled ethos. Verified:
  autolinks + Kotlin compiles.
- sessionStatus.ts uses it when present, else falls back to the expo-notifications
  static expiry-time notification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:03:53 -07:00
b9bd6bd1c6 v0.2.2: active-session "time left" status notification (default on)
Some checks failed
build-apk / build (push) Has been cancelled
Starting a session posts an ongoing notification showing the expiry time + time
remaining, so you can glance at how long you have left without opening the app.
- Default ON; toggle in Account -> Notifications ("Active-session countdown").
- Kept fresh on app open / foreground (re-posts nearest-expiring active session,
  clears when none remain). Quiet channel, no banner/sound on refresh.
- Purely local (expo-notifications); no server/push.

Note: expo-notifications can't render a live per-second ticking countdown in the
background, so it shows the exact expiry time (always accurate) + remaining, and
refreshes on foreground. A true ticking chronometer would need notifee (native).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:46:39 -07:00
73079ca659 docs: show the big-brain-driver icon at the top of the README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:35:48 -07:00
b1620ed19e v0.2.1: big-brain-driver app icon
All checks were successful
build-apk / build (push) Successful in 13m50s
New launcher icon: the big-brain guy driving with his brain filling the back seat.
- icon.png: full-frame artwork (1024x1024).
- adaptive-icon.png: car scaled into the central safe zone so the Android
  circle/squircle mask doesn't crop it; adaptive background set to white.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:33:15 -07:00
8a5408d193 docs: plain-language privacy analysis of the official ParkSmarter app
docs/OFFICIAL_APP_PRIVACY.md — readable-by-anyone comparison based on the official
app's manifest + decompiled bundle (v4.4.0): auto-sends GPS to IPS on map open;
bundles Segment/Amplitude/Firebase/Sentry + ad-ID + install-referrer; can send
IMEI; but NO background location. Contrasts with BigBrainParking (explicit-only
location, zero analytics). Confidence levels labeled. Linked from README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:49:16 -07:00
f10dbbc45c privacy: remove personal test coordinate from committed docs/tests
The Sandpoint lat/lng from .creds.json had been baked into the API-doc example and
the integration test. Doc now uses a rounded ~downtown point; the test reads the
coordinate from the gitignored .creds.json (rounded, non-personal fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:35:33 -07:00
0a6ca53c04 v0.2.0: never auto-send GPS; "Last lot" uses last session; About page rework
All checks were successful
build-apk / build (push) Successful in 11m47s
Location privacy (hard invariant): the device GPS is sent to the API ONLY when
the user taps "My location" and searches.
- Map opens on your LAST session's parking lot (the meter's coordinate from
  history), not your GPS.
- "Last" -> "Last lot": centers on the last session's lot and searches there
  (~couple-mile view) — no GPS.
- "Search this area" falls back to the tracked viewport center, never the GPS fix.

About page: lead with a BigBrainParking open-source blurb + repo link (was showing
ParkSmarter's own About text first); privacy wording updated to match; drop the
now-unused getAbout fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:29:13 -07:00
70047b3964 docs: add ParkSmarter API usage guide + endpoint reference
- docs/PARKSMARTER_API.md: narrative guides (bootstrap/auth, finding parking,
  estimates, initiating a transaction, sessions/receipts, account) + per-endpoint
  reference + a behaviors/gotchas table (200-on-error, stale-token empty body,
  multi-estimate fallback, free windows, flat-rate zones, no free check-in).
- client README: link the guide; promote PastSession/ParkingReceipt to CONFIRMED
  (ActiveSession still pending — no active session at capture time).
- Main README: mark "Start a paid session" as working (confirmed live end-to-end).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:25:54 -07:00
8a2a87a1a9 v0.1.9: About page — plain-English "what's sent to IPS" + privacy blurb
All checks were successful
build-apk / build (push) Successful in 16m49s
Documents exactly what reaches ParkSmarter's API (sign-in, account, a single
map-search coordinate, session-start details) and what never does: device GPS is
used only on-device to position the map and cached locally — never attached to
sign-in/sessions/vehicles/payments; no background location, no IMEI/device-id/ad
tracking, no Google services. Audited against the source: the only endpoint that
sends a coordinate is getMetersByLocation, called solely from map searches with
the map's centered point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:48:33 -07:00
26b6a3ea82 CI: pre-install NDK+CMake with retry (SDK mirror served a corrupt CMake zip)
All checks were successful
build-apk / build (push) Successful in 10m37s
v0.1.7 build failed on ':react-native-screens:configureCMake' —
ZipException "Archive is not a ZIP archive" from a truncated cmake;3.22.1
download. Install ndk+cmake up front and retry up to 3x, clearing the partial
dir between attempts so a bad download self-heals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:42:56 -07:00
4cb5478737 v0.1.8: tap a session for full detail + receipt
- Sessions list items are now tappable -> SessionDetailScreen.
- Past sessions fetch the full receipt (GET /api/ParkingReceipt): vehicle,
  start/end, time purchased, parking/fee/total, payment (MASTERCARD ••1234),
  auth code, meter, city, transaction id. Adds an "Email me this receipt" action.
- Active sessions show zone/space/start/ends/time-remaining.
- Confirm PastSession + ParkingReceipt types from a live DL-zone session (were
  UNCONFIRMED); fix past-session card to show Zone/Description, not blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:38:39 -07:00
42a96b0cff v0.1.7: collapse flat-rate zones to one option; add DL capture script
Some checks failed
build-apk / build (push) Failing after 8m10s
- Start-session: when the single-estimate fallback finds the same price at the
  min and max duration (a flat-rate zone like DL), show ONE option — "$X flat,
  parked until <end>" — instead of a wall of identical-price rungs. No reason to
  offer shorter durations when the price doesn't change.
- capture-dl.mjs: dumps the DL zone's live requests+responses (tokens redacted)
  to ~/Downloads/dl-capture/ for a durable record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:58:49 -07:00
65fbd86644 v0.1.6: validate the session on open, not mid-map
All checks were successful
build-apk / build (push) Successful in 38m15s
A stale/expired token doesn't 401 — /api/User returns an empty body, so
getUserDetail() resolved to undefined without throwing and the launch probe set
signedIn anyway. The dead session then only surfaced when the map/kiosk calls
ran. Now the launch check requires real user data (PersonalPhone/Email); if it's
missing it clears the token and shows the login screen at open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:46:04 -07:00
7212badd2d v0.1.5: detect free windows in the estimate fallback + add API tests
All checks were successful
build-apk / build (push) Successful in 49m41s
App:
- When the single-estimate fallback sees MaxTime 0 (or an all-$0.00 ladder),
  show "Parking is currently free" instead of a bogus $0.00 purchase ladder.
  Free-detection extracted to tested client helpers (isFreeEstimate/ladderAllFree).

Client + tests (node:test, zero deps):
- src/estimates.ts: pure isFreeEstimate/ladderAllFree helpers (exported).
- test/estimates + test/http (mocked fetch): headers, query serialization,
  error->ParkSmarterApiError, rolling Auth_Token refresh, 401 clears token.
- test/integration: live checks against the Sandpoint (DSB) zones; targets the
  `test` instance by default and SKIPS when it's unreachable (non-prod is
  IP-restricted -> 403). Verified 3/3 green against prodv2 read-only.
- CI runs `npm test` as a build gate.

Note: dev/stage/test instances return 403 "Web App - Unavailable" from the
public internet (IP-restricted). API schema is otherwise unchanged vs Jul 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:42:58 -07:00
f8d9e2970c v0.1.4: fix silent failure on zones that reject ParkingEstimateMulti
All checks were successful
build-apk / build (push) Successful in 14m27s
Some zones (e.g. short-term "DL" spaces at the downtown lot) return
Response.Status:Error "Unable to process your request" for ParkingEstimateMulti
even though the single-duration ParkingEstimate works. The screen swallowed that
error -> empty duration list, disabled Pay button, no message ("nothing happens").

- Fall back to a single-estimate ladder across the zone's Min/Max time so these
  zones are purchasable (verified live: flat ~$0.10 up to ~33 min).
- Surface estimate errors with a message + Try again, instead of a blank screen.
- Log the estimate fallback/failure to the on-device diagnostics log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:31:22 -07:00
30f32bc494 CI: force IPv4 on ALL build JVMs via JAVA_TOOL_OPTIONS
All checks were successful
build-apk / build (push) Successful in 16m15s
The refactor dropped the command-line -Djava.net.preferIPv4Stack from gradlew, so
artifact-transform/worker JVMs reverted to IPv6 and couldn't reach dl.google.com
("Network is unreachable"). JAVA_TOOL_OPTIONS is inherited by every spawned JVM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:47:36 -07:00
626e46a55f v0.1.3: version bump for a clean CI trigger on the cached runner
Some checks failed
build-apk / build (push) Failing after 21m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:22:19 -07:00
ba3ae8b3e6 CI: dedicated cached runner for this repo (deploy script + volume caches)
- scripts/deploy-runner.sh: compose-managed BigBrainParking Forgejo runner
  (adapted from the Camp Scan one), with valid_volumes for bigbrainparking-gradle
  and bigbrainparking-npm. No android-sdk volume — the RN build image ships it.
- Workflow mounts those caches (GRADLE_USER_HOME + npm cache) and enables the
  Gradle build cache (org.gradle.caching + --build-cache), so repeat builds skip
  the dep re-download and recompile that dominate a cold ~35 min build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:05:35 -07:00
208cddee33 CI: cap Kotlin daemon heap (the real memory hog) + container swap off
Some checks are pending
build-apk / build (push) Waiting to run
Server report (20GB/4-core box) showed 3 Kotlin daemons at -Xmx5376m each + clang
swarm ~9GB thrashing swap. Cap kotlin.daemon.jvmargs and org.gradle.jvmargs to
1536m in gradle.properties, workers.max=2, no parallel. Container: --cpus=2
--memory=8g with swap disabled (--memory-swap==--memory) so it OOM-fails instead
of thrashing disk. Build now uses ~5GB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:40:39 -07:00
0b8713790b CI: cap build resources so it can't starve the host
Container hard limits: --cpus=2 --memory=6g (Docker-enforced regardless of Gradle).
Gradle: -Xmx3g heap, --max-workers=2, no parallel project builds. Adjust the
--cpus/--memory to the server's size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:37:20 -07:00
870da6f432 v0.1.2: theme Favorites screen (dark mode); versionCode 2
Some checks failed
build-apk / build (push) Failing after 1h1m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:31:48 -07:00
86 changed files with 9643 additions and 316 deletions

View file

@ -22,6 +22,23 @@ jobs:
# A purpose-built RN Android image (Android SDK + NDK + Node). Runner needs Docker.
container:
image: reactnativecommunity/react-native-android:latest
# Hard caps so the build can't starve the 20GB/4-core host: 2 CPUs, 8 GB RAM,
# and NO container swap (--memory-swap == --memory) so it OOM-fails cleanly
# instead of thrashing the disk with swap. With the JVM caps below it uses ~5 GB.
options: --cpus=2 --memory=8g --memory-swap=8g
# Persistent caches (whitelisted in the runner's config.yaml valid_volumes).
# Gradle home = downloaded Maven deps + build cache; npm = package cache.
# Repeat builds skip the re-download/re-compile that dominate a cold build.
volumes:
- bigbrainparking-gradle:/opt/cache/gradle
- bigbrainparking-npm:/opt/cache/npm
env:
GRADLE_USER_HOME: /opt/cache/gradle
npm_config_cache: /opt/cache/npm
# Applies to EVERY JVM the build spawns (Gradle workers, artifact transforms,
# Kotlin daemon) — the runner container has no IPv6 route, so without this the
# transform workers try dl.google.com over IPv6 and get "Network is unreachable".
JAVA_TOOL_OPTIONS: "-Djava.net.preferIPv4Stack=true"
steps:
- uses: actions/checkout@v4
@ -31,11 +48,26 @@ jobs:
- name: Build the API client
run: npm run build --workspace parksmarter-client
# RN 0.79 wants a specific NDK; install it if the image doesn't ship it (best-effort).
- name: Ensure NDK
# Unit tests (mocked fetch) gate the build; integration tests skip here
# because the runner can't reach the IP-restricted non-prod instances.
- name: Run client tests
run: npm test --workspace parksmarter-client
# Pre-install the NDK + CMake the RN native modules need. Do it here (not via
# Gradle's mid-build auto-install) and retry: the SDK mirror sometimes serves
# a truncated archive ("Archive is not a ZIP archive"), which self-heals on a
# clean retry. Clearing the partial dir between tries avoids reusing the bad file.
- name: Ensure NDK + CMake
run: |
yes | sdkmanager --licenses >/dev/null 2>&1 || true
sdkmanager "ndk;27.1.12297006" >/dev/null 2>&1 || true
ok=0
for i in 1 2 3; do
if sdkmanager "ndk;27.1.12297006" "cmake;3.22.1"; then ok=1; break; fi
echo "sdkmanager attempt $i failed; clearing partial CMake and retrying"
rm -rf "${ANDROID_HOME:-/opt/android}/cmake/3.22.1" "${ANDROID_SDK_ROOT:-/opt/android}/cmake/3.22.1" 2>/dev/null || true
sleep 5
done
[ "$ok" = 1 ] || echo "WARNING: NDK/CMake pre-install did not confirm; the build may retry it."
- name: Restore signing keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "${{ github.workspace }}/app/release.keystore"
@ -44,6 +76,22 @@ jobs:
working-directory: app
run: CI=1 npx expo prebuild --platform android --no-install
# The real memory hog is the Kotlin daemon (default ~5.4 GB heap EACH, several
# spawned). Cap every JVM in gradle.properties so the build fits comfortably.
- name: Cap Gradle/Kotlin memory
working-directory: app/android
run: |
cat >> gradle.properties <<'PROPS'
# --- CI resource caps (host is small; don't thrash swap) ---
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Djava.net.preferIPv4Stack=true
org.gradle.workers.max=2
org.gradle.parallel=false
org.gradle.daemon=false
org.gradle.caching=true
kotlin.daemon.jvmargs=-Xmx1536m
PROPS
- name: Assemble signed release
working-directory: app/android
env:
@ -51,10 +99,14 @@ jobs:
BBP_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
BBP_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
BBP_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# Force IPv4: the runner's container has no IPv6 route, so dl.google.com
# (Google Maven) is "Network is unreachable" over its default AAAA record.
GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true -Dorg.gradle.jvmargs=-Djava.net.preferIPv4Stack=true"
run: ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon -Djava.net.preferIPv4Stack=true
# Also cap the no-daemon launcher JVM (org.gradle.jvmargs targets the daemon).
GRADLE_OPTS: "-Xmx1536m -Djava.net.preferIPv4Stack=true"
# --max-workers=2 limits concurrent tasks; container --cpus=2 caps clang -j.
# --build-cache reuses task outputs from GRADLE_USER_HOME across runs.
run: >-
./gradlew :app:assembleRelease
-PreactNativeArchitectures=arm64-v8a
--no-daemon --max-workers=2 --build-cache
# Self-contained publish via the Forgejo API (no external action to fetch).
- name: Publish release + upload APK

100
README.md
View file

@ -1,3 +1,7 @@
<p align="center">
<img src="app/assets/icon.png" alt="BigBrainParking — big-brain driver" width="220">
</p>
# BigBrainParking
An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system,
@ -35,14 +39,69 @@ bigbrainparking/
| --- | --- | --- |
| Phone + password login | ✅ wired | tokens in OS keystore |
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
| "Near my last location" proximity search | ✅ wired | caches last GPS fix |
| City parking map overlay (2h/3h/4h/no-limit/lots) | ✅ wired | the city's printed map, georeferenced — **never touches the IPS API** |
| "Park here" pin + time tracking on any city area | ✅ wired | GPS or hand-placed pin, auto-detects the area, local countdown |
| Last-lot + My-location search | ✅ wired | opens on your last session's lot; GPS sent only via explicit "My location" |
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
| Active / past sessions | ✅ wired | list views |
| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) |
| Active / past sessions | ✅ wired | list views + tap for full receipt |
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
| Ongoing parking countdown | ✅ wired | foreground service; ticks down, **End** / **Extend** buttons, survives reboot |
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
## The city parking map
The **City map** layer on the Map tab is the City of Sandpoint's printed *Downtown &
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
phone's own clock, and the notification is the same foreground service every other session
uses. So tracking your time on a free city spot works with no account, no signal, no
payment, and in Anonymous Mode.
The **green city lots are the exception** — they're the map's only paid category, and paying
for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking
you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park
here" says so rather than reporting nothing nearby.) A single lot can be flipped back via
the server's `requiresAccount` field if it turns out to take payment another way.
Two ways to start:
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
stays on the map until you end the session, because "where did I leave the car" is half
the point.
- **Tap a coloured segment** — pick the block directly, no pin needed.
Either way you choose how long to track, capped at the posted limit (a 2-hour space won't
offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing notification's
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
edits the local timer and says so.
The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour,
end it, and see recent ones — with no account and no network, because that is the only
place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and
failing to reach them (offline, or signed out) never hides the local half.
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
Because a few metres is the difference between two sides of a street, **Account → Align city
map** lets you nudge the whole overlay against a live GPS fix and save it — on the phone, or
published to the server for every device if you hold the admin token.
## Privacy
BigBrainParking sends your location to ParkSmarter **only** when you explicitly tap "My
location" and search; it opens on your last parking lot instead of your GPS, and ships **no**
analytics/tracking (no Segment/Amplitude/Firebase/Sentry, no ad-ID, no Google services).
For a plain-language comparison with the official app — which auto-sends your GPS on map
open and bundles that tracking stack — see
**[docs/OFFICIAL_APP_PRIVACY.md](docs/OFFICIAL_APP_PRIVACY.md)**. What reaches the API and
what never does is also spelled out in the app's **About** page.
## Build & run (dev)
Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device)
@ -61,12 +120,35 @@ in `app/app.json` to point elsewhere.
## Notifications on GrapheneOS
Session-expiry reminders are scheduled **entirely on-device** from each session's end time
(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play
Services. They work fully offline. Configure the lead time (default 15 min) in
**Account → Notifications**, where a **"Send a test reminder"** button lets you confirm it
fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any
*future* server-initiated messages; nothing time-based needs it.
Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services —
so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for
any *future* server-initiated messages; nothing time-based needs it.
**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or
a free check-in — a persistent notification shows the time left and ticks down, with
**End** and **Extend** buttons. It is held up by a real **foreground service**
(`modules/bbp-notify`, type `specialUse`), which is what makes it stick on GrapheneOS the
way ntfy's does: it survives the app being killed, can't be swiped away, and a
`BOOT_COMPLETED` receiver brings it back after a reboot. The service's life is exactly the
session's life — it stops itself, removing the notification, on **End**, when the meter
runs out, or whenever there's no session to show.
The countdown itself costs no battery: the end time is handed to Android as a
[chronometer](https://developer.android.com/reference/android/app/Notification.Builder#setChronometerCountDown(boolean)),
and the system redraws the ticking text with the app closed and no timer of its own.
- **End** stops tracking and clears the notification. On a *free check-in* that genuinely
ends it. On a *paid* session it only stops the display — ParkSmarter has no stop-session
endpoint, so time you already bought keeps running at the meter either way.
- **Extend** (labeled **Pay** on a free check-in) opens the purchase screen for that exact
zone. The active session is stored locally *with its zone*, so this works offline, in
Anonymous Mode, and after a reboot. Extending doesn't end anything until the purchase
actually goes through.
**Expiry reminders** fire a configurable lead time (default 15 min) before the end, via
`AlarmManager`. Configure them in **Account → Notifications**, where a **"Send a test
reminder"** button lets you confirm they fire on your phone; the same screen has a toggle
for the ongoing countdown.
## Distribution via Obtainium (self-hosted)

3
app/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# local native module build outputs
modules/**/android/build/

View file

@ -3,17 +3,18 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.1.1",
"version": "0.6.4",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
"versionCode": 25,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0F2A33"
"backgroundColor": "#FFFFFF"
},
"permissions": [
"ACCESS_COARSE_LOCATION",
@ -43,6 +44,7 @@
],
"extra": {
"psEnvironment": "prodv2",
"zoneLabelsApiUrl": "https://bigbrainparking.mowden.top",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
"unifiedPushDefaultDistributor": "io.heckel.ntfy",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 745 KiB

Before After
Before After

View file

@ -0,0 +1,27 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
group = 'expo.modules.bbpnotify'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
useExpoPublishing()
android {
namespace "expo.modules.bbpnotify"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "androidx.core:core-ktx:1.13.1"
}

View file

@ -0,0 +1,37 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The countdown is pinned by a foreground service so it survives the app being
killed and can be restored after a reboot. "specialUse" is the right type: a
user-visible parking timer isn't media, location, or data sync. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application>
<service
android:name="expo.modules.bbpnotify.BbpSessionService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Ongoing countdown for the parking session the user paid for or checked into, with end/extend actions." />
</service>
<!-- Fully-qualified names: a relative ".Name" would resolve against the app
package, not this module's namespace, once manifests are merged. -->
<receiver
android:name="expo.modules.bbpnotify.BbpActionReceiver"
android:exported="false" />
<!-- BOOT_COMPLETED and MY_PACKAGE_REPLACED are protected system broadcasts, so
the system still delivers them to a non-exported receiver. -->
<receiver
android:name="expo.modules.bbpnotify.BbpBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,40 @@
package expo.modules.bbpnotify
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Handles the notification's "End" / "Extend" buttons, even when the app process is
* dead. Each button records its choice in [BbpSessionStore]; JS picks it up via
* `consumePendingAction` the next time it runs and finishes the job on its side
* (clearing local state, cancelling the expiry reminder, opening the pay screen).
*/
class BbpActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_END -> {
// Ending is immediate and local: drop the session and take the notification
// down with the service, without waiting for the app to be opened.
BbpSessionStore.setPending(context, "end")
BbpSessionStore.clear(context)
BbpSessionService.stop(context)
}
ACTION_EXTEND -> {
// Extending does NOT end anything — the time already bought keeps running,
// and the purchase might be abandoned. Leave the session and its countdown
// alone; JS replaces them if and when more time is actually bought.
BbpSessionStore.setPending(context, "extend")
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(launch)
}
}
}
}
companion object {
const val ACTION_END = "expo.modules.bbpnotify.END"
const val ACTION_EXTEND = "expo.modules.bbpnotify.EXTEND"
}
}

View file

@ -0,0 +1,31 @@
package expo.modules.bbpnotify
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Brings the parking countdown back after a reboot or an app update, without the
* user having to open the app.
*
* `specialUse` is one of the foreground-service types Android 14/15 still allow to
* be started from BOOT_COMPLETED, so the service can claim its notification here.
* [BbpSessionStore.load] returns null for an already-expired session, so a stale
* record from yesterday's parking never resurrects itself.
*/
class BbpBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
else -> return
}
if (BbpSessionStore.load(context) == null) {
// No live session — make sure an expired record doesn't linger.
if (BbpSessionStore.hasRecord(context)) BbpSessionStore.clear(context)
return
}
BbpSessionService.start(context)
}
}

View file

@ -0,0 +1,92 @@
package expo.modules.bbpnotify
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
/**
* Builds the ongoing parking notification.
*
* The remaining time is an Android **chronometer counting down** to the session
* end: `setWhen(endMs)` + `setUsesChronometer` + `setChronometerCountDown` hand the
* end time to the system, which redraws the ticking text itself every second. The
* app burns no CPU and needs no timer of its own, so the countdown stays accurate
* while the app is closed.
*
* Shared by the foreground service and by the boot receiver's fallback path.
*/
internal object BbpNotification {
const val CHANNEL_ID = "session-status"
const val NOTIF_ID = 42421
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(NotificationManager::class.java)
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
val channel =
NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
channel.setShowBadge(false)
mgr.createNotificationChannel(channel)
}
}
fun build(ctx: Context, s: BbpSessionStore.Session): Notification {
ensureChannel(ctx)
val builder = base(ctx)
.setContentTitle(s.title)
.setContentText(s.body)
.setWhen(s.endMs)
.setShowWhen(true)
.setUsesChronometer(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setChronometerCountDown(true)
}
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
builder.setContentIntent(
PendingIntent.getActivity(
ctx, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
builder.addAction(0, s.endLabel, actionIntent(ctx, BbpActionReceiver.ACTION_END))
builder.addAction(0, s.extendLabel, actionIntent(ctx, BbpActionReceiver.ACTION_EXTEND))
return builder.build()
}
/**
* A bare notification for the start-then-immediately-stop path. A service launched
* with startForegroundService() must call startForeground() within a few seconds or
* the system kills the process, and that holds even when we've just discovered there
* is no session to show so we post this and remove it in the same breath. It is
* never on screen long enough to be seen.
*/
fun placeholder(ctx: Context): Notification {
ensureChannel(ctx)
return base(ctx).setContentTitle("Parking").build()
}
private fun base(ctx: Context) =
NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.bbp_stat_parking)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
private fun actionIntent(ctx: Context, action: String): PendingIntent {
val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
return PendingIntent.getBroadcast(
ctx, action.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}

View file

@ -0,0 +1,63 @@
package expo.modules.bbpnotify
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
/**
* JS bridge for the ongoing parking notification.
*
* The module itself holds no state it writes the session to [BbpSessionStore] and
* lets [BbpSessionService] render it. That keeps the app process out of the loop:
* the notification is just as correct after the JS engine is gone as it is while
* the user is looking at the app. Uses only platform APIs, no third-party deps.
*/
class BbpNotifyModule : Module() {
override fun definition() = ModuleDefinition {
Name("BbpNotify")
/**
* Post (or replace) the countdown for an active session. Returns a short
* diagnostic string so JS can log exactly what happened notifications and
* service starts both fail silently otherwise.
*/
AsyncFunction("showSession") {
title: String, body: String, endTimeMillis: Double, endLabel: String, extendLabel: String ->
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
val endMs = endTimeMillis.toLong()
if (endMs <= System.currentTimeMillis()) {
// Nothing to count down to — treat it as "no session" rather than posting
// a notification that is already at zero.
BbpSessionStore.clear(ctx)
BbpSessionService.stop(ctx)
return@AsyncFunction "expired"
}
BbpSessionStore.save(
ctx,
BbpSessionStore.Session(title, body, endMs, endLabel, extendLabel),
)
BbpSessionService.start(ctx)
}
/** No session: drop the record and stop the service, taking the notification with it. */
AsyncFunction("clearSession") {
// No bare `return@AsyncFunction` here: the lambda's inferred return type is
// Any?, so an early return of Unit doesn't type-check.
appContext.reactContext?.let { ctx ->
BbpSessionStore.clear(ctx)
BbpSessionService.stop(ctx)
}
}
/** Read + clear the action a notification button set. Returns "end", "extend", or "". */
AsyncFunction("consumePendingAction") {
val ctx = appContext.reactContext ?: return@AsyncFunction ""
BbpSessionStore.consumePending(ctx)
}
/** True when a live session record exists — lets JS reconcile after a cold start. */
AsyncFunction("hasActiveSession") {
val ctx = appContext.reactContext ?: return@AsyncFunction false
BbpSessionStore.load(ctx) != null
}
}
}

View file

@ -0,0 +1,149 @@
package expo.modules.bbpnotify
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
/**
* Foreground service that owns the ongoing parking notification.
*
* A plain `notify()` was not enough: on Android 14+ the user can swipe an ongoing
* notification away, and nothing brings it back after a reboot. A foreground
* service pins the notification for as long as the service runs the same reason
* ntfy's notification stays put on GrapheneOS.
*
* The service's lifetime is exactly the session's lifetime. It runs while there is
* an active session and fully stops removing the notification the moment there
* isn't one: on "End", when the countdown reaches zero, or when JS clears it. It
* never lingers with nothing to show.
*/
class BbpSessionService : Service() {
private val handler = Handler(Looper.getMainLooper())
private var expiryTask: Runnable? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// A null intent means the system restarted us after killing the process
// (START_STICKY) — fall through and rebuild from the store.
val stopping = intent?.action == ACTION_STOP
val session = if (stopping) null else BbpSessionStore.load(this)
if (session == null) {
// Satisfy the startForegroundService() contract before standing down, or the
// system kills us for not calling startForeground() in time.
goForeground(BbpNotification.placeholder(this))
if (!stopping) BbpSessionStore.clear(this) // expired record — don't leave it around
shutdown()
return START_NOT_STICKY
}
if (!goForeground(BbpNotification.build(this, session))) {
// The system refused to let us hold a foreground service right now. The
// countdown matters more than the pinning, so leave the notification posted
// on its own and stand the service down rather than crash.
postDirectly(this)
stopSelf()
return START_NOT_STICKY
}
scheduleExpiry(session.endMs)
return START_STICKY
}
/**
* startForeground() can still throw on Android 12+ even after startForegroundService()
* was accepted (background-start restrictions are evaluated here too). An uncaught
* throw would crash the app, so report the failure instead and let the caller fall back.
*/
private fun goForeground(notification: android.app.Notification): Boolean =
try {
startForeground(BbpNotification.NOTIF_ID, notification)
true
} catch (e: Exception) {
false
}
/**
* Stop the moment the meter runs out, so a finished session never leaves a dead
* "0:00" notification pinned to the shade. A posted callback is enough here a
* foreground service is alive to run it, no alarm permission needed.
*/
private fun scheduleExpiry(endMs: Long) {
expiryTask?.let { handler.removeCallbacks(it) }
val task = Runnable {
BbpSessionStore.clear(this)
shutdown()
}
expiryTask = task
handler.postDelayed(task, (endMs - System.currentTimeMillis()).coerceAtLeast(0L))
}
private fun shutdown() {
expiryTask?.let { handler.removeCallbacks(it) }
expiryTask = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
expiryTask?.let { handler.removeCallbacks(it) }
expiryTask = null
super.onDestroy()
}
companion object {
const val ACTION_STOP = "expo.modules.bbpnotify.STOP_SERVICE"
/**
* Start (or refresh) the service from the stored session. Returns a short
* diagnostic string JS logs it, because a failure here is otherwise silent.
*/
fun start(ctx: Context): String {
val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
val intent = Intent(ctx, BbpSessionService::class.java)
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
"service-started enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
} catch (e: Exception) {
// Background-start restrictions can refuse the service (Android 12+). The
// countdown still matters more than the pinning, so fall back to posting the
// notification directly; it just becomes swipe-dismissable.
val fallback = postDirectly(ctx)
"service-failed=${e.javaClass.simpleName} fallback=$fallback enabled=$enabled"
}
}
fun stop(ctx: Context) {
try {
ctx.startService(Intent(ctx, BbpSessionService::class.java).setAction(ACTION_STOP))
} catch (_: Exception) {
// The service may already be gone; make sure the notification is too.
}
NotificationManagerCompat.from(ctx).cancel(BbpNotification.NOTIF_ID)
}
/** Last-resort path: the plain ongoing notification, with no service pinning it. */
fun postDirectly(ctx: Context): String {
val session = BbpSessionStore.load(ctx) ?: return "no-session"
return try {
NotificationManagerCompat.from(ctx)
.notify(BbpNotification.NOTIF_ID, BbpNotification.build(ctx, session))
"posted"
} catch (e: SecurityException) {
"no-permission"
} catch (e: Exception) {
"failed=${e.javaClass.simpleName}"
}
}
}
}

View file

@ -0,0 +1,86 @@
package expo.modules.bbpnotify
import android.content.Context
/**
* The single active-parking record the foreground service renders.
*
* It lives in SharedPreferences rather than in memory because every other actor
* here can run without the JS app: the service after a system restart, the boot
* receiver after a reboot, the action receiver after the process is gone. They
* all read the same row, so there is exactly one source of truth for "is there a
* session, and what does its notification say".
*/
internal object BbpSessionStore {
const val PREFS = "bbp_notify"
private const val K_TITLE = "title"
private const val K_BODY = "body"
private const val K_END = "end_ms"
private const val K_END_LABEL = "end_label"
private const val K_EXTEND_LABEL = "extend_label"
private const val K_PENDING = "pending_action"
data class Session(
val title: String,
val body: String,
val endMs: Long,
val endLabel: String,
val extendLabel: String,
)
private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun save(ctx: Context, s: Session) {
prefs(ctx).edit()
.putString(K_TITLE, s.title)
.putString(K_BODY, s.body)
.putLong(K_END, s.endMs)
.putString(K_END_LABEL, s.endLabel)
.putString(K_EXTEND_LABEL, s.extendLabel)
.apply()
}
/**
* The current session, or null when there isn't one. A record whose end time has
* already passed reads as null so an expired session can never re-post itself
* (e.g. a reboot hours after the meter ran out).
*/
fun load(ctx: Context): Session? {
val p = prefs(ctx)
val end = p.getLong(K_END, 0L)
if (end <= System.currentTimeMillis()) return null
return Session(
title = p.getString(K_TITLE, "") ?: "",
body = p.getString(K_BODY, "") ?: "",
endMs = end,
endLabel = p.getString(K_END_LABEL, "End") ?: "End",
extendLabel = p.getString(K_EXTEND_LABEL, "Extend") ?: "Extend",
)
}
/** True when a record exists at all, expired or not — used to decide if cleanup is needed. */
fun hasRecord(ctx: Context): Boolean = prefs(ctx).contains(K_END)
fun clear(ctx: Context) {
prefs(ctx).edit()
.remove(K_TITLE)
.remove(K_BODY)
.remove(K_END)
.remove(K_END_LABEL)
.remove(K_EXTEND_LABEL)
.apply()
}
fun setPending(ctx: Context, action: String) {
prefs(ctx).edit().putString(K_PENDING, action).apply()
}
/** Read + clear the action a notification button recorded while the app was away. */
fun consumePending(ctx: Context): String {
val p = prefs(ctx)
val action = p.getString(K_PENDING, "") ?: ""
if (action.isNotEmpty()) p.edit().remove(K_PENDING).apply()
return action
}
}

View file

@ -0,0 +1,15 @@
<!--
Notification small icon: a white-on-transparent timer glyph (Material "timer").
Small icons MUST be a simple monochrome drawable — the app launcher mipmap
(applicationInfo.icon) is an adaptive/full-colour icon and is dropped as an
invalid small icon on Android 13+/GrapheneOS, so we ship our own here.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15,1L9,1L9,3L15,3L15,1ZM11,14L13,14L13,8L11,8L11,14ZM19.03,7.39L20.45,5.97C20.02,5.46 19.55,4.98 19.04,4.56L17.62,5.98C16.07,4.74 14.12,4 12,4C7.03,4 3,8.03 3,13C3,17.97 7.02,22 12,22C16.98,22 21,17.97 21,13C21,10.88 20.26,8.93 19.03,7.39ZM12,20C8.13,20 5,16.87 5,13C5,9.13 8.13,6 12,6C15.87,6 19,9.13 19,13C19,16.87 15.87,20 12,20Z" />
</vector>

View file

@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.bbpnotify.BbpNotifyModule"]
}
}

View file

@ -0,0 +1,60 @@
import { Platform } from 'react-native';
import { requireOptionalNativeModule } from 'expo-modules-core';
interface BbpNotifyNative {
/**
* Post/replace the ongoing session notification: a native chronometer counting
* down to endTimeMillis, pinned by a foreground service, with "End" and "Extend"
* action buttons. Returns a short diagnostic string (e.g. "service-started …").
*/
showSession(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
extendLabel: string,
): Promise<string>;
/** No active session: stop the service and remove the notification. */
clearSession(): Promise<void>;
/** Read + clear the action a notification button set: 'end' | 'extend' | ''. */
consumePendingAction(): Promise<string>;
/** Whether the native side still holds a live (unexpired) session record. */
hasActiveSession(): Promise<boolean>;
}
export type PendingAction = 'end' | 'extend' | null;
// Android-only, and only present in a build that includes the native module
// (returns null in Expo Go / other platforms — callers degrade gracefully).
const native =
Platform.OS === 'android'
? (requireOptionalNativeModule('BbpNotify') as BbpNotifyNative | null)
: null;
/** True when the native ticking-countdown module is available. */
export const hasNativeCountdown = native != null;
export async function showSession(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
extendLabel: string,
): Promise<string> {
if (!native) return 'no-native-module';
return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
}
export async function clearSession(): Promise<void> {
await native?.clearSession();
}
/** Read + clear a notification-button action taken while the app was away. */
export async function consumePendingAction(): Promise<PendingAction> {
const a = await native?.consumePendingAction();
return a === 'end' || a === 'extend' ? a : null;
}
export async function hasActiveSession(): Promise<boolean> {
return (await native?.hasActiveSession()) ?? false;
}

View file

@ -0,0 +1,7 @@
{
"name": "bbp-notify",
"version": "0.1.0",
"description": "Native ongoing parking countdown notification (Android chronometer) — zero third-party deps.",
"main": "index.ts",
"private": true
}

View file

@ -8,6 +8,7 @@
"android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test test/*.test.ts",
"ios": "expo run:ios"
},
"dependencies": {
@ -37,6 +38,7 @@
"devDependencies": {
"@types/react": "~19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"tsx": "^4.23.12",
"typescript": "~5.4.0"
}
}

18
app/src/api/adminStore.ts Normal file
View file

@ -0,0 +1,18 @@
import * as SecureStore from 'expo-secure-store';
/**
* The zone-labels admin password, kept in the OS keystore (never AsyncStorage).
* Its presence is what unlocks the labeling controls; it's sent as a Bearer token
* to the bigbrainparking.mowden.top API on writes.
*/
const ADMIN_KEY = 'ps_admin_token';
export function getAdminToken(): Promise<string | null> {
return SecureStore.getItemAsync(ADMIN_KEY);
}
export function setAdminToken(token: string | null): Promise<void> {
return token
? SecureStore.setItemAsync(ADMIN_KEY, token)
: SecureStore.deleteItemAsync(ADMIN_KEY);
}

234
app/src/api/parkingAreas.ts Normal file
View file

@ -0,0 +1,234 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAdminToken } from './adminStore';
import bundled from '@/features/citymap/parkingAreas.json';
import {
adjustGeometry,
overlayAnchor,
IDENTITY_OVERLAY,
type AreaGeometry,
type OverlayAdjust,
} from '@/features/citymap/geo';
/**
* The colour-coded areas from the City of Sandpoint's printed Downtown &
* Waterfront parking map, georeferenced.
*
* This is city geography, not ParkSmarter data no call in this file, or in
* anything that uses it, ever reaches the IPS API. That is the whole point:
* tracking your time on one of these spots has to work with no account, no
* network, and no payment.
*
* Three sources, in order: the server (editable without an app release), the
* on-device cache (so it works offline), and a copy bundled with the app (so a
* fresh install works before the server has ever been reached).
*/
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
export interface ParkingArea {
id: string;
kind: AreaKind;
/** Human name, e.g. "N 3rd Ave · Cedar St to Oak St". */
name: string;
/** Short category label, e.g. "3-hour". */
label: string;
/** The map legend's own wording, e.g. "3-hour or permit". */
legend: string;
/** Default tracked duration in hours; 0 for the no-limit category. */
hours: number;
color: string;
shape: 'line' | 'polygon';
geometry: AreaGeometry;
/**
* Overrides the by-category default in [areaRequiresAccount]. Only set this to
* correct a specific lot e.g. one that turns out to be kiosk- or permit-only.
*/
requiresAccount?: boolean;
}
export interface AreaData {
areas: ParkingArea[];
overlay: OverlayAdjust;
/** Where the set came from, for the diagnostics screen. */
source: 'server' | 'cache' | 'bundled';
}
const CACHE_KEY = 'ps_parking_areas';
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
export class ParkingAreasError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'ParkingAreasError';
}
}
/** Colours match the printed map's legend. */
export const AREA_COLORS: Record<AreaKind, string> = {
green_lot: '#75b259',
free_2h: '#d367cc',
limit_3h: '#ccc542',
limit_4h: '#f78b08',
no_limit: '#c3c4c2',
};
/** Whether parking in this category costs money. Only the city lots do. */
export function areaIsFree(kind: AreaKind): boolean {
return kind !== 'green_lot';
}
/**
* Whether you need a ParkSmarter account to park here.
*
* The city lots are the map's only paid category ("City lots Paid hourly or
* permit"); paying for them means ParkSmarter, so they're no use to someone
* browsing without an account. Everything else is free with a posted time limit
* and needs nothing. A single lot can override this if it turns out to take
* payment some other way.
*/
export function areaRequiresAccount(area: ParkingArea): boolean {
return area.requiresAccount ?? area.kind === 'green_lot';
}
/**
* Durations offered when starting tracking, the posted limit first.
*
* Time-limited spots stop at the posted limit offering to track 4 hours in a
* 2-hour space would just be scheduling a ticket. Lots and unlimited spots have
* no posted ceiling, so they get the long options.
*/
export function areaDurationOptions(area: ParkingArea): number[] {
if (area.kind === 'no_limit' || area.kind === 'green_lot') return [1, 2, 3, 4, 8, 12];
const posted = area.hours || 2;
return [...new Set([posted, 3, 2, 1, 0.5].filter((h) => h <= posted))].sort((a, b) => b - a);
}
const BUNDLED: ParkingArea[] = (bundled as any).features.map((f: any) => ({
...f.properties,
geometry: f.geometry,
}));
/* ------------------------------------------------------------------- cache */
let memCache: AreaData | null = null;
async function readCache(): Promise<AreaData | null> {
const raw = await AsyncStorage.getItem(CACHE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { areas: ParkingArea[]; overlay: OverlayAdjust };
if (!parsed.areas?.length) return null;
return { areas: parsed.areas, overlay: parsed.overlay ?? IDENTITY_OVERLAY, source: 'cache' };
} catch {
return null;
}
}
async function writeCache(areas: ParkingArea[], overlay: OverlayAdjust): Promise<void> {
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify({ areas, overlay }));
}
/* -------------------------------------------------------------------- read */
/**
* The area set, without the overlay correction applied. Never throws and never
* blocks on the network the bundled copy is always a valid answer.
*/
export async function loadAreaData(): Promise<AreaData> {
if (memCache) return memCache;
const cached = await readCache();
memCache = cached ?? { areas: BUNDLED, overlay: IDENTITY_OVERLAY, source: 'bundled' };
return memCache;
}
/**
* The area set ready to draw and hit-test, with the alignment correction baked
* in. Display and hit-testing must use the same geometry or tapping a segment
* would select a different one than the one under your finger.
*/
export async function getAdjustedAreas(): Promise<AreaData> {
const data = await loadAreaData();
return { ...data, areas: applyOverlay(data.areas, data.overlay) };
}
/** Apply an overlay correction to a set of areas (also used for live preview). */
export function applyOverlay(areas: ParkingArea[], overlay: OverlayAdjust): ParkingArea[] {
const anchor = overlayAnchor(areas.map((a) => a.geometry));
return areas.map((a) => ({ ...a, geometry: adjustGeometry(a.geometry, overlay, anchor) }));
}
/** Pull the areas + overlay from the server and cache them. */
export async function refreshAreas(): Promise<AreaData> {
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
const res = await fetch(`${BASE_URL}/api/areas`);
if (!res.ok) throw new ParkingAreasError('areas fetch failed', res.status);
const body = (await res.json()) as { areas?: ParkingArea[]; overlay?: OverlayAdjust };
// An empty server (not yet seeded) must not wipe a working local map.
if (!body.areas?.length) return loadAreaData();
const overlay = body.overlay ?? IDENTITY_OVERLAY;
await writeCache(body.areas, overlay);
memCache = { areas: body.areas, overlay, source: 'server' };
return memCache;
}
/* ------------------------------------------------------------------- admin */
async function authHeaders(): Promise<Record<string, string>> {
const token = await getAdminToken();
if (!token) throw new ParkingAreasError('not authenticated as admin');
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
/** Push the app's bundled area set to the server, seeding or resetting it. */
export async function publishBundledAreas(): Promise<number> {
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
const res = await fetch(`${BASE_URL}/api/areas`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify({ areas: BUNDLED }),
});
if (!res.ok) throw new ParkingAreasError('publish failed', res.status);
const body = (await res.json()) as { replaced?: number };
memCache = null;
await refreshAreas().catch(() => {});
return body.replaced ?? 0;
}
/** Persist an alignment correction so every device picks it up. */
export async function saveOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): Promise<OverlayAdjust> {
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
const res = await fetch(`${BASE_URL}/api/areas/overlay`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify(o),
});
if (!res.ok) throw new ParkingAreasError('overlay save failed', res.status);
const saved = (await res.json()) as OverlayAdjust;
const data = await loadAreaData();
await writeCache(data.areas, saved);
memCache = { ...data, overlay: saved };
return saved;
}
/**
* Store an overlay correction on this device only. Lets the alignment be fixed
* without the admin token the nudge is useful to anyone standing on the street.
*/
export async function saveOverlayLocally(o: Omit<OverlayAdjust, 'updatedAt'>): Promise<void> {
const data = await loadAreaData();
const overlay = { ...o, updatedAt: Date.now() };
await writeCache(data.areas, overlay);
memCache = { ...data, overlay };
}
/** Drop the cache so the next read falls back to bundled/server. */
export async function resetAreaCache(): Promise<void> {
memCache = null;
await AsyncStorage.removeItem(CACHE_KEY);
}

30
app/src/api/parseTime.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
* /api/Session + estimates: "07-15-2026 05:00 PM" dashes, 12h, AM/PM
* /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
* Both are device-local wall-clock time. Hermes' Date() can't parse either, so we
* build the Date from explicit components.
*
* This is the single parser for both an earlier stricter copy that required
* dashes and AM/PM silently returned null on the other format, which meant no
* countdown notification at all for sessions whose end time came back that way.
*/
export function parseApiTime(s?: string | null): Date | null {
if (!s) return null;
const m = String(s).match(
/(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
);
if (!m) {
const d = new Date(s);
return Number.isNaN(+d) ? null : d;
}
let hr = parseInt(m[4], 10);
const ap = m[7];
if (ap) {
const pm = /pm/i.test(ap);
if (pm && hr !== 12) hr += 12;
if (!pm && hr === 12) hr = 0;
}
const sec = m[6] ? parseInt(m[6], 10) : 0;
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
}

142
app/src/api/zoneLabels.ts Normal file
View file

@ -0,0 +1,142 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAdminToken } from './adminStore';
/**
* Client for the BigBrainParking zone-labels API (bigbrainparking.mowden.top).
* Reads are public and cached locally so check-in works offline; writes require
* the admin token. This is a plain fetch wrapper the ParkSmarter HttpClient is
* too domain-specific (its own auth headers / rolling tokens) to reuse here.
*/
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
export interface ZoneLabel {
zoneId: string;
customerId: string | null;
zoneName: string | null;
kind: LabelKind;
note: string | null;
updatedAt: number;
updatedBy: string | null;
}
const CACHE_KEY = 'ps_zone_labels';
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
export class ZoneLabelsError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'ZoneLabelsError';
}
}
/** Hours of free parking for a kind, or null for pay-immediate. */
export function labelHours(kind: LabelKind): number | null {
return kind === 'free_2h' ? 2 : kind === 'free_3h' ? 3 : kind === 'free_4h' ? 4 : null;
}
/** Short human label for a kind. */
export function labelText(kind: LabelKind): string {
switch (kind) {
case 'free_2h':
return 'Free · 2h limit';
case 'free_3h':
return 'Free · 3h limit';
case 'free_4h':
return 'Free · 4h limit';
case 'pay_immediate':
return 'Pay immediately';
}
}
let memCache: Record<string, ZoneLabel> | null = null;
async function loadCache(): Promise<Record<string, ZoneLabel>> {
if (memCache) return memCache;
const raw = await AsyncStorage.getItem(CACHE_KEY);
memCache = raw ? (JSON.parse(raw) as Record<string, ZoneLabel>) : {};
return memCache;
}
async function saveCache(map: Record<string, ZoneLabel>): Promise<void> {
memCache = map;
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(map));
}
function ensureConfigured(): void {
if (!BASE_URL) throw new ZoneLabelsError('zoneLabelsApiUrl is not configured');
}
async function authHeaders(): Promise<Record<string, string>> {
const token = await getAdminToken();
if (!token) throw new ZoneLabelsError('not authenticated as admin');
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
/** Pull all labels and refresh the local cache. */
export async function refreshLabels(): Promise<void> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/labels`);
if (!res.ok) throw new ZoneLabelsError('labels fetch failed', res.status);
const body = (await res.json()) as { labels?: ZoneLabel[] };
const map: Record<string, ZoneLabel> = {};
for (const l of body.labels ?? []) map[String(l.zoneId)] = l;
await saveCache(map);
}
/** Cached label for a zone, or null. Never hits the network. */
export async function getCachedLabel(
zoneId: number | string | null | undefined,
): Promise<ZoneLabel | null> {
if (zoneId == null) return null;
const map = await loadCache();
return map[String(zoneId)] ?? null;
}
export async function setLabel(
zoneId: number | string,
kind: LabelKind,
meta: { zoneName?: string | null; customerId?: string | number | null } = {},
): Promise<ZoneLabel> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify({
kind,
zoneName: meta.zoneName ?? null,
customerId: meta.customerId ?? null,
}),
});
if (!res.ok) throw new ZoneLabelsError('set label failed', res.status);
const label = (await res.json()) as ZoneLabel;
const map = await loadCache();
map[String(zoneId)] = label;
await saveCache(map);
return label;
}
export async function deleteLabel(zoneId: number | string): Promise<void> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
method: 'DELETE',
headers: await authHeaders(),
});
if (!res.ok && res.status !== 404) throw new ZoneLabelsError('delete failed', res.status);
const map = await loadCache();
delete map[String(zoneId)];
await saveCache(map);
}
/** Verify a candidate admin token against the server (for the settings "Test" button). */
export async function verifyAdmin(token: string): Promise<boolean> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/whoami`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.ok;
}

34
app/src/api/zoneMirror.ts Normal file
View file

@ -0,0 +1,34 @@
import Constants from 'expo-constants';
import type { Zone } from 'parksmarter-client';
import { getAdminToken } from './adminStore';
/**
* The zone-location mirror on bigbrainparking.mowden.top. Anonymous users read
* areas from here (no ParkSmarter login). Admins push the zones they pull
* (authed) from ParkSmarter so the mirror stays populated.
*/
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
export async function getMirrorZones(): Promise<Zone[]> {
if (!BASE_URL) return [];
const res = await fetch(`${BASE_URL}/api/zones`);
if (!res.ok) throw new Error(`zones fetch failed ${res.status}`);
const body = (await res.json()) as { zones?: Zone[] };
return body.zones ?? [];
}
/** Push authed-pulled zones to the mirror. No-ops unless an admin token is set. */
export async function syncZones(zones: Zone[]): Promise<void> {
if (!BASE_URL || zones.length === 0) return;
const token = await getAdminToken();
if (!token) return; // only admins populate the mirror
try {
await fetch(`${BASE_URL}/api/zones/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ zones }),
});
} catch {
/* best-effort — never block the UI on a mirror push */
}
}

View file

@ -9,13 +9,18 @@ import { ps } from '@/api/client';
import { authBus } from '@/auth/authBus';
import type { ApplicationValidityResponse } from 'parksmarter-client';
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'anonymous';
interface AuthState {
status: AuthStatus;
validity: ApplicationValidityResponse | null;
login: (phoneNumber: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Enter the app without a ParkSmarter login (browse + free check-in only). */
enterAnonymous: () => void;
/** Leave anonymous mode and show the sign-in screen (e.g. to pay). */
requireLogin: () => void;
isAnonymous: boolean;
error: string | null;
}
@ -26,9 +31,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Let the non-React modules see the mode. Kept in sync here rather than read
// from storage: anonymous mode is deliberately not persisted across restarts.
useEffect(() => {
authBus.isAnonymous = status === 'anonymous';
}, [status]);
// Any 401 from the API (expired/rotated token) bounces us back to sign-in.
useEffect(() => {
authBus.onUnauthorized = () => {
// ...except in Anonymous Mode, where there is no session to expire. A 401
// there just means something asked ParkSmarter a question it had no
// business asking, and bouncing to sign-in would make the app unusable
// without an account — which is the whole point of the mode. Read at call
// time, so this stays correct as the status changes.
if (authBus.isAnonymous) return;
setError('Your session expired — please sign in again.');
setStatus('signedOut');
};
@ -37,18 +54,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
}, []);
// On launch: bootstrap (seeds SessionId + feature flags) and probe for an
// existing token by attempting an authenticated read.
// On launch: bootstrap (seeds SessionId + feature flags), then verify any
// stored token BEFORE showing the app. A stale/expired token doesn't 401 —
// /api/User just returns an empty body, so getUserDetail() resolves to
// undefined without throwing. Require real user data to count as signed in,
// and clear the dead token otherwise, so an expired session lands on the
// login screen at open instead of failing later on the map.
useEffect(() => {
(async () => {
try {
const v = await ps.getApplicationValidity();
setValidity(v);
const existing = await ps.tokens.getAuthToken();
if (existing) {
await ps.getUserDetail(); // 401 throws -> treated as signed out
if (!existing) {
setStatus('signedOut');
return;
}
const user = await ps.getUserDetail().catch(() => null);
const valid = !!(user && (user.PersonalPhone || user.PersonalEmailAddress));
if (valid) {
setStatus('signedIn');
} else {
await ps.logoutLocal(); // drop the dead token so the login screen is clean
setStatus('signedOut');
}
} catch {
@ -75,8 +102,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setStatus('signedOut');
};
const enterAnonymous = () => {
setError(null);
setStatus('anonymous');
};
const requireLogin = () => {
setError(null);
setStatus('signedOut');
};
const value = useMemo<AuthState>(
() => ({ status, validity, login, logout, error }),
() => ({
status,
validity,
login,
logout,
enterAnonymous,
requireLogin,
isAnonymous: status === 'anonymous',
error,
}),
[status, validity, error],
);

View file

@ -2,5 +2,13 @@
* Tiny bridge so the API client (created at module load) can notify the React
* auth layer when a 401 happens, without a circular import. AuthProvider
* registers a handler; the client calls it via app/src/api/client.ts.
*
* `isAnonymous` mirrors the auth status for the non-React modules that need it.
* It matters because the 401 hook is global: it fires on every unauthorized
* response whether or not the caller caught the error, so without this a single
* stray ParkSmarter call in Anonymous Mode throws the user to the sign-in screen.
*/
export const authBus: { onUnauthorized?: () => void } = {};
export const authBus: {
onUnauthorized?: () => void;
isAnonymous: boolean;
} = { isAnonymous: false };

View file

@ -0,0 +1,147 @@
/**
* Geometry for the city parking-map overlay.
*
* Everything here is plain arithmetic on lon/lat no turf, no geo library. The
* covered area is ten blocks of downtown Sandpoint, so a local flat-earth
* approximation is accurate to well under a metre, and hit-testing has to run on
* every map tap.
*/
export type LonLat = [number, number];
/** GeoJSON geometry as it comes from the server or the bundled map. */
export type AreaGeometry =
| { type: 'LineString'; coordinates: LonLat[] }
| { type: 'Polygon'; coordinates: LonLat[][] };
export interface OverlayAdjust {
/** Ground metres east. */
dxMeters: number;
/** Ground metres north. */
dyMeters: number;
/** Multiplier about the overlay centroid. */
scale: number;
/** Degrees counter-clockwise about the overlay centroid. */
rotationDeg: number;
updatedAt: number;
}
export const IDENTITY_OVERLAY: OverlayAdjust = {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
};
export function isIdentity(o: OverlayAdjust): boolean {
return o.dxMeters === 0 && o.dyMeters === 0 && o.scale === 1 && o.rotationDeg === 0;
}
const M_PER_DEG_LAT = 110574;
const metresPerDegLon = (lat: number) => 111320 * Math.cos((lat * Math.PI) / 180);
/* ------------------------------------------------------------------ distance */
/** Metres between two lon/lat points (flat-earth; exact enough downtown). */
export function distanceMeters(a: LonLat, b: LonLat): number {
const mx = metresPerDegLon((a[1] + b[1]) / 2);
const dx = (a[0] - b[0]) * mx;
const dy = (a[1] - b[1]) * M_PER_DEG_LAT;
return Math.hypot(dx, dy);
}
/** Metres from `p` to the segment a→b. */
function distToSegment(p: LonLat, a: LonLat, b: LonLat): number {
const mx = metresPerDegLon(p[1]);
const px = p[0] * mx;
const py = p[1] * M_PER_DEG_LAT;
const ax = a[0] * mx;
const ay = a[1] * M_PER_DEG_LAT;
const bx = b[0] * mx;
const by = b[1] * M_PER_DEG_LAT;
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
function ringContains(p: LonLat, ring: LonLat[]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
/**
* Metres from a point to a geometry 0 when the point is inside a polygon, so
* "which area am I in" and "which area is nearest" are the same question.
*/
export function distanceToGeometry(p: LonLat, g: AreaGeometry): number {
if (g.type === 'Polygon') {
const [outer, ...holes] = g.coordinates;
if (!outer?.length) return Infinity;
if (ringContains(p, outer) && !holes.some((h) => ringContains(p, h))) return 0;
let best = Infinity;
for (const ring of g.coordinates) {
for (let i = 1; i < ring.length; i++) best = Math.min(best, distToSegment(p, ring[i - 1], ring[i]));
}
return best;
}
const line = g.coordinates;
if (line.length === 1) return distanceMeters(p, line[0]);
let best = Infinity;
for (let i = 1; i < line.length; i++) best = Math.min(best, distToSegment(p, line[i - 1], line[i]));
return best;
}
/* ------------------------------------------------------------------ overlay */
/**
* Apply the whole-overlay correction: scale and rotate about `anchor`, then shift.
*
* The georeference was fitted to OpenStreetMap and is good to a few metres, but
* "a few metres" is the difference between two sides of a street. This is what
* lets that be corrected from the phone against a live GPS fix, with no rebuild.
*/
export function adjustGeometry(g: AreaGeometry, o: OverlayAdjust, anchor: LonLat): AreaGeometry {
if (isIdentity(o)) return g;
const mx = metresPerDegLon(anchor[1]);
const cosT = Math.cos((o.rotationDeg * Math.PI) / 180);
const sinT = Math.sin((o.rotationDeg * Math.PI) / 180);
const move = (p: LonLat): LonLat => {
// Into local metres relative to the anchor, transform, and back out.
const ex = (p[0] - anchor[0]) * mx;
const ny = (p[1] - anchor[1]) * M_PER_DEG_LAT;
const rx = (ex * cosT - ny * sinT) * o.scale + o.dxMeters;
const ry = (ex * sinT + ny * cosT) * o.scale + o.dyMeters;
return [anchor[0] + rx / mx, anchor[1] + ry / M_PER_DEG_LAT];
};
return g.type === 'Polygon'
? { type: 'Polygon', coordinates: g.coordinates.map((r) => r.map(move)) }
: { type: 'LineString', coordinates: g.coordinates.map(move) };
}
/** Centroid of every vertex in the set — the anchor scale and rotation turn about. */
export function overlayAnchor(geoms: AreaGeometry[]): LonLat {
let x = 0;
let y = 0;
let n = 0;
for (const g of geoms) {
for (const [lon, lat] of g.type === 'Polygon' ? g.coordinates.flat() : g.coordinates) {
x += lon;
y += lat;
n++;
}
}
return n ? [x / n, y / n] : [0, 0];
}

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Location from 'expo-location';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -7,6 +7,20 @@ export interface Coords {
longitude: number;
}
export interface UseLocationOptions {
/**
* Re-read the OS fix this often, in ms. Omit (or 0) for a single fix at mount.
* A fix goes stale as soon as you drive a block, so any screen that shows
* "where am I" for more than a moment wants this.
*/
intervalMs?: number;
/**
* Poll only while true. Callers pass screen focus AND app foreground: polling
* a map nobody is looking at spends battery on an answer no one reads.
*/
active?: boolean;
}
const LAST_LOC_KEY = 'ps_last_location';
/** Persist the most recent fix so the "near my last location" button works cold. */
@ -21,32 +35,54 @@ export async function getLastKnownSavedLocation(): Promise<Coords | null> {
/**
* Foreground location. On GrapheneOS this uses the OS location provider directly
* (no Google Play Services). We prefer a fast last-known fix, then refine.
*
* `updatedAt` is when `coords` was actually read, so callers can tell a fresh fix
* from one that has been sitting there since the screen opened.
*/
export function useLocation() {
export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) {
const [coords, setCoords] = useState<Coords | null>(null);
const [updatedAt, setUpdatedAt] = useState(0);
const [granted, setGranted] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
// Read inside refresh() without making it a dependency — refresh is the
// interval's callback, and a changing identity would restart the timer on
// every fix, so it would never actually reach the interval.
const haveFix = useRef(false);
const permitted = useRef(false);
const refresh = useCallback(async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const ok = status === 'granted';
setGranted(ok);
if (!ok) {
setError('Location permission denied.');
return null;
if (!permitted.current) {
const { status } = await Location.requestForegroundPermissionsAsync();
const ok = status === 'granted';
permitted.current = ok;
setGranted(ok);
if (!ok) {
setError('Location permission denied.');
return null;
}
}
const last = await Location.getLastKnownPositionAsync();
if (last) {
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
setCoords(c);
void saveLastLocation(c);
// Only worth it before we have anything to show: on a later poll the
// last-known fix is usually older than the one we already hold, and
// publishing it would make the dot jump backwards.
if (!haveFix.current) {
const last = await Location.getLastKnownPositionAsync();
if (last) {
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
haveFix.current = true;
setCoords(c);
setUpdatedAt(Date.now());
void saveLastLocation(c);
}
}
const cur = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
haveFix.current = true;
setCoords(c);
setUpdatedAt(Date.now());
setError(null);
void saveLastLocation(c);
return c;
} catch (e: any) {
@ -56,8 +92,19 @@ export function useLocation() {
}, []);
useEffect(() => {
if (!active) return;
// Denied is denied — polling it every 30s just burns wake-ups to be told no.
if (granted === false) return;
// Re-activating (screen focused, app foregrounded) is exactly when the held
// fix is most likely to be stale, so read one straight away rather than
// waiting out a whole interval.
void refresh();
}, [refresh]);
if (!intervalMs) return;
const id = setInterval(() => {
void refresh();
}, intervalMs);
return () => clearInterval(id);
}, [active, granted, intervalMs, refresh]);
return { coords, granted, error, refresh };
return { coords, updatedAt, granted, error, refresh };
}

View file

@ -35,3 +35,19 @@ export async function getRemindersEnabled(): Promise<boolean> {
export async function setRemindersEnabled(on: boolean): Promise<void> {
await AsyncStorage.setItem(ENABLED_KEY, String(on));
}
/**
* When true, an ongoing "time left" status notification is shown while a
* parking session is active, so you can glance at remaining time without
* opening the app. Default ON.
*/
const COUNTDOWN_KEY = 'ps_session_countdown_enabled';
export async function getCountdownEnabled(): Promise<boolean> {
const raw = await AsyncStorage.getItem(COUNTDOWN_KEY);
return raw == null ? true : raw === 'true';
}
export async function setCountdownEnabled(on: boolean): Promise<void> {
await AsyncStorage.setItem(COUNTDOWN_KEY, String(on));
}

View file

@ -0,0 +1,378 @@
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import * as Notifications from 'expo-notifications';
import type { Zone } from 'parksmarter-client';
import { ps } from '@/api/client';
import { authBus } from '@/auth/authBus';
import { parseApiTime } from '@/api/parseTime';
import type { LabelKind } from '@/api/zoneLabels';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import {
ensureNotificationPermission,
REMINDER_CHANNEL_ID,
} from '@/notifications/localReminders';
import {
getCountdownEnabled,
getReminderLeadMinutes,
getRemindersEnabled,
} from '@/features/notifications/reminderPrefs';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { ParkingArea } from '@/api/parkingAreas';
import { recordLocalSession } from './localHistory';
import {
clearActiveParking,
getActiveParking,
setActiveParking,
setParkedPin,
type ActiveParking,
type ParkedSpot,
} from './activeParkingStore';
import {
clearSession,
consumePendingAction,
hasNativeCountdown,
showSession,
} from '../../../modules/bbp-notify';
/**
* Everything that happens while a car is parked, paid or free, lives here.
*
* There is exactly one active parking session at a time, so there is exactly one
* ongoing notification. Both entry points (buying time, checking into a free
* space) write the same record and post the same countdown, which is why the
* notification behaves identically whichever way you parked.
*
* The notification itself is owned by a native foreground service see
* modules/bbp-notify. JS's job is to keep the record truthful; the service
* renders it and stops itself the moment there's nothing to show.
*/
/** One session at a time, so one reminder id. */
const EXPIRY_REMINDER_ID = 'parking-expiry';
/** Fallback ongoing notification for Expo Go, where the native module is absent. */
const FALLBACK_NOTIF_ID = 'parking-status';
/** How much a city-map session's "extend" button adds, and what it's labelled. */
const EXTEND_MINUTES = 60;
const EXTEND_LABEL = '+1 hr';
function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/* ------------------------------------------------------------------ posting */
/** Post (or replace) the ongoing ticking notification for a session. */
async function postNotification(p: ActiveParking): Promise<void> {
if (!(await getCountdownEnabled())) {
logLine('[PARKING] countdown disabled — not posting');
await clearNotification();
return;
}
await ensureNotificationPermission();
const ends = fmtTime(p.endMs);
const free = p.kind === 'free';
const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
// What the second button does depends on what it *can* do. City-map parking has
// no ParkSmarter zone to buy time in, so there it adds an hour to the local
// timer and says so; a real zone gets the purchase screen.
const extendLabel = p.area ? EXTEND_LABEL : free ? 'Pay' : 'Extend';
if (hasNativeCountdown) {
const diag = await showSession(title, body, p.endMs, 'End', extendLabel);
logLine(`[PARKING] ${p.kind} native: ${diag}`);
return;
}
// Expo Go / no native module: a static ongoing notification, no buttons and no
// ticking, refreshed whenever the app is foregrounded.
logLine('[PARKING] no native module — static fallback notification');
await Notifications.scheduleNotificationAsync({
identifier: FALLBACK_NOTIF_ID,
content: {
title,
body,
sticky: true,
autoDismiss: false,
data: { kind: 'session-status' },
},
trigger:
Platform.OS === 'android'
? {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(Date.now() + 400),
channelId: 'session-status',
}
: null,
});
}
async function clearNotification(): Promise<void> {
await clearSession().catch(() => {});
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
}
/* ---------------------------------------------------------------- reminders */
async function scheduleExpiryReminder(p: ActiveParking): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
if (!(await getRemindersEnabled())) return;
const fireAt = p.endMs - p.leadMinutes * 60_000;
if (fireAt <= Date.now()) return;
const free = p.kind === 'free';
await Notifications.scheduleNotificationAsync({
identifier: EXPIRY_REMINDER_ID,
content: {
title: free ? 'Free parking ending soon' : 'Parking expiring soon',
body: `${p.zoneName}: ${free ? 'your free time ends' : 'your session ends'} at ${fmtTime(
p.endMs,
)}. Extend if you need more time.`,
data: { kind: 'parking-expiry' },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(fireAt),
channelId: REMINDER_CHANNEL_ID,
},
});
}
/* ------------------------------------------------------------ start / stop */
/** Begin tracking a paid session that ParkSmarter has just confirmed. */
export async function startPaidSession(args: {
zone: Zone;
endTime: Date;
transactionId?: string | number;
}): Promise<void> {
const state: ActiveParking = {
kind: 'paid',
zone: args.zone,
zoneName: args.zone.ZoneName ?? 'Parking',
startMs: Date.now(),
endMs: args.endTime.getTime(),
transactionId: args.transactionId != null ? String(args.transactionId) : undefined,
leadMinutes: await getReminderLeadMinutes(),
};
await setActiveParking(state);
await postNotification(state);
await scheduleExpiryReminder(state);
}
/** Start a local free check-in for `hours` at the given zone. */
export async function startFreeCheckin(
zone: Zone,
hours: number,
labelKind?: LabelKind,
): Promise<void> {
const now = Date.now();
const state: ActiveParking = {
kind: 'free',
zone,
zoneName: zone.ZoneName ?? 'Parking',
startMs: now,
endMs: now + hours * 3_600_000,
labelKind: labelKind ?? (`free_${hours}h` as LabelKind),
leadMinutes: await getReminderLeadMinutes(),
};
await setActiveParking(state);
await postNotification(state);
await scheduleExpiryReminder(state);
}
/**
* Start tracking time on an area from the city parking map.
*
* Deliberately the whole story: no ParkSmarter call, no account, no network. The
* area came from the local database, the clock is the phone's, and the countdown
* is the same foreground service every other session uses. Works in Anonymous
* Mode, offline, and with the IPS API down.
*/
export async function startAreaParking(args: {
area: ParkingArea;
hours: number;
spot?: ParkedSpot;
}): Promise<void> {
const now = Date.now();
const state: ActiveParking = {
// Only the city lots cost money; everything else on the map is free parking
// that merely has a posted time limit.
kind: args.area.kind === 'green_lot' ? 'paid' : 'free',
area: {
id: args.area.id,
kind: args.area.kind,
name: args.area.name,
legend: args.area.legend,
color: args.area.color,
},
spot: args.spot,
zoneName: args.area.name,
startMs: now,
endMs: now + Math.round(args.hours * 3_600_000),
leadMinutes: await getReminderLeadMinutes(),
};
await setActiveParking(state);
if (args.spot) await setParkedPin(args.spot);
await postNotification(state);
await scheduleExpiryReminder(state);
logLine(`[PARKING] city area ${args.area.id} (${args.area.kind}) for ${args.hours}h`);
}
/**
* Drop the "where is my car" pin. Deliberately independent of any session you
* can pin the car without starting a timer, and the pin has to survive that.
*/
export async function pinParkedSpot(spot: ParkedSpot): Promise<void> {
await setParkedPin(spot);
const current = await getActiveParking();
if (current) await setActiveParking({ ...current, spot });
}
/**
* Add time to a city-map session's local timer. There is nothing to buy here
* the app is only tracking a clock so extending is a local edit, not a purchase.
*/
export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void> {
const current = await getActiveParking();
if (!current) return;
// Extend from now if it already lapsed, so "+1 hr" always means a full hour.
const from = Math.max(current.endMs, Date.now());
const next = { ...current, endMs: from + minutes * 60_000 };
await setActiveParking(next);
await postNotification(next);
await scheduleExpiryReminder(next);
}
/**
* Stop tracking the active session and take its notification down.
*
* For a free check-in this genuinely ends it. For a paid session it only stops
* tracking ParkSmarter has no stop-session endpoint, so the time you bought
* keeps running at the meter whether or not the app is showing it.
*/
export async function endActiveParking(): Promise<void> {
// Write it to history before dropping it. A local session has no server copy, so
// if it isn't recorded here it is simply gone.
const current = await getActiveParking();
if (current) await recordLocalSession(current);
await clearActiveParking();
await clearNotification();
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
}
/* ----------------------------------------------------------------- syncing */
/**
* Ask the server whether a paid session is running. Only used when nothing is
* tracked locally a session bought on another device, or before this version
* started persisting them. The active-session API returns no Zone, so a session
* found this way has no zone to extend into.
*/
async function discoverPaidSession(): Promise<ActiveParking | null> {
try {
const res = await ps.getActiveParkingSessions();
const soonest = (res.ParkingSession ?? [])
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
.filter((x) => x.end != null && x.end.getTime() > Date.now())
.sort((a, b) => a.end!.getTime() - b.end!.getTime())[0];
if (!soonest) return null;
const found: ActiveParking = {
kind: 'paid',
zoneName: soonest.s.ZoneName ?? 'Parking',
startMs: parseApiTime(soonest.s.StartTime)?.getTime() ?? Date.now(),
endMs: soonest.end!.getTime(),
transactionId:
soonest.s.TransactionID != null ? String(soonest.s.TransactionID) : undefined,
leadMinutes: await getReminderLeadMinutes(),
};
logLine(`[PARKING] discovered server session ending ${new Date(found.endMs).toISOString()}`);
await setActiveParking(found);
await scheduleExpiryReminder(found);
return found;
} catch (e: any) {
logLine(`[PARKING] discover failed: ${e?.serverMessage ?? e?.message ?? e}`);
return null;
}
}
/**
* Reconcile the record, its notification and any button the user pressed while the
* app was away. Safe to call repeatedly; it's the app's foreground heartbeat.
*/
export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promise<void> {
const action = await consumePendingAction();
if (action === 'end') {
logLine('[PARKING] notification "End" pressed');
await endActiveParking();
return;
}
let current = await getActiveParking();
// The meter ran out: the service already removed its own notification when the
// countdown hit zero, so this just clears the record behind it.
if (current && current.endMs <= Date.now()) {
await endActiveParking();
current = null;
}
// "+1 hr" on a city-map session is a local edit, not a purchase — handle it here
// and stay put rather than sending the user to a payment screen for a free spot.
if (action === 'extend' && current?.area) {
logLine(`[PARKING] notification "${EXTEND_LABEL}" pressed on city area ${current.area.id}`);
await extendAreaParking();
return;
}
// A city-map session is never on the ParkSmarter server, so don't let a stale
// server session overwrite it. In Anonymous Mode there is no account to ask at
// all — asking anyway would 401 on every single foreground.
if (!current && !authBus.isAnonymous) current = await discoverPaidSession();
if (current) {
await postNotification(current);
} else {
await clearNotification();
}
if (action === 'extend') {
logLine('[PARKING] notification "Extend" pressed');
onExtend(current?.zone);
}
}
/** Called by the tab navigator: sync on open and on every foreground. */
export function useActiveParkingSync(): void {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
useEffect(() => {
const run = () =>
void syncActiveParking((zone) => {
// "Extend" means "sell me more time for this exact spot" — go straight to
// the purchase screen for the stored zone. A server-discovered session has
// no zone, so fall back to the sessions list rather than guessing.
if (zone) navigation.navigate('StartSession', { zone });
else navigation.navigate('Tabs');
});
run();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') run();
});
return () => sub.remove();
}, [navigation]);
}
/** Turn the countdown notification on/off from Settings without touching the record. */
export async function refreshParkingNotification(): Promise<void> {
const current = await getActiveParking();
if (current && current.endMs > Date.now()) await postNotification(current);
else await clearNotification();
}

View file

@ -0,0 +1,104 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Zone } from 'parksmarter-client';
import type { LabelKind } from '@/api/zoneLabels';
import type { AreaKind } from '@/api/parkingAreas';
/**
* The one parking session the app is currently tracking paid or a local free
* check-in. Persisting it locally is what lets the countdown come back after a
* reboot, offline, or in Anonymous Mode: the notification no longer depends on a
* ParkSmarter round-trip to know a session exists.
*
* The full Zone is stored so the notification's "Extend" button can hand straight
* into the paid flow without re-fetching the meter.
*/
const KEY = 'ps_active_parking';
/** Pre-0.5 free check-ins lived here; read once so an in-flight check-in survives the upgrade. */
const LEGACY_CHECKIN_KEY = 'ps_checkin';
/**
* The parked pin lives outside the session on purpose: "where is my car" outlives
* "am I tracking time". You can drop a pin without starting a timer, and it has to
* still be there when you come back to the map.
*/
const PIN_KEY = 'ps_parked_pin';
export type ParkingKind = 'paid' | 'free';
/**
* Where you parked, when the spot came from the city map rather than ParkSmarter.
* Held by value the whole point is that the countdown keeps working with no
* network, no account and no IPS call, so it can't depend on a lookup.
*/
export interface ParkedArea {
id: string;
kind: AreaKind;
name: string;
/** The map legend's own wording, shown on the session screen. */
legend: string;
color: string;
}
/** The pin for "where is my car", dropped from GPS or placed by hand. */
export interface ParkedSpot {
latitude: number;
longitude: number;
/** True when the user placed it themselves because GPS wasn't usable. */
manual: boolean;
}
export interface ActiveParking {
kind: ParkingKind;
/**
* Absent for a paid session discovered from the server (the active-session API
* returns no zone) and for city-map parking, which has no ParkSmarter zone at
* all. "Extend" falls back accordingly.
*/
zone?: Zone;
/** Set instead of `zone` when parked on a city-map area. */
area?: ParkedArea;
/** Where the car actually is. Independent of `area` — you can pin without one. */
spot?: ParkedSpot;
zoneName: string;
startMs: number;
endMs: number;
/** Paid only: the ParkSmarter transaction id, for receipts/debugging. */
transactionId?: string;
/** Free only: which zone-label this check-in came from. */
labelKind?: LabelKind;
/** Minutes before expiry to fire the local reminder. */
leadMinutes: number;
}
export async function getActiveParking(): Promise<ActiveParking | null> {
const raw = await AsyncStorage.getItem(KEY);
if (raw) return JSON.parse(raw) as ActiveParking;
const legacy = await AsyncStorage.getItem(LEGACY_CHECKIN_KEY);
if (!legacy) return null;
const c = JSON.parse(legacy) as Omit<ActiveParking, 'kind'>;
const migrated: ActiveParking = { ...c, kind: 'free' };
await AsyncStorage.setItem(KEY, JSON.stringify(migrated));
await AsyncStorage.removeItem(LEGACY_CHECKIN_KEY);
return migrated;
}
export async function setActiveParking(state: ActiveParking): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(state));
}
export async function clearActiveParking(): Promise<void> {
// The pin goes with it: ending a session means you drove away, and a pin left
// behind would point at a space you no longer occupy.
await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY, PIN_KEY]);
}
/** Where the car is, whether or not a timer is running. */
export async function getParkedPin(): Promise<ParkedSpot | null> {
const raw = await AsyncStorage.getItem(PIN_KEY);
return raw ? (JSON.parse(raw) as ParkedSpot) : null;
}
export async function setParkedPin(spot: ParkedSpot | null): Promise<void> {
if (spot) await AsyncStorage.setItem(PIN_KEY, JSON.stringify(spot));
else await AsyncStorage.removeItem(PIN_KEY);
}

View file

@ -0,0 +1,81 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { AreaKind } from '@/api/parkingAreas';
import type { ActiveParking, ParkedSpot, ParkingKind } from './activeParkingStore';
/**
* History for the sessions ParkSmarter never sees.
*
* A city-map timer or a free check-in exists only on this phone, so if it isn't
* recorded here it vanishes the moment it ends there is no server to ask. Paid
* ParkSmarter sessions are deliberately excluded: those already come back from the
* account, and storing them too would show every one of them twice.
*/
const KEY = 'ps_local_session_history';
/** Enough to cover months of parking without letting the record grow forever. */
const MAX = 50;
export interface LocalSessionRecord {
/** Start time doubles as the id — there is only ever one session at a time. */
id: string;
kind: ParkingKind;
zoneName: string;
areaId?: string;
areaKind?: AreaKind;
color?: string;
legend?: string;
startMs: number;
/** When it was due to end. */
plannedEndMs: number;
/** When it actually ended. */
endedAtMs: number;
/** True when the user ended it before the clock ran out. */
endedEarly: boolean;
spot?: ParkedSpot;
}
/** True when ParkSmarter has no record of this session, so we must keep our own. */
export function isLocalOnly(p: ActiveParking): boolean {
return !p.transactionId;
}
export async function getLocalHistory(): Promise<LocalSessionRecord[]> {
const raw = await AsyncStorage.getItem(KEY);
if (!raw) return [];
try {
const list = JSON.parse(raw) as LocalSessionRecord[];
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
/** Record a finished local session. No-op for anything ParkSmarter already has. */
export async function recordLocalSession(p: ActiveParking): Promise<void> {
if (!isLocalOnly(p)) return;
const endedAtMs = Date.now();
const record: LocalSessionRecord = {
id: String(p.startMs),
kind: p.kind,
zoneName: p.zoneName,
areaId: p.area?.id,
areaKind: p.area?.kind,
color: p.area?.color,
legend: p.area?.legend,
startMs: p.startMs,
plannedEndMs: p.endMs,
endedAtMs,
endedEarly: endedAtMs < p.endMs - 60_000, // a minute's slack for timer wake-up
spot: p.spot,
};
const list = await getLocalHistory();
// Guard against double-recording: ending can be driven from the notification and
// the screen at nearly the same moment.
const deduped = list.filter((r) => r.id !== record.id);
deduped.unshift(record);
await AsyncStorage.setItem(KEY, JSON.stringify(deduped.slice(0, MAX)));
}
export async function clearLocalHistory(): Promise<void> {
await AsyncStorage.removeItem(KEY);
}

View file

@ -21,20 +21,32 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen';
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { StartSessionScreen } from '@/screens/StartSessionScreen';
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
import { AdminScreen } from '@/screens/AdminScreen';
import { CityAreaScreen } from '@/screens/CityAreaScreen';
import { MapAlignScreen } from '@/screens/MapAlignScreen';
import { useTheme } from '@/theme/ThemeContext';
import type { Zone } from 'parksmarter-client';
import { useActiveParkingSync } from '@/features/session/activeParking';
import type { ParkingArea } from '@/api/parkingAreas';
import type { ParkedSpot } from '@/features/session/activeParkingStore';
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
export type RootStackParamList = {
Tabs: undefined;
MeterDetail: { zone: Zone };
StartSession: { zone: Zone };
/** An area from the city's printed parking map — no ParkSmarter zone involved. */
CityArea: { area: ParkingArea; spot?: ParkedSpot };
MapAlign: undefined;
SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' };
About: undefined;
Profile: undefined;
Vehicles: undefined;
PaymentMethods: undefined;
Notifications: undefined;
Diagnostics: undefined;
Admin: undefined;
};
export type TabParamList = {
@ -66,6 +78,9 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
};
function Tabs() {
// Keep the ongoing countdown in sync (on open + every foreground) and apply any
// End/Extend the user pressed on the notification while the app was away.
useActiveParkingSync();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
@ -107,7 +122,7 @@ export function RootNavigator() {
return (
<NavigationContainer theme={navTheme}>
{status === 'signedIn' ? (
{status === 'signedIn' || status === 'anonymous' ? (
<Stack.Navigator>
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
<Stack.Screen
@ -120,6 +135,11 @@ export function RootNavigator() {
component={StartSessionScreen}
options={{ title: 'Start session' }}
/>
<Stack.Screen
name="SessionDetail"
component={SessionDetailScreen}
options={{ title: 'Session' }}
/>
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
@ -138,6 +158,17 @@ export function RootNavigator() {
component={DiagnosticsScreen}
options={{ title: 'Diagnostics' }}
/>
<Stack.Screen
name="CityArea"
component={CityAreaScreen}
options={{ title: 'Parking area' }}
/>
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
<Stack.Screen
name="MapAlign"
component={MapAlignScreen}
options={{ title: 'Align city map' }}
/>
</Stack.Navigator>
) : (
<LoginScreen />

View file

@ -1,9 +1,5 @@
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import {
getReminderLeadMinutes,
getRemindersEnabled,
} from '@/features/notifications/reminderPrefs';
/**
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
@ -11,7 +7,9 @@ import {
* No server, no FCM, no push, no Play Services works fully offline on GrapheneOS.
*/
const CHANNEL_ID = 'session-reminders';
/** Exported so the active-parking module can schedule onto the same channel. */
export const REMINDER_CHANNEL_ID = 'session-reminders';
const CHANNEL_ID = REMINDER_CHANNEL_ID;
/** Android 8+ needs a notification channel; ensure it exists once. */
async function ensureChannel(): Promise<void> {
@ -33,58 +31,21 @@ export async function ensureNotificationPermission(): Promise<boolean> {
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// shouldShowAlert is the legacy field; Banner/List are the newer split.
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
handleNotification: async (notification) => {
// The ongoing "time left" status refreshes on every foreground — keep it
// quiet (no banner/sound), just present in the shade.
const quiet = (notification.request.content.data as any)?.kind === 'session-status';
return {
// shouldShowAlert is the legacy field; Banner/List are the newer split.
shouldShowAlert: !quiet,
shouldShowBanner: !quiet,
shouldShowList: true,
shouldPlaySound: !quiet,
shouldSetBadge: false,
};
},
});
export interface ScheduleReminderArgs {
transactionId: string | number;
zoneName: string;
/** When the parking session ends. */
endTime: Date;
/** Override the user's configured lead time (minutes before end). */
leadMinutes?: number;
}
/**
* Schedule an expiry reminder using the user's Notifications preferences
* (lead time + enabled). Returns the notification id, or null if disabled/late.
*/
export async function scheduleExpiryReminder(
args: ScheduleReminderArgs,
): Promise<string | null> {
if (!(await getRemindersEnabled())) return null;
const leadMinutes = args.leadMinutes ?? (await getReminderLeadMinutes());
const lead = leadMinutes * 60 * 1000;
const fireAt = new Date(args.endTime.getTime() - lead);
if (fireAt.getTime() <= Date.now()) return null; // already too late
await ensureChannel();
return Notifications.scheduleNotificationAsync({
identifier: `session-${args.transactionId}`,
content: {
title: 'Parking expiring soon',
body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
data: { transactionId: String(args.transactionId) },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: fireAt,
channelId: CHANNEL_ID,
},
});
}
export async function cancelExpiryReminder(transactionId: string | number): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
}
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
export async function sendTestReminder(seconds = 10): Promise<boolean> {
if (!(await ensureNotificationPermission())) return false;

View file

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useRef, useState } from 'react';
import {
Image,
Linking,
Modal,
Pressable,
ScrollView,
@ -9,24 +10,17 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
const TAPS_TO_UNLOCK = 7;
const REPO_URL = 'https://git.mowden.top/hank/BigBrainParking';
export function AboutScreen() {
const { colors } = useTheme();
const [about, setAbout] = useState<string | null>(null);
const [showJoel, setShowJoel] = useState(false);
const taps = useRef(0);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
ps.getAbout()
.then((r) => setAbout(r.Value ?? ''))
.catch(() => setAbout(''));
}, []);
// Tap the title 7 times (within a rolling window) to summon Joel.
const onTitleTap = () => {
taps.current += 1;
@ -44,7 +38,41 @@ export function AboutScreen() {
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
</Pressable>
<Text style={[styles.body, { color: colors.text }]}>{about ?? 'Loading…'}</Text>
<Text style={[styles.body, { color: colors.text }]}>
BigBrainParking is a free, <Text style={{ fontWeight: '700' }}>open-source</Text>,
de-Googled client for the ParkSmarter parking system, built to run on GrapheneOS
without any Google services. Its an independent project not affiliated with,
endorsed by, or supported by IPS Group / ParkSmarter.
</Text>
<TouchableOpacity onPress={() => Linking.openURL(REPO_URL)} style={{ marginTop: 12 }}>
<Text style={[styles.link, { color: colors.primary }]}>Source code & issues </Text>
<Text style={[styles.linkUrl, { color: colors.subtext }]}>{REPO_URL}</Text>
</TouchableOpacity>
<Text style={[styles.section, { color: colors.text }]}>What gets sent to ParkSmarter (IPS)</Text>
<Text style={[styles.body, { color: colors.text }]}>
This app talks only to ParkSmarters own API, and only sends what a request needs:
{'\n'} Sign-in (your phone number + password) and your account data vehicles, cards,
and session history the same as the official app.
{'\n'} To find meters, a single map coordinate: the point the map is centered on when you
search. Your device GPS is sent <Text style={{ fontWeight: '700' }}>only</Text> if you tap
My location to center the map on yourself and then search; otherwise its wherever youve
panned to, or your last parking lot.
{'\n'} To start a session: the meter, your vehicle and card, the times, and the amount.
No location is attached.
</Text>
<Text style={[styles.section, { color: colors.text }]}>What it does not do</Text>
<Text style={[styles.body, { color: colors.text }]}>
{'\n'} Your phones GPS is <Text style={{ fontWeight: '700' }}>never sent to the API</Text>{' '}
unless you deliberately tap My location and search. The map opens on your last parking lot
(from your history the meters location, not your GPS), and Last lot does the same. GPS
otherwise only draws your dot on the map and is never attached to sign-in, sessions,
vehicles, or payments.
{'\n'} No background location, no tracking, no device ID / IMEI / advertising identifiers,
and no Google services. Session-expiry reminders are scheduled entirely on your device.
</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
@ -62,7 +90,10 @@ export function AboutScreen() {
const styles = StyleSheet.create({
container: { padding: 20 },
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
section: { fontSize: 17, fontWeight: '700', marginTop: 22, marginBottom: 6 },
body: { fontSize: 15, lineHeight: 22, color: '#333' },
link: { fontSize: 15, fontWeight: '700' },
linkUrl: { fontSize: 13, marginTop: 2 },
meta: { fontSize: 12, color: '#999', marginTop: 24 },
joelBackdrop: {
flex: 1,

View file

@ -11,7 +11,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function AccountScreen() {
const { colors, mode, toggle } = useTheme();
const { logout } = useAuth();
const { logout, isAnonymous, requireLogin } = useAuth();
const navigation = useNavigation<Nav>();
const Item = ({
@ -35,21 +35,33 @@ export function AccountScreen() {
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
<Item
icon="card"
label="Payment methods"
onPress={() => navigation.navigate('PaymentMethods')}
/>
<Item
icon="notifications"
label="Notifications"
onPress={() => navigation.navigate('Notifications')}
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View>
{isAnonymous ? (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Item icon="log-in" label="Sign in to pay & sync" onPress={requireLogin} />
<Item
icon="notifications"
label="Notifications"
onPress={() => navigation.navigate('Notifications')}
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View>
) : (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
<Item
icon="card"
label="Payment methods"
onPress={() => navigation.navigate('PaymentMethods')}
/>
<Item
icon="notifications"
label="Notifications"
onPress={() => navigation.navigate('Notifications')}
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View>
)}
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
<View style={[styles.card, { backgroundColor: colors.card }]}>
@ -59,14 +71,23 @@ export function AccountScreen() {
<Switch value={mode === 'dark'} onValueChange={toggle} />
</View>
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
<Item
icon="git-compare"
label="Align city map"
onPress={() => navigation.navigate('MapAlign')}
/>
<Item
icon="shield-checkmark"
label="Admin (zone labeling)"
onPress={() => navigation.navigate('Admin')}
/>
</View>
<TouchableOpacity
style={[styles.logout, { borderColor: colors.danger }]}
onPress={logout}
>
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
</TouchableOpacity>
{isAnonymous ? null : (
<TouchableOpacity style={[styles.logout, { borderColor: colors.danger }]} onPress={logout}>
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
</TouchableOpacity>
)}
</ScrollView>
);
}

View file

@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useTheme } from '@/theme/ThemeContext';
import { getAdminToken, setAdminToken } from '@/api/adminStore';
import { verifyAdmin } from '@/api/zoneLabels';
/**
* Enter the zone-labels admin password. Once verified + saved it unlocks the
* labeling controls on the meter detail screen. Stored in the OS keystore.
*/
export function AdminScreen() {
const { colors } = useTheme();
const [value, setValue] = useState('');
const [authed, setAuthed] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
void getAdminToken().then((t) => setAuthed(!!t));
}, []);
const saveAndTest = async () => {
const token = value.trim();
if (!token) return;
setBusy(true);
try {
const ok = await verifyAdmin(token);
if (!ok) {
Alert.alert('Not accepted', 'That password was rejected by the server.');
return;
}
await setAdminToken(token);
setAuthed(true);
setValue('');
Alert.alert('Admin enabled', 'You can now label zones from the meter screen.');
} catch (e: any) {
Alert.alert('Could not verify', e?.message ?? 'Check your connection and try again.');
} finally {
setBusy(false);
}
};
const signOut = async () => {
await setAdminToken(null);
setAuthed(false);
Alert.alert('Signed out', 'Admin labeling is now disabled on this device.');
};
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={[styles.section, { color: colors.subtext }]}>
Zone labeling {authed ? '· enabled' : '· disabled'}
</Text>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.help, { color: colors.subtext }]}>
The admin password lets you tag zones as free (2h/3h/4h) or pay-immediately.
It's stored securely on this device and sent only to
bigbrainparking.mowden.top.
</Text>
<TextInput
style={[styles.input, { color: colors.text, borderColor: colors.border }]}
placeholder={authed ? 'Enter a new password to replace' : 'Admin password'}
placeholderTextColor={colors.subtext}
value={value}
onChangeText={setValue}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary, opacity: value.trim() ? 1 : 0.5 }]}
onPress={saveAndTest}
disabled={!value.trim() || busy}
>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Verify &amp; save</Text>
)}
</TouchableOpacity>
</View>
{authed ? (
<TouchableOpacity style={[styles.signout, { borderColor: colors.danger }]} onPress={signOut}>
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out of admin</Text>
</TouchableOpacity>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
section: { marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' },
card: { borderRadius: 12, padding: 16, gap: 12 },
help: { fontSize: 13, lineHeight: 19 },
input: { borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
button: { borderRadius: 10, padding: 14, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
signout: { marginTop: 24, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
});

View file

@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { useTheme } from '@/theme/ThemeContext';
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
import { useAuth } from '@/auth/AuthContext';
import {
endActiveParking,
extendAreaParking,
startAreaParking,
} from '@/features/session/activeParking';
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
type AreaRoute = RouteProp<RootStackParamList, 'CityArea'>;
type Nav = NativeStackNavigationProp<RootStackParamList>;
function fmtHours(h: number): string {
if (h < 1) return `${Math.round(h * 60)} min`;
return h === 1 ? '1 hour' : `${h} hours`;
}
function fmtClock(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function fmtRemaining(ms: number): string {
const mins = Math.max(0, Math.round(ms / 60_000));
const h = Math.floor(mins / 60);
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
}
/**
* One area from the city's printed parking map: what the sign says, and a timer
* for it.
*
* Nothing on this screen talks to ParkSmarter. The area came from the local
* database and the countdown is the phone's own clock, so this works with no
* account, no signal, and in Anonymous Mode.
*/
export function CityAreaScreen() {
const { area, spot } = useRoute<AreaRoute>().params;
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const { isAnonymous, requireLogin } = useAuth();
const [active, setActive] = useState<ActiveParking | null>(null);
const options = areaDurationOptions(area);
const [hours, setHours] = useState<number>(options[0]);
const free = areaIsFree(area.kind);
const parkedHere = active?.area?.id === area.id;
const reload = useCallback(() => {
void getActiveParking().then(setActive);
}, []);
useFocusEffect(reload);
// Re-read while the screen is open so "time left" doesn't sit frozen at whatever
// it was when you opened it. The notification is the real live countdown; this
// just keeps the screen from lying.
useEffect(() => {
if (!active) return;
const id = setInterval(reload, 30_000);
return () => clearInterval(id);
}, [active, reload]);
useEffect(() => {
navigation.setOptions({ title: area.label });
}, [navigation, area.label]);
const start = async () => {
if (active && !parkedHere) {
const where = active.zoneName;
const ok = await new Promise<boolean>((resolve) =>
Alert.alert(
'Already tracking',
`You're tracking parking at ${where}. Replace it with this spot?`,
[
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Replace', style: 'destructive', onPress: () => resolve(true) },
],
),
);
if (!ok) return;
}
await startAreaParking({ area, hours, spot });
navigation.navigate('Tabs');
};
const stop = async () => {
await endActiveParking();
reload();
};
const s = styles(colors);
return (
<ScrollView style={s.screen} contentContainerStyle={s.content}>
<View style={s.card}>
<View style={s.chipRow}>
<View style={[s.swatch, { backgroundColor: area.color }]} />
<Text style={s.chipText}>{area.legend}</Text>
</View>
<Text style={s.name}>{area.name}</Text>
<Text style={s.sub}>
{free ? 'Free parking' : 'Paid — pay at the kiosk or by permit'}
{area.hours > 0 ? ` · ${fmtHours(area.hours)} posted limit` : ' · no posted time limit'}
</Text>
</View>
{spot ? (
<View style={s.card}>
<Text style={s.sectionTitle}>Your car</Text>
<Text style={s.sub}>
Pinned at {spot.latitude.toFixed(5)}, {spot.longitude.toFixed(5)}
</Text>
<Text style={s.hint}>
{spot.manual ? 'Placed by hand.' : 'From GPS.'} The pin stays on the map until you end
the session.
</Text>
</View>
) : null}
{isAnonymous && areaRequiresAccount(area) ? (
<View style={s.card}>
<Text style={s.sectionTitle}>Sign in to park here</Text>
<Text style={s.sub}>
This is a paid city lot parking in it is bought through ParkSmarter, so it needs
an account. The free time-limited streets on the map don't.
</Text>
<TouchableOpacity style={s.primary} onPress={requireLogin}>
<Text style={s.primaryText}>Sign in</Text>
</TouchableOpacity>
</View>
) : parkedHere && active ? (
<View style={s.card}>
<Text style={s.sectionTitle}>Tracking now</Text>
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
<Text style={s.sub}>Until {fmtClock(active.endMs)}</Text>
<View style={s.row}>
<TouchableOpacity
style={s.secondary}
onPress={async () => {
await extendAreaParking();
reload();
}}
>
<Text style={s.secondaryText}>+1 hour</Text>
</TouchableOpacity>
<TouchableOpacity style={s.danger} onPress={stop}>
<Text style={s.primaryText}>End</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={s.card}>
<Text style={s.sectionTitle}>Track my time here</Text>
<View style={s.row}>
{options.map((h) => (
<TouchableOpacity
key={h}
style={[s.pick, hours === h && { borderColor: colors.primary, borderWidth: 2 }]}
onPress={() => setHours(h)}
>
<Text style={[s.pickText, hours === h && { color: colors.primary }]}>
{h < 1 ? `${Math.round(h * 60)}m` : `${h}h`}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.hint}>
{area.hours > 0
? `The sign says ${fmtHours(area.hours)}. The countdown runs on this phone only — it doesn't buy or reserve anything.`
: "No posted limit here — pick however long you'll be. The countdown runs on this phone only."}
</Text>
<TouchableOpacity style={s.primary} onPress={start}>
<Text style={s.primaryText}>Start {fmtHours(hours)} timer</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
}
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
StyleSheet.create({
screen: { flex: 1, backgroundColor: c.bg },
content: { padding: 16, gap: 12 },
card: {
backgroundColor: c.card,
borderRadius: 12,
padding: 16,
gap: 8,
borderWidth: 1,
borderColor: c.border,
},
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
swatch: { width: 22, height: 12, borderRadius: 3 },
chipText: { color: c.subtext, fontSize: 13, fontWeight: '600' },
name: { color: c.text, fontSize: 20, fontWeight: '700' },
sub: { color: c.subtext, fontSize: 14 },
hint: { color: c.subtext, fontSize: 12, lineHeight: 17 },
sectionTitle: { color: c.text, fontSize: 15, fontWeight: '700' },
big: { color: c.text, fontSize: 28, fontWeight: '700' },
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
pick: {
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.bg,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 10,
minWidth: 56,
alignItems: 'center',
},
pickText: { color: c.text, fontWeight: '600' },
primary: {
backgroundColor: c.primary,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
marginTop: 4,
},
primaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
secondary: {
flex: 1,
borderWidth: 1,
borderColor: c.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
secondaryText: { color: c.text, fontWeight: '700' },
danger: {
flex: 1,
backgroundColor: c.danger,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
});

View file

@ -3,6 +3,7 @@ import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import {
listKiosks,
removeKiosk,
@ -15,6 +16,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function FavoritesScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const [items, setItems] = useState<SavedKiosk[]>([]);
useFocusEffect(
@ -38,27 +40,28 @@ export function FavoritesScreen() {
return (
<FlatList
style={{ backgroundColor: colors.bg }}
contentContainerStyle={{ padding: 16 }}
data={items}
keyExtractor={(k) => k.key}
ListEmptyComponent={
<Text style={styles.empty}>
<Text style={[styles.empty, { color: colors.subtext }]}>
No saved kiosks yet. Scan a kiosk QR and tap Save kiosk.
</Text>
}
renderItem={({ item }) => (
<View style={styles.card}>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<TouchableOpacity style={{ flex: 1 }} onPress={() => open(item)}>
<Text style={styles.name}>{item.zoneName ?? item.key}</Text>
<Text style={styles.meta}>
<Text style={[styles.name, { color: colors.text }]}>{item.zoneName ?? item.key}</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{item.scannerCode ? `Code ${item.scannerCode}` : `Serial ${item.terminalSerNo}`}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.action} onPress={() => shareKiosk(item)}>
<Text style={styles.actionText}>Share</Text>
<Text style={[styles.actionText, { color: colors.primary }]}>Share</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.action} onPress={() => remove(item)}>
<Text style={[styles.actionText, { color: '#c0392b' }]}>Remove</Text>
<Text style={[styles.actionText, { color: colors.danger }]}>Remove</Text>
</TouchableOpacity>
</View>
)}
@ -67,18 +70,17 @@ export function FavoritesScreen() {
}
const styles = StyleSheet.create({
empty: { color: '#888', textAlign: 'center', marginTop: 48 },
empty: { textAlign: 'center', marginTop: 48 },
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f4f4f4',
borderRadius: 10,
padding: 14,
marginBottom: 10,
gap: 10,
},
name: { fontSize: 16, fontWeight: '600' },
meta: { color: '#777', fontSize: 12, marginTop: 2 },
meta: { fontSize: 12, marginTop: 2 },
action: { paddingHorizontal: 6, paddingVertical: 4 },
actionText: { color: '#1e6f5c', fontWeight: '600' },
actionText: { fontWeight: '600' },
});

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
KeyboardAvoidingView,
Platform,
@ -13,7 +14,7 @@ import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/theme/ThemeContext';
export function LoginScreen() {
const { login, error } = useAuth();
const { login, error, enterAnonymous } = useAuth();
const { colors } = useTheme();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
@ -30,6 +31,25 @@ export function LoginScreen() {
}
};
const onAnonymous = () => {
Alert.alert(
'Park without signing in',
'Works without a login:\n' +
' • Browse parking areas on the map\n' +
' • See free (2h/3h/4h) vs pay-immediately zones\n' +
' • Start free-parking check-in timers with reminders\n\n' +
'Needs a ParkSmarter login:\n' +
' • Paying for parking\n' +
' • Your active & past sessions\n' +
' • Saved vehicles & payment methods\n\n' +
'You can sign in anytime from the Account tab.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Continue', onPress: enterAnonymous },
],
);
};
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.bg }]}
@ -77,6 +97,10 @@ export function LoginScreen() {
)}
</TouchableOpacity>
<TouchableOpacity style={styles.secondary} onPress={onAnonymous} disabled={busy}>
<Text style={[styles.secondaryText, { color: colors.primary }]}>Park without signing in</Text>
</TouchableOpacity>
<Text style={[styles.hint, { color: colors.subtext }]}>
Forgot your password? Reset it via the official app this build reuses the same
account.
@ -101,5 +125,7 @@ const styles = StyleSheet.create({
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
secondary: { padding: 14, alignItems: 'center', marginTop: 4 },
secondaryText: { fontWeight: '700', fontSize: 15 },
hint: { fontSize: 12, textAlign: 'center', marginTop: 16 },
});

View file

@ -0,0 +1,309 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants';
import {
MapView,
Camera,
ShapeSource,
FillLayer,
LineLayer,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import {
applyOverlay,
loadAreaData,
saveOverlay,
saveOverlayLocally,
publishBundledAreas,
type ParkingArea,
} from '@/api/parkingAreas';
import { getAdminToken } from '@/api/adminStore';
import { IDENTITY_OVERLAY, type OverlayAdjust } from '@/features/citymap/geo';
const MAP_STYLE_LIGHT =
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
'https://tiles.openfreemap.org/styles/liberty';
const MAP_STYLE_DARK =
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
const SANDPOINT: [number, number] = [-116.5533, 48.2766];
/**
* Align the city parking map against reality.
*
* The overlay was georeferenced by fitting the printed map to OpenStreetMap street
* centrelines, which lands within a few metres but a few metres is the difference
* between one side of a street and the other. This is the fix for that: stand on a
* marked block, watch the blue dot against the coloured stripe, and nudge until
* they agree. No rebuild, and no need to re-derive the fit.
*
* "Save on this phone" is deliberately available without the admin token the
* person who can see the misalignment is the person standing on the street.
*/
export function MapAlignScreen() {
const { mode, colors } = useTheme();
const insets = useSafeAreaInsets();
const cameraRef = useRef<any>(null);
const [base, setBase] = useState<ParkingArea[]>([]);
const [adj, setAdj] = useState<Omit<OverlayAdjust, 'updatedAt'>>(IDENTITY_OVERLAY);
const [step, setStep] = useState(2); // metres per nudge
const [isAdmin, setIsAdmin] = useState(false);
const [status, setStatus] = useState('Loading the city map…');
useEffect(() => {
void (async () => {
const data = await loadAreaData();
setBase(data.areas);
const { updatedAt, ...rest } = data.overlay;
setAdj(rest);
setStatus(
`${data.areas.length} areas (${data.source})` +
(updatedAt ? ` · adjusted ${new Date(updatedAt).toLocaleDateString()}` : ''),
);
})();
void getAdminToken().then((t) => setIsAdmin(!!t));
}, []);
const preview = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: applyOverlay(base, { ...adj, updatedAt: 0 }).map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { color: a.color },
})),
}),
[base, adj],
);
const nudge = useCallback(
(dx: number, dy: number) => setAdj((a) => ({ ...a, dxMeters: a.dxMeters + dx, dyMeters: a.dyMeters + dy })),
[],
);
const bump = useCallback(
(key: 'scale' | 'rotationDeg', by: number) =>
setAdj((a) => ({ ...a, [key]: Number((a[key] + by).toFixed(4)) })),
[],
);
const saveLocal = async () => {
await saveOverlayLocally(adj);
setStatus('Saved on this phone.');
};
const savePublished = async () => {
try {
await saveOverlay(adj);
setStatus('Published — every device will pick this up.');
} catch (e: any) {
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
}
};
const publishAreas = async () => {
try {
setStatus('Publishing bundled areas…');
const n = await publishBundledAreas();
setStatus(`Published ${n} areas to the server.`);
} catch (e: any) {
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
}
};
const s = styles(colors);
const dirty =
adj.dxMeters !== 0 || adj.dyMeters !== 0 || adj.scale !== 1 || adj.rotationDeg !== 0;
return (
<View style={s.container}>
<MapView
style={s.map}
mapStyle={mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT}
rotateEnabled={false}
>
<Camera ref={cameraRef} defaultSettings={{ centerCoordinate: SANDPOINT, zoomLevel: 16 }} />
<UserLocation visible renderMode="normal" />
<ShapeSource id="align-preview" shape={preview}>
<FillLayer
id="align-fills"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
/>
<LineLayer
id="align-lines"
style={{
lineColor: ['get', 'color'],
lineWidth: ['interpolate', ['linear'], ['zoom'], 14, 3, 18, 11],
lineOpacity: 0.95,
lineCap: 'round',
}}
/>
</ShapeSource>
</MapView>
<View style={[s.status, { top: insets.top + 12 }]}>
<Text style={s.statusText} numberOfLines={2}>
{status}
</Text>
<Text style={s.readout}>
E {adj.dxMeters.toFixed(1)} m · N {adj.dyMeters.toFixed(1)} m · ×
{adj.scale.toFixed(3)} · {adj.rotationDeg.toFixed(2)}°
</Text>
</View>
<View style={[s.panel, { paddingBottom: insets.bottom + 12 }]}>
<View style={s.padRow}>
<View style={s.pad}>
<TouchableOpacity style={s.key} onPress={() => nudge(0, step)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<View style={s.padMid}>
<TouchableOpacity style={s.key} onPress={() => nudge(-step, 0)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<TouchableOpacity style={s.stepKey} onPress={() => setStep(step >= 8 ? 0.5 : step * 2)}>
<Text style={s.stepText}>{step} m</Text>
</TouchableOpacity>
<TouchableOpacity style={s.key} onPress={() => nudge(step, 0)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={s.key} onPress={() => nudge(0, -step)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
</View>
<View style={s.fine}>
<Text style={s.fineLabel}>Rotate</Text>
<View style={s.fineRow}>
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', -0.25)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', 0.25)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
</View>
<Text style={s.fineLabel}>Scale</Text>
<View style={s.fineRow}>
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', -0.002)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', 0.002)}>
<Text style={s.keyText}>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
<View style={s.btnRow}>
<TouchableOpacity
style={[s.btn, !dirty && s.btnMuted]}
onPress={() => setAdj(IDENTITY_OVERLAY)}
>
<Text style={s.btnText}>Reset</Text>
</TouchableOpacity>
<TouchableOpacity style={s.btnPrimary} onPress={saveLocal}>
<Text style={s.btnTextOn}>Save on this phone</Text>
</TouchableOpacity>
</View>
{isAdmin ? (
<View style={s.btnRow}>
<TouchableOpacity style={s.btn} onPress={publishAreas}>
<Text style={s.btnText}>Publish areas</Text>
</TouchableOpacity>
<TouchableOpacity style={s.btnPrimary} onPress={savePublished}>
<Text style={s.btnTextOn}>Publish alignment</Text>
</TouchableOpacity>
</View>
) : null}
</View>
</View>
);
}
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
StyleSheet.create({
container: { flex: 1, backgroundColor: c.bg },
map: { flex: 1 },
status: {
position: 'absolute',
left: 12,
right: 12,
backgroundColor: 'rgba(0,0,0,0.7)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
gap: 2,
},
statusText: { color: '#fff', fontSize: 13 },
readout: { color: '#c9d6d2', fontSize: 12, fontVariant: ['tabular-nums'] },
panel: {
backgroundColor: c.card,
borderTopWidth: 1,
borderTopColor: c.border,
padding: 12,
gap: 10,
},
padRow: { flexDirection: 'row', gap: 16, alignItems: 'center' },
pad: { alignItems: 'center', gap: 6 },
padMid: { flexDirection: 'row', alignItems: 'center', gap: 6 },
key: {
width: 52,
height: 44,
borderRadius: 10,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
keyText: { color: c.text, fontSize: 20, fontWeight: '700' },
stepKey: {
width: 52,
height: 44,
borderRadius: 10,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
stepText: { color: '#fff', fontWeight: '700', fontSize: 13 },
fine: { flex: 1, gap: 4 },
fineLabel: { color: c.subtext, fontSize: 12, fontWeight: '600' },
fineRow: { flexDirection: 'row', gap: 6 },
fineKey: {
flex: 1,
height: 38,
borderRadius: 10,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
btnRow: { flexDirection: 'row', gap: 8 },
btn: {
flex: 1,
borderWidth: 1,
borderColor: c.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnMuted: { opacity: 0.5 },
btnPrimary: {
flex: 1,
backgroundColor: c.primary,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnText: { color: c.text, fontWeight: '700' },
btnTextOn: { color: '#fff', fontWeight: '700' },
});

View file

@ -1,19 +1,44 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
ActivityIndicator,
Alert,
AppState,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Constants from 'expo-constants';
import {
MapView,
Camera,
ShapeSource,
CircleLayer,
FillLayer,
LineLayer,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useNavigation } from '@react-navigation/native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client';
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
import { useLocation, type Coords } from '@/features/location/useLocation';
import { useAuth } from '@/auth/AuthContext';
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
import {
areaRequiresAccount,
getAdjustedAreas,
refreshAreas,
type ParkingArea,
} from '@/api/parkingAreas';
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
import {
getParkedPin,
setParkedPin,
type ParkedSpot,
} from '@/features/session/activeParkingStore';
import { pinParkedSpot } from '@/features/session/activeParking';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { Zone } from 'parksmarter-client';
@ -24,12 +49,47 @@ const MAP_STYLE_DARK =
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// Fallback view when we have no GPS and no cached location (continental US).
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
const DEFAULT_ZOOM = 4;
// Default view when we have no last-session lot: all of downtown Sandpoint, ID.
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
const DEFAULT_ZOOM = 14;
/**
* How often to re-read the GPS while the map is the screen you're looking at.
* A single fix at mount goes stale the moment you walk a block, which is the
* whole time you'd be looking at this screen.
*/
const GPS_REFRESH_MS = 30_000;
/**
* How old a fix can be before "Park here" / "My location" stops trusting it and
* goes and asks again. A poll and a half, so a fix arriving on schedule is never
* treated as stale.
*/
const FIX_MAX_AGE_MS = 45_000;
type Nav = NativeStackNavigationProp<RootStackParamList>;
/**
* How far from a city-map area a parked pin can be and still be taken as "that's
* where I am". A block is ~170 m, a street ~12 m wide; 40 m picks the right side
* of the right street without silently matching a spot two blocks away.
*/
const AREA_SNAP_METERS = 40;
/** Nearest city-map area to a point, or null if nothing is close enough. */
function areaAt(point: LonLat, areas: ParkingArea[]): ParkingArea | null {
let best: ParkingArea | null = null;
let bestDist = AREA_SNAP_METERS;
for (const a of areas) {
const d = distanceToGeometry(point, a.geometry);
if (d < bestDist) {
best = a;
bestDist = d;
}
}
return best;
}
/** Coerce the server's zone color (hex string, color name, or numeric) into a usable color. */
function normalizeColor(v: unknown): string | null {
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
@ -47,12 +107,33 @@ export function MapScreen() {
const insets = useSafeAreaInsets();
const { mode } = useTheme();
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
const { coords, refresh } = useLocation();
// Poll the GPS while this screen is actually in front of someone. Focus alone
// isn't enough: a backgrounded app stays "focused" on its last tab, and Android
// won't give a foreground app's location out to one that isn't.
const [focused, setFocused] = useState(true);
const [foreground, setForeground] = useState(AppState.currentState === 'active');
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => setForeground(next === 'active'));
return () => sub.remove();
}, []);
const { coords, updatedAt, refresh } = useLocation({
intervalMs: GPS_REFRESH_MS,
active: focused && foreground,
});
const { isAnonymous } = useAuth();
const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
const [loading, setLoading] = useState(false);
const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
// The city's printed parking map, georeferenced. Local geography — loading and
// tapping these never touches the ParkSmarter API.
const [areas, setAreas] = useState<ParkingArea[]>([]);
const [showAreas, setShowAreas] = useState(true);
// Set while waiting for the user to tap where they parked (the GPS-less path).
const [pinning, setPinning] = useState(false);
const [spot, setSpot] = useState<ParkedSpot | null>(null);
const mapRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const [mapReady, setMapReady] = useState(false);
@ -66,13 +147,36 @@ export function MapScreen() {
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
// The native UserLocation dot has its own GPS feed — capture it so "My
// location" works even when expo-location can't get a fix (e.g. indoors).
const nativeFix = useRef<Coords | null>(null);
// Stamped, because that feed goes quiet whenever the map isn't drawing and a
// silently stale fix is worse than no fix.
const nativeFix = useRef<(Coords & { at: number }) | null>(null);
// Seed the initial camera target from a cached location so we don't strand at 0,0.
// Look up the LAST session's parking-lot coordinate (the meter's own location
// from history — never the user's GPS). Used to open the map and by "Last lot".
const lastSessionLot = useCallback(async (): Promise<Coords | null> => {
// No account, no session history — and asking anyway 401s, which the global
// handler would turn into a bogus "session expired" bounce.
if (isAnonymous) return null;
try {
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
const s = past.Session?.[0] as Record<string, any> | undefined;
const lat = Number(s?.Lat);
const lng = Number(s?.Long);
if (s && Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
return { latitude: lat, longitude: lng };
}
} catch {
/* ignore */
}
return null;
}, [isAnonymous]);
// Open on your last parking lot — NOT your GPS. Your location is only ever sent
// to the API when you explicitly tap "My location", so we never auto-center on it.
useEffect(() => {
(async () => {
const last = await getLastKnownSavedLocation();
setInitialCenter(last ?? coords ?? DEFAULT_CENTER);
const lot = await lastSessionLot();
setInitialCenter(lot ?? DEFAULT_CENTER);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -89,47 +193,117 @@ export function MapScreen() {
}
}, [mapReady, initialCenter]);
const searchAt = useCallback(async (c: Coords, label: string) => {
setLoading(true);
setStatus(`Searching ${label}`);
try {
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
} catch (e: any) {
setZones([]);
setStatus(
e?.status === 401
? 'Session expired — sign in again.'
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
);
} finally {
setLoading(false);
}
const searchAt = useCallback(
async (c: Coords, label: string) => {
setLoading(true);
setStatus(`Searching ${label}`);
try {
if (isAnonymous) {
// No ParkSmarter login — read areas from our mirror (all of them; the
// covered area is small). The device GPS is never sent.
const all = await getMirrorZones();
const found = all.filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
return;
}
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
void syncZones(found); // admin-only; no-ops otherwise
} catch (e: any) {
setZones([]);
setStatus(
e?.status === 401
? 'Session expired — sign in again.'
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
);
} finally {
setLoading(false);
}
},
[isAnonymous],
);
// Anonymous: load the mirrored areas once on open (no ParkSmarter needed).
useEffect(() => {
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
}, [isAnonymous, searchAt]);
// City parking map: draw the cached/bundled copy immediately so the overlay is
// there offline, then quietly refresh from the server behind it.
useEffect(() => {
let alive = true;
(async () => {
const local = await getAdjustedAreas();
if (alive) setAreas(local.areas);
try {
await refreshAreas();
const fresh = await getAdjustedAreas();
if (alive) setAreas(fresh.areas);
} catch {
/* offline or unseeded — the local copy is already drawn */
}
})();
return () => {
alive = false;
};
}, []);
// Search whatever the map is currently centered on (works with no GPS).
// Re-read the pin on every focus: the session may have ended on another screen
// (or from the notification), which clears it. Focus also gates the GPS poll.
useFocusEffect(
useCallback(() => {
void getParkedPin().then(setSpot);
setFocused(true);
return () => setFocused(false);
}, []),
);
/**
* The freshest fix we can get, in the order it's cheapest to get it: the map's
* own dot, then the polled expo fix, then a forced read. Whichever we hold is
* only used if it's recent the point of the poll is that "where am I" answers
* with where you are now, not where you were when the screen opened.
*/
const bestFix = useCallback(async (): Promise<Coords | null> => {
const now = Date.now();
const n = nativeFix.current;
if (n && now - n.at < FIX_MAX_AGE_MS) return { latitude: n.latitude, longitude: n.longitude };
if (coords && now - updatedAt < FIX_MAX_AGE_MS) return coords;
const fresh = await refresh();
if (fresh) return fresh;
// Nothing current and nothing new — a stale fix still beats no answer.
return n ? { latitude: n.latitude, longitude: n.longitude } : coords;
}, [coords, updatedAt, refresh]);
// Search whatever the map is currently centered on. This only ever sends the
// map's center point — never the device GPS. (If you want to search your own
// location, tap "My location" to center there first, then Search this area.)
const searchThisArea = async () => {
let center: [number, number] | null = null;
try {
const center = await mapRef.current?.getCenter?.(); // [lng, lat]
if (center && center.length === 2) {
// Commit the current view as the camera's stop so the results re-render
// doesn't revert to the last programmatic (e.g. "My location") target.
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
return;
}
const c = await mapRef.current?.getCenter?.(); // [lng, lat]
if (Array.isArray(c) && c.length === 2) center = [c[0], c[1]];
} catch {
/* fall through */
}
if (coords) await searchAt(coords, 'this area');
// Fallback is the tracked viewport center — deliberately NOT the GPS fix.
if (!center && viewRef.current) center = viewRef.current.center;
if (!center) {
setStatus('Move the map, then tap “Search this area”.');
return;
}
// Commit the current view as the camera's stop so the results re-render
// doesn't revert to the last programmatic (e.g. "My location") target.
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
};
// Recenter on the live GPS fix (if available) and search there. Prefer the
// native map fix (the blue dot), then expo-location, then a forced refresh.
// Recenter on the live GPS fix (if available) and search there.
const goToMyLocation = async () => {
const c = nativeFix.current ?? coords ?? (await refresh());
const c = await bestFix();
if (!c) {
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
return;
@ -142,20 +316,162 @@ export function MapScreen() {
await searchAt(c, 'you');
};
const searchLastKnown = async () => {
const last = (await getLastKnownSavedLocation()) ?? coords;
if (!last) {
setStatus('No saved location yet — enable location once to cache it.');
// Center on the LAST SESSION's parking lot (the meter's own coordinate from
// history — never your GPS) and search around it with a ~couple-mile view.
const searchLastSessionLot = async () => {
if (isAnonymous) {
setStatus('Sign in to use your last parking lot — it comes from your account history.');
return;
}
setLoading(true);
setStatus('Finding your last parking lot…');
const lot = await lastSessionLot();
if (!lot) {
setStatus('No past parking session with a location yet.');
setLoading(false);
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [last.longitude, last.latitude],
zoomLevel: 15,
centerCoordinate: [lot.longitude, lot.latitude],
zoomLevel: 12.5, // ~a couple miles across
animationDuration: 600,
});
await searchAt(lot, 'your last lot');
};
/* -------------------------------------------------- parking-map interaction */
/**
* The paid city lots are ParkSmarter-only, so they're hidden from someone
* browsing without an account showing parking you can't actually buy is worse
* than not showing it.
*/
const hidden = useCallback(
(a: ParkingArea) => isAnonymous && areaRequiresAccount(a),
[isAnonymous],
);
/** What actually gets drawn and tapped. */
const visibleAreas = useMemo(() => areas.filter((a) => !hidden(a)), [areas, hidden]);
/** Open an area, carrying the pin along if we have one. */
const openArea = useCallback(
(area: ParkingArea, at?: ParkedSpot) => {
navigation.navigate('CityArea', { area, spot: at });
},
[navigation],
);
/** Drop the pin at `c`, work out which area that is, and open it. */
const pinAt = useCallback(
(c: Coords, manual: boolean) => {
const at: ParkedSpot = { latitude: c.latitude, longitude: c.longitude, manual };
setSpot(at);
// Persist immediately — the pin is worth keeping even if you never start a
// timer, and even if you back out of the screen we're about to open.
void pinParkedSpot(at);
// Detect against every area, including the ones hidden from this user, so
// standing in a paid lot gets an explanation rather than "nothing found".
const found = areaAt([c.longitude, c.latitude], areas);
if (found && hidden(found)) {
setStatus(`Pinned. ${found.name} is a paid city lot — sign in to park there.`);
} else if (found) {
setStatus(`Parked at ${found.name}`);
openArea(found, at);
} else {
// Pin still stands — you parked somewhere, it's just not on the city map.
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
}
},
[areas, hidden, openArea],
);
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
// garage, indoors, GPS off) fall back to letting the user tap the spot — the
// pin is the point, so it must not depend on the GPS working.
const parkHere = async () => {
if (pinning) {
setPinning(false);
setStatus('Pin cancelled.');
return;
}
const c = await bestFix();
if (!c) {
setPinning(true);
setStatus('No GPS fix — tap the map where you parked.');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 17,
animationDuration: 500,
});
await searchAt(last, 'last location');
pinAt(c, false);
};
/** A tap on the map: places the manual pin, but only while we asked for one. */
const onMapPress = (e: any) => {
if (!pinning) return;
const c = e?.geometry?.coordinates;
if (!Array.isArray(c) || c.length !== 2) return;
setPinning(false);
pinAt({ latitude: c[1], longitude: c[0] }, true);
};
/** A tap on your own pin: the only way to take it down without ending a session. */
const onSpotPress = () => {
if (!spot) return;
Alert.alert('Your car', 'Remove the parked pin?', [
{ text: 'Keep', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
setSpot(null);
void setParkedPin(null);
setStatus('Pin removed.');
},
},
]);
};
/** A tap on a coloured segment: the other start flow, no pin involved. */
const onAreaPress = (e: any) => {
const id = e?.features?.[0]?.properties?.id;
const found = visibleAreas.find((a) => a.id === id);
if (found) openArea(found, spot ?? undefined);
};
const areaFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: visibleAreas.map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { id: a.id, color: a.color, kind: a.kind },
})),
}),
[visibleAreas],
);
const spotFeature = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: spot
? [
{
type: 'Feature' as const,
id: 'parked',
geometry: { type: 'Point' as const, coordinates: [spot.longitude, spot.latitude] },
properties: {},
},
]
: [],
}),
[spot],
);
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
// than React MarkerViews, which floated and thrashed the camera.
const meterFeatures = useMemo(
@ -196,6 +512,7 @@ export function MapScreen() {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
at: Date.now(),
};
}
}}
@ -212,6 +529,7 @@ export function MapScreen() {
mapStyle={mapStyle}
rotateEnabled={false}
onDidFinishLoadingMap={() => setMapReady(true)}
onPress={onMapPress}
onRegionDidChange={(f: any) => {
const c = f?.geometry?.coordinates;
const z = f?.properties?.zoomLevel;
@ -223,6 +541,57 @@ export function MapScreen() {
{cameraEl}
{userLocationEl}
{/* The city parking map, under the meter pins so pins stay tappable. */}
{showAreas ? (
<ShapeSource id="city-areas" shape={areaFeatures} onPress={onAreaPress}>
<FillLayer
id="city-area-fills"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
/>
<LineLayer
id="city-area-outlines"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ lineColor: ['get', 'color'], lineWidth: 1.5, lineOpacity: 0.9 }}
/>
{/* Street segments, scaled with zoom so they read as painted kerb. */}
<LineLayer
id="city-area-lines"
filter={['==', ['geometry-type'], 'LineString']}
style={{
lineColor: ['get', 'color'],
lineOpacity: 0.95,
lineCap: 'round',
lineWidth: ['interpolate', ['linear'], ['zoom'], 12, 2, 15, 5, 18, 11],
}}
/>
{/* A fat, near-invisible line purely to make thin segments tappable
a 5 px kerb stripe is far too small a target for a fingertip. */}
<LineLayer
id="city-area-touch"
filter={['==', ['geometry-type'], 'LineString']}
style={{ lineColor: '#000000', lineOpacity: 0.01, lineWidth: 24 }}
/>
</ShapeSource>
) : null}
{/* Where the car is. Drawn above everything — it's the thing you came back for. */}
<ShapeSource id="parked-spot" shape={spotFeature} onPress={onSpotPress}>
<CircleLayer
id="parked-halo"
style={{ circleColor: '#1e6f5c', circleOpacity: 0.25, circleRadius: 18 }}
/>
<CircleLayer
id="parked-dot"
style={{
circleColor: '#1e6f5c',
circleStrokeColor: '#ffffff',
circleStrokeWidth: 3,
circleRadius: 8,
}}
/>
</ShapeSource>
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
<CircleLayer
id="meter-circles"
@ -253,15 +622,31 @@ export function MapScreen() {
</View>
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
<Text style={styles.pillText}>Search this area</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<Text style={styles.pillText}>My location</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
<Text style={styles.pillText}>Last</Text>
</TouchableOpacity>
<View style={styles.row}>
<TouchableOpacity
style={pinning ? styles.pillActive : styles.pillPrimary}
onPress={parkHere}
>
<Text style={styles.pillText}>{pinning ? 'Tap the map…' : 'Park here'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={showAreas ? styles.pillOn : styles.pill}
onPress={() => setShowAreas((v) => !v)}
>
<Text style={styles.pillText}>City map</Text>
</TouchableOpacity>
</View>
<View style={styles.row}>
<TouchableOpacity style={styles.pill} onPress={searchThisArea}>
<Text style={styles.pillText}>Search this area</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<Text style={styles.pillText}>My location</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
<Text style={styles.pillText}>Last lot</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
@ -287,9 +672,10 @@ const styles = StyleSheet.create({
position: 'absolute',
bottom: 24,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
row: { flexDirection: 'row', gap: 8 },
pill: {
backgroundColor: '#444',
paddingHorizontal: 14,
@ -302,5 +688,18 @@ const styles = StyleSheet.create({
paddingVertical: 10,
borderRadius: 22,
},
/** Waiting for the user to tap where they parked. */
pillActive: {
backgroundColor: '#c07a12',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
pillOn: {
backgroundColor: '#2f6f60',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
},
pillText: { color: '#fff', fontWeight: '600' },
});

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
@ -7,6 +7,28 @@ import type { RootStackParamList } from '@/navigation/RootNavigator';
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
import { useTheme } from '@/theme/ThemeContext';
import type { SpacePolicy } from 'parksmarter-client';
import { getAdminToken } from '@/api/adminStore';
import { useAuth } from '@/auth/AuthContext';
import {
deleteLabel,
getCachedLabel,
labelHours,
labelText,
refreshLabels,
setLabel,
type LabelKind,
type ZoneLabel,
} from '@/api/zoneLabels';
import { startFreeCheckin } from '@/features/session/activeParking';
/** Admin labeling buttons — kind, or 'clear' to remove. */
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
{ label: '2h', kind: 'free_2h' },
{ label: '3h', kind: 'free_3h' },
{ label: '4h', kind: 'free_4h' },
{ label: 'Pay now', kind: 'pay_immediate' },
{ label: 'Clear', kind: 'clear' },
];
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
@ -66,9 +88,48 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function MeterDetailScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const { isAnonymous, requireLogin } = useAuth();
const { params } = useRoute<DetailRoute>();
const z = params.zone;
const [saved, setSaved] = useState(false);
const [label, setLabelState] = useState<ZoneLabel | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [savingKind, setSavingKind] = useState<string | null>(null);
useEffect(() => {
let alive = true;
void getAdminToken().then((t) => alive && setIsAdmin(!!t));
void getCachedLabel(z.ZoneId).then((l) => alive && setLabelState(l));
// Refresh from the server, then re-read this zone's label.
void refreshLabels()
.then(() => getCachedLabel(z.ZoneId))
.then((l) => alive && setLabelState(l))
.catch(() => {});
return () => {
alive = false;
};
}, [z.ZoneId]);
const applyLabel = async (kind: LabelKind | 'clear') => {
if (z.ZoneId == null) return;
setSavingKind(kind);
try {
if (kind === 'clear') {
await deleteLabel(z.ZoneId);
setLabelState(null);
} else {
const l = await setLabel(z.ZoneId, kind, {
zoneName: z.ZoneName ?? null,
customerId: z.CustomerId ?? null,
});
setLabelState(l);
}
} catch (e: any) {
Alert.alert('Could not save label', e?.message ?? 'error');
} finally {
setSavingKind(null);
}
};
const onSave = async () => {
await saveKiosk(toSavedKiosk(z));
@ -76,6 +137,42 @@ export function MeterDetailScreen() {
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
};
const labeledHours = label ? labelHours(label.kind) : null;
const doCheckin = async (hours: number) => {
await startFreeCheckin(z, hours, label?.kind);
Alert.alert(
'Checked in',
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
[{ text: 'OK', onPress: () => navigation.navigate('Tabs') }],
);
};
const onPay = () => {
if (isAnonymous) {
Alert.alert('Sign in to pay', 'Paying for parking needs a ParkSmarter login.', [
{ text: 'Not now', style: 'cancel' },
{ text: 'Sign in', onPress: requireLogin },
]);
return;
}
navigation.navigate('StartSession', { zone: z });
};
// Labeled free zone → check in straight at its known limit. Unlabeled → pick.
const onCheckin = () => {
if (labeledHours) {
void doCheckin(labeledHours);
return;
}
Alert.alert('Free check-in', 'Start a local timer for the free limit here:', [
{ text: '2 hours', onPress: () => doCheckin(2) },
{ text: '3 hours', onPress: () => doCheckin(3) },
{ text: '4 hours', onPress: () => doCheckin(4) },
{ text: 'Cancel', style: 'cancel' },
]);
};
const firstSpace = z.Spaces?.[0];
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
@ -98,6 +195,17 @@ export function MeterDetailScreen() {
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
) : null}
<View
style={[
styles.badge,
{ backgroundColor: label ? (label.kind === 'pay_immediate' ? '#8a4b00' : '#1b5e20') : colors.card },
]}
>
<Text style={[styles.badgeText, { color: label ? '#fff' : colors.subtext }]}>
{label ? labelText(label.kind) : 'Unlabeled'}
</Text>
</View>
<View style={styles.row}>
<Field label="Scanner code" value={z.ScannerCode} />
<Field label="Serial" value={z.TerminalSerNo} />
@ -136,6 +244,36 @@ export function MeterDetailScreen() {
</View>
) : null}
{isAdmin ? (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>Label this zone (admin)</Text>
<View style={styles.chipRow}>
{LABEL_CHOICES.map((c) => {
const active = label?.kind === c.kind;
return (
<TouchableOpacity
key={c.label}
style={[
styles.chip,
{
borderColor: colors.border,
backgroundColor: active ? colors.primary : 'transparent',
opacity: savingKind != null && !active ? 0.5 : 1,
},
]}
onPress={() => applyLabel(c.kind)}
disabled={savingKind != null}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600' }}>
{c.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
) : null}
<TouchableOpacity
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
onPress={onSave}
@ -146,11 +284,20 @@ export function MeterDetailScreen() {
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]}
onPress={() => navigation.navigate('StartSession', { zone: z })}
>
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
{label?.kind === 'pay_immediate' ? (
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately no free window here.</Text>
) : (
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
<Text style={[styles.buttonText, { color: '#fff' }]}>
{labeledHours ? `Check in (free · ${labeledHours}h)` : 'Check in (free timer)'}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.button, { backgroundColor: colors.card }]} onPress={onPay}>
<Text style={[styles.buttonText, { color: colors.text }]}>
{isAnonymous ? 'Sign in to pay' : 'Start parking session (pay)'}
</Text>
</TouchableOpacity>
</ScrollView>
);
@ -171,4 +318,9 @@ const styles = StyleSheet.create({
policyRate: { fontSize: 13, fontWeight: '600' },
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
buttonText: { fontWeight: '600', fontSize: 16 },
badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4 },
badgeText: { fontSize: 13, fontWeight: '700' },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
note: { marginTop: 12, fontSize: 13, textAlign: 'center' },
});

View file

@ -3,6 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '@/theme/ThemeContext';
import { sendTestReminder } from '@/notifications/localReminders';
import { refreshParkingNotification } from '@/features/session/activeParking';
import {
DEFAULT_LEAD_MINUTES,
LEAD_STEP,
@ -10,19 +11,23 @@ import {
MIN_LEAD_MINUTES,
getReminderLeadMinutes,
getRemindersEnabled,
getCountdownEnabled,
setReminderLeadMinutes,
setRemindersEnabled,
setCountdownEnabled,
} from '@/features/notifications/reminderPrefs';
export function NotificationsScreen() {
const { colors } = useTheme();
const [enabled, setEnabled] = useState(true);
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
const [countdown, setCountdown] = useState(true);
useFocusEffect(
useCallback(() => {
void getRemindersEnabled().then(setEnabled);
void getReminderLeadMinutes().then(setLead);
void getCountdownEnabled().then(setCountdown);
}, []),
);
@ -31,6 +36,11 @@ export function NotificationsScreen() {
void setRemindersEnabled(v);
};
const toggleCountdown = (v: boolean) => {
setCountdown(v);
void setCountdownEnabled(v).then(refreshParkingNotification);
};
const bump = (delta: number) => {
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
setLead(next);
@ -53,6 +63,21 @@ export function NotificationsScreen() {
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.card, marginTop: 12 }]}>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={[styles.rowTitle, { color: colors.text }]}>
Active-session countdown
</Text>
<Text style={[styles.rowSub, { color: colors.subtext }]}>
An ongoing notification with the time left, so you can glance without opening
the app.
</Text>
</View>
<Switch value={countdown} onValueChange={toggleCountdown} />
</View>
</View>
<Text style={[styles.section, { color: colors.subtext }]}>Remind me</Text>
<View style={[styles.card, { backgroundColor: colors.card, opacity: enabled ? 1 : 0.4 }]}>
<View style={styles.stepper}>

View file

@ -18,6 +18,8 @@ import {
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useAuth } from '@/auth/AuthContext';
import { getMirrorZones } from '@/api/zoneMirror';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@ -52,6 +54,7 @@ export function parseScannedCode(raw: string): { code?: string; isAppLink?: bool
export function ScanScreen() {
const navigation = useNavigation<Nav>();
const { isAnonymous } = useAuth();
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
@ -66,26 +69,67 @@ export function ScanScreen() {
const lookupCode = useCallback(
async (code: string) => {
setBusy(true);
try {
let res = await ps.getMetersByScannerCode(code);
let zone = res.Zones?.[0];
if (!zone) {
res = await ps.getMetersByZoneName(code);
// Anonymous: no ParkSmarter API — match against the mirrored areas.
if (isAnonymous) {
try {
const lc = code.toLowerCase();
const zone = (await getMirrorZones()).find(
(z) =>
String(z.ScannerCode ?? '').toLowerCase() === lc ||
String(z.ZoneName ?? '').toLowerCase() === lc ||
String(z.TerminalSerNo ?? '') === code,
);
if (zone) {
setManualOpen(false);
navigation.navigate('MeterDetail', { zone });
} else {
Alert.alert('Not found', `No mirrored area matches "${code}". Sign in to search live.`);
}
} catch (e: any) {
Alert.alert('Lookup failed', e?.message ?? 'Could not reach the area mirror.');
} finally {
setBusy(false);
}
return;
}
// A scanned/typed code can resolve by terminal serial, scanner code, or
// zone name. Unmatched lookups return an error envelope (which the client
// throws), and ZoneName is case-sensitive server-side — the official app
// queries it lowercased. So try each strategy independently and don't let
// one failing attempt abort the others.
// ScannerCode is what kiosk QRs encode and what the official app honors;
// zone name (case-sensitive) and terminal serial are extra fallbacks.
const attempts = [
() => ps.getMetersByScannerCode(code),
() => ps.getMetersByZoneName(code),
() => ps.getMetersByZoneName(code.toLowerCase()),
() => ps.getMetersBySerialNumber(code),
];
let zone:
| NonNullable<Awaited<ReturnType<typeof ps.getMetersByZoneName>>['Zones']>[number]
| undefined;
let anyResponded = false;
for (const attempt of attempts) {
try {
const res = await attempt();
anyResponded = true;
zone = res.Zones?.[0];
if (zone) break;
} catch {
// strategy didn't apply (e.g. error envelope) — try the next one
}
if (zone) {
setManualOpen(false);
navigation.navigate('MeterDetail', { zone });
} else {
Alert.alert('Not found', `No meter matches "${code}".`);
}
} catch (e: any) {
Alert.alert('Lookup failed', e?.serverMessage ?? e?.message ?? 'error');
} finally {
setBusy(false);
}
setBusy(false);
if (zone) {
setManualOpen(false);
navigation.navigate('MeterDetail', { zone });
} else if (anyResponded) {
Alert.alert('Not found', `No meter matches "${code}".`);
} else {
Alert.alert('Lookup failed', 'Could not reach ParkSmarter. Check your connection.');
}
},
[navigation],
[navigation, isAnonymous],
);
const onScanned = useCallback(

View file

@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useRoute } from '@react-navigation/native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { ParkingReceipt } from 'parksmarter-client';
type DetailRoute = RouteProp<RootStackParamList, 'SessionDetail'>;
const show = (v: unknown) => (v == null || v === '' ? '—' : String(v));
const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`);
/** API's TimeRemaining is in SECONDS — render it as a human "Xh Ym". */
const fmtRemaining = (secs: unknown): string | undefined => {
const n = Number(secs);
if (!Number.isFinite(n) || n <= 0) return undefined;
const total = Math.round(n / 60);
const h = Math.floor(total / 60);
const m = total % 60;
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
};
export function SessionDetailScreen() {
const { colors } = useTheme();
const { session, kind } = useRoute<DetailRoute>().params;
const s = session as Record<string, any>;
const tid = s.TransactionID;
const [receipt, setReceipt] = useState<ParkingReceipt | null>(null);
const [loading, setLoading] = useState(kind === 'past' && tid != null);
const [emailing, setEmailing] = useState(false);
// Past sessions have a full receipt (auth code, payment, amounts) — fetch it.
useEffect(() => {
if (kind !== 'past' || tid == null) return;
(async () => {
try {
const r = await ps.getParkingReceipt(tid);
if (r?.ParkingReceipt && (r as any).Response?.Status !== 'Error') {
setReceipt(r.ParkingReceipt);
}
} catch (e: any) {
logLine(`[RECEIPT] fetch failed tid=${tid}: ${e?.serverMessage ?? e?.message ?? e}`);
} finally {
setLoading(false);
}
})();
}, [kind, tid]);
const emailReceipt = async () => {
if (tid == null) return;
setEmailing(true);
try {
await ps.emailParkingReceipt(tid);
Alert.alert('Receipt sent', 'Emailed to your account address.');
} catch (e: any) {
Alert.alert('Couldnt email receipt', e?.serverMessage ?? e?.message ?? 'Please try again.');
} finally {
setEmailing(false);
}
};
const r = receipt;
const rows: Array<[string, string | undefined]> =
kind === 'past'
? [
['Vehicle', r?.Vehicle ?? s.VehicleNumber],
['Zone', s.Description ?? s.Zone ?? r?.MeterNumber],
['Space', r?.Space ?? s.Space],
['Started', r?.StartTime ?? s.StartTime],
['Ended', r?.EndTime ?? s.EndTime],
['Time purchased', s.TimePurchased ? `${s.TimePurchased} min` : undefined],
['Parking', r?.Amount ?? dollars(s.Amount)],
['Fee', r?.TransactionFee ?? dollars(s.TransactionFee)],
['Total', r?.Total],
[
'Payment',
[r?.PaymentDisplay ?? s.PaymentDisplay, r?.CC ?? (s.CardLastFour ? `••${s.CardLastFour}` : '')]
.filter(Boolean)
.join(' ') || undefined,
],
['Auth code', r?.AuthCode],
['City', s.City ?? r?.CustomerName],
['Transaction', tid != null ? String(tid) : undefined],
]
: [
['Vehicle', s.VehiclePlate ?? s.VehicleNumber],
['Zone', s.ZoneName ?? s.Zone],
['Space', s.SpaceName ?? s.Space],
['Started', s.StartTimeDisplay ?? s.StartTime],
['Ends', s.EndTimeDisplay ?? s.EndTime],
['Time remaining', fmtRemaining(s.TimeRemaining)],
['Amount', dollars(s.Amount)],
];
if (loading) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
const title = s.Description ?? s.ZoneName ?? s.Zone ?? r?.MeterNumber ?? 'Session';
const visible = rows.filter(([, v]) => v != null && v !== '');
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
{kind === 'active' ? (
<View style={[styles.badge, { backgroundColor: colors.primary + '22' }]}>
<Text style={{ color: colors.primary, fontWeight: '700' }}>Active session</Text>
</View>
) : null}
<View style={[styles.card, { backgroundColor: colors.card }]}>
{visible.map(([label, value], i) => (
<View
key={label}
style={[styles.row, i === visible.length - 1 && { borderBottomWidth: 0 }]}
>
<Text style={{ color: colors.subtext }}>{label}</Text>
<Text style={[styles.value, { color: colors.text }]}>{show(value)}</Text>
</View>
))}
</View>
{kind === 'past' && tid != null ? (
<TouchableOpacity
style={[styles.btn, { borderColor: colors.primary, opacity: emailing ? 0.6 : 1 }]}
disabled={emailing}
onPress={emailReceipt}
>
<Text style={{ color: colors.primary, fontWeight: '700' }}>
{emailing ? 'Sending…' : 'Email me this receipt'}
</Text>
</TouchableOpacity>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: 22, fontWeight: '700', marginBottom: 12 },
badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginBottom: 12 },
card: { borderRadius: 12, padding: 4, paddingHorizontal: 14 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#8883',
},
value: { fontWeight: '600', flexShrink: 1, textAlign: 'right', marginLeft: 12 },
btn: { marginTop: 16, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
});

View file

@ -1,18 +1,72 @@
import React, { useCallback, useState } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useEffect, useState } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/theme/ThemeContext';
import { endActiveParking, extendAreaParking } from '@/features/session/activeParking';
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
import {
getLocalHistory,
isLocalOnly,
type LocalSessionRecord,
} from '@/features/session/localHistory';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { ActiveSession, PastSession } from 'parksmarter-client';
type Nav = NativeStackNavigationProp<RootStackParamList>;
function fmtClock(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function fmtDate(ms: number): string {
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function fmtRemaining(ms: number): string {
const mins = Math.max(0, Math.round(ms / 60_000));
const h = Math.floor(mins / 60);
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
}
function fmtSpan(from: number, to: number): string {
const mins = Math.max(0, Math.round((to - from) / 60_000));
const h = Math.floor(mins / 60);
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
}
/**
* Sessions, in two halves that must not depend on each other.
*
* Anything tracked on this phone a city-map timer, a free check-in is shown
* and managed with no network and no account, because that is the only place it
* exists. ParkSmarter's own sessions are layered on top when signed in, and a
* failure to reach them (offline, or simply not logged in) must never hide the
* local half.
*/
export function SessionsScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const { isAnonymous, requireLogin } = useAuth();
const [local, setLocal] = useState<ActiveParking | null>(null);
const [history, setHistory] = useState<LocalSessionRecord[]>([]);
const [active, setActive] = useState<ActiveSession[]>([]);
const [past, setPast] = useState<PastSession[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [remoteError, setRemoteError] = useState<string | null>(null);
const load = useCallback(async () => {
setRefreshing(true);
/** On-device only. Never awaits the network, so it works offline and signed out. */
const loadLocal = useCallback(async () => {
const [a, h] = await Promise.all([getActiveParking(), getLocalHistory()]);
setLocal(a && a.endMs > Date.now() ? a : null);
setHistory(h);
}, []);
const loadRemote = useCallback(async () => {
if (isAnonymous) return;
try {
const [a, p] = await Promise.all([
ps.getActiveParkingSessions(),
@ -20,10 +74,22 @@ export function SessionsScreen() {
]);
setActive(a.ParkingSession ?? []);
setPast(p.Session ?? []);
setRemoteError(null);
} catch (e: any) {
// Offline or the API is unhappy. Say so quietly and keep the local half.
setRemoteError(e?.serverMessage ?? e?.message ?? 'Could not reach ParkSmarter.');
}
}, [isAnonymous]);
const load = useCallback(async () => {
setRefreshing(true);
try {
await loadLocal();
await loadRemote();
} finally {
setRefreshing(false);
}
}, []);
}, [loadLocal, loadRemote]);
useFocusEffect(
useCallback(() => {
@ -31,38 +97,159 @@ export function SessionsScreen() {
}, [load]),
);
// Keep "time left" honest while the screen sits open.
useEffect(() => {
if (!local) return;
const id = setInterval(() => void loadLocal(), 30_000);
return () => clearInterval(id);
}, [local, loadLocal]);
const s = styles;
return (
<ScrollView
style={{ backgroundColor: colors.bg }}
contentContainerStyle={{ padding: 16 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
>
<Text style={[styles.header, { color: colors.text }]}>Active</Text>
{active.length === 0 ? (
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
) : (
active.map((s, i) => (
<View key={i} style={[styles.card, { backgroundColor: colors.primary + '22' }]}>
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''}
</Text>
<Text style={[s.header, { color: colors.text }]}>Tracking on this phone</Text>
{local ? (
<View style={[s.card, { backgroundColor: colors.primary + '22' }]}>
<View style={s.chipRow}>
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
</View>
))
<Text style={[s.big, { color: colors.text }]}>
{fmtRemaining(local.endMs - Date.now())} left
</Text>
<Text style={[s.meta, { color: colors.subtext }]}>
{local.area?.legend ? `${local.area.legend} · ` : ''}
{local.kind === 'free' ? 'Free' : 'Paid'} until {fmtClock(local.endMs)}
{local.spot ? (local.spot.manual ? ' · pin placed by hand' : ' · pinned from GPS') : ''}
</Text>
<View style={s.row}>
{/* Same split as the notification's second button: a local timer can be
nudged for free, but a bought session can only be extended by buying
more, so that one goes to the purchase screen instead of lying. */}
{isLocalOnly(local) ? (
<TouchableOpacity
style={[s.btn, { borderColor: colors.border }]}
onPress={async () => {
await extendAreaParking();
await loadLocal();
}}
>
<Text style={{ color: colors.text, fontWeight: '700' }}>+1 hour</Text>
</TouchableOpacity>
) : local.zone ? (
<TouchableOpacity
style={[s.btn, { borderColor: colors.border }]}
onPress={() => navigation.navigate('StartSession', { zone: local.zone! })}
>
<Text style={{ color: colors.text, fontWeight: '700' }}>Extend</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity
style={[s.btnFilled, { backgroundColor: colors.danger }]}
onPress={async () => {
await endActiveParking();
await loadLocal();
}}
>
<Text style={{ color: '#fff', fontWeight: '700' }}>End</Text>
</TouchableOpacity>
</View>
<Text style={[s.hint, { color: colors.subtext }]}>
{isLocalOnly(local)
? 'Works offline — this timer lives on your phone, not on a server.'
: 'Ending stops the countdown here. Time you bought keeps running at the meter.'}
</Text>
</View>
) : (
<Text style={[s.empty, { color: colors.subtext }]}>
Nothing being tracked. Start one from the map Park here, or tap a coloured block.
</Text>
)}
<Text style={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
{past.length === 0 ? (
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
{history.length > 0 ? (
<>
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Recent on this phone</Text>
{history.map((r) => (
<View key={r.id} style={[s.card, { backgroundColor: colors.card }]}>
<View style={s.chipRow}>
{r.color ? <View style={[s.swatch, { backgroundColor: r.color }]} /> : null}
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{r.zoneName}</Text>
</View>
<Text style={[s.meta, { color: colors.subtext }]}>
{fmtDate(r.startMs)} · {fmtClock(r.startMs)}{fmtClock(r.endedAtMs)} ·{' '}
{fmtSpan(r.startMs, r.endedAtMs)}
{r.endedEarly ? ' · ended early' : ' · ran out'}
</Text>
</View>
))}
</>
) : null}
{isAnonymous ? (
<View style={[s.card, { backgroundColor: colors.card, marginTop: 20 }]}>
<Text style={[s.zone, { color: colors.text }]}>Paid ParkSmarter sessions</Text>
<Text style={[s.meta, { color: colors.subtext, marginBottom: 10 }]}>
Sessions you bought live in your ParkSmarter account. Sign in to see them here
everything above stays on this phone either way.
</Text>
<TouchableOpacity
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
onPress={requireLogin}
>
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
</TouchableOpacity>
</View>
) : (
past.map((s, i) => (
<View key={i} style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
</Text>
</View>
))
<>
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
{remoteError ? (
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
) : active.length === 0 ? (
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
) : (
active.map((sess, i) => (
<TouchableOpacity
key={i}
style={[s.card, { backgroundColor: colors.primary + '22' }]}
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'active' })}
>
<Text style={[s.zone, { color: colors.text }]}>{sess.ZoneName ?? 'Session'}</Text>
<Text style={[s.meta, { color: colors.subtext }]}>
{sess.SpaceName ?? sess.Space ?? ''} · ends{' '}
{sess.EndTimeDisplay ?? sess.EndTime ?? ''} tap for details
</Text>
</TouchableOpacity>
))
)}
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>History (ParkSmarter)</Text>
{remoteError ? (
<Text style={[s.empty, { color: colors.subtext }]}>Unavailable offline.</Text>
) : past.length === 0 ? (
<Text style={[s.empty, { color: colors.subtext }]}>No past sessions.</Text>
) : (
past.map((sess, i) => (
<TouchableOpacity
key={i}
style={[s.card, { backgroundColor: colors.card }]}
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
>
<Text style={[s.zone, { color: colors.text }]}>
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
</Text>
<Text style={[s.meta, { color: colors.subtext }]}>
{sess.StartTime ?? ''} · {sess.Amount != null ? `$${sess.Amount}` : ''} tap for
receipt
</Text>
</TouchableOpacity>
))
)}
</>
)}
</ScrollView>
);
@ -71,7 +258,20 @@ export function SessionsScreen() {
const styles = StyleSheet.create({
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
empty: { marginBottom: 8 },
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
card: { borderRadius: 10, padding: 14, marginBottom: 10, gap: 4 },
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
swatch: { width: 20, height: 11, borderRadius: 3 },
zone: { fontSize: 16, fontWeight: '600' },
big: { fontSize: 26, fontWeight: '700' },
meta: { fontSize: 13, marginTop: 2 },
hint: { fontSize: 12, marginTop: 6 },
row: { flexDirection: 'row', gap: 8, marginTop: 8 },
btn: {
flex: 1,
borderWidth: 1,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnFilled: { flex: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
});

View file

@ -14,13 +14,16 @@ import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import { scheduleExpiryReminder } from '@/notifications/localReminders';
import { parseApiTime } from '@/api/parseTime';
import { startPaidSession } from '@/features/session/activeParking';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type {
CreditCardDetail,
ParkingDetail,
VehicleDetail,
import {
isFreeEstimate,
ladderAllFree,
type CreditCardDetail,
type ParkingDetail,
type VehicleDetail,
} from 'parksmarter-client';
type SessionRoute = RouteProp<RootStackParamList, 'StartSession'>;
@ -34,16 +37,73 @@ function fmtDuration(min?: number): string {
}
const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
function parseApiTime(s?: string): Date | null {
if (!s) return null;
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
if (!m) return null;
let hr = parseInt(m[4], 10);
const pm = /pm/i.test(m[6]);
if (pm && hr !== 12) hr += 12;
if (!pm && hr === 12) hr = 0;
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
/**
* Some zones (e.g. short-term "DL" spaces) reject ParkingEstimateMulti with
* "Unable to process your request" even though the single-duration estimate
* works. Build a price ladder from ParkingEstimate (single) across the zone's
* Min/Max time so those zones stay purchasable instead of showing a blank screen.
*/
export interface SingleLadderResult {
/** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */
free: boolean;
/** Flat-rate zone: one price for any duration, so we offer a single max option. */
flat: boolean;
ladder: ParkingDetail[];
}
/** Price a single duration; returns a rung (Minutes overridden to the request) or null. */
async function singleRung(
base: { zoneId: number; spaceId: number; customerId: number; vehicleId: number },
minutes: number,
): Promise<ParkingDetail | null> {
try {
const r = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: minutes, creditCardId: 0 });
const p = r.ParkingDetail;
if (!p || (r as any)?.Response?.Status === 'Error') return null;
// The single endpoint often echoes Minutes:0; trust the requested duration.
return { ...p, Minutes: minutes } as ParkingDetail;
} catch {
return null;
}
}
export async function buildSingleLadder(base: {
zoneId: number;
spaceId: number;
customerId: number;
vehicleId: number;
}): Promise<SingleLadderResult> {
const probe = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: 30, creditCardId: 0 });
if ((probe as any)?.Response?.Status === 'Error') {
throw new Error((probe as any).Response?.Message || 'Estimate unavailable for this space.');
}
// MaxTime 0 means no paid time is available right now — this is a free window,
// not a purchasable zone. Don't build a bogus $0.00 ladder.
if (isFreeEstimate(probe)) return { free: true, flat: false, ladder: [] };
const min = Math.max(5, Number(probe.MinTime) || 5);
const max = Math.max(min, Number(probe.MaxTime) || min);
// Price both ends first. If they match, it's a flat-rate zone — one price no
// matter how long you stay — so there's no reason to pick a shorter duration;
// offer a single option for the whole window (park to MaxTime).
const [minRung, maxRung] = await Promise.all([singleRung(base, min), singleRung(base, max)]);
if (maxRung && ladderAllFree([maxRung])) return { free: true, flat: false, ladder: [] };
if (minRung && maxRung && Number(minRung.ParkingCost) === Number(maxRung.ParkingCost)) {
return { free: false, flat: true, ladder: [maxRung] };
}
// Graded rate: fill in intermediate durations between the two ends.
const span = max - min;
const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5;
const mids = new Set<number>();
for (let d = min + step; d < max; d += step) mids.add(d);
const midRungs = await Promise.all([...mids].map((d) => singleRung(base, d)));
const ladder = [minRung, ...midRungs, maxRung]
.filter((r): r is ParkingDetail => r != null)
.sort((a, b) => Number(a.Minutes) - Number(b.Minutes));
if (ladderAllFree(ladder)) return { free: true, flat: false, ladder: [] };
return { free: false, flat: false, ladder };
}
export function StartSessionScreen() {
@ -67,6 +127,11 @@ export function StartSessionScreen() {
const [selIdx, setSelIdx] = useState(0);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [ladderError, setLadderError] = useState<string | null>(null);
// Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy.
const [estimatedFree, setEstimatedFree] = useState(false);
// Flat-rate zone: one price for the whole window (a single option, not a ladder).
const [flatRate, setFlatRate] = useState(false);
// Load the account's vehicles + cards and pick the defaults.
useEffect(() => {
@ -88,16 +153,50 @@ export function StartSessionScreen() {
const loadLadder = useCallback(async () => {
if (vehicleId == null || space?.SpaceId == null) return;
setLoading(true);
setLadderError(null);
setEstimatedFree(false);
setFlatRate(false);
const base = {
zoneId: zone.ZoneId!,
spaceId: space.SpaceId!,
customerId: zone.CustomerId!,
vehicleId,
};
try {
const est = await ps.getParkingEstimateMulti({
zoneId: zone.ZoneId!,
spaceId: space.SpaceId!,
customerId: zone.CustomerId!,
vehicleId,
});
const details = est.ParkingDetail ?? [];
let details: ParkingDetail[] = [];
let multiErrored = false;
try {
const est = await ps.getParkingEstimateMulti(base);
if ((est as any)?.Response?.Status === 'Error') multiErrored = true;
else details = est.ParkingDetail ?? [];
} catch {
multiErrored = true;
}
// Multi is unsupported or empty for some zones — fall back to single estimates.
if (!details.length) {
logLine(
`[ESTIMATE] multi ${multiErrored ? 'errored' : 'empty'} for zone=${base.zoneId} ` +
`space=${base.spaceId}; falling back to single`,
);
const fb = await buildSingleLadder(base);
if (fb.free) {
logLine(`[ESTIMATE] zone=${base.zoneId} is currently free (MaxTime 0)`);
setEstimatedFree(true);
return;
}
setFlatRate(fb.flat);
details = fb.ladder;
}
if (!details.length) {
throw new Error('No parking options are available for this space right now.');
}
setLadder(details);
setSelIdx((i) => Math.min(i, Math.max(0, details.length - 1)));
} catch (e: any) {
const msg = e?.serverMessage ?? e?.message ?? 'Could not load parking options.';
setLadder([]);
setLadderError(msg);
logLine(`[ESTIMATE] failed zone=${base.zoneId} space=${base.spaceId}: ${msg}`);
} finally {
setLoading(false);
}
@ -163,14 +262,18 @@ export function StartSessionScreen() {
minCreditAmount: zone.MinimumAmount,
meterTypeId: zone.MeterTypeId!,
});
// Schedule the local expiry reminder from the purchased end time.
// Becomes the one active parking session, replacing any free check-in (and,
// when this purchase is an extension, the session it extends). That posts the
// ongoing countdown notification and schedules the expiry reminder.
const end = parseApiTime(selected.EndTime);
if (end) {
await scheduleExpiryReminder({
transactionId: (res as any)?.TransactionID ?? Date.now(),
zoneName: zone.ZoneName ?? 'Parking',
await startPaidSession({
zone,
endTime: end,
transactionId: (res as any)?.TransactionID,
});
} else {
logLine(`[SESSION] no countdown: unparseable EndTime "${selected.EndTime}"`);
}
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
@ -218,6 +321,42 @@ export function StartSessionScreen() {
);
}
if (estimatedFree) {
return (
<View style={[styles.center, { backgroundColor: colors.bg, padding: 24 }]}>
<Text style={[styles.zone, { color: colors.text }]}>{zone.ZoneName}</Text>
<View style={[styles.freeBanner, { backgroundColor: '#e8f5e9', marginTop: 16, alignSelf: 'stretch' }]}>
<Text style={{ color: '#2e7d32', fontWeight: '700', fontSize: 16 }}>
Parking is currently free
</Text>
<Text style={{ color: colors.subtext, marginTop: 6 }}>
No payment needed right now just park.
</Text>
</View>
</View>
);
}
if (ladderError && !ladder.length) {
return (
<View style={[styles.center, { backgroundColor: colors.bg, padding: 24 }]}>
<Text style={[styles.zone, { color: colors.text }]}>{zone.ZoneName}</Text>
<View style={[styles.freeBanner, { backgroundColor: '#fdecea', marginTop: 16, alignSelf: 'stretch' }]}>
<Text style={{ color: '#c0392b', fontWeight: '700', fontSize: 16 }}>
Couldnt load parking options
</Text>
<Text style={{ color: colors.subtext, marginTop: 6 }}>{ladderError}</Text>
</View>
<TouchableOpacity
style={[styles.payBtn, { backgroundColor: colors.primary, marginTop: 20, alignSelf: 'stretch' }]}
onPress={() => void loadLadder()}
>
<Text style={styles.payText}>Try again</Text>
</TouchableOpacity>
</View>
);
}
const Chip = ({
active,
label,
@ -257,20 +396,29 @@ export function StartSessionScreen() {
))}
</View>
<Text style={[styles.label, { color: colors.subtext }]}>Duration</Text>
<FlatList
horizontal
data={ladder}
keyExtractor={(d, i) => String(d.Minutes ?? i)}
showsHorizontalScrollIndicator={false}
renderItem={({ item, index }) => (
<Chip
active={index === selIdx}
label={`${fmtDuration(item.Minutes)} · ${money(item.ParkingCost)}`}
onPress={() => setSelIdx(index)}
/>
)}
/>
<Text style={[styles.label, { color: colors.subtext }]}>
{flatRate ? 'Flat rate' : 'Duration'}
</Text>
{flatRate ? (
<Text style={{ color: colors.text, marginBottom: 4 }}>
{money(selected?.ParkingCost)} flat one price for the whole window
{selected?.EndTime ? `, parked until ${selected.EndTime}` : ''}.
</Text>
) : (
<FlatList
horizontal
data={ladder}
keyExtractor={(d, i) => String(d.Minutes ?? i)}
showsHorizontalScrollIndicator={false}
renderItem={({ item, index }) => (
<Chip
active={index === selIdx}
label={`${fmtDuration(item.Minutes)} · ${money(item.ParkingCost)}`}
onPress={() => setSelIdx(index)}
/>
)}
/>
)}
<Text style={[styles.label, { color: colors.subtext }]}>Card</Text>
<View style={styles.chipRow}>

152
app/test/geo.test.ts Normal file
View file

@ -0,0 +1,152 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
adjustGeometry,
distanceMeters,
distanceToGeometry,
overlayAnchor,
IDENTITY_OVERLAY,
type AreaGeometry,
type LonLat,
} from '../src/features/citymap/geo';
import bundled from '../src/features/citymap/parkingAreas.json';
/**
* The city-map geometry, checked against the real bundled area set.
*
* This is the maths that decides which street you are tracking time on, so it is
* tested against the actual 49 areas rather than toy shapes the awkward cases
* (an L-shaped run down two streets, a crescent-shaped beach lot) only exist in
* the real data.
*
* Run with: npm test --workspace app
*/
interface Area {
id: string;
shape: 'line' | 'polygon';
geometry: AreaGeometry;
}
const areas: Area[] = (bundled as any).features.map((f: any) => ({
...f.properties,
geometry: f.geometry,
}));
const geoms: AreaGeometry[] = areas.map((a) => a.geometry);
/**
* Points that genuinely lie on an area: edge midpoints. A centroid is no good
* an L-shaped run's lands mid-block and the crescent City Beach lot's lands in
* the water.
*/
function onGeometry(g: AreaGeometry): LonLat[] {
const rings = g.type === 'Polygon' ? g.coordinates : [g.coordinates];
const out: LonLat[] = [];
for (const r of rings) {
for (let i = 1; i < r.length; i++) {
out.push([(r[i - 1][0] + r[i][0]) / 2, (r[i - 1][1] + r[i][1]) / 2]);
}
}
return out;
}
function nearest(p: LonLat): { area: Area; dist: number } {
let best = areas[0];
let bd = Infinity;
for (const a of areas) {
const d = distanceToGeometry(p, a.geometry);
if (d < bd) {
best = a;
bd = d;
}
}
return { area: best, dist: bd };
}
test('the bundled map has the expected shape', () => {
assert.equal(areas.length, 49);
assert.ok(areas.some((a) => a.shape === 'polygon'), 'city lots should be polygons');
assert.ok(areas.some((a) => a.shape === 'line'), 'on-street runs should be lines');
});
test('a point on an area measures zero distance to it', () => {
for (const a of areas) {
for (const p of onGeometry(a.geometry)) {
const d = distanceToGeometry(p, a.geometry);
assert.ok(d < 0.01, `${a.id}: a point on it measured ${d.toFixed(3)} m away`);
}
}
});
test('hit-testing resolves each area from points on it', () => {
for (const a of areas) {
for (const p of onGeometry(a.geometry)) {
const hit = nearest(p);
if (hit.area.id === a.id) continue;
// Categories meet at intersections, so an exact tie is acceptable; silently
// resolving to something FURTHER away is the bug this guards against.
assert.ok(
hit.dist < 0.01,
`${a.id}: a point on it resolved to ${hit.area.id} at ${hit.dist.toFixed(2)} m`,
);
}
}
});
test('a point off the map does not snap to an area', () => {
// East of the highway, across Sand Creek — no mapped parking anywhere near.
assert.ok(nearest([-116.5445, 48.2705]).dist > 40);
});
test('the identity overlay is a no-op', () => {
const anchor = overlayAnchor(geoms);
for (const g of geoms) assert.deepEqual(adjustGeometry(g, IDENTITY_OVERLAY, anchor), g);
});
test('a shift moves every vertex by exactly that distance', () => {
const anchor = overlayAnchor(geoms);
for (const g of geoms) {
const moved = adjustGeometry(g, { ...IDENTITY_OVERLAY, dxMeters: 10 }, anchor);
const a = g.type === 'Polygon' ? g.coordinates[0] : g.coordinates;
const b = moved.type === 'Polygon' ? moved.coordinates[0] : moved.coordinates;
for (let i = 0; i < a.length; i++) {
assert.ok(Math.abs(distanceMeters(a[i], b[i]) - 10) < 0.05, 'shift distance');
assert.ok(b[i][0] > a[i][0], 'positive dxMeters must move east');
}
}
});
test('rotation is rigid about the anchor and 360° returns home', () => {
const anchor = overlayAnchor(geoms);
const g = geoms.find((x) => x.type === 'LineString') as Extract<
AreaGeometry,
{ type: 'LineString' }
>;
const spun = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 360 }, anchor) as typeof g;
for (let i = 0; i < g.coordinates.length; i++) {
assert.ok(distanceMeters(g.coordinates[i], spun.coordinates[i]) < 0.01, '360° round trip');
}
const rot = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 5 }, anchor) as typeof g;
for (let i = 0; i < g.coordinates.length; i++) {
const r0 = distanceMeters(anchor, g.coordinates[i]);
const r1 = distanceMeters(anchor, rot.coordinates[i]);
assert.ok(Math.abs(r0 - r1) < 0.5, `rotation changed radius ${r0.toFixed(1)} -> ${r1.toFixed(1)}`);
}
});
test('scale is anchored and proportional', () => {
const anchor = overlayAnchor(geoms);
const far = (geoms.find((x) => x.type === 'LineString') as any).coordinates[0] as LonLat;
const scaled = adjustGeometry(
{ type: 'LineString', coordinates: [anchor, far] },
{ ...IDENTITY_OVERLAY, scale: 2 },
anchor,
) as Extract<AreaGeometry, { type: 'LineString' }>;
assert.ok(distanceMeters(scaled.coordinates[0], anchor) < 0.01, 'the anchor must not move');
const before = distanceMeters(anchor, far);
const after = distanceMeters(anchor, scaled.coordinates[1]);
assert.ok(Math.abs(after - 2 * before) < 0.5, `×2: ${before.toFixed(1)} -> ${after.toFixed(1)}`);
});

View file

@ -0,0 +1,156 @@
# What the official ParkSmarter app does with your privacy
*A plain-language look at the official **ParkSmarter** app for Android
(`com.ipsgroupinc.parksmarter`, version 4.4.0), and how **BigBrainParking** — this
open-source alternative — is different.*
This was written by taking the official app apart: reading its program code, and
watching its real network traffic through a proxy. Where we're **sure**, we say
so; where something is situational, we say that too. (For the technical teardown
with diagrams and evidence, see
[OFFICIAL_APP_REVERSE_ENGINEERING.md](OFFICIAL_APP_REVERSE_ENGINEERING.md).)
---
## The short version
- **Your exact location is sent to ParkSmarter's servers when you use the map**
to find meters — and the app is built to do this **automatically** when the map
opens, not only when you ask it to "find parking near me."
- **The app carries one heavy tracking tool — Instabug** — that can record your
screen, take screenshots, and log the app's network activity, reporting to an
outside company.
- **It sends a permanent per-device ID to ParkSmarter.** It is *not* your phone's
IMEI (despite the app calling the field `IMEINumber`) — it's Android's device
ID. Still a fixed code that identifies your specific phone to ParkSmarter.
- **Good news:** it **cannot** track your location while the app is closed.
- **BigBrainParking removes the tracking entirely** and only sends your location
when *you* deliberately tap "My location" and search.
---
## First, why does this matter?
"Location data" is a record of where you physically are. On its own, one location
isn't a big deal. But a *stream* of them — collected quietly and often — reveals
where you live, work, worship, and who you visit. Tie that to a permanent ID for
your phone and it becomes a profile of you that can be sold, leaked, or
subpoenaed. So the questions that matter are: **who collects it, how often, is it
tied to your identity, and does it leave in ways you didn't ask for?**
---
## When does the official app send your location to ParkSmarter?
**To ParkSmarter's own servers, your GPS is used for one thing: finding nearby
meters.** We confirmed this by watching the traffic: the coordinates go out as a
`GET …/api/Meter?Lat=…&Long=…` request, and your location is **not** attached to
logging in, starting a session, or paying. That part is reasonable.
**The catch is *when*.** The app is built to grab your live GPS and ask "what
meters are near me?" **automatically when the map opens** — no tap required — as
long as you're logged in and have granted location permission. So simply browsing
the map quietly sends your precise location to ParkSmarter.
> **One nuance we saw firsthand:** on a de-Googled phone (GrapheneOS), the map
> itself doesn't draw (it needs Google services), which can suppress that
> automatic send. On a normal Google phone the auto-send happens as designed.
**BigBrainParking does the opposite on purpose:** it opens on your **last parking
lot** (from your own history, not your GPS), and it only sends your location when
you *explicitly* tap **"My location"** and then search.
---
## The bigger privacy concerns
### 1. A heavy tracking tool: Instabug
The app bundles **Instabug**, a monitoring/bug-reporting toolkit — and it's the
serious one. It's capable of:
- **recording your session / replaying what you did**,
- **taking screenshots** of the app,
- **logging the app's network requests**,
- **tracking your taps and the steps you take** through the app.
All of this reports to an outside company's servers (`api.instabug.com`). We saw
it phone home the moment the app started.
> **What it is *not*:** we specifically checked and the app does **not** contain
> Segment, Amplitude, Sentry, Google Analytics/Firebase-Analytics, an advertising
> ID, or Play Store install-tracking. (An earlier version of this note listed
> those — that was wrong; Instabug is the actual story.) Google is present only
> for **push notifications**.
> **BigBrainParking has none of this.** No Instabug, no analytics, no ad-ID, no
> Google services at all.
### 2. A permanent device ID sent to ParkSmarter
When you log in, the app sends ParkSmarter a fixed identifier for your phone,
inside a request to `…/api/Device`. The field is **named `IMEINumber`**, which is
misleading — **it is not your IMEI**. Modern Android forbids apps from reading the
IMEI at all, and this app never tries. The value it actually sends is your
phone's **Android device ID** (a code assigned to your device). Unlike a password,
it doesn't change, so it can be used to recognize your specific phone over time.
It goes only to ParkSmarter, not to a third party.
> **BigBrainParking never reads or sends any device ID.**
### 3. Other capabilities worth knowing about
- **Microphone & camera permissions** — camera is for scanning kiosk QR codes;
microphone is unusual for a parking app (may be an unused library leftover).
- **Wi-Fi & phone-state access** — used by a networking library to report your
connection type and carrier name; it does **not** read your IMEI.
- **Bluetooth** — for talking to parking meters directly.
- **Google push messaging (FCM)** — notifications route through Google.
---
## To be fair — what it does *not* do
- **No background location tracking.** The app has the everyday location
permission but **not** the "all the time / background" one, so it can't follow
you when it's closed.
- **No IMEI, no advertising ID, no Segment/Amplitude/Sentry.**
- Your location going to ParkSmarter is genuinely limited to the "find nearby
meters" feature — it isn't stapled onto payments or your account details.
---
## Side-by-side
| | Official ParkSmarter app | BigBrainParking |
| --- | --- | --- |
| Sends GPS to ParkSmarter | **Automatically** when the map opens | **Only** when you tap "My location" and search |
| Opens the map on… | Your current GPS location | Your **last parking lot** (not your GPS) |
| Third-party tracking SDKs | **Instabug** (session replay, screenshots, network logs) | **None** |
| Advertising ID / cross-app tracking | **No** (none present) | **Never** |
| Sends a permanent device ID | **Yes** — Android device ID, sent as `IMEINumber` | **Never** |
| Reads your hardware IMEI | **No** (not possible on modern Android) | **Never** |
| Google services required | Yes (push notifications) | **None** (runs on GrapheneOS) |
| Background location | No | No |
| Source code you can inspect | No (closed) | **Yes** (this repo) |
---
## How we checked (for the curious)
- **Permissions** come from the app's own `AndroidManifest`. Confirmed present:
precise + approximate location, read-phone-state, Wi-Fi, microphone, camera,
Bluetooth, Google push. Confirmed **absent**: background location.
- **The device-ID and location behavior** were read from the app's decompiled
code (it's a React Native / Expo app, so the logic is in a JavaScript bundle)
**and confirmed on the wire** with a live proxy capture: the `…/api/Device`
request carrying the Android ID as `IMEINumber`, and the `…/api/Meter?Lat=…`
request carrying GPS.
- **The SDK list** (Instabug present; Segment/Amplitude/Sentry/Firebase-Analytics/
ad-ID absent) comes from searching the app's bundled code and its network
endpoints.
*Not affiliated with or endorsed by IPS Group / ParkSmarter. This is an
independent, good-faith analysis of a publicly distributed app for the purpose of
building a privacy-respecting alternative. Findings reflect version 4.4.0 and
could change in later versions.*

View file

@ -0,0 +1,277 @@
# Reverse-engineering the official ParkSmarter app
A technical teardown of the official **Park Smarter** Android app
(`com.ipsgroupinc.parksmarter`, **v4.4.0**, versionCode 170), focused on **what
data leaves the device, when, and to whom** — with special attention to the
**device identifier** and **GPS location**. Combines static decompilation with a
live man-in-the-middle capture.
> Independent, good-faith security/privacy research on a publicly distributed
> app, for the purpose of building a privacy-respecting alternative
> (BigBrainParking). Not affiliated with or endorsed by IPS Group. Findings
> reflect v4.4.0. The full decompiled source is kept in a **private** mirror; this
> report quotes only the excerpts needed as evidence.
---
## TL;DR
| Claim | Verdict | Evidence |
|---|---|---|
| The app reads your hardware **IMEI** | **False** | No `TelephonyManager.getImei/getDeviceId` anywhere; `react-native-device-info` isn't even bundled; and Android 10+ blocks IMEI for normal apps regardless. |
| The app sends a **persistent device ID** to its backend | **True** | `PUT /api/Device` carries the **Android SSAID** in a field misleadingly named `IMEINumber`, plus the push token as `DeviceID`. |
| The app sends your **GPS** to its servers | **True**, for meter search only | `GET /api/Meter?Lat=…&Long=…`; captured live. Not attached to login/session/payment. |
| GPS is sent **automatically** on map open | **True in code**, situational at runtime | Wired to map-ready when authenticated + permission granted; on GrapheneOS the map fails to render (needs Play Services), which suppresses the auto-fetch. |
| Bundles Segment / Amplitude / Firebase-Analytics / Sentry | **False** | None present. |
| Bundles heavy telemetry | **True** | **Instabug** — session replay, screenshot capture, network-request logging, visual user-steps → `api.instabug.com`. |
| Advertising ID / install-referrer | **False** | Neither is present. |
---
## Methodology
Both static and dynamic analysis were used.
```mermaid
flowchart LR
A["Park+Smarter_4.4.0.xapk<br/>(base + 3 config splits)"] --> B[jadx 1.5.1<br/>dex → Java]
A --> C["hermes-dec<br/>index.android.bundle → JS"]
A --> D[apktool 2.10.0<br/>manifest + resources]
A --> E["APKEditor merge → 1 universal APK<br/>+ apk-mitm (trust user CA)"]
E --> F["mitmproxy on device<br/>(live HTTPS capture)"]
B --> G[(Findings)]
C --> G
D --> G
F --> G
```
- **jadx** decompiled the dex → Java. This is only the RN framework + native
modules + SDKs; the app's own logic is **not** here.
- The app is **React Native / Expo SDK 53**, so its business logic ships as
**Hermes bytecode** (`assets/index.android.bundle`, Hermes v96). **hermes-dec**
recovered readable pseudocode (`decomp.js`) with de-obfuscated function names.
- **apktool** decoded the manifest and resources.
- For the live capture, the split APKs were merged into one universal APK
(APKEditor) and repackaged to trust a user CA (`network_security_config`), then
driven through **mitmproxy**. (Merging first was necessary: patching only the
base split renumbers its resource IDs and desyncs the untouched `config.hdpi`
split, which crashes the app on the first text field.)
---
## App architecture & network model
Expo SDK 53 RN app. All API traffic goes through one request builder that stamps
these headers on **every** call:
| Header | Value | Notes |
|---|---|---|
| `Application_Token` | `B66EEDDA-…` (static) | App-wide, embedded in the bundle |
| `X-Request-Id` | random UUID | Per request |
| `Auth_Token` | rolling | Rotates on each response |
| `ParkSmarter_SessionId` | per session | |
| `Content-Type` | `application/json` | POST/PUT only |
| `User-Agent` | `okhttp/4.12.0` | RN's HTTP stack |
Five hard-coded environments (bundle string literals):
- Prod: `https://apiv2.parksmarter.com`, `https://apiv3.parksmarter.com`
- Staging: `https://staging-parksmarter-api.ipsmeters.com`
- Testing: `https://testing-parksmarter-api.ipsmeters.com`
No TLS pinning is configured (okhttp's `CertificatePinner` class is present but no
pins are set), and the manifest declares no `networkSecurityConfig` — which is why
a stock install can't be MITM'd without repackaging.
---
## Finding 1 — Device identifier: the "IMEINumber" that isn't an IMEI
The app defines a Redux thunk literally named `device/setIMEINumberAsyncThunk`.
Despite the name, it **never reads an IMEI**. It reads the **Android SSAID**
(`Application.androidId` from `expo-application`), caches it in secure storage as
`psUUID`, and later transmits it.
Evidence (`decomp.js`):
```js
// getAndroidId → expo-application's Application.androidId (the SSAID)
r0 = r0.default; r0 = r0.androidId; return r0;
// stored under 'psUUID'
r2 = 'psUUID'; r2 = setObjectAsync.bind(...)('psUUID', androidId);
// later placed on the wire:
r2['DeviceID'] = pushNotificationsToken;
r2['IMEINumber'] = imeiNumber; // === Application.androidId (SSAID)
r2['DeviceType'] = '1';
// endpoint: putUpdateDeviceToken → { url: '/api/Device' }
```
Confirmations:
- **No real IMEI read.** Zero `getImei` / `TelephonyManager.getDeviceId` /
`getSubscriberId` / `getSimSerialNumber` in the Java **or** the bundle. Every
`getDeviceId()` match in the Java is unrelated — `MotionEvent`/`KeyEvent`
device IDs (input routing) or `Context.getDeviceId()` (the API-34 *virtual*
device id, an `int`). `react-native-device-info` (which could read IMEI) isn't
bundled at all.
- **`READ_PHONE_STATE`** comes from `@react-native-community/netinfo`, which uses
`TelephonyManager` only for `getNetworkOperatorName()` (carrier name) and
network type — not identity.
- **Modern Android blocks it anyway**: since Android 10, `getImei()` throws for
non-privileged apps regardless of the permission.
```mermaid
sequenceDiagram
participant App
participant OS as Android OS
participant PS as apiv2.parksmarter.com
Note over App: cold start / login / token change
App->>OS: Application.androidId (SSAID)
OS-->>App: e.g. "a1b2c3…"
App->>App: cache as psUUID (secure store)
App->>PS: PUT /api/Device<br/>{ IMEINumber: SSAID, DeviceID: pushToken, DeviceType: "1" }
Note over PS: a stable per-device ID,<br/>tied to the account — just not the IMEI
```
**Net:** the app does transmit a persistent, per-device identifier to its own
backend, mislabeled `IMEINumber`. It is the SSAID, not the hardware IMEI, and it
is **not** sent to any third party. The earlier "sends your IMEI" claim was wrong
in substance but pointed at something real — this field.
---
## Finding 2 — GPS location
Location is acquired via **expo-location** (`getLastKnownPositionAsync` +
`watchPositionAsync` at `Accuracy.Balanced`) and sent to the backend **only** on
the meter-search endpoints:
```
GET /api/Meter?Lat=<lat>&Long=<long>&localeCode=en-US
```
The query carries **only** `Lat`/`Long` (no radius/limit); "all vs limited meters"
is an endpoint choice, not a parameter. Location is **not** attached to login,
session load, or payment — confirmed both in code and in the live capture.
**Auto-send on map open.** The map hub wires `useAutoGoToUserLocation`, a state
machine that — when `isAuthenticated && isMapReady && locationPermissionGranted`
and the app is foreground — recenters on the user's GPS and fires the meter query
**without any tap**. Panning/zooming fires more (debounced).
```mermaid
sequenceDiagram
participant User
participant Map as Map screen
participant Loc as expo-location
participant PS as apiv2.parksmarter.com
User->>Map: open Map (logged in, permission granted)
Map->>Loc: watchPositionAsync (Balanced)
Loc-->>Map: {lat, long}
Map->>PS: GET /api/Meter?Lat=…&Long=… (auto, no tap)
User->>Map: pan / zoom
Map->>PS: GET /api/Meter?Lat=…&Long=… (debounced)
```
**GrapheneOS caveat (observed).** `react-native-maps` needs Google Play Services
for tiles; on GrapheneOS the map doesn't render, so `isMapReady` may never flip
and the auto-fetch can be suppressed. In the live capture the single coordinate
sent (`Lat=48.274147628&Long=-116.550122619`) exactly matched a *known lot*
location rather than a fresh arbitrary GPS fix — consistent with the map being
degraded. Location egress is proven; the fully-automatic-on-open behavior is a
property of the code that a Play-Services device would exercise more visibly.
**BigBrainParking, by contrast**, never sends GPS to the API except when you
explicitly tap "My location" and search; the map opens on your last lot from
history, not your GPS.
---
## Finding 3 — Third-party telemetry: Instabug (and only Instabug)
Contrary to the earlier analysis, **Segment, Amplitude, Sentry, Firebase
Analytics, Crashlytics, advertising-ID, and Play install-referrer are all
absent** (verified by package-dir and endpoint-host search). Firebase is present
only as **Cloud Messaging** (push), not analytics.
The one real telemetry SDK is **Instabug** (2,328 classes), and its scope is
broad:
- **Session replay** (`library/sessionreplay`)
- **Screenshot / screen capture** (`instacapture`, `screenshot`)
- **Network-request logging & interception** (`apm/networking`, `networkinterception`)
- **Visual user-steps / interaction tracking** (`visualusersteps`, `interactionstracking`)
- Crash reports, APM, surveys, user attributes
All reporting to `api.instabug.com`. Captured live on startup:
`POST /api/sdk/v3/sessions/v2`, `GET /api/sdk/v3/features`, `/api/sdk/v3/first_seen`.
```mermaid
flowchart TD
D[Your device]
D -->|"SSAID as IMEINumber + push token<br/>GPS (Lat/Long) on meter search<br/>account, vehicle, payment, sessions"| PS[apiv2.parksmarter.com]
D -->|"session replay, screenshots,<br/>network logs, user-steps, crashes"| IB[api.instabug.com]
D -->|push registration| FCM[Firebase Cloud Messaging]
style PS fill:#294,color:#fff
style IB fill:#922,color:#fff
style FCM fill:#247,color:#fff
```
---
## Permissions (from the manifest)
Present: `ACCESS_FINE/COARSE_LOCATION`, `READ_PHONE_STATE` (netinfo),
`ACCESS_WIFI_STATE`, `CAMERA`, `RECORD_AUDIO`, `BLUETOOTH_SCAN/CONNECT` (meter
BLE), `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `FOREGROUND_SERVICE`,
`USE_BIOMETRIC`, `SYSTEM_ALERT_WINDOW`, `DETECT_SCREEN_CAPTURE`, install-referrer
service binding, plus a long list of launcher badge permissions.
**Absent (important):** `ACCESS_BACKGROUND_LOCATION`. The app **cannot** track
location while closed.
---
## Live capture — evidence timeline
Captured through mitmproxy against a repackaged (user-CA-trusting) universal APK,
authenticated session:
```
18:44:07 GET apiv2.parksmarter.com/api/ParkingSession
18:44:07 GET apiv2.parksmarter.com/api/Session
18:44:08 POST api.instabug.com/api/sdk/v3/sessions/v2 (414B) ← telemetry
18:44:08 GET api.instabug.com/api/sdk/v3/first_seen
18:44:16 GET apiv2.parksmarter.com/api/Meter?ZoneName=dl
18:44:17 GET apiv2.parksmarter.com/api/Meter?Lat=48.274…&Long=-116.550… ← GPS
18:44:20 GET apiv2.parksmarter.com/api/ParkingEstimateItems
18:44:33 GET apiv2.parksmarter.com/api/ApplicationValidity
18:44:39 GET apiv2.parksmarter.com/api/ParkSmarterPrivacyPolicies
```
Header sample on a parksmarter request: `Application_Token: B66EEDDA-…`,
`Auth_Token: …` (rolling), `ParkSmarter_SessionId: …`, `User-Agent: okhttp/4.12.0`.
`PUT /api/Device` (the `IMEINumber` PUT) did **not** fire in this session because
the login was cached — it sends on fresh login / token change.
---
## Reproducing this
The repackaged capture-ready APK and step-by-step mitmproxy instructions live in
the private source mirror (`ParkSmarterSourceCode`, `MITMPROXY.md`). In short:
merge splits → inject a user-CA `network_security_config` → resign → install the
CA as a user cert → proxy the phone through mitmproxy (mobile data **off** so the
Wi-Fi proxy applies).
---
## Appendix — backend endpoint inventory (from the bundle)
`ApplicationValidity`, `Auth`, `SignUp`, `User`, `Device`, `Session`,
`ParkingSession`, `Meter` (by location / scanner code / serial / zone name),
`ParkingLots`/`ParkingLogix`, `ParkingEstimateSingle/Multi/Items`,
`StartParkingSession`, `ParkingReceipt`, credit-card + vehicle CRUD,
`NotificationSettings`, `ParkSmarterPrivacyPolicies` / `Terms` / `About` / `FAQ`.
```

321
docs/PARKSMARTER_API.md Normal file
View file

@ -0,0 +1,321 @@
# ParkSmarter API — usage guide & endpoint reference
A commented guide to the ParkSmarter (IPS Group) API as exposed by
[`parksmarter-client`](../parksmarter-client). It reverse-engineers the official
Android app (`com.ipsgroupinc.parksmarter` 4.4.0) and has been verified against
production. For install/quick-start and the auth-header model, see the
[client README](../parksmarter-client/README.md); this document focuses on **how to
actually use the API** — searching for parking and running a transaction — plus a
per-endpoint reference and the behavioral quirks worth knowing.
> Everything here is for interoperability/research with **your own** account. All
> methods below are on a `ParkSmarterClient` instance (`const ps = new ParkSmarterClient(...)`).
---
## Mental model
- **Zone** — a metered area (e.g. Sandpoint `DSB3`, `DL`). Has an id (`ZoneId`), a
`CustomerId` (the operator/city, e.g. `217` = "Sandpoint, ID"), and one or more **Spaces**.
- **Space** — an individual stall/segment within a zone (`SpaceId`). Estimates and
sessions are always for a specific `(zone, space)`.
- **Policy** — a zone/space's schedule: a list of time slots, each with a `RateType`
(`Free`, `Hour`, `Variable`, `No Parking`, `Prepay`), a `Rate`, a `MaxTime`, and a
`CurrentSlot` flag marking the one in effect now. Policies are **descriptive** — they
tell you when/what it costs; there is no "check-in", grace-token, or free-session
concept in the API.
- **Estimate** — a price quote for parking a given `(zone, space, vehicle)` for some
duration. Three flavors (multi / single / items) — see below.
- **Session** — a paid parking transaction (`POST /api/Session`). Requires a card and a
charge; there is **no $0 / free session**. Free parking is simply unmetered time.
---
## 1. Bootstrap & authentication
Auth is **custom-header**, not OAuth. Call `getApplicationValidity()` first (seeds a
`ParkSmarter_SessionId`), then log in. The client stores `Auth_Token` + `SessionId`
automatically and refreshes the rolling `Auth_Token` from the `Response` envelope.
```ts
const ps = new ParkSmarterClient({ environment: 'prodv2' });
const validity = await ps.getApplicationValidity();
if (validity.Config?.IsInMaintenanceMode) throw new Error('maintenance');
await ps.loginWithPhone({ phoneNumber: '5551234567', password: '…' });
// From here, authenticated endpoints work. Auth_Token is sent as a header.
```
**Gotchas**
- A **failed login returns HTTP 200** with `Status: "Error"` + null `Auth_Token`. The
client throws `LoginError` for you; if you call `/api/Auth` yourself, check both.
- A `POST /api/Auth` returning **201** means the account needs SMS verification — not a
normal login. Treat it distinctly (`requestVerifyUser``verifyUser`).
- **Validating a stored token:** a stale/expired `Auth_Token` does **not** 401 —
`GET /api/User` just returns an **empty body** (`getUserDetail()` resolves to
`undefined`). To check "am I really signed in?", require real fields:
`const u = await ps.getUserDetail(); const ok = !!(u && (u.PersonalPhone || u.PersonalEmailAddress));`
- Persist tokens across launches with a `TokenStore` (see README's RN example) so you
don't have to log in every time.
---
## 2. Finding parking (search)
There are several ways in, all returning the same `MetersResponse` shape
(`{ Zones: Zone[], Response }`). **Meter search requires a valid `Auth_Token`** — these
lookups 401 when signed out, despite being "data" reads.
| You have… | Use | Sends |
| --- | --- | --- |
| A map location | `getMetersByLocation({ latitude, longitude })` | `Lat`,`Long` |
| A map location (lighter list) | `getLimitedMetersByLocation({ latitude, longitude })` | `Lat`,`Long` |
| A zone name (e.g. `"DL"`) | `getMetersByZoneName('DL')` | `ZoneName` |
| Free-text zone/space | `searchMetersByZoneOrSpace('Main St')` | `Query` |
| A meter serial number | `getMetersBySerialNumber('…')` | `TerminalSerNo` |
| A scanned QR/barcode | `getMetersByScannerCode('DSB13')` | `ScannerCode` |
| Gated lots w/ occupancy | `getParkingLots()` | — |
```ts
// By location (only a single coordinate is ever sent — the point you're searching):
const res = await ps.getMetersByLocation({ latitude: 48.28, longitude: -116.55 }); // ~downtown Sandpoint
const zones = res.Zones ?? [];
// Pick a zone + space to act on:
const zone = zones[0];
const space = zone.Spaces?.[0];
const base = {
zoneId: zone.ZoneId!,
spaceId: space!.SpaceId!,
customerId: zone.CustomerId!,
};
// Is it free / paid right now? Read the CurrentSlot policy:
const current = space!.Policies?.find((p) => p.CurrentSlot);
const free = (current?.RateType ?? '').toLowerCase().includes('free');
```
**Notes**
- `Zone.Lat`/`Long` in results is the **meter's** location (for map pins), not yours.
- QR codes on kiosks are often a generic `parksmarter.com/home/processQR` URL with **no
zone in it** — fall back to prompting for the printed Zone ID and
`getMetersByScannerCode`/`getMetersByZoneName`.
- Scanner codes map to zones: e.g. `DSB13` → zone `113150`.
---
## 3. Getting a price (estimates)
Before a transaction you quote a price for a `(zone, space, vehicle)`. Three endpoints:
| Method | Path | Use |
| --- | --- | --- |
| `getParkingEstimateMulti(base)` | `/api/ParkingEstimateMulti` | A **ladder** of durations → prices (the app's default). |
| `getParkingEstimateSingle({…, durationInMinutes, creditCardId})` | `/api/ParkingEstimate` | One duration → one price. |
| `getParkingEstimateItems(base)` | `/api/ParkingEstimateItems` | Item/product-style options. |
```ts
const vehicleId = (await ps.getUserDetail()).VehicleDetails![0].VehicleID!;
const q = { ...base, vehicleId };
const multi = await ps.getParkingEstimateMulti(q);
const ladder = multi.ParkingDetail ?? []; // [{ Minutes, StartTime, EndTime, ParkingCost, TransactionFee }, …]
```
**This is where most of the quirks live:**
- **Multi is not universal.** Some zones (e.g. short-term `DL` spaces) reject
`ParkingEstimateMulti` with HTTP 200 + `Response.Status: "Error"`
("Unable to process your request…") even though **single works**. Detect the error
envelope and fall back to `getParkingEstimateSingle` across the zone's `MinTime..MaxTime`.
- **Free windows signal via `MaxTime: 0` / `$0.00`.** During a free period the single
estimate returns `MinTime: 5, MaxTime: 0, ParkingCost: "0.00"` and multi returns an
empty/error ladder. Treat `MaxTime <= 0` (helper: `isFreeEstimate`) or an all-`$0.00`
ladder (`ladderAllFree`) as "currently free — just park", **not** a purchasable option.
- **Flat-rate zones.** Some zones charge one flat price for any duration up to a cap
(e.g. `DL` = **$0.10 flat** until the ~5 PM boundary). Every single-estimate duration
returns the same `ParkingCost`; `MaxTime` is *minutes remaining until the paid-window
boundary* and shrinks through the day. Offer one option ("park to MaxTime"), not a
ladder of identical prices.
- **`ParkingDetail.Minutes` may be `0`** on single estimates — the real duration is in
`StartTime`/`EndTime`. Trust the duration you requested.
- **Estimates need auth** (like meter search).
---
## 4. Initiating a transaction (start a session)
`startParkingSession()` (`POST /api/Session`) is the **only** session-creating call, and
it **charges the card**. Build it from a chosen estimate rung:
```ts
const rung = ladder[selectedIndex]; // from a multi/single estimate
const res = await ps.startParkingSession({
creditCardId: card.CCID!,
vehicleId,
zoneId: base.zoneId,
spaceId: base.spaceId,
customerId: base.customerId,
meterTypeId: zone.MeterTypeId!,
startTime: rung.StartTime!,
endTime: rung.EndTime!,
minutesToPurchase: rung.Minutes!,
parkingCost: Number(rung.ParkingCost ?? 0),
transactionFee: Number(rung.TransactionFee ?? 0),
minCreditAmount: zone.MinimumAmount, // optional
});
```
**Request body** carries `CCID, Amount (cost+fee), SpaceID, StartTime, EndTime,
CustomerID, VehicleID, TimePurchased, ParkingCost, TransactionFee, ZoneID,
MinCreditAmount?, MeterTypeId` (and `BleEncBytes` for BLE meters). **No device location
is sent.**
**Gotchas**
- **A decline still returns HTTP 200**, with `Response.Status: "Error"` and often
`OriginalErrorMessage: "DECLINED"`. The client throws on this envelope so you don't get a
phantom success — surface `serverMessage` to the user.
- **You cannot buy during a free window.** The app blocks purchase
("Purchases are currently not allowed. Parking is currently free.") and the API rejects
zero-minute / zero-cost sessions (`unableToPurchaseZeroMinutes`, `minTimeNotReached`).
- **No free check-in.** There is no way to register a $0 session; free time is just
unmetered — you park, enforcement (LPR/chalk) handles the limit.
- Schedule your own **local** expiry reminder from `EndTime`; the API has no per-session
reminder push.
---
## 5. Sessions & receipts
```ts
const active = (await ps.getActiveParkingSessions()).ParkingSession ?? [];
const past = (await ps.getPastParkingSessions({ currentPage: 1, pageSize: 20 })).Session ?? [];
// Full receipt for a past session (auth code, payment, amounts):
const tid = past[0]?.TransactionID;
const receipt = (await ps.getParkingReceipt(tid!)).ParkingReceipt;
await ps.emailParkingReceipt(tid!); // emails it to the account holder
```
- `PastSession` uses `Zone`/`Description` (not `ZoneName`) for the zone label, and echoes
`Lat`/`Long` of the meter.
- `ParkingReceipt` has `PaymentType`, `PaymentDisplay` ("MASTERCARD"), masked `CC`,
`AuthCode`, `Vehicle` (plate), `Amount`/`TransactionFee`/`Total` (display strings) plus
`*Value` numerics.
---
## 6. Account management
| Task | Method(s) |
| --- | --- |
| Vehicles | `addVehicle`, `updateVehicle`, `deleteVehicle` (list via `getUserDetail().VehicleDetails`) |
| Cards | `addCard`, `updateCard`, `setCardDefault`, `deleteCard` (list via `getUserDetail().CreditCardDetails`) |
| Profile | `updateProfile`, `requestDeleteUser` |
| Password | `requestResetPassword`, `updatePassword` |
| Notifications | `getNotificationSettings`, `setNotificationSettings` |
Mutations return a `{ Status, Message }` envelope — treat `Status === 'Success'` as the
success signal. Card numbers are only ever **sent** (add/update); responses mask them.
---
## Behaviors & gotchas (quick reference)
| Behavior | Detail |
| --- | --- |
| 200-on-error | Failed login / declined payment / bad request often return **HTTP 200** with `Response.Status: "Error"`. Always check the envelope, not just the status code. |
| Stale token ≠ 401 | An expired `Auth_Token` yields an **empty** `/api/User` body, not a 401. Validate by requiring real user fields. |
| Rolling token refresh | Non-empty `Response.Auth_Token` (or top-level) on any response replaces your token — the client stores it. |
| Meter/estimate search needs auth | `/api/Meter`, `/api/MeterList`, and estimates 401 when signed out. |
| Multi estimate unsupported on some zones | Falls back to single; detect `Response.Status: "Error"`. |
| Free window = `MaxTime 0` / `$0.00` | Show "currently free", don't offer a $0 ladder. |
| Flat-rate zones | One price for any duration; `MaxTime` = minutes to the paid-window boundary. |
| No free check-in | Only paid sessions exist; free parking is unmetered time. |
| `localeCode` | Every request carries `localeCode` (default `en`). |
| Non-prod is IP-restricted | `dev`/`stage`/`test` return 403 "Web App - Unavailable" from the public internet. |
---
## Endpoint reference
All methods are on `ParkSmarterClient`. Friendly camelCase inputs are mapped to the wire
format; responses are the raw server PascalCase JSON (typed in
[`src/types.ts`](../parksmarter-client/src/types.ts)).
### Bootstrap
- **`getApplicationValidity()`** → `GET /api/ApplicationValidity` — feature flags,
maintenance/upgrade, seeds `SessionId`. Public. Call first.
### Auth
- **`loginWithPhone({ phoneNumber, password })`** → `POST /api/Auth` — persists token.
- **`loginWithApple({ emailAddress, appleId, providerAuth, providerIdentity })`** → `POST /api/Auth`.
- **`loginWithCachedToken(authToken)`** — set a stored token (then bootstrap).
- **`logoutAllDevices()`** → `POST /api/Auth/Logout` — server-side invalidate.
- **`logoutLocal()`** — clear local tokens only.
### Sign-up / password / verification
- **`signUp({ emailAddress, mobilePhone, password })`** → `POST /api/User`. Public.
- **`requestResetPassword({ reqType, phoneNumber })`** → `POST /api/Password`. Public.
- **`updatePassword({ oldPassword, newPassword })`** → `PUT /api/Password`.
- **`requestVerifyUser({ phoneNumber })`** → `POST /api/UserVerification` — SMS code.
- **`verifyUser({ phoneNumber, code })`** → `GET /api/UserVerification`.
- **`isEmailRegistered(email)` / `isPhoneRegistered(phone)`** → `GET /api/User`. Public boolean.
### Profile / device
- **`getUserDetail()`** → `GET /api/User` — profile incl. `VehicleDetails` + `CreditCardDetails`. (Empty body ⇒ token stale.)
- **`updateProfile({ emailAddress, phoneNumber })`** → `PUT /api/User`.
- **`requestDeleteUser()`** → `DELETE /api/User`.
- **`updateDeviceToken({ pushNotificationsToken, imeiNumber, deviceType?, language })`** → `PUT /api/Device`. (BigBrainParking does **not** call this — avoids sending device id/IMEI.)
### Vehicles
- **`addVehicle({ plate, state, vehicleAlias, isDefaultVehicle? })`** → `POST /api/Vehicle`.
- **`updateVehicle({ id, plate, state, vehicleAlias, isDefaultVehicle? })`** → `PUT /api/Vehicle`.
- **`deleteVehicle(vehicleId)`** → `DELETE /api/Vehicle`.
### Cards
- **`addCard({ cardNumber, alias, expDate, zipCode, isDefaultCard? })`** → `POST /api/Card`.
- **`updateCard({ id, … })`** → `POST /api/Card` (full replace).
- **`setCardDefault({ id, isDefaultCard })`** → `PUT /api/Card`.
- **`deleteCard(cardId)`** → `DELETE /api/Card`.
### Meters / zones
- **`getMetersByLocation({ latitude, longitude })`** → `GET /api/Meter?Lat=&Long=`.
- **`getLimitedMetersByLocation({ latitude, longitude })`** → `GET /api/MeterList?Lat=&Long=`.
- **`getMetersByZoneName(zoneName)`** → `GET /api/Meter?ZoneName=`.
- **`searchMetersByZoneOrSpace(query)`** → `GET /api/Meter?Query=`.
- **`getMetersBySerialNumber(serial)`** → `GET /api/Meter?TerminalSerNo=`.
- **`getMetersByScannerCode(code)`** → `GET /api/Meter?ScannerCode=`.
- **`getParkingLots()`** → `GET /api/ParkingLogix` — gated lots + occupancy.
### Estimates
- **`getParkingEstimateMulti({ zoneId, spaceId, customerId, vehicleId, minCreditAmount?, bleEncBytes? })`** → `GET /api/ParkingEstimateMulti` — duration ladder.
- **`getParkingEstimateSingle({ …, durationInMinutes, creditCardId })`** → `GET /api/ParkingEstimate` — one duration.
- **`getParkingEstimateItems(base)`** → `GET /api/ParkingEstimateItems`.
- Helpers: **`isFreeEstimate(probe)`**, **`ladderAllFree(rungs)`** (pure; exported).
### Sessions / receipts
- **`startParkingSession({ creditCardId, vehicleId, zoneId, spaceId, customerId, meterTypeId, startTime, endTime, minutesToPurchase, parkingCost, transactionFee, minCreditAmount?, bleEncBytes? })`** → `POST /api/Session`. **Charges the card.** Throws on the `Status:Error` decline envelope.
- **`getActiveParkingSessions()`** → `GET /api/ParkingSession`.
- **`getPastParkingSessions({ currentPage, pageSize })`** → `GET /api/Session`.
- **`getParkingReceipt(transactionId)`** → `GET /api/ParkingReceipt`.
- **`emailParkingReceipt(id)`** → `POST /api/ParkingReceipt` — email to account holder.
### Settings / content
- **`getNotificationSettings()` / `setNotificationSettings(...)`** → `/api/Setting`.
- **`getStates()`** → `GET /api/State`. (Note: seen returning 404 on prodv2 — pass an id form if needed.)
- **`getAbout()` / `getFAQ()` / `getPrivacyPolicy()` / `getTerms()`** → `GET /api/ParkSmarter*` — static content.
---
## Verification status
Response models are verified against production (`sweep.mjs` records field-names+types
only, no PII). **CONFIRMED live:** login, `UserDetail`, vehicles, cards,
`Zone`/`Space`/`SpacePolicy`, parking lots, all three estimates, and — from a real DL-zone
session — `PastSession` and `ParkingReceipt`. **Still UNCONFIRMED:** `ActiveSession` (no
active session existed on the account at capture time). Re-run `sweep.mjs` (schema diff) or
`capture-dl.mjs` (full DL request/response record) to refresh.
Not affiliated with or endorsed by IPS Group / ParkSmarter.

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

36
docs/zone-qr/README.md Normal file
View file

@ -0,0 +1,36 @@
# Zone QR codes
A parking-zone QR code just encodes the zone's **`ScannerCode`** as plain text.
BigBrainParking's scanner (and the official ParkSmarter app) resolve that code to
the zone. No URL, no wrapper — the raw scanner code is enough.
## Make one
```bash
qrencode -o my-zone.png -s 14 -m 4 "<ScannerCode>"
```
`<ScannerCode>` is the zone's `ScannerCode` field (visible on the meter detail
screen, or in a `/api/Meter` response). For the DL zone in Sandpoint it's `DL`:
```bash
qrencode -o DL-sandpoint.png -s 14 -m 4 "DL"
```
![DL zone, Sandpoint](DL-sandpoint.png)
## Which field to encode
Use **`ScannerCode`** — it's what physical kiosk QR stickers encode and the only
field the official ParkSmarter app accepts when scanning.
- `ScannerCode` — ✅ works in BigBrainParking **and** the official app.
- `ZoneName` (e.g. `dl`) — works as a fallback in BigBrainParking (case-sensitive
server-side; the app tries the lowercased form too).
- `TerminalSerNo` (e.g. `30004246`) — uniquely identifies the terminal and works
in BigBrainParking, but the **official app rejects a bare serial** ("not
recognized as a ParkSmarter zone"), so don't use it for cross-app QR codes.
When BigBrainParking scans a code it tries, in order: `ScannerCode → ZoneName →
zonename(lowercased) → TerminalSerNo`, each independently, so any of these
resolve.

504
package-lock.json generated
View file

@ -40,6 +40,7 @@
"devDependencies": {
"@types/react": "~19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"tsx": "^4.23.12",
"typescript": "~5.4.0"
}
},
@ -1599,6 +1600,448 @@
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@expo/cli": {
"version": "0.24.24",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
@ -4502,6 +4945,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -9151,6 +9636,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View file

@ -12,6 +12,11 @@ Node 18+. It has **zero runtime dependencies** (uses `fetch`). If you specifical
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
directly onto Retrofit/OkHttp — ask and it can be ported.
> **Usage guide:** for narrative walk-throughs (searching, quoting a price, running a
> transaction) plus per-endpoint commentary and the behavioral quirks, see
> **[`docs/PARKSMARTER_API.md`](../docs/PARKSMARTER_API.md)**. The tables below are the
> quick reference.
## Install / build
```bash
@ -170,19 +175,26 @@ Request shapes were recovered from the app code and are exact. Response models w
- **CONFIRMED via live capture:** login (`AuthResponse`), `UserDetail`, `VehicleDetail`,
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
states wrapper, the password-reset flow, and the shared `Response` envelope.
- **UNCONFIRMED (no session history on the test account):** `ActiveSession`,
`PastSession`, `ParkingReceipt`. Field names are from static analysis; capture from an
account with at least one past/active session to confirm.
states wrapper, the password-reset flow, the shared `Response` envelope, and — from a real
DL-zone session — `PastSession` and `ParkingReceipt`.
- **UNCONFIRMED:** `ActiveSession` (no *active* session existed on the account at capture
time — field names are from static analysis; capture while parked to confirm).
- `capture-dl.mjs` records a full DL-zone request/response set; `sweep.mjs` refreshes the
PII-free schema skeletons.
Two behaviors worth knowing (both confirmed live):
Behaviors worth knowing (all confirmed live — full list in
[`docs/PARKSMARTER_API.md`](../docs/PARKSMARTER_API.md)):
1. **Meter search requires auth.** `/api/Meter` and `/api/MeterList` return **401** without a
valid `Auth_Token`, despite being data lookups.
2. **`Response` envelope + token refresh.** Most authenticated responses embed
`Response: { Auth_Token, Message, Status }`. When `Response.Auth_Token` (or a top-level
`Auth_Token`) is non-empty, it's a rolling refresh of your user token — the client stores
it automatically.
1. **200-on-error.** Failed login, declined payment, and some bad requests return **HTTP
200** with `Response.Status: "Error"`. Check the envelope, not just the status code.
2. **Meter/estimate search requires auth.** `/api/Meter`, `/api/MeterList`, and estimates
**401** without a valid `Auth_Token`, despite being data lookups.
3. **Rolling token refresh.** A non-empty `Response.Auth_Token` (or top-level) replaces your
user token — the client stores it automatically.
4. **Stale token ≠ 401.** An expired token makes `GET /api/User` return an **empty body**
(not a 401); validate by requiring real user fields before trusting the session.
5. **Estimate quirks.** Multi is unsupported on some zones (falls back to single);
`MaxTime: 0`/`$0.00` means a free window; some zones are flat-rate.
To capture the remaining UNCONFIRMED models, re-run `sweep.mjs` on an account that has a
saved card and session history. There's **no TLS pinning**, so a proxy capture on a rooted

View file

@ -0,0 +1,65 @@
/**
* capture-dl.mjs record the live requests + responses for the Sandpoint "DL"
* zone (ZoneID 113165 / SpaceID 329522 / CustomerID 217) so we have a durable
* record of its odd flat-rate behavior.
*
* Writes one JSON file per call (request line with tokens redacted by the client
* + the full parsed response) to ~/Downloads/dl-capture/ plus a _summary.json.
*
* Usage: node capture-dl.mjs (needs ./.creds.json)
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ParkSmarterClient } from './dist/index.js';
const c = JSON.parse(readFileSync('./.creds.json', 'utf8'));
const OUT = join(homedir(), 'Downloads', 'dl-capture');
mkdirSync(OUT, { recursive: true });
const reqLog = [];
const ps = new ParkSmarterClient({
environment: 'prodv2',
timeoutMs: 20000,
logRequests: true, // the client redacts Auth_Token/Application_Token/SessionId
logSink: (line) => reqLog.push(line),
});
await ps.loginWithPhone({ phoneNumber: c.phoneNumber, password: c.password });
const me = await ps.getUserDetail();
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID;
const base = { zoneId: 113165, spaceId: 329522, customerId: 217, vehicleId };
const save = (name, obj) => writeFileSync(join(OUT, `${name}.json`), JSON.stringify(obj, null, 2) + '\n');
const summary = { zone: base, note: 'Sandpoint DL zone — flat $0.10, MaxTime = minutes until the paid-window boundary; ParkingEstimateMulti errors here.', calls: [] };
async function cap(name, fn) {
const from = reqLog.length;
let response = null, error = null;
try { response = await fn(); } catch (e) { error = `${e.name} ${e.status ?? ''} ${e.message}`; }
const entry = { name, capturedField: 'request+response', request: reqLog.slice(from), response, error };
save(name, entry);
summary.calls.push({ name, ok: !error, error });
console.log(` ${name}: ${error ? 'ERR ' + error : 'ok'}`);
}
console.log(`Capturing DL zone -> ${OUT}`);
// The zone/space/policy record (search for DL by name).
await cap('00-zone-DL', async () => {
const byName = await ps.getMetersByZoneName('DL');
const z = (byName?.Zones || []).find((z) => (z.ZoneId ?? z.ZoneID) == 113165) ?? byName?.Zones?.[0];
return z ?? { note: 'DL zone not found by name lookup' };
});
// Single-estimate at a spread of durations (shows the flat $0.10 + moving MaxTime).
for (const d of [5, 15, 30, 60, 120, 240, 480]) {
await cap(`10-estimate-single-${String(d).padStart(3, '0')}min`, () =>
ps.getParkingEstimateSingle({ ...base, durationInMinutes: d, creditCardId: 0 }),
);
}
// The multi ladder (errors for this zone) and the item-based estimate.
await cap('20-estimate-multi', () => ps.getParkingEstimateMulti(base));
await cap('21-estimate-items', () => ps.getParkingEstimateItems(base));
summary.capturedAtLocalNote = 'timestamps are inside each request/response line';
save('_summary', summary);
console.log(`Done. ${summary.calls.length + 1} files in ${OUT}`);

View file

@ -12,12 +12,22 @@
"import": "./dist/index.js"
}
},
"files": ["dist", "src", "README.md"],
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test test/*.test.mjs"
},
"keywords": ["parksmarter", "parking", "ips", "api-client"],
"keywords": [
"parksmarter",
"parking",
"ips",
"api-client"
],
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0"

View file

@ -0,0 +1,24 @@
/**
* Helpers for interpreting ParkSmarter parking estimates.
*
* Pulled out as pure functions so the "is this a free window?" decision is
* testable without the app, the network, or React Native. The BigBrainParking
* app's single-estimate fallback uses these to avoid showing a bogus "$0.00"
* purchase ladder during free windows.
*/
/**
* A free window: the single estimate reports no purchasable time. Live captures
* show free zones return `MaxTime: 0` (and `$0.00`), while paid zones return a
* positive MaxTime. Anything <= 0 (or non-numeric) counts as free.
*/
export function isFreeEstimate(probe: { MaxTime?: number | string | null } | null | undefined): boolean {
return (Number(probe?.MaxTime) || 0) <= 0;
}
/** Every rung of a built ladder is $0.00 — effectively a free window too. */
export function ladderAllFree(
rungs: ReadonlyArray<{ ParkingCost?: number | string | null }>,
): boolean {
return rungs.length > 0 && rungs.every((r) => (Number(r?.ParkingCost) || 0) === 0);
}

View file

@ -17,4 +17,5 @@ export type {
HttpMethod,
HttpClientConfig,
} from './http.js';
export { isFreeEstimate, ladderAllFree } from './estimates.js';
export * from './types.js';

View file

@ -488,6 +488,7 @@ export interface ActiveSession {
EndTime?: string;
StartTimeDisplay?: string;
EndTimeDisplay?: string;
/** Seconds left on the session (CONFIRMED via a live session — NOT minutes). */
TimeRemaining?: number | string;
VehiclePlate?: string;
VehicleNumber?: string;
@ -504,17 +505,34 @@ export interface ActiveSessionsResponse {
[key: string]: unknown;
}
/** UNCONFIRMED element shape (no past sessions on the test account). */
/** CONFIRMED via a live DL-zone session (fields observed on GET /api/Session). */
export interface PastSession {
TransactionID?: number | string;
ZoneName?: string;
Zone?: string;
ZoneID?: number | string;
Space?: string;
SpaceID?: number | string;
StartTime?: string;
EndTime?: string;
TimePurchased?: number | string;
VehicleNumber?: string;
Amount?: number | string;
PaymentType?: string;
TransactionFee?: number | string;
CardFirstSix?: string;
CardLastFour?: string;
PaymentDisplay?: string;
VehiclePlate?: string;
IsPaid?: boolean;
CustomerID?: number | string;
CustomerName?: string;
City?: string;
Description?: string;
MeterTypeId?: number;
Lat?: number | string;
Long?: number | string;
IsFavorite?: boolean;
FavoriteID?: number | string;
Logo?: string;
/** Legacy/alt field some views used; prefer `Zone`. */
ZoneName?: string;
[key: string]: unknown;
}
@ -534,21 +552,41 @@ export interface PastSessionsParams {
/* Receipts */
/* ------------------------------------------------------------------ */
/** CONFIRMED via a live DL-zone receipt (GET /api/ParkingReceipt). */
export interface ParkingReceipt {
TransactionID?: number | string;
ZoneName?: string;
CustomerID?: number | string;
CustomerName?: string;
Space?: string;
SpaceID?: number | string;
MeterNumber?: string;
MeterTypeId?: number;
StartTime?: string;
EndTime?: string;
Amount?: number | string;
Total?: number | string;
TotalCost?: number | string;
TransactionFee?: number | string;
PaymentType?: string;
VehiclePlate?: string;
PaymentDisplay?: string;
CardType?: string | null;
/** Masked card, e.g. "****1234". */
CC?: string;
AuthCode?: string;
VehicleID?: number | string;
Vehicle?: string;
/** Display strings, e.g. "$0.10". */
Amount?: string;
AmountCharged?: string;
TransactionFee?: string;
Total?: string;
/** Numeric equivalents. */
AmountValue?: number;
TransactionFeeValue?: number;
TotalValue?: number;
Logo?: string;
BackgroundColor?: string;
ForegroundColor?: string;
ZoneName?: string;
[key: string]: unknown;
}
/** UNCONFIRMED (no receipts on the test account) — field names from static analysis. */
export interface ReceiptResponse {
ParkingReceipt?: ParkingReceipt;
Response?: ResponseEnvelope;

View file

@ -0,0 +1,32 @@
// Pure unit tests for the estimate-classification helpers (no network).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isFreeEstimate, ladderAllFree } from '../dist/index.js';
test('isFreeEstimate: MaxTime 0 is a free window', () => {
assert.equal(isFreeEstimate({ MaxTime: 0 }), true);
assert.equal(isFreeEstimate({ MaxTime: '0' }), true); // API sends numbers as strings
});
test('isFreeEstimate: positive MaxTime is a paid zone', () => {
assert.equal(isFreeEstimate({ MaxTime: 33 }), false);
assert.equal(isFreeEstimate({ MaxTime: '33' }), false);
});
test('isFreeEstimate: missing/null MaxTime => no paid time => free', () => {
assert.equal(isFreeEstimate({}), true);
assert.equal(isFreeEstimate(null), true);
assert.equal(isFreeEstimate(undefined), true);
});
test('ladderAllFree: every rung $0.00 => true', () => {
assert.equal(ladderAllFree([{ ParkingCost: '0.00' }, { ParkingCost: 0 }]), true);
});
test('ladderAllFree: any priced rung => false', () => {
assert.equal(ladderAllFree([{ ParkingCost: '0.00' }, { ParkingCost: '0.10' }]), false);
});
test('ladderAllFree: empty ladder => false (nothing to conclude)', () => {
assert.equal(ladderAllFree([]), false);
});

View file

@ -0,0 +1,81 @@
// Client HTTP behavior via an injected mock fetch (no network).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ParkSmarterClient, ParkSmarterApiError, MemoryTokenStore } from '../dist/index.js';
/** Build a fake fetch that records calls and returns canned responses. */
function mockFetch(handler) {
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url: String(url), init });
const { status = 200, body = {} } = handler({ url: String(url), init, calls }) ?? {};
const text = typeof body === 'string' ? body : JSON.stringify(body);
return {
status,
ok: status >= 200 && status < 300,
text: async () => text,
headers: new Map(),
};
};
return { fetchImpl, calls };
}
test('every request carries Application_Token and a fresh X-Request-Id', async () => {
const { fetchImpl, calls } = mockFetch(() => ({ body: { Response: { Status: 'Success' } } }));
const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl });
await ps.getApplicationValidity();
const h = calls[0].init.headers;
assert.ok(h['Application_Token'], 'Application_Token present');
assert.match(h['X-Request-Id'], /[0-9a-f]{8}-[0-9a-f]{4}/i, 'X-Request-Id is a UUID');
});
test('single-estimate query serializes the ParkSmarter param names', async () => {
const { fetchImpl, calls } = mockFetch(() => ({
body: { ParkingDetail: {}, MinTime: 5, MaxTime: 33, Response: { Status: 'Success' } },
}));
const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl });
await ps.getParkingEstimateSingle({
zoneId: 113165, spaceId: 329522, customerId: 217, vehicleId: 42,
durationInMinutes: 30, creditCardId: 0,
});
const url = calls[0].url;
assert.match(url, /ZoneID=113165/);
assert.match(url, /SpaceID=329522/);
assert.match(url, /CustomerID=217/);
assert.match(url, /ParkingDuration=30/);
assert.match(url, /VehicleID=42/);
});
test('a non-2xx response throws ParkSmarterApiError carrying the status', async () => {
const { fetchImpl } = mockFetch(() => ({ status: 500, body: { message: 'boom' } }));
const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl });
await assert.rejects(
() => ps.getApplicationValidity(),
(e) => e instanceof ParkSmarterApiError && e.status === 500,
);
});
test('rolling Auth_Token from the Response envelope is stored and re-sent', async () => {
const tokens = new MemoryTokenStore();
const { fetchImpl, calls } = mockFetch(() => ({
body: { Response: { Status: 'Success', Auth_Token: 'REFRESHED123' } },
}));
const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl, tokens });
await ps.getUserDetail();
await ps.getUserDetail();
assert.equal(calls[1].init.headers['Auth_Token'], 'REFRESHED123');
assert.equal(await tokens.getAuthToken(), 'REFRESHED123');
});
test('a 401 clears the auth token and invokes onUnauthorized', async () => {
const tokens = new MemoryTokenStore();
await tokens.setAuthToken('OLD');
let unauthorized = 0;
const { fetchImpl } = mockFetch(() => ({ status: 401, body: { Response: { Status: 'Error' } } }));
const ps = new ParkSmarterClient({
environment: 'prodv2', fetchImpl, tokens, onUnauthorized: () => { unauthorized += 1; },
});
await assert.rejects(() => ps.getUserDetail());
assert.equal(unauthorized, 1);
assert.equal(await tokens.getAuthToken(), null);
});

View file

@ -0,0 +1,72 @@
// Live integration tests against a ParkSmarter instance, using the Sandpoint
// zones. Targets the `test` instance by default (PS_TEST_ENV to override).
//
// NOTE: the non-prod instances (dev/stage/test) are IP-restricted to IPS's
// network and return 403 "Web App - Unavailable" from the public internet, so
// these SKIP with a clear reason unless the instance is reachable. To run them
// live against prod (read-only) with the Sandpoint zones:
// PS_TEST_ENV=prodv2 node --test test/integration.test.mjs
// (needs parksmarter-client/.creds.json for the authenticated calls).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { ParkSmarterClient, isFreeEstimate } from '../dist/index.js';
const ENV = process.env.PS_TEST_ENV || 'test';
const credsUrl = new URL('../.creds.json', import.meta.url);
const creds = existsSync(credsUrl) ? JSON.parse(readFileSync(credsUrl, 'utf8')) : {};
// Search point: your own coordinate from the gitignored .creds.json when present,
// else a rounded, non-personal downtown-Sandpoint point (~1 km precision — not a home).
const SANDPOINT = { latitude: Number(creds.lat) || 48.28, longitude: Number(creds.lng) || -116.55 };
// Probe reachability once; if the instance blocks us, skip the whole suite.
let reason = false;
try {
const av = await new ParkSmarterClient({ environment: ENV, timeoutMs: 12000 }).getApplicationValidity();
if (!av || (!av.SessionId && !av.Config)) throw new Error('no bootstrap payload');
} catch (e) {
reason = `'${ENV}' instance unreachable (${e.name} ${e.status ?? ''}); non-prod is IP-restricted. `
+ `Run PS_TEST_ENV=prodv2 with .creds.json to exercise these live.`;
}
async function client() {
const ps = new ParkSmarterClient({ environment: ENV, timeoutMs: 15000 });
if (creds.phoneNumber) await ps.loginWithPhone({ phoneNumber: creds.phoneNumber, password: creds.password });
return ps;
}
test(`[${ENV}] ApplicationValidity bootstraps a session`, { skip: reason }, async () => {
const av = await new ParkSmarterClient({ environment: ENV }).getApplicationValidity();
assert.ok(av.SessionId || av.Config, 'expected a bootstrap payload');
});
test(`[${ENV}] Sandpoint (DSB) zones are returned near downtown`, { skip: reason }, async () => {
const ps = await client();
const m = await ps.getMetersByLocation(SANDPOINT);
assert.ok(Array.isArray(m.Zones) && m.Zones.length > 0, 'expected zones near Sandpoint');
const names = m.Zones.map((z) => z.ZoneName || '');
assert.ok(names.some((n) => /^DSB/i.test(n)), `expected DSB* zones, got: ${names.slice(0, 8).join(',')}`);
});
test(`[${ENV}] single estimate is well-formed and free/paid is coherent`, { skip: reason }, async () => {
const ps = await client();
const me = creds.phoneNumber ? await ps.getUserDetail() : null;
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID ?? 0;
const m = await ps.getMetersByLocation(SANDPOINT);
const z = m.Zones[0];
const sp = z.Spaces?.[0];
const base = { zoneId: z.ZoneId, spaceId: sp.SpaceId, customerId: z.CustomerId, vehicleId };
const single = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: 30, creditCardId: 0 });
assert.equal(single.Response?.Status, 'Success', 'single estimate should succeed');
// Multi may be empty or error for some zones/windows — assert only the shape.
const multi = await ps.getParkingEstimateMulti(base);
assert.ok(Object.prototype.hasOwnProperty.call(multi, 'ParkingDetail'), 'multi has ParkingDetail');
if (isFreeEstimate(single)) {
assert.equal(Number(single.MaxTime) || 0, 0, 'free window => MaxTime 0');
} else {
assert.ok(Number(single.ParkingDetail?.ParkingCost) >= 0, 'paid window => a numeric cost');
}
});

149
scripts/deploy-runner.sh Executable file
View file

@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# Deploy the BigBrainParking Forgejo Actions runner on a host (Ubuntu/Debian).
# Self-contained: installs Docker if missing, writes the runner compose + env,
# registers against git.mowden.top, and starts the runner.
#
# Usage:
# sudo bash deploy-runner.sh <REGISTRATION_TOKEN> [INSTALL_DIR]
#
# REGISTRATION_TOKEN From Forgejo -> hank/BigBrainParking ->
# Settings -> Actions -> Runners -> Create new runner.
# INSTALL_DIR Where to place the runner (default /opt/bigbrainparking-runner).
#
# Re-running is safe: it updates files and restarts. The token is only consumed
# on the first registration (stored afterwards in <INSTALL_DIR>/data/.runner).
#
# NOTE: this host also runs the Camp Scan runner and several databases on a small
# box (20 GB / 4 cores). The BigBrainParking build workflow hard-caps itself to
# --cpus=2 --memory=8g, but if a Camp Scan build and a BigBrainParking build run
# at the SAME time they'll together want ~4 cores / ~16 GB. Keep an eye on that,
# or lower the caps in .forgejo/workflows/build-apk.yml if the box gets tight.
set -euo pipefail
FORGEJO_INSTANCE="https://git.mowden.top"
RUNNER_IMAGE="code.forgejo.org/forgejo/runner:6.3.1"
RUNNER_NAME="${RUNNER_NAME:-$(hostname)-bigbrainparking}"
JOB_LABEL="docker:docker://node:22-bookworm"
TOKEN="${1:-${REGISTRATION_TOKEN:-}}"
INSTALL_DIR="${2:-/opt/bigbrainparking-runner}"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
err() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; }
if [ "$(id -u)" -ne 0 ]; then
err "Run with sudo/root (needs to install packages and access the Docker socket)."
exit 1
fi
if [ -z "$TOKEN" ] && [ ! -f "$INSTALL_DIR/data/.runner" ]; then
err "No registration token given and no existing registration found."
echo "Usage: sudo bash deploy-runner.sh <REGISTRATION_TOKEN> [INSTALL_DIR]" >&2
exit 1
fi
# --- 1. Docker ---------------------------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker (get.docker.com)..."
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
else
log "Docker already installed: $(docker --version)"
fi
if ! docker compose version >/dev/null 2>&1; then
log "Installing docker compose plugin..."
apt-get update -y
apt-get install -y docker-compose-plugin
fi
DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)"
log "Docker socket group id: $DOCKER_GID"
# --- 2. Files ----------------------------------------------------------------
log "Writing runner files to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR/data"
# The runner image runs as uid/gid 1000 and must own its config dir.
chown -R 1000:1000 "$INSTALL_DIR/data"
# Runner config: allow the build workflow to mount the persistent cache volumes
# (Gradle home = Maven deps + build cache, npm cache) so APK builds don't
# redownload and recompile everything each run. Without valid_volumes listed,
# the runner rejects the workflow's `volumes:` and the job fails.
# (No android-sdk volume: the build image already ships the Android SDK/NDK.)
cat > "$INSTALL_DIR/config.yaml" <<'CONFIG'
container:
valid_volumes:
- bigbrainparking-gradle
- bigbrainparking-npm
CONFIG
chown 1000:1000 "$INSTALL_DIR/config.yaml"
# Pre-create the named cache volumes (idempotent).
docker volume create bigbrainparking-gradle >/dev/null
docker volume create bigbrainparking-npm >/dev/null
cat > "$INSTALL_DIR/docker-compose.yml" <<COMPOSE
services:
runner:
image: ${RUNNER_IMAGE}
container_name: bigbrainparking-runner
restart: unless-stopped
working_dir: /data
group_add:
- "\${DOCKER_GID}"
volumes:
- ./data:/data
- ./config.yaml:/data/config.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock
env_file: .env
command:
- sh
- -c
- |
if [ ! -f /data/.runner ]; then
echo "Registering runner with ${FORGEJO_INSTANCE} ..."
forgejo-runner register --no-interactive \\
--instance "${FORGEJO_INSTANCE}" \\
--token "\${REGISTRATION_TOKEN}" \\
--name "\${RUNNER_NAME}" \\
--labels "${JOB_LABEL}"
fi
exec forgejo-runner daemon --config /data/config.yaml
COMPOSE
# Preserve an existing token line if re-running without a new token.
cat > "$INSTALL_DIR/.env" <<ENV
REGISTRATION_TOKEN=${TOKEN}
RUNNER_NAME=${RUNNER_NAME}
DOCKER_GID=${DOCKER_GID}
ENV
chmod 600 "$INSTALL_DIR/.env"
# --- 3. Start ----------------------------------------------------------------
log "Starting runner..."
cd "$INSTALL_DIR"
docker compose up -d
sleep 6
echo
log "Status:"
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
echo
log "Recent logs (look for 'declared successfully' then 'poller launched'):"
docker compose logs --no-log-prefix 2>&1 | tail -8
cat <<DONE
------------------------------------------------------------------
BigBrainParking runner deployed to $INSTALL_DIR
Logs: cd $INSTALL_DIR && docker compose logs -f
Restart: cd $INSTALL_DIR && docker compose restart
Stop: cd $INSTALL_DIR && docker compose down
The signing secrets (ANDROID_KEYSTORE_B64 etc.) are already set on the repo.
Push a tag (e.g. v0.1.3) to trigger the APK build; it runs on THIS host and
reuses the bigbrainparking-gradle / bigbrainparking-npm caches.
------------------------------------------------------------------
DONE

7
server/.env.example Normal file
View file

@ -0,0 +1,7 @@
# Copy to .env and set a long random secret (>= 16 chars). This is the admin
# password you paste into the app's Settings → Admin. Generate one with:
# openssl rand -base64 32
BBP_ADMIN_TOKEN=change-me-to-a-long-random-secret
# Optional: comma-separated IPs to reject outright.
# BBP_BLOCKED_IPS=1.2.3.4,5.6.7.8

5
server/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
data/
*.log

23
server/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Build stage — compiles TS and builds better-sqlite3's native addon.
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --no-audit --no-fund
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
# Runtime stage — same base (binary-compatible native addon), no build tools.
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /data && chown -R node:node /data
USER node
EXPOSE 8090
CMD ["node", "dist/index.js"]

59
server/README.md Normal file
View file

@ -0,0 +1,59 @@
# BigBrainParking zone-labels API
A tiny service that classifies parking zones so the app knows whether a space is
**free for a limit** (`free_2h` / `free_3h` / `free_4h`) or a **pay-immediately**
lot (`pay_immediate`). Public reads; writes require the admin password.
Lives in the monorepo but is **not** an npm workspace (keeps its native
`better-sqlite3` dep out of the app/CI install). Deploy it independently.
## API
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/healthz` | | liveness |
| GET | `/api/labels` | | all labels `{ labels: [...] }` (app bulk-caches) |
| GET | `/api/labels/:zoneId` | | one label, 404 if none |
| PUT | `/api/labels/:zoneId` | admin | upsert `{ kind, zoneName?, customerId?, note? }` |
| DELETE | `/api/labels/:zoneId` | admin | remove |
| GET | `/api/whoami` | admin | `{ admin: true }` — used by the app's "test password" |
Auth: `Authorization: Bearer <BBP_ADMIN_TOKEN>` (timing-safe compare). Reads are
public but rate-limited (~120/min/IP; writes ~20/min). Repeated bad tokens from an
IP auto-block it for a cooldown; `blocked_ips` (DB) + `BBP_BLOCKED_IPS` (env) are a
manual denylist. `zoneId` is the ParkSmarter `ZoneId` (e.g. `113165`).
## Develop
```bash
cd server
npm install
npm test # node --test via tsx
BBP_ADMIN_TOKEN=dev-secret-please-change npm run dev
```
## Deploy (Docker + nginx on the host the CNAME points to)
```bash
cd server
cp .env.example .env
sed -i "s#change-me-to-a-long-random-secret#$(openssl rand -base64 32)#" .env # set the admin secret
docker compose up -d --build
# nginx + TLS (first time)
sudo cp deploy/bigbrainparking.mowden.top.conf /etc/nginx/sites-available/bigbrainparking.mowden.top
sudo ln -s ../sites-available/bigbrainparking.mowden.top /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d bigbrainparking.mowden.top
curl https://bigbrainparking.mowden.top/healthz # {"ok":true}
```
The admin secret (from `.env`) is what you paste into the app under
**Settings → Admin**. Rotate by editing `.env` and `docker compose up -d`.
## Update
```bash
git pull && docker compose up -d --build
```

View file

@ -0,0 +1,25 @@
# nginx reverse proxy for the zone-labels API.
# Install: sudo cp this file to /etc/nginx/sites-available/bigbrainparking.mowden.top
# sudo ln -s ../sites-available/bigbrainparking.mowden.top /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
# TLS: sudo certbot --nginx -d bigbrainparking.mowden.top
# (certbot rewrites this file to add the :443 server block + HTTP->HTTPS redirect.)
server {
listen 80;
listen [::]:80;
server_name bigbrainparking.mowden.top;
# Small API; cap request bodies.
client_max_body_size 32k;
location / {
proxy_pass http://127.0.0.1:8097;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}

23
server/docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
services:
bbp-labels:
build: .
image: bbp-labels:latest
container_name: bbp-labels
restart: unless-stopped
env_file: .env
environment:
- BBP_DB_PATH=/data/labels.db
- PORT=8090
- HOST=0.0.0.0
# Bound to loopback only — nginx terminates TLS and reverse-proxies to it.
# Host port 8097 (8090 is used by another container); container stays 8090.
ports:
- "127.0.0.1:8097:8090"
volumes:
- bbp-labels-data:/data
# Shared host — keep this service small.
mem_limit: 256m
cpus: 0.5
volumes:
bbp-labels-data:

1700
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
server/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "bbp-labels-server",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "BigBrainParking zone-labels API — classifies parking zones (free 2h/3h/4h street vs pay-immediately lots).",
"engines": { "node": ">=22" },
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "node --import tsx --watch src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@fastify/rate-limit": "^10.2.2",
"better-sqlite3": "^11.8.1",
"fastify": "^5.2.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.13.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

231
server/src/app.ts Normal file
View file

@ -0,0 +1,231 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import rateLimit from '@fastify/rate-limit';
import {
LabelDb,
LABEL_KINDS,
isLabelKind,
isAreaKind,
AREA_KINDS,
type ZoneLabel,
type ParkingArea,
} from './db.js';
import { AbuseGuard, safeEqual } from './auth.js';
export interface BuildOptions {
adminToken: string;
/** File path for the SQLite DB, or ':memory:' (tests). Ignored if `db` is given. */
dbPath?: string;
db?: LabelDb;
trustProxy?: boolean;
rateLimitMax?: number;
rateLimitWindow?: string;
writeRateLimitMax?: number;
authFailLimit?: number;
authFailWindowMs?: number;
blockCooldownMs?: number;
seedBlockedIps?: string[];
}
interface ZoneParams {
zoneId: string;
}
interface PutBody {
kind?: unknown;
zoneName?: unknown;
customerId?: unknown;
note?: unknown;
}
const str = (v: unknown): string | null => (v == null ? null : String(v));
export async function buildApp(opts: BuildOptions) {
const db = opts.db ?? new LabelDb(opts.dbPath ?? ':memory:');
if (opts.seedBlockedIps?.length) db.seedBlocked(opts.seedBlockedIps);
const guard = new AbuseGuard(
opts.authFailLimit ?? 8,
opts.authFailWindowMs ?? 15 * 60 * 1000,
opts.blockCooldownMs ?? 60 * 60 * 1000,
);
// 1 MB: zone-sync batches carry full Zone objects (policies, logos, …).
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 1024 * 1024 });
await app.register(rateLimit, {
max: opts.rateLimitMax ?? 120,
timeWindow: opts.rateLimitWindow ?? '1 minute',
});
// Reject blocked IPs (manual denylist + auto-block) before anything else.
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
if (guard.isBlocked(req.ip, Date.now()) || db.isBlocked(req.ip)) {
return reply.code(403).send({ error: 'forbidden' });
}
});
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
const hdr = req.headers.authorization ?? '';
const token = /^Bearer\s+(.+)$/i.exec(hdr)?.[1] ?? '';
if (!token || !safeEqual(token, opts.adminToken)) {
const tripped = guard.recordFail(req.ip, Date.now());
return reply.code(401).send({ error: 'unauthorized', blocked: tripped });
}
guard.recordSuccess(req.ip);
};
const writeLimit = {
config: { rateLimit: { max: opts.writeRateLimitMax ?? 20, timeWindow: '1 minute' } },
};
app.get('/healthz', async () => ({ ok: true }));
app.get('/api/whoami', { preHandler: requireAdmin }, async () => ({ admin: true }));
app.get('/api/labels', async () => ({ labels: db.all() }));
app.get('/api/labels/:zoneId', async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => {
const label = db.get(req.params.zoneId);
if (!label) return reply.code(404).send({ error: 'not_found' });
return label;
});
app.put(
'/api/labels/:zoneId',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Params: ZoneParams; Body: PutBody }>, reply) => {
const body = req.body ?? {};
if (!isLabelKind(body.kind)) {
return reply.code(400).send({ error: 'bad_kind', allowed: LABEL_KINDS });
}
const label: ZoneLabel = {
zoneId: String(req.params.zoneId),
customerId: str(body.customerId),
zoneName: str(body.zoneName),
kind: body.kind,
note: body.note == null ? null : String(body.note).slice(0, 500),
updatedAt: Date.now(),
updatedBy: 'admin',
};
db.upsert(label);
return label;
},
);
app.delete(
'/api/labels/:zoneId',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => {
if (!db.delete(req.params.zoneId)) return reply.code(404).send({ error: 'not_found' });
return { deleted: true };
},
);
// ---- Zone mirror (for anonymous browsing) --------------------------------
const coord = (v: unknown): number | null => {
const n = Number(v);
return Number.isFinite(n) && n !== 0 ? n : null;
};
app.get('/api/zones', async () => ({ zones: db.allZones(), count: db.zoneCount() }));
// The app pushes the Zone list it just pulled (authed) from ParkSmarter so
// anonymous users can read areas without a ParkSmarter login.
app.post(
'/api/zones/sync',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: { zones?: unknown[] } }>, reply) => {
const zones = Array.isArray(req.body?.zones) ? req.body!.zones : null;
if (!zones) return reply.code(400).send({ error: 'zones_required' });
const rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }> = [];
for (const z of zones as Array<Record<string, unknown>>) {
if (z == null || z.ZoneId == null) continue;
rows.push({
zoneId: String(z.ZoneId),
zoneName: z.ZoneName != null ? String(z.ZoneName) : null,
lat: coord(z.Lat),
long: coord(z.Long),
data: JSON.stringify(z),
});
}
return { synced: db.upsertZones(rows), total: db.zoneCount() };
},
);
// ---- City parking-map areas ---------------------------------------------
// The colour-coded areas from the city's printed Downtown & Waterfront parking
// map, georeferenced. Purely local geography: none of this touches ParkSmarter,
// which is the point — tracking time on these spots must work with no IPS call.
const num = (v: unknown, fallback: number): number => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
app.get('/api/areas', async () => ({
areas: db.allAreas(),
overlay: db.getOverlay(),
count: db.areaCount(),
updatedAt: db.areasUpdatedAt(),
}));
// Replace-all, not upsert: the areas come from one source document, so a partial
// update would leave a map matching neither the old print nor the new one.
app.put(
'/api/areas',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => {
const input = Array.isArray(req.body?.areas) ? req.body!.areas : null;
if (!input) return reply.code(400).send({ error: 'areas_required' });
const areas: ParkingArea[] = [];
for (const raw of input as Array<Record<string, unknown>>) {
if (raw == null || raw.id == null) continue;
if (!isAreaKind(raw.kind)) {
return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS });
}
const g = raw.geometry as { type?: unknown } | null;
if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) {
return reply.code(400).send({ error: 'bad_geometry', id: raw.id });
}
areas.push({
id: String(raw.id),
kind: raw.kind,
name: String(raw.name ?? raw.id),
label: String(raw.label ?? raw.kind),
legend: String(raw.legend ?? ''),
hours: num(raw.hours, 0),
color: String(raw.color ?? '#888888'),
shape: g.type === 'Polygon' ? 'polygon' : 'line',
geometry: g,
// Absent means "let the client decide by category"; only an explicit
// boolean overrides a specific lot.
requiresAccount: typeof raw.requiresAccount === 'boolean' ? raw.requiresAccount : null,
});
}
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
},
);
// Whole-overlay alignment correction, set from the phone against a live GPS fix.
app.put(
'/api/areas/overlay',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: Record<string, unknown> }>, reply) => {
const b = req.body ?? {};
const scale = num(b.scale, 1);
if (scale <= 0.5 || scale >= 2) {
// A fit that needs more than a ±2x correction is a broken fit, not a nudge.
return reply.code(400).send({ error: 'scale_out_of_range' });
}
return db.setOverlay({
dxMeters: num(b.dxMeters, 0),
dyMeters: num(b.dyMeters, 0),
scale,
rotationDeg: num(b.rotationDeg, 0),
});
},
);
app.addHook('onClose', async () => db.close());
return app;
}

50
server/src/auth.ts Normal file
View file

@ -0,0 +1,50 @@
import { timingSafeEqual } from 'node:crypto';
/** Constant-time string compare (guards the admin token check against timing attacks). */
export function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* Tracks failed admin-auth attempts per IP and auto-blocks an IP for a cooldown
* after too many failures in a rolling window. In-memory (resets on restart)
* complements the persistent manual denylist in the DB.
*/
export class AbuseGuard {
private fails = new Map<string, number[]>();
private blockedUntil = new Map<string, number>();
constructor(
private readonly limit: number,
private readonly windowMs: number,
private readonly cooldownMs: number,
) {}
isBlocked(ip: string, now: number): boolean {
const until = this.blockedUntil.get(ip);
if (until == null) return false;
if (until > now) return true;
this.blockedUntil.delete(ip);
return false;
}
/** Returns true if this failure just tripped an auto-block. */
recordFail(ip: string, now: number): boolean {
const recent = (this.fails.get(ip) ?? []).filter((t) => now - t < this.windowMs);
recent.push(now);
if (recent.length >= this.limit) {
this.blockedUntil.set(ip, now + this.cooldownMs);
this.fails.delete(ip);
return true;
}
this.fails.set(ip, recent);
return false;
}
recordSuccess(ip: string): void {
this.fails.delete(ip);
}
}

326
server/src/db.ts Normal file
View file

@ -0,0 +1,326 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
export const LABEL_KINDS: LabelKind[] = ['free_2h', 'free_3h', 'free_4h', 'pay_immediate'];
export function isLabelKind(v: unknown): v is LabelKind {
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
}
/** The five categories on the city's printed Downtown & Waterfront parking map. */
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit'];
export function isAreaKind(v: unknown): v is AreaKind {
return typeof v === 'string' && (AREA_KINDS as string[]).includes(v);
}
/**
* One coloured area from the city map: an on-street segment (LineString) or a
* city lot (Polygon). These are city geography, unrelated to ParkSmarter zones
* nothing here ever reaches the IPS API.
*/
export interface ParkingArea {
id: string;
kind: AreaKind;
name: string;
label: string;
legend: string;
hours: number;
color: string;
shape: 'line' | 'polygon';
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
geometry: unknown;
/**
* Overrides the client's by-category default (paid city lots need a
* ParkSmarter account, free time-limited streets don't). Null means "use the
* default"; set it only to correct a specific lot.
*/
requiresAccount: boolean | null;
}
/**
* A whole-overlay correction. The map was georeferenced by fitting it to OSM, which
* is good to a few metres but not perfect; this lets the alignment be nudged from the
* phone against a live GPS fix and persisted, with no app release.
*
* Offsets are ground metres (east/north); scale and rotation apply about the
* overlay's own centroid.
*/
export interface OverlayAdjust {
dxMeters: number;
dyMeters: number;
scale: number;
rotationDeg: number;
updatedAt: number;
}
export const IDENTITY_OVERLAY: OverlayAdjust = {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
};
export interface ZoneLabel {
zoneId: string;
customerId: string | null;
zoneName: string | null;
kind: LabelKind;
note: string | null;
updatedAt: number;
updatedBy: string | null;
}
interface Row {
zone_id: string;
customer_id: string | null;
zone_name: string | null;
kind: string;
note: string | null;
updated_at: number;
updated_by: string | null;
}
const toLabel = (r: Row): ZoneLabel => ({
zoneId: r.zone_id,
customerId: r.customer_id,
zoneName: r.zone_name,
kind: r.kind as LabelKind,
note: r.note,
updatedAt: r.updated_at,
updatedBy: r.updated_by,
});
/** SQLite-backed store for zone labels + a manual IP denylist. */
export class LabelDb {
private db: Database.Database;
constructor(path: string) {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
this.db = new Database(path);
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS zone_labels (
zone_id TEXT PRIMARY KEY,
customer_id TEXT,
zone_name TEXT,
kind TEXT NOT NULL,
note TEXT,
updated_at INTEGER NOT NULL,
updated_by TEXT
);
CREATE TABLE IF NOT EXISTS blocked_ips (
ip TEXT PRIMARY KEY,
reason TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS zones (
zone_id TEXT PRIMARY KEY,
zone_name TEXT,
lat REAL,
long REAL,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS parking_areas (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
label TEXT NOT NULL,
legend TEXT NOT NULL,
hours REAL NOT NULL,
color TEXT NOT NULL,
shape TEXT NOT NULL,
geometry TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS map_overlay (
id TEXT PRIMARY KEY,
dx_meters REAL NOT NULL,
dy_meters REAL NOT NULL,
scale REAL NOT NULL,
rotation REAL NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// CREATE TABLE IF NOT EXISTS won't add a column to a table that already
// exists, so added columns need an explicit migration.
this.addColumnIfMissing('parking_areas', 'requires_account', 'INTEGER');
}
private addColumnIfMissing(table: string, column: string, type: string): void {
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (cols.some((c) => c.name === column)) return;
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
}
/* ------------------------------------------------ city parking-map areas */
allAreas(): ParkingArea[] {
const rows = this.db
.prepare('SELECT * FROM parking_areas ORDER BY id')
.all() as Array<Record<string, any>>;
return rows.map((r) => ({
id: r.id,
kind: r.kind as AreaKind,
name: r.name,
label: r.label,
legend: r.legend,
hours: r.hours,
color: r.color,
shape: r.shape as 'line' | 'polygon',
geometry: JSON.parse(r.geometry),
// SQLite has no boolean; null stays null so the client applies its default.
requiresAccount: r.requires_account == null ? null : !!r.requires_account,
}));
}
areaCount(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n;
}
/** Newest updated_at across areas — the app uses it to skip redundant refreshes. */
areasUpdatedAt(): number {
const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as {
t: number | null;
};
return r.t ?? 0;
}
/**
* Replace the whole area set in one transaction. The areas come from a single
* source document, so a partial update would leave a map that matches neither
* the old print nor the new one.
*/
replaceAreas(areas: ParkingArea[]): number {
const now = Date.now();
const insert = this.db.prepare(
`INSERT INTO parking_areas
(id, kind, name, label, legend, hours, color, shape, geometry, requires_account, updated_at)
VALUES
(@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @requiresAccount, @updatedAt)`,
);
this.db.transaction((items: ParkingArea[]) => {
this.db.prepare('DELETE FROM parking_areas').run();
for (const a of items) {
insert.run({
...a,
geometry: JSON.stringify(a.geometry),
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
updatedAt: now,
});
}
})(areas);
return areas.length;
}
getOverlay(): OverlayAdjust {
const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as
| Record<string, any>
| undefined;
if (!r) return IDENTITY_OVERLAY;
return {
dxMeters: r.dx_meters,
dyMeters: r.dy_meters,
scale: r.scale,
rotationDeg: r.rotation,
updatedAt: r.updated_at,
};
}
setOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): OverlayAdjust {
const updatedAt = Date.now();
this.db
.prepare(
`INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at)
VALUES ('default', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters,
scale = excluded.scale, rotation = excluded.rotation,
updated_at = excluded.updated_at`,
)
.run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt);
return { ...o, updatedAt };
}
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
upsertZones(
rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>,
): number {
const stmt = this.db.prepare(
`INSERT INTO zones (zone_id, zone_name, lat, long, data, updated_at)
VALUES (@zoneId, @zoneName, @lat, @long, @data, @updatedAt)
ON CONFLICT(zone_id) DO UPDATE SET
zone_name = excluded.zone_name,
lat = excluded.lat, long = excluded.long,
data = excluded.data, updated_at = excluded.updated_at`,
);
const now = Date.now();
this.db.transaction((items: typeof rows) => {
for (const it of items) stmt.run({ ...it, updatedAt: now });
})(rows);
return rows.length;
}
/** All mirrored zones as their original Zone objects. */
allZones(): unknown[] {
const rows = this.db.prepare('SELECT data FROM zones').all() as { data: string }[];
return rows.map((r) => JSON.parse(r.data));
}
zoneCount(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n;
}
all(): ZoneLabel[] {
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
}
get(zoneId: string): ZoneLabel | undefined {
const r = this.db.prepare('SELECT * FROM zone_labels WHERE zone_id = ?').get(zoneId) as Row | undefined;
return r ? toLabel(r) : undefined;
}
upsert(label: ZoneLabel): void {
this.db
.prepare(
`INSERT INTO zone_labels (zone_id, customer_id, zone_name, kind, note, updated_at, updated_by)
VALUES (@zoneId, @customerId, @zoneName, @kind, @note, @updatedAt, @updatedBy)
ON CONFLICT(zone_id) DO UPDATE SET
customer_id = excluded.customer_id,
zone_name = excluded.zone_name,
kind = excluded.kind,
note = excluded.note,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`,
)
.run(label);
}
delete(zoneId: string): boolean {
return this.db.prepare('DELETE FROM zone_labels WHERE zone_id = ?').run(zoneId).changes > 0;
}
isBlocked(ip: string): boolean {
return !!this.db.prepare('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip);
}
block(ip: string, reason: string): void {
this.db
.prepare('INSERT OR REPLACE INTO blocked_ips (ip, reason, created_at) VALUES (?, ?, ?)')
.run(ip, reason, Date.now());
}
seedBlocked(ips: string[]): void {
for (const ip of ips) this.block(ip, 'seed');
}
close(): void {
this.db.close();
}
}

34
server/src/index.ts Normal file
View file

@ -0,0 +1,34 @@
import { buildApp } from './app.js';
const adminToken = process.env.BBP_ADMIN_TOKEN ?? '';
if (adminToken.length < 16) {
console.error('FATAL: BBP_ADMIN_TOKEN is missing or shorter than 16 chars.');
process.exit(1);
}
const app = await buildApp({
adminToken,
dbPath: process.env.BBP_DB_PATH ?? '/data/labels.db',
trustProxy: true,
seedBlockedIps: (process.env.BBP_BLOCKED_IPS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
});
const port = Number(process.env.PORT ?? 8090);
const host = process.env.HOST ?? '0.0.0.0';
try {
await app.listen({ port, host });
console.log(`bbp-labels listening on ${host}:${port}`);
} catch (err) {
console.error(err);
process.exit(1);
}
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.on(sig, () => {
void app.close().then(() => process.exit(0));
});
}

248
server/test/areas.test.ts Normal file
View file

@ -0,0 +1,248 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp, type BuildOptions } from '../src/app.ts';
const TOKEN = 'test-admin-token-0123456789';
const auth = { authorization: `Bearer ${TOKEN}` };
const make = (o: Partial<BuildOptions> = {}) =>
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
const line = (id: string, kind = 'free_2h') => ({
id,
kind,
name: `${id} name`,
label: '2-hour free',
legend: 'Permits not valid',
hours: 2,
color: '#d367cc',
geometry: {
type: 'LineString',
coordinates: [
[-116.5535, 48.2766],
[-116.5525, 48.2766],
],
},
});
test('areas are public to read and admin-only to replace', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.statusCode, 200);
assert.deepEqual(r.json().areas, []);
assert.equal(r.json().count, 0);
r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } });
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().replaced, 2);
r = await app.inject({ method: 'GET', url: '/api/areas' });
const areas = r.json().areas;
assert.equal(areas.length, 2);
assert.equal(areas[0].id, 'sp-001');
assert.equal(areas[0].kind, 'free_2h');
assert.equal(areas[0].shape, 'line');
// Geometry survives the round-trip as real GeoJSON, not a string.
assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]);
await app.close();
});
test('replace is wholesale — stale areas do not survive', async () => {
const app = await make();
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-001'), line('sp-002')] },
});
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-003')] },
});
const r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().count, 1);
assert.equal(r.json().areas[0].id, 'sp-003');
await app.close();
});
test('polygons are accepted; bad kinds and geometries are rejected', async () => {
const app = await make();
const lot = {
id: 'sp-022',
kind: 'green_lot',
name: 'Lot off Oak St',
label: 'City lot',
legend: 'Paid hourly or permit',
hours: 2,
color: '#75b259',
geometry: {
type: 'Polygon',
coordinates: [
[
[-116.554, 48.2766],
[-116.553, 48.2766],
[-116.553, 48.2772],
[-116.554, 48.2766],
],
],
},
};
let r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [lot] },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().replaced, 1);
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().areas[0].shape, 'polygon');
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] },
});
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_kind');
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] },
});
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_geometry');
// A rejected batch must not have clobbered the good one.
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().count, 1);
assert.equal(r.json().areas[0].id, 'sp-022');
await app.close();
});
test('requiresAccount is null unless explicitly overridden', async () => {
const app = await make();
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: {
areas: [
line('sp-001'), // no override -> client decides by category
{ ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account
{ ...line('sp-003'), requiresAccount: true },
],
},
});
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount]));
assert.equal(by['sp-001'], null, 'no override should stay null, not become false');
assert.equal(by['sp-002'], false);
assert.equal(by['sp-003'], true);
await app.close();
});
test('a database created before requires_account existed still works', async () => {
// Simulate an older deployment: build the table without the column, then let
// the migration add it. CREATE TABLE IF NOT EXISTS alone would not.
const Database = (await import('better-sqlite3')).default;
const file = `/tmp/bbp-migrate-${process.pid}.db`;
const raw = new Database(file);
raw.exec(`CREATE TABLE parking_areas (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL,
legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL,
geometry TEXT NOT NULL, updated_at INTEGER NOT NULL)`);
raw.close();
const app = await make({ dbPath: file });
const r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] },
});
assert.equal(r.statusCode, 200);
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
assert.equal(areas[0].requiresAccount, true);
await app.close();
(await import('node:fs')).rmSync(file, { force: true });
});
test('overlay defaults to identity and round-trips', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.deepEqual(r.json().overlay, {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
});
r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
payload: { dxMeters: 3 },
});
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
headers: auth,
payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().dxMeters, 3.5);
assert.ok(r.json().updatedAt > 0);
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().overlay.dyMeters, -2);
assert.equal(r.json().overlay.rotationDeg, 0.4);
await app.close();
});
test('an absurd overlay scale is refused rather than stored', async () => {
const app = await make();
for (const scale of [0, 0.4, 2, 5]) {
const r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
headers: auth,
payload: { scale },
});
assert.equal(r.statusCode, 400, `scale ${scale} should be refused`);
}
const r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().overlay.scale, 1);
await app.close();
});

140
server/test/labels.test.ts Normal file
View file

@ -0,0 +1,140 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp, type BuildOptions } from '../src/app.ts';
const TOKEN = 'test-admin-token-0123456789';
const auth = { authorization: `Bearer ${TOKEN}` };
const make = (o: Partial<BuildOptions> = {}) =>
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
test('reads are public; writes require the admin token', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/labels' });
assert.equal(r.statusCode, 200);
assert.deepEqual(r.json().labels, []);
r = await app.inject({ method: 'PUT', url: '/api/labels/113165', payload: { kind: 'free_2h' } });
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/labels/113165',
headers: auth,
payload: { kind: 'free_2h', zoneName: 'DL', customerId: 217 },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().kind, 'free_2h');
assert.equal(r.json().zoneName, 'DL');
assert.equal(r.json().customerId, '217');
r = await app.inject({ method: 'GET', url: '/api/labels/113165' });
assert.equal(r.statusCode, 200);
assert.equal(r.json().kind, 'free_2h');
await app.close();
});
test('upsert replaces the kind', async () => {
const app = await make();
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_2h' } });
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'pay_immediate' } });
const r = await app.inject({ method: 'GET', url: '/api/labels/1' });
assert.equal(r.json().kind, 'pay_immediate');
await app.close();
});
test('invalid kind is rejected', async () => {
const app = await make();
const r = await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_9h' } });
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_kind');
await app.close();
});
test('unknown zone → 404 on GET and DELETE', async () => {
const app = await make();
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/nope' })).statusCode, 404);
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/nope', headers: auth })).statusCode, 404);
await app.close();
});
test('delete removes the label', async () => {
const app = await make();
await app.inject({ method: 'PUT', url: '/api/labels/9', headers: auth, payload: { kind: 'free_4h' } });
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/9', headers: auth })).statusCode, 200);
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/9' })).statusCode, 404);
await app.close();
});
test('whoami reflects the token', async () => {
const app = await make();
assert.equal((await app.inject({ method: 'GET', url: '/api/whoami' })).statusCode, 401);
const ok = await app.inject({ method: 'GET', url: '/api/whoami', headers: auth });
assert.equal(ok.statusCode, 200);
assert.equal(ok.json().admin, true);
await app.close();
});
test('repeated auth failures auto-block the IP (403)', async () => {
const app = await make({ authFailLimit: 3, blockCooldownMs: 60_000 });
for (let i = 0; i < 3; i++) {
await app.inject({
method: 'PUT',
url: '/api/labels/1',
headers: { authorization: 'Bearer wrong' },
payload: { kind: 'free_2h' },
});
}
const r = await app.inject({ method: 'GET', url: '/api/labels' }); // even a public read is now blocked
assert.equal(r.statusCode, 403);
await app.close();
});
test('rate limit returns 429 past the threshold', async () => {
const app = await make({ rateLimitMax: 3, rateLimitWindow: '1 minute' });
let last;
for (let i = 0; i < 5; i++) last = await app.inject({ method: 'GET', url: '/api/labels' });
assert.equal(last!.statusCode, 429);
await app.close();
});
test('zone mirror: admin sync then public read', async () => {
const app = await make();
// sync requires auth
let r = await app.inject({
method: 'POST',
url: '/api/zones/sync',
payload: { zones: [{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55 }] },
});
assert.equal(r.statusCode, 401);
// authed sync (one zone lacks ZoneId → skipped)
r = await app.inject({
method: 'POST',
url: '/api/zones/sync',
headers: auth,
payload: {
zones: [
{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55, Spaces: [{ SpaceId: 1 }] },
{ ZoneName: 'no-id' },
],
},
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().synced, 1);
// public read returns full Zone objects
r = await app.inject({ method: 'GET', url: '/api/zones' });
assert.equal(r.statusCode, 200);
assert.equal(r.json().count, 1);
assert.equal(r.json().zones[0].ZoneName, 'DL');
assert.equal(r.json().zones[0].Spaces[0].SpaceId, 1);
await app.close();
});
test('seeded IP denylist blocks with 403', async () => {
// app.inject uses 127.0.0.1 as the client IP
const app = await make({ seedBlockedIps: ['127.0.0.1'] });
assert.equal((await app.inject({ method: 'GET', url: '/api/labels' })).statusCode, 403);
await app.close();
});

16
server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": false,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

72
tools/citymap/README.md Normal file
View file

@ -0,0 +1,72 @@
# Georeferencing the city parking map
Turns the City of Sandpoint's printed **Downtown & Waterfront Public Parking** PDF into
`app/src/features/citymap/parkingAreas.json` — the colour-coded overlay the app draws and
hit-tests against.
Run this again when the city publishes a new edition of the map.
## Why it needs doing at all
The PDF carries **no** georeferencing metadata (no `/Measure`, `/GPTS`, `/LPTS`, `/GEO`,
`/Viewport`, `/GCS`). It is a north-up Web Mercator screenshot of a slippy map with vector
parking stripes drawn on top, so page coordinates relate to the world by a plain affine
transform — which has to be recovered by fitting the drawing to something we already know
the coordinates of. That something is OpenStreetMap's street centrelines.
Two structural details cost the most time, so they are worth knowing up front:
- `pdftocairo` writes each **stroked** street segment with its own `matrix()` transform and
*local* coordinates, while **filled** lots are in absolute page coordinates. Both have to
be handled or the streets land in a heap near the origin.
- The legend swatches are drawn in the same five colours as the real geometry. They are
identified by stroke-width 7 inside the legend card's x-band and dropped.
## Pipeline
```bash
# 0. deps: poppler-utils (pdftocairo, pdftotext, pdfimages), python3, curl
pdftocairo -svg downtown_and_waterfront_public_parking_map.pdf map.svg
# 1. vector geometry -> page coordinates, bucketed by the legend's five colours
python3 extract_map.py map.svg map_page_coords.json
# 2. OSM street centrelines for downtown Sandpoint
curl -s --data-binary @roads.overpass https://overpass-api.de/api/interpreter -o osm.json
# 3. fit page -> Web Mercator against named streets; prints per-street residuals
python3 georef.py # writes fit_raw.json
# 4. apply the fit, name each area from OSM, verify, emit the GeoJSON
python3 build_geojson.py # writes parking_areas.geojson
cp parking_areas.geojson ../../app/src/features/citymap/parkingAreas.json
```
## What "good" looks like
`georef.py` prints a residual per control street and `build_geojson.py` prints how far each
on-street segment sits from the nearest OSM road. The current edition fits to:
| Check | Result |
| --- | --- |
| X control residual (avenues) | **RMS 4.1 m** |
| Y control residual (streets) | **RMS 3.5 m** |
| On-street segments vs nearest OSM road | **mean 4.0 m**, 40 of 42 under 10 m |
The two segments over 10 m (`sp-039`, `sp-040`) are correct, not errors: they are angled
bays along the old rail corridor that sit on no named road at all — the nearest way is a
service alley 19 m off. Anything much worse than the table above means a control street was
mis-identified; `georef.py`'s per-street residuals will say which.
Residual error is also correctable after the fact without re-running any of this — the app's
**Account → Align city map** screen nudges the whole overlay against a live GPS fix and
persists the correction.
## Control points
`georef.py` maps page grid lines to OSM street names by hand (`AVENUES` / `STREETS`). Sandpoint's
grid **jogs** between its north and south halves — North 2nd Ave and South 2nd Ave are 38 m
apart — so each control street is measured only over the span its page segment actually
covers, and both halves are used as independent control points. That jog is a useful sanity
check: the page shows the same 9.3 pt offset, which at the fitted scale is 38 m.

View file

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Apply the fitted transform and emit the final parking-areas GeoJSON.
Also verifies the result the only way that matters: every stroked segment should
land on an actual road, so measure each one's distance to the nearest OSM road
centreline. Lots are skipped in that check they are off-street by definition.
Each feature gets a human name from OSM (the street it runs along, plus the two
cross streets it lies between) so the app can list areas without the map.
"""
import json
import math
R = 6378137.0
COS = math.cos(math.radians(48.278))
fit = json.load(open("fit_raw.json"))
SX, TX, SY, TY = fit["sx"], fit["tx"], fit["sy"], fit["ty"]
def to_merc(x, y):
return (SX * x + TX, SY * y + TY)
def to_lonlat(x, y):
X, Y = to_merc(x, y)
return (round(math.degrees(X / R), 7), round(math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), 7))
def merc(lat, lon):
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
def dist_to_seg(p, a, b):
px, py = p
ax, ay = a
bx, by = b
dx, dy = bx - ax, by - ay
L = dx * dx + dy * dy
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
# ---------------------------------------------------------------- OSM roads
osm = json.load(open("osm.json"))
SKIP = {"footway", "path", "cycleway", "steps", "track", "service"}
roads = [] # (a, b, name) in mercator
for w in osm["elements"]:
t = w.get("tags", {})
if t.get("highway") in SKIP or "geometry" not in w:
continue
g = [merc(p["lat"], p["lon"]) for p in w["geometry"]]
nm = t.get("name")
for a, b in zip(g, g[1:]):
roads.append((a, b, nm))
named = [r for r in roads if r[2]]
SHORT = [
("North ", "N "), ("South ", "S "), ("East ", "E "), ("West ", "W "),
(" Street", " St"), (" Avenue", " Ave"), (" Boulevard", " Blvd"),
(" Road", " Rd"), (" Drive", " Dr"), (" Lane", " Ln"), (" Bridge", " Brg"),
]
def short(n):
for a, b in SHORT:
n = n.replace(a, b)
return n
def nearest_name(p, exclude=None, limit=60.0):
best, bestd = None, limit
for a, b, nm in named:
if nm == exclude:
continue
d = dist_to_seg(p, a, b)
if d < bestd:
best, bestd = nm, d
return best
def describe(pts_merc, is_line):
"""'N 3rd Ave · Cedar St to Oak St' for a segment, or the nearest road for a lot."""
mid = pts_merc[len(pts_merc) // 2]
on = nearest_name(mid) if is_line else None
if not is_line:
near = nearest_name(mid, limit=200.0)
return f"Lot off {short(near)}" if near else "City lot"
ends = [pts_merc[0], pts_merc[-1]]
cross = []
for e in ends:
c = nearest_name(e, exclude=on, limit=45.0)
if c and short(c) not in cross:
cross.append(short(c))
if not on:
# Bays along the old rail corridor sit on no named road — describe them
# by what they are near rather than inventing a street.
near = nearest_name(mid, limit=150.0)
return f"Off-street bays near {short(near)}" if near else "Off-street bays"
base = short(on)
if len(cross) == 2:
return f"{base} · {cross[0]} to {cross[1]}"
if len(cross) == 1:
return f"{base} · at {cross[0]}"
return base
# --------------------------------------------------------------- build output
page = json.load(open("map_page_coords.json"))
def is_legend(f):
"""The five legend swatches: stroke-width 7 sitting in the legend card's x-band."""
x0, y0, x1, y1 = f["bbox"]
return f["strokeWidth"] > 5 and 25 < x0 < 28 and 60 < y0 < 130
# kind -> (short label, legend text, default tracked hours, colour)
KINDS = {
"green_lot": ("City lot", "Paid hourly or permit", 2, "#75b259"),
"free_2h": ("2-hour free", "Permits not valid", 2, "#d367cc"),
"limit_3h": ("3-hour", "3-hour or permit", 3, "#ccc542"),
"limit_4h": ("4-hour", "4-hour or permit", 4, "#f78b08"),
"no_limit": ("No time limit", "No posted time limit", 0, "#c3c4c2"),
}
features = []
n_legend = 0
for i, f in enumerate(page["features"]):
if is_legend(f):
n_legend += 1
continue
is_line = f["geom"] == "line"
pm = [to_merc(x, y) for x, y in f["points"]]
coords = [to_lonlat(x, y) for x, y in f["points"]]
if is_line:
geom = {"type": "LineString", "coordinates": coords}
else:
if coords[0] != coords[-1]:
coords.append(coords[0])
geom = {"type": "Polygon", "coordinates": [coords]}
label, legend, hours, color = KINDS[f["kind"]]
features.append(
{
"type": "Feature",
"id": f"sp-{i:03d}",
"geometry": geom,
"properties": {
"id": f"sp-{i:03d}",
"kind": f["kind"],
"label": label,
"legend": legend,
"hours": hours,
"color": color,
"shape": "line" if is_line else "polygon",
"name": describe(pm, is_line),
},
}
)
fc = {
"type": "FeatureCollection",
"features": features,
"metadata": {
"source": "City of Sandpoint — Downtown & Waterfront Public Parking map",
"generated": "from downtown_and_waterfront_public_parking_map.pdf",
"georeference": "affine page->WebMercator fitted to OSM street centrelines",
},
}
json.dump(fc, open("parking_areas.geojson", "w"), indent=1)
print(f"{len(features)} features written ({n_legend} legend swatches dropped)\n")
for f in features:
p = f["properties"]
print(f" {p['id']} {p['kind']:10s} {p['shape']:7s} {p['name']}")
# ---- verification: distance from each on-street segment to the nearest road
worst = []
for f in features:
if f["properties"]["shape"] != "line":
continue
ds = [min(dist_to_seg(merc(lat, lon), a, b) for a, b, _ in roads) * COS
for lon, lat in f["geometry"]["coordinates"]]
worst.append((max(ds), sum(ds) / len(ds), f["properties"]["id"], f["properties"]["kind"]))
worst.sort(reverse=True)
print(f"\non-street segments: {len(worst)}, mean offset from nearest road = "
f"{sum(m for _, m, _, _ in worst)/len(worst):.1f} m")
print(f"segments with mean offset > 10 m: {sum(1 for _, m, _, _ in worst if m > 10)}")
for mx, mn, fid, kind in worst[:4]:
print(f" worst: {fid} {kind:10s} max={mx:6.1f} m mean={mn:6.1f} m")

View file

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Extract the color-coded parking geometry from the city's parking-map PDF.
pdftocairo emits every street segment as a stroked <path> carrying its own
matrix() transform with local coordinates, and every lot as an absolute filled
<path>. So: parse the path, flatten curves, push through the path's own matrix,
and bucket by the exact colour pdftocairo wrote.
Output is GeoJSON-shaped but still in SVG *page* coordinates (y down); the
georeferencing step turns that into lat/lon.
"""
import json
import re
import sys
from xml.etree import ElementTree as ET
SVG = sys.argv[1] if len(sys.argv) > 1 else "/tmp/map.svg"
OUT = sys.argv[2] if len(sys.argv) > 2 else "map_page_coords.json"
# Colours exactly as pdftocairo writes them, mapped to the map legend.
COLORS = {
"rgb(76.499939%, 76.899719%, 76.098633%)": "no_limit",
"rgb(96.899414%, 54.499817%, 3.09906%)": "limit_4h",
"rgb(79.998779%, 77.2995%, 25.898743%)": "limit_3h",
"rgb(82.699585%, 40.39917%, 79.998779%)": "free_2h",
"rgb(45.899963%, 69.799805%, 34.899902%)": "green_lot",
}
NUM = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
def parse_matrix(s):
"""matrix(a,b,c,d,e,f) -> tuple. Identity when absent."""
if not s:
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
m = re.search(r"matrix\s*\(([^)]*)\)", s)
if not m:
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
v = [float(x) for x in NUM.findall(m.group(1))]
return tuple(v[:6])
def apply(mtx, x, y):
a, b, c, d, e, f = mtx
return (a * x + c * y + e, b * x + d * y + f)
def bezier(p0, p1, p2, p3, steps=8):
"""Flatten a cubic to points. The map's curves are gentle; 8 is plenty."""
out = []
for i in range(1, steps + 1):
t = i / steps
u = 1 - t
out.append(
(
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
)
)
return out
def parse_path(d):
"""Return a list of subpaths [(points, closed)] in the path's local space."""
tokens = re.findall(r"([MmLlHhVvCcSsZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", d)
subpaths, pts = [], []
cur = (0.0, 0.0)
start = (0.0, 0.0)
cmd = None
i = 0
flat = [(c, n) for c, n in tokens]
def nums(k):
nonlocal i
vals = []
while len(vals) < k and i < len(flat) and flat[i][1]:
vals.append(float(flat[i][1]))
i += 1
return vals
while i < len(flat):
c, n = flat[i]
if c:
cmd = c
i += 1
elif cmd is None:
i += 1
continue
if cmd in "Zz":
if pts:
subpaths.append((pts, True))
pts = []
cur = start
cmd = None
continue
if cmd in "Mm":
v = nums(2)
if len(v) < 2:
break
if pts:
subpaths.append((pts, False))
pts = []
cur = (v[0], v[1]) if cmd == "M" else (cur[0] + v[0], cur[1] + v[1])
start = cur
pts = [cur]
cmd = "L" if cmd == "M" else "l"
elif cmd in "Ll":
v = nums(2)
if len(v) < 2:
break
cur = (v[0], v[1]) if cmd == "L" else (cur[0] + v[0], cur[1] + v[1])
pts.append(cur)
elif cmd in "Hh":
v = nums(1)
if not v:
break
cur = (v[0], cur[1]) if cmd == "H" else (cur[0] + v[0], cur[1])
pts.append(cur)
elif cmd in "Vv":
v = nums(1)
if not v:
break
cur = (cur[0], v[0]) if cmd == "V" else (cur[0], cur[1] + v[0])
pts.append(cur)
elif cmd in "Cc":
v = nums(6)
if len(v) < 6:
break
if cmd == "C":
p1, p2, p3 = (v[0], v[1]), (v[2], v[3]), (v[4], v[5])
else:
p1 = (cur[0] + v[0], cur[1] + v[1])
p2 = (cur[0] + v[2], cur[1] + v[3])
p3 = (cur[0] + v[4], cur[1] + v[5])
pts.extend(bezier(cur, p1, p2, p3))
cur = p3
else:
i += 1
if pts:
subpaths.append((pts, False))
return subpaths
def main():
tree = ET.parse(SVG)
root = tree.getroot()
ns = "{http://www.w3.org/2000/svg}"
features = []
for el in root.iter(ns + "path"):
stroke = (el.get("stroke") or "").strip()
fill = (el.get("fill") or "").strip()
kind = COLORS.get(stroke) or COLORS.get(fill)
if not kind:
continue
is_stroke = stroke in COLORS
mtx = parse_matrix(el.get("transform"))
width = float(el.get("stroke-width") or 0)
for local, closed in parse_path(el.get("d") or ""):
world = [apply(mtx, x, y) for x, y in local]
if len(world) < 2:
continue
xs = [p[0] for p in world]
ys = [p[1] for p in world]
features.append(
{
"kind": kind,
"geom": "line" if is_stroke else "polygon",
"closed": closed,
"strokeWidth": width,
"bbox": [min(xs), min(ys), max(xs), max(ys)],
"points": [[round(x, 3), round(y, 3)] for x, y in world],
}
)
with open(OUT, "w") as fh:
json.dump({"viewBox": [0, 0, 491.87, 529.043], "features": features}, fh, indent=1)
from collections import Counter
print(f"{len(features)} features -> {OUT}")
for (k, g), n in sorted(Counter((f["kind"], f["geom"]) for f in features).items()):
print(f" {k:10s} {g:8s} {n}")
# Where are they? Legend swatches cluster in one corner; real geometry spreads out.
print("\nbbox spread by kind:")
for k in COLORS.values():
fs = [f for f in features if f["kind"] == k]
if not fs:
continue
print(
f" {k:10s} x[{min(f['bbox'][0] for f in fs):7.1f},{max(f['bbox'][2] for f in fs):7.1f}] "
f"y[{min(f['bbox'][1] for f in fs):7.1f},{max(f['bbox'][3] for f in fs):7.1f}]"
)
main()

114
tools/citymap/georef.py Normal file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Georeference the parking-map page coordinates against OSM.
The base map is a north-up Web Mercator screenshot, so page -> mercator is a
uniform scale plus a translation (3 free params, not 6). Control points are
street centrelines identified by name: an avenue pins X, a street pins Y.
Streets in Sandpoint jog between the north and south halves of the grid, so each
control street's mercator coordinate is measured only over the span the page
segment actually covers, not over the whole way.
"""
import json
import math
R = 6378137.0
def merc(lat, lon):
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
def unmerc(X, Y):
return (math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), math.degrees(X / R))
osm = json.load(open("osm.json"))
ways = {}
for w in osm["elements"]:
n = w.get("tags", {}).get("name")
if not n or "geometry" not in w:
continue
ways.setdefault(n, []).append([merc(p["lat"], p["lon"]) for p in w["geometry"]])
def centreline(name, axis, lo, hi):
"""Mean coordinate on `axis` of `name`, over the other axis' [lo,hi] window."""
other = 1 - axis
vals = []
for g in ways.get(name, []):
for (x0, y0), (x1, y1) in zip(g, g[1:]):
p0, p1 = (x0, y0), (x1, y1)
if not (lo <= p0[other] <= hi or lo <= p1[other] <= hi):
continue
vals.append((p0[axis] + p1[axis]) / 2)
return sum(vals) / len(vals) if vals else None
# Page grid lines read off the rendered map, with the mercator window each spans.
# X window for E-W streets / Y window for N-S avenues, in mercator metres.
XW = (-12974700, -12974000) # 5th Ave .. 1st Ave
YW = (6152200, 6153300) # Lake St .. Poplar St
AVENUES = [ # page x, OSM name
(83.1, "North 5th Avenue"),
(120.0, "North 4th Avenue"),
(163.8, "North 3rd Avenue"),
(207.7, "North 2nd Avenue"),
(235.7, "North 1st Avenue"),
(164.3, "South 3rd Avenue"),
(198.4, "South 2nd Avenue"),
]
STREETS = [ # page y, OSM name
(184.4, "Poplar Street"),
(228.3, "Alder Street"),
(272.3, "Cedar Street"),
(314.9, "Oak Street"),
(359.1, "Church Street"),
(398.0, "Pine Street"),
(438.2, "Lake Street"),
(492.1, "Superior Street"),
]
def fit(pairs, flip):
"""Least-squares v = s*p + t. Returns (s, t, residuals)."""
n = len(pairs)
sp = sum(p for p, v in pairs)
sv = sum(v for p, v in pairs)
spp = sum(p * p for p, v in pairs)
spv = sum(p * v for p, v in pairs)
s = (n * spv - sp * sv) / (n * spp - sp * sp)
t = (sv - s * sp) / n
return s, t, [(p, v, s * p + t - v) for p, v in pairs]
ax = [(px, centreline(n, 0, *YW)) for px, n in AVENUES]
ay = [(py, centreline(n, 1, *XW)) for py, n in STREETS]
print("control points (mercator metres):")
for (px, n), (_, v) in zip(AVENUES, ax):
print(f" x {px:7.1f} {n:20s} {v if v is None else round(v,1)}")
for (py, n), (_, v) in zip(STREETS, ay):
print(f" y {py:7.1f} {n:20s} {v if v is None else round(v,1)}")
ax = [(p, v) for p, v in ax if v is not None]
ay = [(p, v) for p, v in ay if v is not None]
COS = math.cos(math.radians(48.278)) # mercator metres -> ground metres here
def report(label, pairs, names):
s, t, res = fit(pairs, False)
print(f"\n{label}: scale={s:.4f} merc-m/pt ({abs(s)*COS:.4f} ground-m/pt), offset={t:.1f}")
for (p, v, r), nm in zip(res, names):
print(f" {nm:20s} page={p:7.1f} residual={r*COS:7.1f} ground-m")
rms = math.sqrt(sum(r * r for _, _, r in res) / len(res)) * COS
print(f" RMS = {rms:.1f} ground-m")
return s, t, rms
sx, tx, rx = report("X (avenues)", ax, [n for _, n in AVENUES])
sy, ty, ry = report("Y (streets)", ay, [n for _, n in STREETS])
print(f"\nscale ratio |sy/sx| = {abs(sy/sx):.4f} (1.0 == truly uniform / north-up)")
json.dump({"sx": sx, "tx": tx, "sy": sy, "ty": ty}, open("fit_raw.json", "w"), indent=1)

View file

@ -0,0 +1,3 @@
[out:json][timeout:60];
way["highway"](48.2600,-116.5750,48.2900,-116.5300);
out geom;