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>
301 lines
9.3 KiB
TypeScript
301 lines
9.3 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
|
|
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
|
|
export const LABEL_KINDS: LabelKind[] = ['free_2h', 'free_3h', 'free_4h', 'pay_immediate'];
|
|
|
|
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;
|
|
zoneName: string | null;
|
|
kind: LabelKind;
|
|
note: string | null;
|
|
updatedAt: number;
|
|
updatedBy: string | null;
|
|
}
|
|
|
|
interface Row {
|
|
zone_id: string;
|
|
customer_id: string | null;
|
|
zone_name: string | null;
|
|
kind: string;
|
|
note: string | null;
|
|
updated_at: number;
|
|
updated_by: string | null;
|
|
}
|
|
|
|
const toLabel = (r: Row): ZoneLabel => ({
|
|
zoneId: r.zone_id,
|
|
customerId: r.customer_id,
|
|
zoneName: r.zone_name,
|
|
kind: r.kind as LabelKind,
|
|
note: r.note,
|
|
updatedAt: r.updated_at,
|
|
updatedBy: r.updated_by,
|
|
});
|
|
|
|
/** SQLite-backed store for zone labels + a manual IP denylist. */
|
|
export class LabelDb {
|
|
private db: Database.Database;
|
|
|
|
constructor(path: string) {
|
|
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
|
this.db = new Database(path);
|
|
this.db.pragma('journal_mode = WAL');
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS zone_labels (
|
|
zone_id TEXT PRIMARY KEY,
|
|
customer_id TEXT,
|
|
zone_name TEXT,
|
|
kind TEXT NOT NULL,
|
|
note TEXT,
|
|
updated_at INTEGER NOT NULL,
|
|
updated_by TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS blocked_ips (
|
|
ip TEXT PRIMARY KEY,
|
|
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
|
|
);
|
|
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 }>,
|
|
): 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);
|
|
}
|
|
|
|
get(zoneId: string): ZoneLabel | undefined {
|
|
const r = this.db.prepare('SELECT * FROM zone_labels WHERE zone_id = ?').get(zoneId) as Row | undefined;
|
|
return r ? toLabel(r) : undefined;
|
|
}
|
|
|
|
upsert(label: ZoneLabel): void {
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO zone_labels (zone_id, customer_id, zone_name, kind, note, updated_at, updated_by)
|
|
VALUES (@zoneId, @customerId, @zoneName, @kind, @note, @updatedAt, @updatedBy)
|
|
ON CONFLICT(zone_id) DO UPDATE SET
|
|
customer_id = excluded.customer_id,
|
|
zone_name = excluded.zone_name,
|
|
kind = excluded.kind,
|
|
note = excluded.note,
|
|
updated_at = excluded.updated_at,
|
|
updated_by = excluded.updated_by`,
|
|
)
|
|
.run(label);
|
|
}
|
|
|
|
delete(zoneId: string): boolean {
|
|
return this.db.prepare('DELETE FROM zone_labels WHERE zone_id = ?').run(zoneId).changes > 0;
|
|
}
|
|
|
|
isBlocked(ip: string): boolean {
|
|
return !!this.db.prepare('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip);
|
|
}
|
|
|
|
block(ip: string, reason: string): void {
|
|
this.db
|
|
.prepare('INSERT OR REPLACE INTO blocked_ips (ip, reason, created_at) VALUES (?, ?, ?)')
|
|
.run(ip, reason, Date.now());
|
|
}
|
|
|
|
seedBlocked(ips: string[]): void {
|
|
for (const ip of ips) this.block(ip, 'seed');
|
|
}
|
|
|
|
close(): void {
|
|
this.db.close();
|
|
}
|
|
}
|