v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone
Some checks failed
build-apk / build (push) Failing after 1h56m52s

Anonymous Mode — "Park without signing in" on the login screen (with a popup of
what works vs needs a login). Anonymous users browse parking areas from our
mirror, see labels, and start free check-in timers; paying, sessions, and
account screens prompt to sign in. AuthContext gains an 'anonymous' status +
enterAnonymous/requireLogin.

Zone mirror — server gains a `zones` table + public GET /api/zones and admin
POST /api/zones/sync. Signed-in admins push the zones they pull (authed) from
ParkSmarter after each map search, so anonymous users can read areas without a
ParkSmarter login. Map/Scan read the mirror when anonymous.

Also: the free "Check in" button is now always available with a 2h/3h/4h picker
(no longer gated on a prior label) — fixes "couldn't start a timer on a free
zone". CORS probe confirmed ParkSmarter allows any origin but only Content-Type,
so a future PWA can't auth to it — the mirror is what makes anonymous browsing
(and a PWA) possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 21:38:32 +00:00
parent 463facbe5a
commit 4a5660e7e1
13 changed files with 351 additions and 68 deletions

View file

@ -62,9 +62,46 @@ export class LabelDb {
reason TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS zones (
zone_id TEXT PRIMARY KEY,
zone_name TEXT,
lat REAL,
long REAL,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
upsertZones(
rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>,
): number {
const stmt = this.db.prepare(
`INSERT INTO zones (zone_id, zone_name, lat, long, data, updated_at)
VALUES (@zoneId, @zoneName, @lat, @long, @data, @updatedAt)
ON CONFLICT(zone_id) DO UPDATE SET
zone_name = excluded.zone_name,
lat = excluded.lat, long = excluded.long,
data = excluded.data, updated_at = excluded.updated_at`,
);
const now = Date.now();
this.db.transaction((items: typeof rows) => {
for (const it of items) stmt.run({ ...it, updatedAt: now });
})(rows);
return rows.length;
}
/** All mirrored zones as their original Zone objects. */
allZones(): unknown[] {
const rows = this.db.prepare('SELECT data FROM zones').all() as { data: string }[];
return rows.map((r) => JSON.parse(r.data));
}
zoneCount(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n;
}
all(): ZoneLabel[] {
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
}