v0.1.5: detect free windows in the estimate fallback + add API tests
All checks were successful
build-apk / build (push) Successful in 49m41s

App:
- When the single-estimate fallback sees MaxTime 0 (or an all-$0.00 ladder),
  show "Parking is currently free" instead of a bogus $0.00 purchase ladder.
  Free-detection extracted to tested client helpers (isFreeEstimate/ladderAllFree).

Client + tests (node:test, zero deps):
- src/estimates.ts: pure isFreeEstimate/ladderAllFree helpers (exported).
- test/estimates + test/http (mocked fetch): headers, query serialization,
  error->ParkSmarterApiError, rolling Auth_Token refresh, 401 clears token.
- test/integration: live checks against the Sandpoint (DSB) zones; targets the
  `test` instance by default and SKIPS when it's unreachable (non-prod is
  IP-restricted -> 403). Verified 3/3 green against prodv2 read-only.
- CI runs `npm test` as a build gate.

Note: dev/stage/test instances return 403 "Web App - Unavailable" from the
public internet (IP-restricted). API schema is otherwise unchanged vs Jul 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-12 20:42:58 -07:00
parent f8d9e2970c
commit 7212badd2d
9 changed files with 276 additions and 13 deletions

View file

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

View file

@ -17,10 +17,12 @@ import { useTheme } from '@/theme/ThemeContext';
import { scheduleExpiryReminder } from '@/notifications/localReminders';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type {
CreditCardDetail,
ParkingDetail,
VehicleDetail,
import {
isFreeEstimate,
ladderAllFree,
type CreditCardDetail,
type ParkingDetail,
type VehicleDetail,
} from 'parksmarter-client';
type SessionRoute = RouteProp<RootStackParamList, 'StartSession'>;
@ -40,16 +42,26 @@ const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
* 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: {
export interface SingleLadderResult {
/** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */
free: boolean;
ladder: ParkingDetail[];
}
export async function buildSingleLadder(base: {
zoneId: number;
spaceId: number;
customerId: number;
vehicleId: number;
}): Promise<ParkingDetail[]> {
}): Promise<SingleLadderResult> {
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.');
}
// 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: [] };
const min = Math.max(5, Number(probe.MinTime) || 5);
const max = Math.max(min, Number(probe.MaxTime) || min);
const span = max - min;
@ -70,7 +82,10 @@ async function buildSingleLadder(base: {
}
}),
);
return rungs.filter((r): r is ParkingDetail => r != 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 };
}
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
@ -107,6 +122,8 @@ export function StartSessionScreen() {
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [ladderError, setLadderError] = useState<string | null>(null);
// Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy.
const [estimatedFree, setEstimatedFree] = useState(false);
// Load the account's vehicles + cards and pick the defaults.
useEffect(() => {
@ -129,6 +146,7 @@ export function StartSessionScreen() {
if (vehicleId == null || space?.SpaceId == null) return;
setLoading(true);
setLadderError(null);
setEstimatedFree(false);
const base = {
zoneId: zone.ZoneId!,
spaceId: space.SpaceId!,
@ -151,7 +169,13 @@ export function StartSessionScreen() {
`[ESTIMATE] multi ${multiErrored ? 'errored' : 'empty'} for zone=${base.zoneId} ` +
`space=${base.spaceId}; falling back to single`,
);
details = await buildSingleLadder(base);
const fb = await buildSingleLadder(base);
if (fb.free) {
logLine(`[ESTIMATE] zone=${base.zoneId} is currently free (MaxTime 0)`);
setEstimatedFree(true);
return;
}
details = fb.ladder;
}
if (!details.length) {
throw new Error('No parking options are available for this space right now.');
@ -283,6 +307,22 @@ export function StartSessionScreen() {
);
}
if (estimatedFree) {
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: '#e8f5e9', marginTop: 16, alignSelf: 'stretch' }]}>
<Text style={{ color: '#2e7d32', fontWeight: '700', fontSize: 16 }}>
Parking is currently free
</Text>
<Text style={{ color: colors.subtext, marginTop: 6 }}>
No payment needed right now just park.
</Text>
</View>
</View>
);
}
if (ladderError && !ladder.length) {
return (
<View style={[styles.center, { backgroundColor: colors.bg, padding: 24 }]}>