v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s
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:
parent
ad55559f55
commit
148e1635d3
23 changed files with 3027 additions and 21 deletions
196
server/test/areas.test.ts
Normal file
196
server/test/areas.test.ts
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue