Compare commits

...

6 commits
v0.4.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
44 changed files with 4517 additions and 653 deletions

View file

@ -39,14 +39,59 @@ bigbrainparking/
| --- | --- | --- |
| Phone + password login | ✅ wired | tokens in OS keystore |
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
| 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 + 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
@ -75,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)

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.4.1",
"version": "0.6.4",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
"versionCode": 19,
"versionCode": 25,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",

View file

@ -1,9 +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>
<!-- Fully-qualified name: relative ".Name" would resolve against the app
<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

@ -3,24 +3,28 @@ package expo.modules.bbpnotify
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
/**
* Handles the check-in notification's "End" / "Pay" buttons, even when the app
* process is dead. It records the choice in SharedPreferences (read by JS via
* `consumePendingAction` on next foreground) and, for "Pay", relaunches the app
* so the check-in can hand off to the paid-session flow.
* 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) {
val prefs = context.getSharedPreferences(BbpNotifyModule.PREFS_NAME, Context.MODE_PRIVATE)
when (intent.action) {
BbpNotifyModule.ACTION_END -> {
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply()
NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID)
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)
}
BbpNotifyModule.ACTION_PAY -> {
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply()
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)
@ -28,4 +32,9 @@ class BbpActionReceiver : BroadcastReceiver() {
}
}
}
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

@ -1,138 +1,63 @@
package expo.modules.bbpnotify
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
import androidx.core.app.NotificationManagerCompat
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
/**
* Posts an ongoing notification whose "time" is a native Android chronometer
* counting DOWN to an end time the system ticks it every second with no app
* CPU/battery, so it works while the app is closed.
* JS bridge for the ongoing parking notification.
*
* Two flavours: a plain paid-session countdown (`showCountdown`) and a free
* check-in countdown (`showCheckin`) that adds "End" / "Pay" action buttons.
* The buttons fire a BroadcastReceiver ([BbpActionReceiver]) that records the
* choice in SharedPreferences; JS reads it via `consumePendingAction` on the
* next foreground. Uses only platform APIs no third-party dependencies.
* 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")
// Returns a short diagnostic string so JS can log exactly what happened
// (posted / notifications-disabled / exception) — notify() fails silently.
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
/**
* 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"
postCountdown(ctx, title, body, endTimeMillis, null, null)
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)
}
// Same ticking countdown, plus "End" and "Pay" action buttons.
AsyncFunction("showCheckin") {
title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String ->
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel)
}
AsyncFunction("clear") {
val ctx = appContext.reactContext
if (ctx != null) {
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
/** 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 while the app was away.
// Returns "end", "pay", or "".
/** Read + clear the action a notification button set. Returns "end", "extend", or "". */
AsyncFunction("consumePendingAction") {
val ctx = appContext.reactContext ?: return@AsyncFunction ""
val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val action = prefs.getString(PREF_PENDING, "") ?: ""
if (action.isNotEmpty()) prefs.edit().remove(PREF_PENDING).apply()
action
}
}
private fun postCountdown(
ctx: Context,
title: String,
body: String,
endTimeMillis: Double,
endLabel: String?,
payLabel: String?,
): String {
ensureChannel(ctx)
val mgr = NotificationManagerCompat.from(ctx)
val enabled = mgr.areNotificationsEnabled()
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.bbp_stat_parking)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(true)
.setWhen(endTimeMillis.toLong())
.setUsesChronometer(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setChronometerCountDown(true)
BbpSessionStore.consumePending(ctx)
}
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
builder.setContentIntent(
PendingIntent.getActivity(
ctx, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
/** 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
}
if (endLabel != null) builder.addAction(0, endLabel, actionIntent(ctx, ACTION_END))
if (payLabel != null) builder.addAction(0, payLabel, actionIntent(ctx, ACTION_PAY))
return try {
mgr.notify(NOTIF_ID, builder.build())
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
} catch (e: SecurityException) {
"security-exception enabled=$enabled msg=${e.message}"
} catch (e: Exception) {
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
}
}
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,
)
}
private fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
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)
}
}
}
companion object {
private const val CHANNEL_ID = "session-status"
const val NOTIF_ID = 42421
const val PREFS_NAME = "bbp_notify"
const val PREF_PENDING = "pending_action"
const val ACTION_END = "expo.modules.bbpnotify.END"
const val ACTION_PAY = "expo.modules.bbpnotify.PAY"
}
}

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

@ -3,25 +3,26 @@ import { requireOptionalNativeModule } from 'expo-modules-core';
interface BbpNotifyNative {
/**
* Post/replace an ongoing notification with a native chronometer counting down
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
* 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 …").
*/
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
/** Same ticking countdown, plus "End" / "Pay" action buttons. */
showCheckin(
showSession(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
payLabel: string,
extendLabel: string,
): Promise<string>;
/** Remove the countdown notification. */
clear(): Promise<void>;
/** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
/** 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' | 'pay' | null;
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).
@ -33,32 +34,27 @@ const native =
/** True when the native ticking-countdown module is available. */
export const hasNativeCountdown = native != null;
export async function showCountdown(
title: string,
body: string,
endTimeMillis: number,
): Promise<string> {
if (!native) return 'no-native-module';
return native.showCountdown(title, body, endTimeMillis);
}
export async function showCheckin(
export async function showSession(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
payLabel: string,
extendLabel: string,
): Promise<string> {
if (!native) return 'no-native-module';
return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
}
export async function clearCountdown(): Promise<void> {
await native?.clear();
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 === 'pay' ? a : null;
return a === 'end' || a === 'extend' ? a : null;
}
export async function hasActiveSession(): Promise<boolean> {
return (await native?.hasActiveSession()) ?? false;
}

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"
}
}

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);
}

