From 7fc92d24e4b5432f7819e1307fd435e5dfef7bba Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 13 Aug 2026 04:19:12 +0000 Subject: [PATCH 1/4] v0.6.1: stop Anonymous Mode bouncing to sign-in on every foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/app.json | 4 ++-- app/src/auth/AuthContext.tsx | 12 ++++++++++++ app/src/auth/authBus.ts | 10 +++++++++- app/src/features/session/activeParking.ts | 6 ++++-- app/src/screens/MapScreen.tsx | 9 ++++++++- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/app.json b/app/app.json index ffcaf4d..0f67bfb 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.6.0", + "version": "0.6.1", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 21, + "versionCode": 22, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx index 1d94e82..4f894a4 100644 --- a/app/src/auth/AuthContext.tsx +++ b/app/src/auth/AuthContext.tsx @@ -31,9 +31,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [validity, setValidity] = useState(null); const [error, setError] = useState(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'); }; diff --git a/app/src/auth/authBus.ts b/app/src/auth/authBus.ts index 075d9fd..02c1a3b 100644 --- a/app/src/auth/authBus.ts +++ b/app/src/auth/authBus.ts @@ -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 }; diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts index 456a040..61a2e4d 100644 --- a/app/src/features/session/activeParking.ts +++ b/app/src/features/session/activeParking.ts @@ -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'; @@ -326,8 +327,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); diff --git a/app/src/screens/MapScreen.tsx b/app/src/screens/MapScreen.tsx index 36da5d1..eaf81b8 100644 --- a/app/src/screens/MapScreen.tsx +++ b/app/src/screens/MapScreen.tsx @@ -113,6 +113,9 @@ export function MapScreen() { // 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 => { + // 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 | undefined; @@ -125,7 +128,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. @@ -257,6 +260,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(); From 6af3dad762f32e07b40c7abd8d0ba1d94db7f5a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 13 Aug 2026 05:39:14 +0000 Subject: [PATCH 2/4] v0.6.2: show and manage on-phone sessions offline and signed out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 + app/app.json | 4 +- app/src/features/session/activeParking.ts | 5 + app/src/features/session/localHistory.ts | 81 +++++++ app/src/screens/SessionsScreen.tsx | 270 +++++++++++++++++----- 5 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 app/src/features/session/localHistory.ts diff --git a/README.md b/README.md index 008270b..bc8e5ab 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,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 diff --git a/app/app.json b/app/app.json index 0f67bfb..b00e0ad 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.6.1", + "version": "0.6.2", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 22, + "versionCode": 23, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts index 61a2e4d..373b351 100644 --- a/app/src/features/session/activeParking.ts +++ b/app/src/features/session/activeParking.ts @@ -20,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, @@ -255,6 +256,10 @@ export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise * keeps running at the meter whether or not the app is showing it. */ export async function endActiveParking(): Promise { + // 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(() => {}); diff --git a/app/src/features/session/localHistory.ts b/app/src/features/session/localHistory.ts new file mode 100644 index 0000000..0ce68cc --- /dev/null +++ b/app/src/features/session/localHistory.ts @@ -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 { + 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 { + 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 { + await AsyncStorage.removeItem(KEY); +} diff --git a/app/src/screens/SessionsScreen.tsx b/app/src/screens/SessionsScreen.tsx index 619fe68..69de98a 100644 --- a/app/src/screens/SessionsScreen.tsx +++ b/app/src/screens/SessionsScreen.tsx @@ -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; +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