Compare commits

...

4 commits
v0.6.0 ... 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
14 changed files with 624 additions and 99 deletions

View file

@ -56,11 +56,19 @@ The **City map** layer on the Map tab is the City of Sandpoint's printed *Downto
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:
**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
@ -73,6 +81,11 @@ offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing no
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

View file

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

View file

@ -40,6 +40,11 @@ export interface ParkingArea {
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 {
@ -76,6 +81,19 @@ 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.
*

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

@ -5,6 +5,7 @@ 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';
@ -19,6 +20,7 @@ import {
} from '@/features/notifications/reminderPrefs';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { ParkingArea } from '@/api/parkingAreas';
import { recordLocalSession } from './localHistory';
import {
clearActiveParking,
getActiveParking,
@ -254,6 +256,10 @@ export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void>
* 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(() => {});
@ -326,8 +332,9 @@ export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promis
}
// 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();
// 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);

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

@ -5,7 +5,8 @@ import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/nativ
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { useTheme } from '@/theme/ThemeContext';
import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
import { useAuth } from '@/auth/AuthContext';
import {
endActiveParking,
extendAreaParking,
@ -43,6 +44,7 @@ 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);
@ -121,7 +123,18 @@ export function CityAreaScreen() {
</View>
) : null}
{parkedHere && active ? (
{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>

View file

@ -1,5 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Alert, 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,
@ -18,7 +26,12 @@ 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 { getAdjustedAreas, refreshAreas, type ParkingArea } from '@/api/parkingAreas';
import {
areaRequiresAccount,
getAdjustedAreas,
refreshAreas,
type ParkingArea,
} from '@/api/parkingAreas';
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
import {
getParkedPin,
@ -40,6 +53,20 @@ 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>;
/**
@ -80,7 +107,19 @@ 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”.');
@ -108,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;
@ -125,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.
@ -208,13 +252,32 @@ export function MapScreen() {
}, []);
// Re-read the pin on every focus: the session may have ended on another screen
// (or from the notification), which clears it.
// (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.)
@ -238,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;
@ -257,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();
@ -275,6 +341,19 @@ export function MapScreen() {
/* -------------------------------------------------- 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) => {
@ -291,8 +370,12 @@ export function MapScreen() {
// 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) {
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 {
@ -300,7 +383,7 @@ export function MapScreen() {
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
}
},
[areas, openArea],
[areas, hidden, openArea],
);
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
@ -312,7 +395,7 @@ export function MapScreen() {
setStatus('Pin cancelled.');
return;
}
const c = nativeFix.current ?? coords ?? (await refresh());
const c = await bestFix();
if (!c) {
setPinning(true);
setStatus('No GPS fix — tap the map where you parked.');
@ -355,21 +438,21 @@ export function MapScreen() {
/** 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 = areas.find((a) => a.id === id);
const found = visibleAreas.find((a) => a.id === id);
if (found) openArea(found, spot ?? undefined);
};
const areaFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: areas.map((a) => ({
features: visibleAreas.map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { id: a.id, color: a.color, kind: a.kind },
})),
}),
[areas],
[visibleAreas],
);
const spotFeature = useMemo(
@ -429,6 +512,7 @@ export function MapScreen() {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
at: Date.now(),
};
}
}}

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

@ -197,6 +197,9 @@ export async function buildApp(opts: BuildOptions) {
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() };

View file

@ -33,6 +33,12 @@ export interface ParkingArea {
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;
}
/**
@ -141,6 +147,16 @@ export class LabelDb {
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 */
@ -159,6 +175,8 @@ export class LabelDb {
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,
}));
}
@ -182,13 +200,20 @@ export class LabelDb {
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, updated_at)
VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`,
`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), updatedAt: now });
insert.run({
...a,
geometry: JSON.stringify(a.geometry),
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
updatedAt: now,
});
}
})(areas);
return areas.length;

View file

@ -140,6 +140,58 @@ test('polygons are accepted; bad kinds and geometries are rejected', async () =>
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();