v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s

Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking"
map as a georeferenced overlay, and lets you track your time on any of its
areas without ever touching the ParkSmarter/IPS API.

Georeferencing (tools/citymap/)
- The PDF carries no geo metadata, so the page->WebMercator affine is
  recovered by fitting the drawing to OSM street centrelines.
- pdftocairo writes stroked street segments with per-path matrix()
  transforms in local coords while filled lots are absolute; both are
  handled. The five legend swatches share the real geometry's colours and
  are identified by stroke-width and position, then dropped.
- 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a
  mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because
  they are angled bays along the old rail corridor, on no named road at all.
- Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows
  the same jog at the fitted scale, which independently confirms the fit.

App
- Map tab: "City map" layer in the legend's colours, tappable.
- "Park here" pins the car from GPS and auto-detects the containing area
  (40 m snap). With no fix it asks you to tap the spot instead, so the pin
  never depends on GPS working.
- The pin lives in its own storage key, not inside the session: pinning the
  car without starting a timer must survive backing out of the screen.
- Durations cap at the posted limit — a 2-hour space is not offered a
  4-hour timer. Lots and no-limit spots get the long options.
- Reuses the existing foreground-service countdown. The second notification
  button reads "+1 hr" for a city area rather than "Extend": there is
  nothing to buy, so it edits the local timer and says so.
- Account -> Align city map: nudge/scale/rotate the whole overlay against a
  live GPS fix. Save-on-phone needs no admin token, since the person who can
  see the misalignment is the one standing on the street.

Server
- parking_areas + map_overlay tables, public read, admin replace-all. The
  areas come from one source document, so replacement is wholesale rather
  than an upsert.

Dropped geometryCenter from the geo module: on the real data it returns a
point in the water for the crescent City Beach lot and mid-block for
L-shaped runs. Nothing used it.

Tests: 8 geometry tests in app/, 5 area/overlay tests in server/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 03:28:18 +00:00
parent ad55559f55
commit 148e1635d3
23 changed files with 3027 additions and 21 deletions

View file

@ -1,6 +1,14 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { LabelDb, LABEL_KINDS, isLabelKind, type ZoneLabel } from './db.js';
import {
LabelDb,
LABEL_KINDS,
isLabelKind,
isAreaKind,
AREA_KINDS,
type ZoneLabel,
type ParkingArea,
} from './db.js';
import { AbuseGuard, safeEqual } from './auth.js';
export interface BuildOptions {
@ -143,6 +151,78 @@ export async function buildApp(opts: BuildOptions) {
},
);
// ---- City parking-map areas ---------------------------------------------
// The colour-coded areas from the city's printed Downtown & Waterfront parking
// map, georeferenced. Purely local geography: none of this touches ParkSmarter,
// which is the point — tracking time on these spots must work with no IPS call.
const num = (v: unknown, fallback: number): number => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
app.get('/api/areas', async () => ({
areas: db.allAreas(),
overlay: db.getOverlay(),
count: db.areaCount(),
updatedAt: db.areasUpdatedAt(),
}));
// Replace-all, not upsert: the areas come from one source document, so a partial
// update would leave a map matching neither the old print nor the new one.
app.put(
'/api/areas',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => {
const input = Array.isArray(req.body?.areas) ? req.body!.areas : null;
if (!input) return reply.code(400).send({ error: 'areas_required' });
const areas: ParkingArea[] = [];
for (const raw of input as Array<Record<string, unknown>>) {
if (raw == null || raw.id == null) continue;
if (!isAreaKind(raw.kind)) {
return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS });
}
const g = raw.geometry as { type?: unknown } | null;
if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) {
return reply.code(400).send({ error: 'bad_geometry', id: raw.id });
}
areas.push({
id: String(raw.id),
kind: raw.kind,
name: String(raw.name ?? raw.id),
label: String(raw.label ?? raw.kind),
legend: String(raw.legend ?? ''),
hours: num(raw.hours, 0),
color: String(raw.color ?? '#888888'),
shape: g.type === 'Polygon' ? 'polygon' : 'line',
geometry: g,
});
}
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
},
);
// Whole-overlay alignment correction, set from the phone against a live GPS fix.
app.put(
'/api/areas/overlay',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: Record<string, unknown> }>, reply) => {
const b = req.body ?? {};
const scale = num(b.scale, 1);
if (scale <= 0.5 || scale >= 2) {
// A fit that needs more than a ±2x correction is a broken fit, not a nudge.
return reply.code(400).send({ error: 'scale_out_of_range' });
}
return db.setOverlay({
dxMeters: num(b.dxMeters, 0),
dyMeters: num(b.dyMeters, 0),
scale,
rotationDeg: num(b.rotationDeg, 0),
});
},
);
app.addHook('onClose', async () => db.close());
return app;
}

