v0.1.7: collapse flat-rate zones to one option; add DL capture script
Some checks failed
build-apk / build (push) Failing after 8m10s
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:
parent
65fbd86644
commit
42a96b0cff
3 changed files with 132 additions and 37 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
65
parksmarter-client/capture-dl.mjs
Normal file
65
parksmarter-client/capture-dl.mjs
Normal 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}`);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue