From 65fbd86644a085d1071172960c945fcaedf97013 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 10:46:04 -0700 Subject: [PATCH 01/29] v0.1.6: validate the session on open, not mid-map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale/expired token doesn't 401 — /api/User returns an empty body, so getUserDetail() resolved to undefined without throwing and the launch probe set signedIn anyway. The dead session then only surfaced when the map/kiosk calls ran. Now the launch check requires real user data (PersonalPhone/Email); if it's missing it clears the token and shows the login screen at open. Co-Authored-By: Claude Fable 5 --- app/app.json | 4 ++-- app/src/auth/AuthContext.tsx | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/app.json b/app/app.json index 3b6ab44..75800ba 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.1.5", + "version": "0.1.6", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 5, + "versionCode": 6, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx index 3b5e098..7f31272 100644 --- a/app/src/auth/AuthContext.tsx +++ b/app/src/auth/AuthContext.tsx @@ -37,18 +37,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; }, []); - // On launch: bootstrap (seeds SessionId + feature flags) and probe for an - // existing token by attempting an authenticated read. + // On launch: bootstrap (seeds SessionId + feature flags), then verify any + // stored token BEFORE showing the app. A stale/expired token doesn't 401 — + // /api/User just returns an empty body, so getUserDetail() resolves to + // undefined without throwing. Require real user data to count as signed in, + // and clear the dead token otherwise, so an expired session lands on the + // login screen at open instead of failing later on the map. useEffect(() => { (async () => { try { const v = await ps.getApplicationValidity(); setValidity(v); const existing = await ps.tokens.getAuthToken(); - if (existing) { - await ps.getUserDetail(); // 401 throws -> treated as signed out + if (!existing) { + setStatus('signedOut'); + return; + } + const user = await ps.getUserDetail().catch(() => null); + const valid = !!(user && (user.PersonalPhone || user.PersonalEmailAddress)); + if (valid) { setStatus('signedIn'); } else { + await ps.logoutLocal(); // drop the dead token so the login screen is clean setStatus('signedOut'); } } catch { From 42a96b0cffe6d670d4838cb43ad476e98ba26fca Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 10:58:49 -0700 Subject: [PATCH 02/29] v0.1.7: collapse flat-rate zones to one option; add DL capture script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Start-session: when the single-estimate fallback finds the same price at the min and max duration (a flat-rate zone like DL), show ONE option — "$X flat, parked until " — instead of a wall of identical-price rungs. No reason to offer shorter durations when the price doesn't change. - capture-dl.mjs: dumps the DL zone's live requests+responses (tokens redacted) to ~/Downloads/dl-capture/ for a durable record. Co-Authored-By: Claude Fable 5 --- app/app.json | 4 +- app/src/screens/StartSessionScreen.tsx | 100 ++++++++++++++++--------- parksmarter-client/capture-dl.mjs | 65 ++++++++++++++++ 3 files changed, 132 insertions(+), 37 deletions(-) create mode 100644 parksmarter-client/capture-dl.mjs diff --git a/app/app.json b/app/app.json index 75800ba..4c94380 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.1.6", + "version": "0.1.7", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 6, + "versionCode": 7, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/screens/StartSessionScreen.tsx b/app/src/screens/StartSessionScreen.tsx index a525d5c..c834e2d 100644 --- a/app/src/screens/StartSessionScreen.tsx +++ b/app/src/screens/StartSessionScreen.tsx @@ -45,9 +45,27 @@ const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`; export interface SingleLadderResult { /** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */ free: boolean; + /** Flat-rate zone: one price for any duration, so we offer a single max option. */ + flat: boolean; ladder: ParkingDetail[]; } +/** Price a single duration; returns a rung (Minutes overridden to the request) or null. */ +async function singleRung( + base: { zoneId: number; spaceId: number; customerId: number; vehicleId: number }, + minutes: number, +): Promise { + try { + const r = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: minutes, creditCardId: 0 }); + const p = r.ParkingDetail; + if (!p || (r as any)?.Response?.Status === 'Error') return null; + // The single endpoint often echoes Minutes:0; trust the requested duration. + return { ...p, Minutes: minutes } as ParkingDetail; + } catch { + return null; + } +} + export async function buildSingleLadder(base: { zoneId: number; spaceId: number; @@ -60,32 +78,31 @@ export async function buildSingleLadder(base: { } // MaxTime 0 means no paid time is available right now — this is a free window, // not a purchasable zone. Don't build a bogus $0.00 ladder. - if (isFreeEstimate(probe)) return { free: true, ladder: [] }; + if (isFreeEstimate(probe)) return { free: true, flat: false, ladder: [] }; const min = Math.max(5, Number(probe.MinTime) || 5); const max = Math.max(min, Number(probe.MaxTime) || min); + + // Price both ends first. If they match, it's a flat-rate zone — one price no + // matter how long you stay — so there's no reason to pick a shorter duration; + // offer a single option for the whole window (park to MaxTime). + const [minRung, maxRung] = await Promise.all([singleRung(base, min), singleRung(base, max)]); + if (maxRung && ladderAllFree([maxRung])) return { free: true, flat: false, ladder: [] }; + if (minRung && maxRung && Number(minRung.ParkingCost) === Number(maxRung.ParkingCost)) { + return { free: false, flat: true, ladder: [maxRung] }; + } + + // Graded rate: fill in intermediate durations between the two ends. const span = max - min; const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5; - const durs = new Set(); - for (let d = min; d < max; d += step) durs.add(d); - durs.add(max); - const rungs = await Promise.all( - [...durs].map(async (d) => { - try { - const r = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: d, creditCardId: 0 }); - const p = r.ParkingDetail; - if (!p || (r as any)?.Response?.Status === 'Error') return null; - // The single endpoint often echoes Minutes:0; trust the requested duration. - return { ...p, Minutes: d } as ParkingDetail; - } catch { - return null; - } - }), - ); - const ladder = rungs.filter((r): r is ParkingDetail => r != null); - // If every rung came back $0.00, it's effectively a free window too. - if (ladderAllFree(ladder)) return { free: true, ladder: [] }; - return { free: false, ladder }; + const mids = new Set(); + for (let d = min + step; d < max; d += step) mids.add(d); + const midRungs = await Promise.all([...mids].map((d) => singleRung(base, d))); + const ladder = [minRung, ...midRungs, maxRung] + .filter((r): r is ParkingDetail => r != null) + .sort((a, b) => Number(a.Minutes) - Number(b.Minutes)); + if (ladderAllFree(ladder)) return { free: true, flat: false, ladder: [] }; + return { free: false, flat: false, ladder }; } /** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */ @@ -124,6 +141,8 @@ export function StartSessionScreen() { const [ladderError, setLadderError] = useState(null); // Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy. const [estimatedFree, setEstimatedFree] = useState(false); + // Flat-rate zone: one price for the whole window (a single option, not a ladder). + const [flatRate, setFlatRate] = useState(false); // Load the account's vehicles + cards and pick the defaults. useEffect(() => { @@ -147,6 +166,7 @@ export function StartSessionScreen() { setLoading(true); setLadderError(null); setEstimatedFree(false); + setFlatRate(false); const base = { zoneId: zone.ZoneId!, spaceId: space.SpaceId!, @@ -175,6 +195,7 @@ export function StartSessionScreen() { setEstimatedFree(true); return; } + setFlatRate(fb.flat); details = fb.ladder; } if (!details.length) { @@ -382,20 +403,29 @@ export function StartSessionScreen() { ))} - Duration - String(d.Minutes ?? i)} - showsHorizontalScrollIndicator={false} - renderItem={({ item, index }) => ( - setSelIdx(index)} - /> - )} - /> + + {flatRate ? 'Flat rate' : 'Duration'} + + {flatRate ? ( + + {money(selected?.ParkingCost)} flat — one price for the whole window + {selected?.EndTime ? `, parked until ${selected.EndTime}` : ''}. + + ) : ( + String(d.Minutes ?? i)} + showsHorizontalScrollIndicator={false} + renderItem={({ item, index }) => ( + setSelIdx(index)} + /> + )} + /> + )} Card diff --git a/parksmarter-client/capture-dl.mjs b/parksmarter-client/capture-dl.mjs new file mode 100644 index 0000000..e247c48 --- /dev/null +++ b/parksmarter-client/capture-dl.mjs @@ -0,0 +1,65 @@ +/** + * capture-dl.mjs — record the live requests + responses for the Sandpoint "DL" + * zone (ZoneID 113165 / SpaceID 329522 / CustomerID 217) so we have a durable + * record of its odd flat-rate behavior. + * + * Writes one JSON file per call (request line with tokens redacted by the client + * + the full parsed response) to ~/Downloads/dl-capture/ plus a _summary.json. + * + * Usage: node capture-dl.mjs (needs ./.creds.json) + */ +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { ParkSmarterClient } from './dist/index.js'; + +const c = JSON.parse(readFileSync('./.creds.json', 'utf8')); +const OUT = join(homedir(), 'Downloads', 'dl-capture'); +mkdirSync(OUT, { recursive: true }); + +const reqLog = []; +const ps = new ParkSmarterClient({ + environment: 'prodv2', + timeoutMs: 20000, + logRequests: true, // the client redacts Auth_Token/Application_Token/SessionId + logSink: (line) => reqLog.push(line), +}); + +await ps.loginWithPhone({ phoneNumber: c.phoneNumber, password: c.password }); +const me = await ps.getUserDetail(); +const vehicleId = me?.VehicleDetails?.[0]?.VehicleID; +const base = { zoneId: 113165, spaceId: 329522, customerId: 217, vehicleId }; + +const save = (name, obj) => writeFileSync(join(OUT, `${name}.json`), JSON.stringify(obj, null, 2) + '\n'); +const summary = { zone: base, note: 'Sandpoint DL zone — flat $0.10, MaxTime = minutes until the paid-window boundary; ParkingEstimateMulti errors here.', calls: [] }; + +async function cap(name, fn) { + const from = reqLog.length; + let response = null, error = null; + try { response = await fn(); } catch (e) { error = `${e.name} ${e.status ?? ''} ${e.message}`; } + const entry = { name, capturedField: 'request+response', request: reqLog.slice(from), response, error }; + save(name, entry); + summary.calls.push({ name, ok: !error, error }); + console.log(` ${name}: ${error ? 'ERR ' + error : 'ok'}`); +} + +console.log(`Capturing DL zone -> ${OUT}`); +// The zone/space/policy record (search for DL by name). +await cap('00-zone-DL', async () => { + const byName = await ps.getMetersByZoneName('DL'); + const z = (byName?.Zones || []).find((z) => (z.ZoneId ?? z.ZoneID) == 113165) ?? byName?.Zones?.[0]; + return z ?? { note: 'DL zone not found by name lookup' }; +}); +// Single-estimate at a spread of durations (shows the flat $0.10 + moving MaxTime). +for (const d of [5, 15, 30, 60, 120, 240, 480]) { + await cap(`10-estimate-single-${String(d).padStart(3, '0')}min`, () => + ps.getParkingEstimateSingle({ ...base, durationInMinutes: d, creditCardId: 0 }), + ); +} +// The multi ladder (errors for this zone) and the item-based estimate. +await cap('20-estimate-multi', () => ps.getParkingEstimateMulti(base)); +await cap('21-estimate-items', () => ps.getParkingEstimateItems(base)); + +summary.capturedAtLocalNote = 'timestamps are inside each request/response line'; +save('_summary', summary); +console.log(`Done. ${summary.calls.length + 1} files in ${OUT}`); From 4cb54787372bbba4bcc6d8a656b9357596784620 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 18:38:39 -0700 Subject: [PATCH 03/29] v0.1.8: tap a session for full detail + receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sessions list items are now tappable -> SessionDetailScreen. - Past sessions fetch the full receipt (GET /api/ParkingReceipt): vehicle, start/end, time purchased, parking/fee/total, payment (MASTERCARD ••1234), auth code, meter, city, transaction id. Adds an "Email me this receipt" action. - Active sessions show zone/space/start/ends/time-remaining. - Confirm PastSession + ParkingReceipt types from a live DL-zone session (were UNCONFIRMED); fix past-session card to show Zone/Description, not blank. Co-Authored-By: Claude Fable 5 --- app/app.json | 4 +- app/src/navigation/RootNavigator.tsx | 9 +- app/src/screens/SessionDetailScreen.tsx | 159 ++++++++++++++++++++++++ app/src/screens/SessionsScreen.tsx | 35 ++++-- parksmarter-client/src/types.ts | 61 +++++++-- 5 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 app/src/screens/SessionDetailScreen.tsx diff --git a/app/app.json b/app/app.json index 4c94380..1ac0da5 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.1.7", + "version": "0.1.8", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 7, + "versionCode": 8, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index 21df729..b0295a5 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -21,14 +21,16 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen'; import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen'; import { NotificationsScreen } from '@/screens/NotificationsScreen'; import { StartSessionScreen } from '@/screens/StartSessionScreen'; +import { SessionDetailScreen } from '@/screens/SessionDetailScreen'; import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { useTheme } from '@/theme/ThemeContext'; -import type { Zone } from 'parksmarter-client'; +import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; export type RootStackParamList = { Tabs: undefined; MeterDetail: { zone: Zone }; StartSession: { zone: Zone }; + SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' }; About: undefined; Profile: undefined; Vehicles: undefined; @@ -120,6 +122,11 @@ export function RootNavigator() { component={StartSessionScreen} options={{ title: 'Start session' }} /> + diff --git a/app/src/screens/SessionDetailScreen.tsx b/app/src/screens/SessionDetailScreen.tsx new file mode 100644 index 0000000..eb1e770 --- /dev/null +++ b/app/src/screens/SessionDetailScreen.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import type { RouteProp } from '@react-navigation/native'; +import { useRoute } from '@react-navigation/native'; +import { ps } from '@/api/client'; +import { useTheme } from '@/theme/ThemeContext'; +import { logLine } from '@/features/diagnostics/fileLogger'; +import type { RootStackParamList } from '@/navigation/RootNavigator'; +import type { ParkingReceipt } from 'parksmarter-client'; + +type DetailRoute = RouteProp; + +const show = (v: unknown) => (v == null || v === '' ? '—' : String(v)); +const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`); + +export function SessionDetailScreen() { + const { colors } = useTheme(); + const { session, kind } = useRoute().params; + const s = session as Record; + const tid = s.TransactionID; + + const [receipt, setReceipt] = useState(null); + const [loading, setLoading] = useState(kind === 'past' && tid != null); + const [emailing, setEmailing] = useState(false); + + // Past sessions have a full receipt (auth code, payment, amounts) — fetch it. + useEffect(() => { + if (kind !== 'past' || tid == null) return; + (async () => { + try { + const r = await ps.getParkingReceipt(tid); + if (r?.ParkingReceipt && (r as any).Response?.Status !== 'Error') { + setReceipt(r.ParkingReceipt); + } + } catch (e: any) { + logLine(`[RECEIPT] fetch failed tid=${tid}: ${e?.serverMessage ?? e?.message ?? e}`); + } finally { + setLoading(false); + } + })(); + }, [kind, tid]); + + const emailReceipt = async () => { + if (tid == null) return; + setEmailing(true); + try { + await ps.emailParkingReceipt(tid); + Alert.alert('Receipt sent', 'Emailed to your account address.'); + } catch (e: any) { + Alert.alert('Couldn’t email receipt', e?.serverMessage ?? e?.message ?? 'Please try again.'); + } finally { + setEmailing(false); + } + }; + + const r = receipt; + const rows: Array<[string, string | undefined]> = + kind === 'past' + ? [ + ['Vehicle', r?.Vehicle ?? s.VehicleNumber], + ['Zone', s.Description ?? s.Zone ?? r?.MeterNumber], + ['Space', r?.Space ?? s.Space], + ['Started', r?.StartTime ?? s.StartTime], + ['Ended', r?.EndTime ?? s.EndTime], + ['Time purchased', s.TimePurchased ? `${s.TimePurchased} min` : undefined], + ['Parking', r?.Amount ?? dollars(s.Amount)], + ['Fee', r?.TransactionFee ?? dollars(s.TransactionFee)], + ['Total', r?.Total], + [ + 'Payment', + [r?.PaymentDisplay ?? s.PaymentDisplay, r?.CC ?? (s.CardLastFour ? `••${s.CardLastFour}` : '')] + .filter(Boolean) + .join(' ') || undefined, + ], + ['Auth code', r?.AuthCode], + ['City', s.City ?? r?.CustomerName], + ['Transaction', tid != null ? String(tid) : undefined], + ] + : [ + ['Vehicle', s.VehiclePlate ?? s.VehicleNumber], + ['Zone', s.ZoneName ?? s.Zone], + ['Space', s.SpaceName ?? s.Space], + ['Started', s.StartTimeDisplay ?? s.StartTime], + ['Ends', s.EndTimeDisplay ?? s.EndTime], + ['Time remaining', s.TimeRemaining != null ? `${s.TimeRemaining} min` : undefined], + ['Amount', dollars(s.Amount)], + ]; + + if (loading) { + return ( + + + + ); + } + + const title = s.Description ?? s.ZoneName ?? s.Zone ?? r?.MeterNumber ?? 'Session'; + const visible = rows.filter(([, v]) => v != null && v !== ''); + + return ( + + {title} + {kind === 'active' ? ( + + Active session + + ) : null} + + + {visible.map(([label, value], i) => ( + + {label} + {show(value)} + + ))} + + + {kind === 'past' && tid != null ? ( + + + {emailing ? 'Sending…' : 'Email me this receipt'} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + title: { fontSize: 22, fontWeight: '700', marginBottom: 12 }, + badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginBottom: 12 }, + card: { borderRadius: 12, padding: 4, paddingHorizontal: 14 }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#8883', + }, + value: { fontWeight: '600', flexShrink: 1, textAlign: 'right', marginLeft: 12 }, + btn: { marginTop: 16, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' }, +}); diff --git a/app/src/screens/SessionsScreen.tsx b/app/src/screens/SessionsScreen.tsx index 6d23cf9..33d8ed0 100644 --- a/app/src/screens/SessionsScreen.tsx +++ b/app/src/screens/SessionsScreen.tsx @@ -1,12 +1,17 @@ import React, { useCallback, useState } from 'react'; -import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { useFocusEffect } from '@react-navigation/native'; +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 { useTheme } from '@/theme/ThemeContext'; +import type { RootStackParamList } from '@/navigation/RootNavigator'; import type { ActiveSession, PastSession } from 'parksmarter-client'; +type Nav = NativeStackNavigationProp; + export function SessionsScreen() { const { colors } = useTheme(); + const navigation = useNavigation