From 42a96b0cffe6d670d4838cb43ad476e98ba26fca Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 10:58:49 -0700 Subject: [PATCH] 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}`);