From 6af3dad762f32e07b40c7abd8d0ba1d94db7f5a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 13 Aug 2026 05:39:14 +0000 Subject: [PATCH 1/3] 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