v0.1.4: fix silent failure on zones that reject ParkingEstimateMulti
All checks were successful
build-apk / build (push) Successful in 14m27s

Some zones (e.g. short-term "DL" spaces at the downtown lot) return
Response.Status:Error "Unable to process your request" for ParkingEstimateMulti
even though the single-duration ParkingEstimate works. The screen swallowed that
error -> empty duration list, disabled Pay button, no message ("nothing happens").

- Fall back to a single-estimate ladder across the zone's Min/Max time so these
  zones are purchasable (verified live: flat ~$0.10 up to ~33 min).
- Surface estimate errors with a message + Try again, instead of a blank screen.
- Log the estimate fallback/failure to the on-device diagnostics log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-09 16:31:22 -07:00
parent 30f32bc494
commit f8d9e2970c
2 changed files with 94 additions and 9 deletions

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking", "name": "BigBrainParking",
"slug": "bigbrainparking", "slug": "bigbrainparking",
"scheme": "bigbrainparking", "scheme": "bigbrainparking",
"version": "0.1.3", "version": "0.1.4",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"android": { "android": {
"package": "top.mowden.bigbrainparking", "package": "top.mowden.bigbrainparking",
"versionCode": 3, "versionCode": 4,
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",

View file

@ -34,6 +34,45 @@ function fmtDuration(min?: number): string {
} }
const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`; const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
/**
* Some zones (e.g. short-term "DL" spaces) reject ParkingEstimateMulti with
* "Unable to process your request" even though the single-duration estimate
* works. Build a price ladder from ParkingEstimate (single) across the zone's
* Min/Max time so those zones stay purchasable instead of showing a blank screen.
*/
async function buildSingleLadder(base: {
zoneId: number;
spaceId: number;
customerId: number;
vehicleId: number;
}): Promise<ParkingDetail[]> {
const probe = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: 30, creditCardId: 0 });
if ((probe as any)?.Response?.Status === 'Error') {
throw new Error((probe as any).Response?.Message || 'Estimate unavailable for this space.');
}
const min = Math.max(5, Number(probe.MinTime) || 5);
const max = Math.max(min, Number(probe.MaxTime) || min);
const span = max - min;
const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5;
const durs = new Set<number>();
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;
}
}),
);
return rungs.filter((r): r is ParkingDetail => r != null);
}
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */ /** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
function parseApiTime(s?: string): Date | null { function parseApiTime(s?: string): Date | null {
if (!s) return null; if (!s) return null;
@ -67,6 +106,7 @@ export function StartSessionScreen() {
const [selIdx, setSelIdx] = useState(0); const [selIdx, setSelIdx] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false); const [paying, setPaying] = useState(false);
const [ladderError, setLadderError] = useState<string | null>(null);
// Load the account's vehicles + cards and pick the defaults. // Load the account's vehicles + cards and pick the defaults.
useEffect(() => { useEffect(() => {
@ -88,16 +128,41 @@ export function StartSessionScreen() {
const loadLadder = useCallback(async () => { const loadLadder = useCallback(async () => {
if (vehicleId == null || space?.SpaceId == null) return; if (vehicleId == null || space?.SpaceId == null) return;
setLoading(true); setLoading(true);
setLadderError(null);
const base = {
zoneId: zone.ZoneId!,
spaceId: space.SpaceId!,
customerId: zone.CustomerId!,
vehicleId,
};
try { try {
const est = await ps.getParkingEstimateMulti({ let details: ParkingDetail[] = [];
zoneId: zone.ZoneId!, let multiErrored = false;
spaceId: space.SpaceId!, try {
customerId: zone.CustomerId!, const est = await ps.getParkingEstimateMulti(base);
vehicleId, if ((est as any)?.Response?.Status === 'Error') multiErrored = true;
}); else details = est.ParkingDetail ?? [];
const details = est.ParkingDetail ?? []; } catch {
multiErrored = true;
}
// Multi is unsupported or empty for some zones — fall back to single estimates.
if (!details.length) {
logLine(
`[ESTIMATE] multi ${multiErrored ? 'errored' : 'empty'} for zone=${base.zoneId} ` +
`space=${base.spaceId}; falling back to single`,
);
details = await buildSingleLadder(base);
}
if (!details.length) {
throw new Error('No parking options are available for this space right now.');
}
setLadder(details); setLadder(details);
setSelIdx((i) => Math.min(i, Math.max(0, details.length - 1))); setSelIdx((i) => Math.min(i, Math.max(0, details.length - 1)));
} catch (e: any) {
const msg = e?.serverMessage ?? e?.message ?? 'Could not load parking options.';
setLadder([]);
setLadderError(msg);
logLine(`[ESTIMATE] failed zone=${base.zoneId} space=${base.spaceId}: ${msg}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -218,6 +283,26 @@ export function StartSessionScreen() {
); );
} }
if (ladderError && !ladder.length) {
return (
<View style={[styles.center, { backgroundColor: colors.bg, padding: 24 }]}>
<Text style={[styles.zone, { color: colors.text }]}>{zone.ZoneName}</Text>
<View style={[styles.freeBanner, { backgroundColor: '#fdecea', marginTop: 16, alignSelf: 'stretch' }]}>
<Text style={{ color: '#c0392b', fontWeight: '700', fontSize: 16 }}>
Couldnt load parking options
</Text>
<Text style={{ color: colors.subtext, marginTop: 6 }}>{ladderError}</Text>
</View>
<TouchableOpacity
style={[styles.payBtn, { backgroundColor: colors.primary, marginTop: 20, alignSelf: 'stretch' }]}
onPress={() => void loadLadder()}
>
<Text style={styles.payText}>Try again</Text>
</TouchableOpacity>
</View>
);
}
const Chip = ({ const Chip = ({
active, active,
label, label,