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>
This commit is contained in:
Erik 2026-08-13 05:39:14 +00:00
parent 7fc92d24e4
commit 6af3dad762
5 changed files with 309 additions and 56 deletions

View file

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

View file

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

View file

@ -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<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(() => {});

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

@ -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) => (
<Text style={[s.empty, { color: colors.subtext }]}>
Nothing being tracked. Start one from the map Park here, or tap a coloured block.
</Text>
)}
{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
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
onPress={requireLogin}
>
<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={[styles.card, { backgroundColor: colors.primary + '22' }]}
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
style={[s.card, { backgroundColor: colors.primary + '22' }]}
onPress={() => navigation.navigate('SessionDetail', { session: sess, 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 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={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
{past.length === 0 ? (
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
<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((s, i) => (
past.map((sess, i) => (
<TouchableOpacity
key={i}
style={[styles.card, { backgroundColor: colors.card }]}
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
style={[s.card, { backgroundColor: colors.card }]}
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
>
<Text style={[styles.zone, { color: colors.text }]}>
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
<Text style={[s.zone, { color: colors.text }]}>
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} tap for receipt
<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' },
});