diff --git a/README.md b/README.md index 39f7ab9..008270b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ 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) | @@ -48,6 +50,35 @@ bigbrainparking/ | 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. + +**None of it touches ParkSmarter.** The areas 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 city spot works with no account, no signal, no payment, and in +Anonymous Mode. 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 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 diff --git a/app/app.json b/app/app.json index 3f64a24..ffcaf4d 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.5.0", + "version": "0.6.0", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 20, + "versionCode": 21, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/package.json b/app/package.json index ec8ccb8..6975cda 100644 --- a/app/package.json +++ b/app/package.json @@ -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" } } diff --git a/app/src/api/parkingAreas.ts b/app/src/api/parkingAreas.ts new file mode 100644 index 0000000..0200636 --- /dev/null +++ b/app/src/api/parkingAreas.ts @@ -0,0 +1,216 @@ +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; +} + +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 = { + 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'; +} + +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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): Promise { + 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): Promise { + 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 { + memCache = null; + await AsyncStorage.removeItem(CACHE_KEY); +} diff --git a/app/src/features/citymap/geo.ts b/app/src/features/citymap/geo.ts new file mode 100644 index 0000000..a62baec --- /dev/null +++ b/app/src/features/citymap/geo.ts @@ -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]; +} diff --git a/app/src/features/citymap/parkingAreas.json b/app/src/features/citymap/parkingAreas.json new file mode 100644 index 0000000..2745c48 --- /dev/null +++ b/app/src/features/citymap/parkingAreas.json @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","id":"sp-000","geometry":{"type":"LineString","coordinates":[[-116.5503661,48.2782914],[-116.5503661,48.2793557]]},"properties":{"id":"sp-000","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"N 3rd Ave \u00b7 Poplar St to Fir St"}},{"type":"Feature","id":"sp-001","geometry":{"type":"LineString","coordinates":[[-116.5519169,48.2782914],[-116.5519169,48.2793557]]},"properties":{"id":"sp-001","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"N 4th Ave \u00b7 Poplar St to Fir St"}},{"type":"Feature","id":"sp-002","geometry":{"type":"LineString","coordinates":[[-116.5533041,48.2771426],[-116.5488263,48.2771426]]},"properties":{"id":"sp-002","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 2nd Ave \u00b7 at Alder St"}},{"type":"Feature","id":"sp-003","geometry":{"type":"LineString","coordinates":[[-116.5533041,48.2781829],[-116.5520634,48.2781829]]},"properties":{"id":"sp-003","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Poplar St \u00b7 N 5th Ave to N 4th Ave"}},{"type":"Feature","id":"sp-004","geometry":{"type":"LineString","coordinates":[[-116.5503566,48.2772164],[-116.5503566,48.2780943]]},"properties":{"id":"sp-004","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 3rd Ave \u00b7 Alder St to Poplar St"}},{"type":"Feature","id":"sp-005","geometry":{"type":"LineString","coordinates":[[-116.5536782,48.2750842],[-116.5555487,48.2750916]]},"properties":{"id":"sp-005","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 6th Ave \u00b7 at Oak St"}},{"type":"Feature","id":"sp-006","geometry":{"type":"LineString","coordinates":[[-116.5536782,48.2740439],[-116.5547212,48.2740439]]},"properties":{"id":"sp-006","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Church St \u00b7 at N 5th Ave"}},{"type":"Feature","id":"sp-007","geometry":{"type":"LineString","coordinates":[[-116.5520595,48.2731143],[-116.5533405,48.273129]]},"properties":{"id":"sp-007","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Pine St \u00b7 N 4th Ave to N 5th Ave"}},{"type":"Feature","id":"sp-008","geometry":{"type":"LineString","coordinates":[[-116.5513089,48.2731216],[-116.5505494,48.2731216]]},"properties":{"id":"sp-008","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Pine St \u00b7 S 4th Ave to S 3rd Ave"}},{"type":"Feature","id":"sp-009","geometry":{"type":"LineString","coordinates":[[-116.550436,48.2729224],[-116.5504247,48.2721994]]},"properties":{"id":"sp-009","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"S 3rd Ave \u00b7 Pine St to Lake St"}},{"type":"Feature","id":"sp-010","geometry":{"type":"LineString","coordinates":[[-116.5504304,48.2721584],[-116.5478514,48.2721846]]},"properties":{"id":"sp-010","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"S 1st Ave \u00b7 at Lake St"}},{"type":"Feature","id":"sp-011","geometry":{"type":"LineString","coordinates":[[-116.5476359,48.2721699],[-116.5468538,48.2721699]]},"properties":{"id":"sp-011","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"E Lake St \u00b7 at S 1st Ave"}},{"type":"Feature","id":"sp-012","geometry":{"type":"LineString","coordinates":[[-116.5528053,48.2760991],[-116.5519098,48.2760991]]},"properties":{"id":"sp-012","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Cedar St \u00b7 at N 4th Ave"}},{"type":"Feature","id":"sp-013","geometry":{"type":"LineString","coordinates":[[-116.5556205,48.2732176],[-116.5556205,48.2770688]]},"properties":{"id":"sp-013","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"N 6th Ave \u00b7 Pine St to Alder St"}},{"type":"Feature","id":"sp-014","geometry":{"type":"LineString","coordinates":[[-116.5536782,48.2771426],[-116.5554844,48.2771426]]},"properties":{"id":"sp-014","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Alder St \u00b7 N 5th Ave to N 6th Ave"}},{"type":"Feature","id":"sp-015","geometry":{"type":"LineString","coordinates":[[-116.5519386,48.2772283],[-116.5519386,48.2781062]]},"properties":{"id":"sp-015","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 4th Ave \u00b7 Alder St to Poplar St"}},{"type":"Feature","id":"sp-016","geometry":{"type":"LineString","coordinates":[[-116.5518485,48.276088],[-116.5479474,48.2760991],[-116.5477493,48.2760102],[-116.5477493,48.2731216]]},"properties":{"id":"sp-016","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"N 1st Ave \u00b7 Cedar St to S 1st Ave"}},{"type":"Feature","id":"sp-017","geometry":{"type":"LineString","coordinates":[[-116.548736,48.2760324],[-116.548736,48.2731216]]},"properties":{"id":"sp-017","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Pine St \u00b7 at N 2nd Ave"}},{"type":"Feature","id":"sp-018","geometry":{"type":"LineString","coordinates":[[-116.5486065,48.2749405],[-116.5478556,48.274777]]},"properties":{"id":"sp-018","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Main St \u00b7 N 2nd Ave to N 1st Ave"}},{"type":"Feature","id":"sp-019","geometry":{"type":"LineString","coordinates":[[-116.5502626,48.2740447],[-116.5488165,48.2740447]]},"properties":{"id":"sp-019","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Church St \u00b7 N 3rd Ave to N 2nd Ave"}},{"type":"Feature","id":"sp-020","geometry":{"type":"LineString","coordinates":[[-116.5488511,48.2749881],[-116.5498283,48.2751437]]},"properties":{"id":"sp-020","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Oak St \u00b7 at Main St"}},{"type":"Feature","id":"sp-021","geometry":{"type":"LineString","coordinates":[[-116.5503479,48.2760324],[-116.5503479,48.275538]]},"properties":{"id":"sp-021","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"N 3rd Ave \u00b7 Cedar St to Main St"}},{"type":"Feature","id":"sp-022","geometry":{"type":"Polygon","coordinates":[[[-116.5492389,48.2741203],[-116.5501074,48.2741203],[-116.5501074,48.2749357],[-116.5492389,48.2749357],[-116.5492389,48.2741203]]]},"properties":{"id":"sp-022","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Oak St"}},{"type":"Feature","id":"sp-023","geometry":{"type":"Polygon","coordinates":[[[-116.549816,48.2754086],[-116.5501662,48.2754086],[-116.5501662,48.2755119],[-116.549816,48.2755119],[-116.549816,48.2754086]]]},"properties":{"id":"sp-023","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off N 3rd Ave"}},{"type":"Feature","id":"sp-024","geometry":{"type":"Polygon","coordinates":[[[-116.5455778,48.2731979],[-116.5452046,48.2731979],[-116.5452493,48.2734081],[-116.5452383,48.2734255],[-116.5452274,48.2734429],[-116.5452165,48.2734603],[-116.5452055,48.2734776],[-116.5451946,48.273495],[-116.5451837,48.2735124],[-116.5451727,48.2735297],[-116.5451618,48.2735471],[-116.5452244,48.2738554],[-116.5455924,48.2738238],[-116.5455287,48.2735099],[-116.5455387,48.2734968],[-116.5455486,48.2734836],[-116.5455586,48.2734704],[-116.5455685,48.2734572],[-116.5455785,48.2734441],[-116.5455884,48.2734309],[-116.5455984,48.2734177],[-116.5456084,48.2734045],[-116.5456045,48.2733787],[-116.5456007,48.2733529],[-116.5455968,48.2733271],[-116.545593,48.2733012],[-116.5455892,48.2732754],[-116.5455854,48.2732496],[-116.5455816,48.2732238],[-116.5455778,48.2731979]]]},"properties":{"id":"sp-024","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Sand Creek Byway"}},{"type":"Feature","id":"sp-025","geometry":{"type":"Polygon","coordinates":[[[-116.5443801,48.2733396],[-116.5443162,48.2733406],[-116.5442523,48.2733417],[-116.5441885,48.2733428],[-116.5441246,48.2733438],[-116.5440607,48.2733449],[-116.5439968,48.273346],[-116.5439329,48.273347],[-116.543869,48.2733481],[-116.5438725,48.2733643],[-116.543876,48.2733805],[-116.5438794,48.2733967],[-116.5438829,48.2734129],[-116.5438863,48.2734291],[-116.5438898,48.2734453],[-116.5438933,48.2734615],[-116.5438968,48.2734776],[-116.5438351,48.2734754],[-116.5437734,48.2734732],[-116.5437117,48.273471],[-116.54365,48.2734688],[-116.5435884,48.2734666],[-116.5435267,48.2734644],[-116.5434651,48.2734621],[-116.5434034,48.27346],[-116.5435702,48.2738474],[-116.5436354,48.2738531],[-116.5436966,48.2738589],[-116.5437537,48.2738646],[-116.5438067,48.2738702],[-116.5438557,48.2738756],[-116.5439005,48.2738809],[-116.5439414,48.2738859],[-116.5439781,48.2738906],[-116.5439995,48.2738934],[-116.5440227,48.2738964],[-116.544048,48.2738995],[-116.5440754,48.2739025],[-116.544105,48.2739055],[-116.544137,48.2739083],[-116.5441714,48.2739107],[-116.5442083,48.2739127],[-116.544221,48.2739133],[-116.5442374,48.2739139],[-116.5442573,48.2739145],[-116.5442804,48.273915],[-116.5443065,48.2739153],[-116.5443353,48.2739154],[-116.5443665,48.2739151],[-116.5443999,48.2739144],[-116.5444222,48.2739137],[-116.5444437,48.2739128],[-116.5444644,48.2739119],[-116.5444843,48.2739108],[-116.5445034,48.2739096],[-116.5445216,48.2739083],[-116.544539,48.273907],[-116.5445555,48.2739056],[-116.5445336,48.2738349],[-116.5445116,48.2737641],[-116.5444897,48.2736934],[-116.5444678,48.2736226],[-116.5444459,48.2735519],[-116.5444239,48.2734811],[-116.544402,48.2734103],[-116.5443801,48.2733396]]]},"properties":{"id":"sp-025","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Bridge St"}},{"type":"Feature","id":"sp-026","geometry":{"type":"Polygon","coordinates":[[[-116.5410174,48.2720238],[-116.5412053,48.2720167],[-116.5414279,48.2720384],[-116.5416715,48.2720848],[-116.5419226,48.2721517],[-116.5421676,48.2722352],[-116.542393,48.2723311],[-116.542585,48.2724354],[-116.5427301,48.2725441],[-116.5427859,48.2726311],[-116.542826,48.272699],[-116.5428531,48.2727516],[-116.5428704,48.2727922],[-116.5428806,48.2728246],[-116.542887,48.2728521],[-116.5428922,48.2728785],[-116.5428995,48.2729071],[-116.5429167,48.2729575],[-116.5429365,48.2730038],[-116.5429578,48.2730458],[-116.5429795,48.273083],[-116.5430004,48.2731152],[-116.5430194,48.273142],[-116.5430355,48.2731632],[-116.5430475,48.2731783],[-116.5430754,48.2732158],[-116.5431076,48.2732635],[-116.5431445,48.2733232],[-116.5431864,48.2733962],[-116.5432336,48.2734842],[-116.5432867,48.2735888],[-116.543346,48.2737115],[-116.5434117,48.2738539],[-116.5434305,48.2738783],[-116.5434439,48.2738994],[-116.5434524,48.2739173],[-116.5434568,48.2739324],[-116.5434576,48.273945],[-116.5434555,48.2739552],[-116.5434511,48.2739635],[-116.5434451,48.2739699],[-116.5434271,48.2739797],[-116.5434013,48.2739844],[-116.5433665,48.2739847],[-116.5433212,48.2739812],[-116.5432643,48.2739745],[-116.5431944,48.2739651],[-116.5431103,48.2739537],[-116.5430108,48.2739409],[-116.542993,48.2739387],[-116.5429614,48.2739349],[-116.5429179,48.2739299],[-116.5428641,48.273924],[-116.5428019,48.2739174],[-116.542733,48.2739106],[-116.5426591,48.2739038],[-116.5425821,48.2738974],[-116.5425339,48.2738938],[-116.5424954,48.2738913],[-116.5424621,48.2738894],[-116.5424298,48.2738879],[-116.5423938,48.2738861],[-116.5423498,48.2738838],[-116.5422934,48.2738805],[-116.5422201,48.2738757],[-116.5421078,48.2738677],[-116.542017,48.2738603],[-116.5419448,48.2738534],[-116.5418882,48.2738469],[-116.5418442,48.2738405],[-116.5418098,48.2738342],[-116.541782,48.2738279],[-116.541758,48.2738213],[-116.5417212,48.2738099],[-116.5416872,48.2737981],[-116.5416556,48.2737858],[-116.5416259,48.2737731],[-116.5415977,48.2737601],[-116.5415707,48.2737468],[-116.5415444,48.2737334],[-116.5415185,48.2737199],[-116.541474,48.2736954],[-116.5414391,48.2736741],[-116.5414102,48.2736543],[-116.541384,48.2736346],[-116.5413568,48.2736136],[-116.5413251,48.2735898],[-116.5412856,48.2735617],[-116.5412345,48.2735278],[-116.5411344,48.2734637],[-116.5410532,48.2734124],[-116.5409892,48.2733725],[-116.5409407,48.2733428],[-116.5409059,48.2733219],[-116.5408832,48.2733086],[-116.5408708,48.2733015],[-116.540867,48.2732995],[-116.5408537,48.2732923],[-116.5408381,48.2732834],[-116.5408213,48.2732727],[-116.5408046,48.2732604],[-116.5407891,48.2732463],[-116.5407759,48.2732307],[-116.5407663,48.2732133],[-116.5407613,48.2731943],[-116.5407608,48.2731846],[-116.5407616,48.2731739],[-116.5407638,48.2731629],[-116.5407675,48.2731522],[-116.5407729,48.2731422],[-116.54078,48.2731334],[-116.5407891,48.2731265],[-116.5408002,48.2731219],[-116.5408258,48.2731212],[-116.5408555,48.2731313],[-116.5408897,48.2731507],[-116.540929,48.2731782],[-116.5409738,48.2732125],[-116.5410245,48.2732522],[-116.5410815,48.2732962],[-116.5411455,48.273343],[-116.5411854,48.273371],[-116.5412329,48.2734033],[-116.5412879,48.2734387],[-116.5413503,48.2734764],[-116.5414203,48.2735152],[-116.5414976,48.2735543],[-116.5415824,48.2735927],[-116.5416745,48.2736293],[-116.5417589,48.2736584],[-116.5418403,48.2736828],[-116.5419176,48.2737029],[-116.54199,48.2737193],[-116.5420563,48.2737322],[-116.5421157,48.2737421],[-116.542167,48.2737495],[-116.5422093,48.2737546],[-116.5422822,48.2737619],[-116.542352,48.2737668],[-116.5424185,48.2737698],[-116.5424816,48.2737709],[-116.5425409,48.2737706],[-116.5425961,48.273769],[-116.5426471,48.2737665],[-116.5426934,48.2737634],[-116.5426926,48.2737556],[-116.5426918,48.2737469],[-116.5426907,48.2737375],[-116.5426895,48.2737274],[-116.542688,48.2737165],[-116.5426864,48.2737049],[-116.5426845,48.2736927],[-116.5426823,48.27368],[-116.54267,48.2736201],[-116.5426551,48.2735641],[-116.5426381,48.2735117],[-116.5426194,48.2734626],[-116.5425995,48.2734164],[-116.5425789,48.2733728],[-116.5425581,48.2733315],[-116.5425376,48.2732922],[-116.5424936,48.2732094],[-116.542455,48.2731382],[-116.54242,48.2730747],[-116.5423867,48.2730153],[-116.5423531,48.272956],[-116.5423172,48.2728931],[-116.5422772,48.2728228],[-116.5422312,48.2727414],[-116.5421714,48.2726344],[-116.5421221,48.2725473],[-116.5420805,48.2724775],[-116.5420438,48.2724221],[-116.5420088,48.2723785],[-116.5419728,48.2723441],[-116.5419329,48.2723161],[-116.5418861,48.2722919],[-116.5418586,48.2722801],[-116.5418267,48.2722677],[-116.5417908,48.2722544],[-116.541751,48.2722404],[-116.5417076,48.2722256],[-116.5416607,48.2722099],[-116.5416106,48.2721934],[-116.5415575,48.272176],[-116.5415085,48.27216],[-116.5414654,48.2721461],[-116.5414275,48.2721342],[-116.5413943,48.2721243],[-116.5413651,48.2721163],[-116.5413393,48.2721102],[-116.5413164,48.2721059],[-116.5412958,48.2721035],[-116.5412306,48.2721014],[-116.5411714,48.2721047],[-116.5411178,48.2721111],[-116.5410696,48.2721183],[-116.5410264,48.2721241],[-116.5409877,48.2721263],[-116.5409532,48.2721226],[-116.5409227,48.2721108],[-116.5409138,48.272105],[-116.5409057,48.2720984],[-116.5408986,48.2720912],[-116.5408929,48.2720836],[-116.5408887,48.2720757],[-116.5408866,48.2720678],[-116.5408867,48.2720601],[-116.5408894,48.2720528],[-116.5408958,48.2720451],[-116.5409055,48.2720384],[-116.5409183,48.2720327],[-116.5409339,48.2720281],[-116.5409519,48.2720249],[-116.540972,48.2720229],[-116.540994,48.2720226],[-116.5410174,48.2720238]]]},"properties":{"id":"sp-026","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"City lot"}},{"type":"Feature","id":"sp-027","geometry":{"type":"Polygon","coordinates":[[[-116.5454933,48.2796814],[-116.5460255,48.2796034],[-116.5460806,48.2797625],[-116.5455483,48.2798405],[-116.5454933,48.2796814]]]},"properties":{"id":"sp-027","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Sandpoint Ave"}},{"type":"Feature","id":"sp-028","geometry":{"type":"Polygon","coordinates":[[[-116.541093,48.2759292],[-116.5444657,48.2756121],[-116.5444386,48.2754902],[-116.5410659,48.2758073],[-116.541093,48.2759292]]]},"properties":{"id":"sp-028","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Dock St"}},{"type":"Feature","id":"sp-029","geometry":{"type":"LineString","coordinates":[[-116.5507735,48.2740382],[-116.553392,48.2740382]]},"properties":{"id":"sp-029","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Church St \u00b7 at N 5th Ave"}},{"type":"Feature","id":"sp-030","geometry":{"type":"LineString","coordinates":[[-116.5497959,48.2750906],[-116.5533223,48.2750906]]},"properties":{"id":"sp-030","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 5th Ave \u00b7 at Oak St"}},{"type":"Feature","id":"sp-031","geometry":{"type":"LineString","coordinates":[[-116.5533921,48.2757951],[-116.5504418,48.275299]]},"properties":{"id":"sp-031","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Main St \u00b7 N 5th Ave to N 3rd Ave"}},{"type":"Feature","id":"sp-032","geometry":{"type":"LineString","coordinates":[[-116.5519606,48.2771015],[-116.5519606,48.2762306]]},"properties":{"id":"sp-032","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 4th Ave \u00b7 Alder St to Cedar St"}},{"type":"Feature","id":"sp-033","geometry":{"type":"LineString","coordinates":[[-116.5503661,48.2771091],[-116.5503661,48.2762382]]},"properties":{"id":"sp-033","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 3rd Ave \u00b7 Alder St to Cedar St"}},{"type":"Feature","id":"sp-034","geometry":{"type":"LineString","coordinates":[[-116.5487666,48.2771091],[-116.5487666,48.276223]]},"properties":{"id":"sp-034","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 2nd Ave \u00b7 Alder St to Cedar St"}},{"type":"Feature","id":"sp-035","geometry":{"type":"LineString","coordinates":[[-116.5519606,48.2739431],[-116.5519606,48.2731478]]},"properties":{"id":"sp-035","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Pine St \u00b7 at N 4th Ave"}},{"type":"Feature","id":"sp-036","geometry":{"type":"LineString","coordinates":[[-116.5503661,48.2739735],[-116.5503661,48.2731705]]},"properties":{"id":"sp-036","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 3rd Ave \u00b7 Church St to Pine St"}},{"type":"Feature","id":"sp-037","geometry":{"type":"LineString","coordinates":[[-116.55018,48.2731099],[-116.5484691,48.2731175]]},"properties":{"id":"sp-037","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Pine St \u00b7 N 3rd Ave to N 2nd Ave"}},{"type":"Feature","id":"sp-038","geometry":{"type":"LineString","coordinates":[[-116.5491442,48.2730721],[-116.5491208,48.2721858]]},"properties":{"id":"sp-038","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"S 2nd Ave \u00b7 Pine St to Lake St"}},{"type":"Feature","id":"sp-039","geometry":{"type":"LineString","coordinates":[[-116.5542223,48.2756805],[-116.5542292,48.2756529],[-116.5542372,48.2756242],[-116.5542462,48.2755943],[-116.5542565,48.2755632],[-116.5542681,48.2755309],[-116.5542812,48.2754974],[-116.5542957,48.2754626],[-116.5543119,48.2754265],[-116.5543283,48.2753923],[-116.5543453,48.2753592],[-116.5543628,48.2753272],[-116.5543807,48.2752964],[-116.5543989,48.2752666],[-116.5544174,48.275238],[-116.5544358,48.2752105],[-116.5544543,48.2751841]]},"properties":{"id":"sp-039","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Off-street bays near Oak St"}},{"type":"Feature","id":"sp-040","geometry":{"type":"LineString","coordinates":[[-116.554545,48.2749596],[-116.5545662,48.2749163],[-116.5545916,48.2748706],[-116.5546212,48.2748226],[-116.5546548,48.2747726],[-116.5546923,48.2747207],[-116.5547334,48.2746671],[-116.554778,48.2746121],[-116.554826,48.2745557],[-116.554877,48.2744986],[-116.5549275,48.2744448],[-116.5549776,48.2743943],[-116.5550273,48.2743471],[-116.5550764,48.2743031],[-116.5551249,48.2742625],[-116.5551728,48.274225],[-116.55522,48.2741909]]},"properties":{"id":"sp-040","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Off-street bays near N 6th Ave"}},{"type":"Feature","id":"sp-046","geometry":{"type":"LineString","coordinates":[[-116.5514456,48.2721699],[-116.5514456,48.2730004]]},"properties":{"id":"sp-046","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"S 4th Ave \u00b7 Lake St to Pine St"}},{"type":"Feature","id":"sp-047","geometry":{"type":"LineString","coordinates":[[-116.5491075,48.2709522],[-116.5491075,48.2720888]]},"properties":{"id":"sp-047","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"S 2nd Ave \u00b7 Superior St to Lake St"}},{"type":"Feature","id":"sp-048","geometry":{"type":"LineString","coordinates":[[-116.5503479,48.2709522],[-116.5503479,48.2720888]]},"properties":{"id":"sp-048","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"S 3rd Ave \u00b7 Superior St to Lake St"}},{"type":"Feature","id":"sp-049","geometry":{"type":"LineString","coordinates":[[-116.5502389,48.2708911],[-116.5492155,48.2708911]]},"properties":{"id":"sp-049","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Superior St \u00b7 S 3rd Ave to S 2nd Ave"}},{"type":"Feature","id":"sp-050","geometry":{"type":"LineString","coordinates":[[-116.5489537,48.2708911],[-116.5479304,48.2708911]]},"properties":{"id":"sp-050","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Superior St \u00b7 S 2nd Ave to S 1st Ave"}},{"type":"Feature","id":"sp-051","geometry":{"type":"LineString","coordinates":[[-116.5542223,48.2721699],[-116.5542223,48.2730036]]},"properties":{"id":"sp-051","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Euclid Ave \u00b7 Lake St to Pine St"}},{"type":"Feature","id":"sp-052","geometry":{"type":"LineString","coordinates":[[-116.548615,48.2740439],[-116.5478919,48.2740439]]},"properties":{"id":"sp-052","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Church St \u00b7 N 2nd Ave to N 1st Ave"}},{"type":"Feature","id":"sp-053","geometry":{"type":"LineString","coordinates":[[-116.5504281,48.2781829],[-116.5518711,48.2781829]]},"properties":{"id":"sp-053","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Poplar St \u00b7 N 3rd Ave to N 4th Ave"}}],"metadata":{"source":"City of Sandpoint \u2014 Downtown & Waterfront Public Parking map","generated":"from downtown_and_waterfront_public_parking_map.pdf","georeference":"affine page->WebMercator fitted to OSM street centrelines"}} \ No newline at end of file diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts index 7485eeb..456a040 100644 --- a/app/src/features/session/activeParking.ts +++ b/app/src/features/session/activeParking.ts @@ -18,11 +18,14 @@ import { getRemindersEnabled, } from '@/features/notifications/reminderPrefs'; import { logLine } from '@/features/diagnostics/fileLogger'; +import type { ParkingArea } from '@/api/parkingAreas'; import { clearActiveParking, getActiveParking, setActiveParking, + setParkedPin, type ActiveParking, + type ParkedSpot, } from './activeParkingStore'; import { clearSession, @@ -48,6 +51,9 @@ import { 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' }); @@ -68,9 +74,10 @@ async function postNotification(p: ActiveParking): Promise { const free = p.kind === 'free'; const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`; const body = free ? `Free until ${ends}` : `Paid until ${ends}`; - // Free time isn't bought, so the button that buys time reads "Pay"; on a paid - // session it genuinely extends what you already have. - const extendLabel = free ? 'Pay' : 'Extend'; + // 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); @@ -176,6 +183,69 @@ export async function startFreeCheckin( 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 { + 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 { + 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 { + 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. * @@ -247,6 +317,16 @@ export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promis 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. if (!current) current = await discoverPaidSession(); if (current) { diff --git a/app/src/features/session/activeParkingStore.ts b/app/src/features/session/activeParkingStore.ts index 7f348db..979a512 100644 --- a/app/src/features/session/activeParkingStore.ts +++ b/app/src/features/session/activeParkingStore.ts @@ -1,6 +1,7 @@ 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 @@ -14,16 +15,49 @@ import type { LabelKind } from '@/api/zoneLabels'; 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 only for a paid session discovered from the server (the active-session - * API returns no zone), in which case "Extend" falls back to the Sessions tab. + * 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; @@ -53,5 +87,18 @@ export async function setActiveParking(state: ActiveParking): Promise { } export async function clearActiveParking(): Promise { - await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY]); + // 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 { + const raw = await AsyncStorage.getItem(PIN_KEY); + return raw ? (JSON.parse(raw) as ParkedSpot) : null; +} + +export async function setParkedPin(spot: ParkedSpot | null): Promise { + if (spot) await AsyncStorage.setItem(PIN_KEY, JSON.stringify(spot)); + else await AsyncStorage.removeItem(PIN_KEY); } diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index 7475d99..118da2a 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -24,14 +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 { 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; @@ -151,7 +158,17 @@ export function RootNavigator() { component={DiagnosticsScreen} options={{ title: 'Diagnostics' }} /> + + ) : ( diff --git a/app/src/screens/AccountScreen.tsx b/app/src/screens/AccountScreen.tsx index 759ae8b..e3f1732 100644 --- a/app/src/screens/AccountScreen.tsx +++ b/app/src/screens/AccountScreen.tsx @@ -71,6 +71,11 @@ export function AccountScreen() { navigation.navigate('Diagnostics')} /> + navigation.navigate('MapAlign')} + /> ; +type Nav = NativeStackNavigationProp; + +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().params; + const navigation = useNavigation