v0.1.7: collapse flat-rate zones to one option; add DL capture script
Some checks failed
build-apk / build (push) Failing after 8m10s

- 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 <end>" — 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 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 10:58:49 -07:00
parent 65fbd86644
commit 42a96b0cff
3 changed files with 132 additions and 37 deletions

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking", "name": "BigBrainParking",
"slug": "bigbrainparking", "slug": "bigbrainparking",
"scheme": "bigbrainparking", "scheme": "bigbrainparking",
"version": "0.1.6", "version": "0.1.7",
"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": 6, "versionCode": 7,
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",

View file

@ -45,9 +45,27 @@ const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
export interface SingleLadderResult { export interface SingleLadderResult {
/** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */ /** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */
free: boolean; free: boolean;
/** Flat-rate zone: one price for any duration, so we offer a single max option. */
flat: boolean;
ladder: ParkingDetail[]; 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<ParkingDetail | null> {
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: { export async function buildSingleLadder(base: {
zoneId: number; zoneId: number;
spaceId: 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, // 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. // 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 min = Math.max(5, Number(probe.MinTime) || 5);
const max = Math.max(min, Number(probe.MaxTime) || min); 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 span = max - min;
const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5; const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5;
const durs = new Set<number>(); const mids = new Set<number>();
for (let d = min; d < max; d += step) durs.add(d); for (let d = min + step; d < max; d += step) mids.add(d);
durs.add(max); const midRungs = await Promise.all([...mids].map((d) => singleRung(base, d)));
const rungs = await Promise.all( const ladder = [minRung, ...midRungs, maxRung]
[...durs].map(async (d) => { .filter((r): r is ParkingDetail => r != null)
try { .sort((a, b) => Number(a.Minutes) - Number(b.Minutes));
const r = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: d, creditCardId: 0 }); if (ladderAllFree(ladder)) return { free: true, flat: false, ladder: [] };
const p = r.ParkingDetail; return { free: false, flat: false, ladder };
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 };
} }
/** 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. */
@ -124,6 +141,8 @@ export function StartSessionScreen() {
const [ladderError, setLadderError] = useState<string | null>(null); const [ladderError, setLadderError] = useState<string | null>(null);
// Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy. // Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy.
const [estimatedFree, setEstimatedFree] = useState(false); 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. // Load the account's vehicles + cards and pick the defaults.
useEffect(() => { useEffect(() => {
@ -147,6 +166,7 @@ export function StartSessionScreen() {
setLoading(true); setLoading(true);
setLadderError(null); setLadderError(null);
setEstimatedFree(false); setEstimatedFree(false);
setFlatRate(false);
const base = { const base = {
zoneId: zone.ZoneId!, zoneId: zone.ZoneId!,
spaceId: space.SpaceId!, spaceId: space.SpaceId!,
@ -175,6 +195,7 @@ export function StartSessionScreen() {
setEstimatedFree(true); setEstimatedFree(true);
return; return;
} }
setFlatRate(fb.flat);
details = fb.ladder; details = fb.ladder;
} }
if (!details.length) { if (!details.length) {
@ -382,7 +403,15 @@ export function StartSessionScreen() {
))} ))}
</View> </View>
<Text style={[styles.label, { color: colors.subtext }]}>Duration</Text> <Text style={[styles.label, { color: colors.subtext }]}>
{flatRate ? 'Flat rate' : 'Duration'}
</Text>
{flatRate ? (
<Text style={{ color: colors.text, marginBottom: 4 }}>
{money(selected?.ParkingCost)} flat one price for the whole window
{selected?.EndTime ? `, parked until ${selected.EndTime}` : ''}.
</Text>
) : (
<FlatList <FlatList
horizontal horizontal
data={ladder} data={ladder}
@ -396,6 +425,7 @@ export function StartSessionScreen() {
/> />
)} )}
/> />
)}
<Text style={[styles.label, { color: colors.subtext }]}>Card</Text> <Text style={[styles.label, { color: colors.subtext }]}>Card</Text>
<View style={styles.chipRow}> <View style={styles.chipRow}>

View file

@ -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}`);