View file

@ -31,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');
};

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

@ -1,135 +0,0 @@
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 type { RootStackParamList } from '@/navigation/RootNavigator';
import {
showCheckin,
clearCountdown,
consumePendingAction,
hasNativeCountdown,
} from '../../../modules/bbp-notify';
import { getReminderLeadMinutes, getRemindersEnabled, getCountdownEnabled } from '@/features/notifications/reminderPrefs';
import { ensureNotificationPermission } from '@/notifications/localReminders';
import { logLine } from '@/features/diagnostics/fileLogger';
import { labelHours, type LabelKind } from '@/api/zoneLabels';
import { getCheckin, setCheckin, clearCheckinState, type CheckinState } from './checkinStore';
const ALERT_ID = 'checkin-alert';
const FALLBACK_NOTIF_ID = 'checkin-status';
function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/** Post (or re-post) the ongoing ticking check-in notification with End/Pay buttons. */
async function postCheckinNotification(s: CheckinState): Promise<void> {
if (!(await getCountdownEnabled())) return;
const ends = fmtTime(s.endMs);
if (hasNativeCountdown) {
const diag = await showCheckin(`Free parking · ${s.zoneName}`, `Free until ${ends}`, s.endMs, 'End', 'Pay');
logLine(`[CHECKIN] native: ${diag}`);
return;
}
// Fallback (Expo Go / non-native): a static ongoing notification, no buttons.
await Notifications.scheduleNotificationAsync({
identifier: FALLBACK_NOTIF_ID,
content: {
title: `Free parking · ${s.zoneName}`,
body: `Free until ${ends}`,
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 scheduleCheckinAlert(s: CheckinState): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
if (!(await getRemindersEnabled())) return;
const fireAt = s.endMs - s.leadMinutes * 60_000;
if (fireAt <= Date.now()) return;
await Notifications.scheduleNotificationAsync({
identifier: ALERT_ID,
content: {
title: 'Free parking ending soon',
body: `${s.zoneName}: your free time ends at ${fmtTime(s.endMs)}. Pay to extend or move your car.`,
data: { kind: 'checkin-alert' },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(fireAt),
channelId: 'session-reminders',
},
});
}
/** Start a local free check-in for `hours` at the given zone. */
export async function startCheckin(zone: Zone, hours: number): Promise<void> {
const now = Date.now();
const state: CheckinState = {
zone,
zoneName: zone.ZoneName ?? 'Parking',
startMs: now,
endMs: now + hours * 3_600_000,
kind: `free_${hours}h` as LabelKind,
leadMinutes: await getReminderLeadMinutes(),
};
await setCheckin(state);
await ensureNotificationPermission();
await postCheckinNotification(state);
await scheduleCheckinAlert(state);
}
/** End the active check-in and clear its notifications. */
export async function endCheckin(): Promise<void> {
await clearCheckinState();
await clearCountdown().catch(() => {});
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
}
/** Hours of free time for a label kind, or null for pay-immediate. */
export { labelHours };
/**
* Keep the check-in in sync on foreground: apply any notification-button action
* (End clears it; Pay hands off to the paid flow), drop expired check-ins, and
* re-post the ongoing notification (e.g. after a reboot). Mirrors useSessionStatusSync.
*/
export function useCheckinSync(): void {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
useEffect(() => {
const run = async () => {
const action = await consumePendingAction();
if (action === 'end') {
await endCheckin();
return;
}
if (action === 'pay') {
const s = await getCheckin();
await endCheckin();
if (s?.zone) navigation.navigate('StartSession', { zone: s.zone });
return;
}
const s = await getCheckin();
if (!s) return;
if (s.endMs <= Date.now()) {
await endCheckin();
} else {
await postCheckinNotification(s);
}
};
void run();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') void run();
});
return () => sub.remove();
}, [navigation]);
}

View file

@ -1,32 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Zone } from 'parksmarter-client';
import type { LabelKind } from '@/api/zoneLabels';
/**
* A local, API-free "check-in" for a free time-limited space. The full Zone is
* stored so the notification's "Pay" button can hand off straight into the paid
* flow without re-fetching. Only one active check-in at a time.
*/
const KEY = 'ps_checkin';
export interface CheckinState {
zone: Zone;
zoneName: string;
startMs: number;
endMs: number;
kind: LabelKind;
leadMinutes: number;
}
export async function getCheckin(): Promise<CheckinState | null> {
const raw = await AsyncStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as CheckinState) : null;
}
export async function setCheckin(state: CheckinState): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(state));
}
export async function clearCheckinState(): Promise<void> {
await AsyncStorage.removeItem(KEY);
}

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

@ -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

@ -24,15 +24,21 @@ 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 { useSessionStatusSync } from '@/notifications/sessionStatus';
import { useCheckinSync } from '@/features/checkin/checkin';
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;
@ -72,10 +78,9 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
};
function Tabs() {
// Keep the ongoing "time left" status notification fresh (on open + foreground).
useSessionStatusSync();
// Apply check-in notification actions (End/Pay) + keep its countdown in sync.
useCheckinSync();
// 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 }) => ({
@ -153,7 +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> {
@ -48,48 +46,6 @@ Notifications.setNotificationHandler({
},
});
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,159 +0,0 @@
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import { ps } from '@/api/client';
import { getCountdownEnabled } from '@/features/notifications/reminderPrefs';
import { ensureNotificationPermission } from '@/notifications/localReminders';
import { logLine } from '@/features/diagnostics/fileLogger';
import { hasNativeCountdown, showCountdown, clearCountdown } from '../../modules/bbp-notify';
/**
* Ongoing "time left" status notification while a parking session is active.
*
* Preferred path: a tiny native module (modules/bbp-notify) posts a notification
* whose time is an Android **chronometer counting down** to the session end the
* system ticks it every second with no app running (glance without opening the app).
* If that native module isn't present, we fall back to an expo-notifications
* ongoing notification showing the (static) expiry time, refreshed on foreground.
*/
const CHANNEL = 'session-status';
const NOTIF_ID = 'session-status';
async function ensureExpoChannel(): Promise<void> {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync(CHANNEL, {
name: 'Active parking',
importance: Notifications.AndroidImportance.LOW,
showBadge: false,
});
}
/**
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
* /api/Session (past): "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.
*/
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);
}
function fmtLeft(min: number): string {
if (min <= 0) return 'expired';
const h = Math.floor(min / 60);
const m = min % 60;
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
}
/** Post (or replace) the ongoing status notification for a session end time. */
export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise<void> {
if (!(await getCountdownEnabled())) {
logLine('[STATUS] skip: countdown disabled');
return;
}
const granted = await ensureNotificationPermission();
const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
logLine(
`[STATUS] show native=${hasNativeCountdown} perm=${granted} end=${args.endTime.toISOString()}`,
);
if (hasNativeCountdown) {
// Native chronometer: a live ticking countdown, updated by the OS.
try {
const diag = await showCountdown(
`Parking · ${args.zoneName}`,
`Expires ${ends}`,
args.endTime.getTime(),
);
logLine(`[STATUS] native result: ${diag}`);
} catch (e: any) {
logLine(`[STATUS] native threw: ${e?.message ?? e}`);
}
return;
}
// Fallback: static expiry-time notification, refreshed on foreground.
await ensureExpoChannel();
const remainMin = Math.round((args.endTime.getTime() - Date.now()) / 60000);
await Notifications.scheduleNotificationAsync({
identifier: NOTIF_ID,
content: {
title: remainMin > 0 ? `Parking: ${fmtLeft(remainMin)} left` : 'Parking expired',
body: `${args.zoneName} · expires ${ends}`,
sticky: true,
autoDismiss: false,
data: { kind: 'session-status' },
},
trigger:
Platform.OS === 'android'
? {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(Date.now() + 400),
channelId: CHANNEL,
}
: null,
});
}
export async function clearSessionStatus(): Promise<void> {
await clearCountdown().catch(() => {});
await Notifications.dismissNotificationAsync(NOTIF_ID).catch(() => {});
await Notifications.cancelScheduledNotificationAsync(NOTIF_ID).catch(() => {});
}
/**
* Fetch active sessions and re-post the status for the soonest-expiring one,
* or clear it if there are none / the feature is off.
*/
export async function refreshSessionStatus(): Promise<void> {
if (!(await getCountdownEnabled())) {
await clearSessionStatus();
return;
}
try {
const res = await ps.getActiveParkingSessions();
logLine(`[STATUS] active sessions raw: ${JSON.stringify(res.ParkingSession ?? [])}`);
const withEnd = (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());
logLine(`[STATUS] parsed ${withEnd.length} future-end session(s) of ${(res.ParkingSession ?? []).length}`);
if (withEnd.length === 0) {
await clearSessionStatus();
return;
}
const { s, end } = withEnd[0];
await showSessionStatus({ zoneName: s.ZoneName ?? s.Zone ?? 'Parking', endTime: end! });
} catch (e: any) {
logLine(`[STATUS] refresh failed: ${e?.serverMessage ?? e?.message ?? e}`);
}
}
/** Keep the status notification fresh: on mount and each time the app is foregrounded. */
export function useSessionStatusSync(): void {
useEffect(() => {
void refreshSessionStatus();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') void refreshSessionStatus();
});
return () => sub.remove();
}, []);
}

