v0.1.5: detect free windows in the estimate fallback + add API tests
All checks were successful
build-apk / build (push) Successful in 49m41s
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:
parent
f8d9e2970c
commit
7212badd2d
9 changed files with 276 additions and 13 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }]}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue