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