View file

@ -71,6 +71,11 @@ 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)"

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

@ -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,14 +1,24 @@
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';
@ -16,6 +26,19 @@ import { ps } from '@/api/client';
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';
@ -30,8 +53,43 @@ const MAP_STYLE_DARK =
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');
@ -49,13 +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);
@ -69,11 +147,16 @@ 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);
// 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;
@ -86,7 +169,7 @@ export function MapScreen() {
/* 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.
@ -148,6 +231,53 @@ export function MapScreen() {
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;
};
}, []);
// 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.)
@ -171,10 +301,9 @@ export function MapScreen() {
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;
@ -190,6 +319,10 @@ export function MapScreen() {
// 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();
@ -206,6 +339,139 @@ export function MapScreen() {
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,
});
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(
@ -246,6 +512,7 @@ export function MapScreen() {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
at: Date.now(),
};
}
}}
@ -262,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;
@ -273,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"
@ -303,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={searchLastSessionLot}>
<Text style={styles.pillText}>Last lot</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>
);
@ -337,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,
@ -352,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

@ -19,7 +19,7 @@ import {
type LabelKind,
type ZoneLabel,
} from '@/api/zoneLabels';
import { startCheckin } from '@/features/checkin/checkin';
import { startFreeCheckin } from '@/features/session/activeParking';
/** Admin labeling buttons — kind, or 'clear' to remove. */
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
@ -140,7 +140,7 @@ export function MeterDetailScreen() {
const labeledHours = label ? labelHours(label.kind) : null;
const doCheckin = async (hours: number) => {
await startCheckin(z, hours);
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.`,

View file

@ -3,7 +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 { refreshSessionStatus, clearSessionStatus } from '@/notifications/sessionStatus';
import { refreshParkingNotification } from '@/features/session/activeParking';
import {
DEFAULT_LEAD_MINUTES,
LEAD_STEP,
@ -38,7 +38,7 @@ export function NotificationsScreen() {
const toggleCountdown = (v: boolean) => {
setCountdown(v);
void setCountdownEnabled(v).then(() => (v ? refreshSessionStatus() : clearSessionStatus()));
void setCountdownEnabled(v).then(refreshParkingNotification);
};
const bump = (delta: number) => {

View file

@ -1,26 +1,72 @@
import React, { useCallback, useState } from 'react';
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 () => {
/** 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;
setRefreshing(true);
try {
const [a, p] = await Promise.all([
ps.getActiveParkingSessions(),
@ -28,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);
}
}, [isAnonymous]);
}, [loadLocal, loadRemote]);
useFocusEffect(
useCallback(() => {
@ -39,24 +97,14 @@ export function SessionsScreen() {
}, [load]),
);
if (isAnonymous) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
Sign in to see your sessions
</Text>
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
Your active and past parking sessions live in your ParkSmarter account.
</Text>
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
onPress={requireLogin}
>
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
</TouchableOpacity>
</View>
);
}
// 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
@ -64,52 +112,166 @@ export function SessionsScreen() {
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>
<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>
) : (
active.map((s, i) => (
<TouchableOpacity
key={i}
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
>
<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 ?? ''} tap for details
</Text>
</TouchableOpacity>
))
<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>
) : (
past.map((s, i) => (
{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
key={i}
style={[styles.card, { backgroundColor: colors.card }]}
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
onPress={requireLogin}
>
<Text style={[styles.zone, { color: colors.text }]}>
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} tap for receipt
</Text>
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
</TouchableOpacity>
))
</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>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
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,9 +14,8 @@ 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 { showSessionStatus } from '@/notifications/sessionStatus';
import { endCheckin } from '@/features/checkin/checkin';
import { parseApiTime } from '@/api/parseTime';
import { startPaidSession } from '@/features/session/activeParking';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import {
@ -107,18 +106,6 @@ export async function buildSingleLadder(base: {
return { free: false, flat: false, ladder };
}
/** 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]);
}
export function StartSessionScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
@ -275,18 +262,18 @@ export function StartSessionScreen() {
minCreditAmount: zone.MinimumAmount,
meterTypeId: zone.MeterTypeId!,
});
// Paid session supersedes any free check-in (they share the notification).
await endCheckin();
// 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,
});
// Ongoing "time left" status notification (glanceable countdown).
await showSessionStatus({ zoneName: zone.ZoneName ?? 'Parking', endTime: end });
} else {
logLine(`[SESSION] no countdown: unparseable EndTime "${selected.EndTime}"`);
}
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [

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)}`);
});

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

@ -1,6 +1,14 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { LabelDb, LABEL_KINDS, isLabelKind, type ZoneLabel } from './db.js';
import {
LabelDb,
LABEL_KINDS,
isLabelKind,
isAreaKind,
AREA_KINDS,
type ZoneLabel,
type ParkingArea,
} from './db.js';
import { AbuseGuard, safeEqual } from './auth.js';
export interface BuildOptions {
@ -143,6 +151,81 @@ export async function buildApp(opts: BuildOptions) {
},
);
// ---- 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;
}

View file

@ -9,6 +9,62 @@ 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;
@ -70,7 +126,126 @@ export class LabelDb {
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. */

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();
});

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;