BigBrainParking/app/src/features/session/activeParking.ts
Erik 148e1635d3
All checks were successful
build-apk / build (push) Successful in 10m38s
v0.6.0: city parking-map overlay + local time tracking, no IPS API
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

371 lines
13 KiB
TypeScript

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 { 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 {
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> {
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.
if (!current) 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();
}