View file

@ -9,6 +9,56 @@ export function isLabelKind(v: unknown): v is LabelKind {
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
}
/** The five categories on the city's printed Downtown & Waterfront parking map. */
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit'];
export function isAreaKind(v: unknown): v is AreaKind {
return typeof v === 'string' && (AREA_KINDS as string[]).includes(v);
}
/**
* One coloured area from the city map: an on-street segment (LineString) or a
* city lot (Polygon). These are city geography, unrelated to ParkSmarter zones
* nothing here ever reaches the IPS API.
*/
export interface ParkingArea {
id: string;
kind: AreaKind;
name: string;
label: string;
legend: string;
hours: number;
color: string;
shape: 'line' | 'polygon';
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
geometry: unknown;
}
/**
* A whole-overlay correction. The map was georeferenced by fitting it to OSM, which
* is good to a few metres but not perfect; this lets the alignment be nudged from the
* phone against a live GPS fix and persisted, with no app release.
*
* Offsets are ground metres (east/north); scale and rotation apply about the
* overlay's own centroid.
*/
export interface OverlayAdjust {
dxMeters: number;
dyMeters: number;
scale: number;
rotationDeg: number;
updatedAt: number;
}
export const IDENTITY_OVERLAY: OverlayAdjust = {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
};
export interface ZoneLabel {
zoneId: string;
customerId: string | null;
@ -70,9 +120,109 @@ export class LabelDb {
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS parking_areas (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
label TEXT NOT NULL,
legend TEXT NOT NULL,
hours REAL NOT NULL,
color TEXT NOT NULL,
shape TEXT NOT NULL,
geometry TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS map_overlay (
id TEXT PRIMARY KEY,
dx_meters REAL NOT NULL,
dy_meters REAL NOT NULL,
scale REAL NOT NULL,
rotation REAL NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
/* ------------------------------------------------ city parking-map areas */
allAreas(): ParkingArea[] {
const rows = this.db
.prepare('SELECT * FROM parking_areas ORDER BY id')
.all() as Array<Record<string, any>>;
return rows.map((r) => ({
id: r.id,
kind: r.kind as AreaKind,
name: r.name,
label: r.label,
legend: r.legend,
hours: r.hours,
color: r.color,
shape: r.shape as 'line' | 'polygon',
geometry: JSON.parse(r.geometry),
}));
}
areaCount(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n;
}
/** Newest updated_at across areas — the app uses it to skip redundant refreshes. */
areasUpdatedAt(): number {
const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as {
t: number | null;
};
return r.t ?? 0;
}
/**
* Replace the whole area set in one transaction. The areas come from a single
* source document, so a partial update would leave a map that matches neither
* the old print nor the new one.
*/
replaceAreas(areas: ParkingArea[]): number {
const now = Date.now();
const insert = this.db.prepare(
`INSERT INTO parking_areas (id, kind, name, label, legend, hours, color, shape, geometry, updated_at)
VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`,
);
this.db.transaction((items: ParkingArea[]) => {
this.db.prepare('DELETE FROM parking_areas').run();
for (const a of items) {
insert.run({ ...a, geometry: JSON.stringify(a.geometry), updatedAt: now });
}
})(areas);
return areas.length;
}
getOverlay(): OverlayAdjust {
const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as
| Record<string, any>
| undefined;
if (!r) return IDENTITY_OVERLAY;
return {
dxMeters: r.dx_meters,
dyMeters: r.dy_meters,
scale: r.scale,
rotationDeg: r.rotation,
updatedAt: r.updated_at,
};
}
setOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): OverlayAdjust {
const updatedAt = Date.now();
this.db
.prepare(
`INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at)
VALUES ('default', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters,
scale = excluded.scale, rotation = excluded.rotation,
updated_at = excluded.updated_at`,
)
.run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt);
return { ...o, updatedAt };
}
/** 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 }>,

196
server/test/areas.test.ts Normal file
View file

@ -0,0 +1,196 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp, type BuildOptions } from '../src/app.ts';
const TOKEN = 'test-admin-token-0123456789';
const auth = { authorization: `Bearer ${TOKEN}` };
const make = (o: Partial<BuildOptions> = {}) =>
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
const line = (id: string, kind = 'free_2h') => ({
id,
kind,
name: `${id} name`,
label: '2-hour free',
legend: 'Permits not valid',
hours: 2,
color: '#d367cc',
geometry: {
type: 'LineString',
coordinates: [
[-116.5535, 48.2766],
[-116.5525, 48.2766],
],
},
});
test('areas are public to read and admin-only to replace', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.statusCode, 200);
assert.deepEqual(r.json().areas, []);
assert.equal(r.json().count, 0);
r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } });
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().replaced, 2);
r = await app.inject({ method: 'GET', url: '/api/areas' });
const areas = r.json().areas;
assert.equal(areas.length, 2);
assert.equal(areas[0].id, 'sp-001');
assert.equal(areas[0].kind, 'free_2h');
assert.equal(areas[0].shape, 'line');
// Geometry survives the round-trip as real GeoJSON, not a string.
assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]);
await app.close();
});
test('replace is wholesale — stale areas do not survive', async () => {
const app = await make();
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-001'), line('sp-002')] },
});
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-003')] },
});
const r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().count, 1);
assert.equal(r.json().areas[0].id, 'sp-003');
await app.close();
});
test('polygons are accepted; bad kinds and geometries are rejected', async () => {
const app = await make();
const lot = {
id: 'sp-022',
kind: 'green_lot',
name: 'Lot off Oak St',
label: 'City lot',
legend: 'Paid hourly or permit',
hours: 2,
color: '#75b259',
geometry: {
type: 'Polygon',
coordinates: [
[
[-116.554, 48.2766],
[-116.553, 48.2766],
[-116.553, 48.2772],
[-116.554, 48.2766],
],
],
},
};
let r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [lot] },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().replaced, 1);
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().areas[0].shape, 'polygon');
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] },
});
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_kind');
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] },
});
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_geometry');
// A rejected batch must not have clobbered the good one.
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().count, 1);
assert.equal(r.json().areas[0].id, 'sp-022');
await app.close();
});
test('overlay defaults to identity and round-trips', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.deepEqual(r.json().overlay, {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
});
r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
payload: { dxMeters: 3 },
});
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
headers: auth,
payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().dxMeters, 3.5);
assert.ok(r.json().updatedAt > 0);
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().overlay.dyMeters, -2);
assert.equal(r.json().overlay.rotationDeg, 0.4);
await app.close();
});
test('an absurd overlay scale is refused rather than stored', async () => {
const app = await make();
for (const scale of [0, 0.4, 2, 5]) {
const r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
headers: auth,
payload: { scale },
});
assert.equal(r.statusCode, 400, `scale ${scale} should be refused`);
}
const r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().overlay.scale, 1);
await app.close();
});