v0.6.3: hide the paid city lots from users without a ParkSmarter account
All checks were successful
build-apk / build (push) Successful in 9m54s
All checks were successful
build-apk / build (push) Successful in 9m54s
The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.
Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.
- Paid lots are filtered out of the overlay when signed out, so they are
neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
turns out to take payment another way. Null means "use the category
default", so an unset value can't be confused with an explicit false.
The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6af3dad762
commit
4217e88338
8 changed files with 159 additions and 18 deletions
18
README.md
18
README.md
|
|
@ -56,11 +56,19 @@ The **City map** layer on the Map tab is the City of Sandpoint's printed *Downto
|
||||||
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
|
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
|
||||||
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
|
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
|
||||||
|
|
||||||
**None of it touches ParkSmarter.** The areas live in the local database (bundled with the
|
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
|
||||||
app, refreshed from the zone-labels server, cached on-device), the countdown is the phone's
|
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
|
||||||
own clock, and the notification is the same foreground service every other session uses. So
|
phone's own clock, and the notification is the same foreground service every other session
|
||||||
tracking your time on a city spot works with no account, no signal, no payment, and in
|
uses. So tracking your time on a free city spot works with no account, no signal, no
|
||||||
Anonymous Mode. Two ways to start:
|
payment, and in Anonymous Mode.
|
||||||
|
|
||||||
|
The **green city lots are the exception** — they're the map's only paid category, and paying
|
||||||
|
for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking
|
||||||
|
you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park
|
||||||
|
here" says so rather than reporting nothing nearby.) A single lot can be flipped back via
|
||||||
|
the server's `requiresAccount` field if it turns out to take payment another way.
|
||||||
|
|
||||||
|
Two ways to start:
|
||||||
|
|
||||||
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
|
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
|
||||||
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
|
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.6.2",
|
"version": "0.6.3",
|
||||||
"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": 23,
|
"versionCode": 24,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ export interface ParkingArea {
|
||||||
color: string;
|
color: string;
|
||||||
shape: 'line' | 'polygon';
|
shape: 'line' | 'polygon';
|
||||||
geometry: AreaGeometry;
|
geometry: AreaGeometry;
|
||||||
|
/**
|
||||||
|
* Overrides the by-category default in [areaRequiresAccount]. Only set this to
|
||||||
|
* correct a specific lot — e.g. one that turns out to be kiosk- or permit-only.
|
||||||
|
*/
|
||||||
|
requiresAccount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AreaData {
|
export interface AreaData {
|
||||||
|
|
@ -76,6 +81,19 @@ export function areaIsFree(kind: AreaKind): boolean {
|
||||||
return kind !== 'green_lot';
|
return kind !== 'green_lot';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether you need a ParkSmarter account to park here.
|
||||||
|
*
|
||||||
|
* The city lots are the map's only paid category ("City lots — Paid hourly or
|
||||||
|
* permit"); paying for them means ParkSmarter, so they're no use to someone
|
||||||
|
* browsing without an account. Everything else is free with a posted time limit
|
||||||
|
* and needs nothing. A single lot can override this if it turns out to take
|
||||||
|
* payment some other way.
|
||||||
|
*/
|
||||||
|
export function areaRequiresAccount(area: ParkingArea): boolean {
|
||||||
|
return area.requiresAccount ?? area.kind === 'green_lot';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Durations offered when starting tracking, the posted limit first.
|
* Durations offered when starting tracking, the posted limit first.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/nativ
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
|
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import {
|
import {
|
||||||
endActiveParking,
|
endActiveParking,
|
||||||
extendAreaParking,
|
extendAreaParking,
|
||||||
|
|
@ -43,6 +44,7 @@ export function CityAreaScreen() {
|
||||||
const { area, spot } = useRoute<AreaRoute>().params;
|
const { area, spot } = useRoute<AreaRoute>().params;
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
const { isAnonymous, requireLogin } = useAuth();
|
||||||
const [active, setActive] = useState<ActiveParking | null>(null);
|
const [active, setActive] = useState<ActiveParking | null>(null);
|
||||||
|
|
||||||
const options = areaDurationOptions(area);
|
const options = areaDurationOptions(area);
|
||||||
|
|
@ -121,7 +123,18 @@ export function CityAreaScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{parkedHere && active ? (
|
{isAnonymous && areaRequiresAccount(area) ? (
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.sectionTitle}>Sign in to park here</Text>
|
||||||
|
<Text style={s.sub}>
|
||||||
|
This is a paid city lot — parking in it is bought through ParkSmarter, so it needs
|
||||||
|
an account. The free time-limited streets on the map don't.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={s.primary} onPress={requireLogin}>
|
||||||
|
<Text style={s.primaryText}>Sign in</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : parkedHere && active ? (
|
||||||
<View style={s.card}>
|
<View style={s.card}>
|
||||||
<Text style={s.sectionTitle}>Tracking now</Text>
|
<Text style={s.sectionTitle}>Tracking now</Text>
|
||||||
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,12 @@ import { ps } from '@/api/client';
|
||||||
import { useLocation, type Coords } from '@/features/location/useLocation';
|
import { useLocation, type Coords } from '@/features/location/useLocation';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
||||||
import { getAdjustedAreas, refreshAreas, type ParkingArea } from '@/api/parkingAreas';
|
import {
|
||||||
|
areaRequiresAccount,
|
||||||
|
getAdjustedAreas,
|
||||||
|
refreshAreas,
|
||||||
|
type ParkingArea,
|
||||||
|
} from '@/api/parkingAreas';
|
||||||
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
|
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
|
||||||
import {
|
import {
|
||||||
getParkedPin,
|
getParkedPin,
|
||||||
|
|
@ -282,6 +287,19 @@ export function MapScreen() {
|
||||||
|
|
||||||
/* -------------------------------------------------- parking-map interaction */
|
/* -------------------------------------------------- parking-map interaction */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paid city lots are ParkSmarter-only, so they're hidden from someone
|
||||||
|
* browsing without an account — showing parking you can't actually buy is worse
|
||||||
|
* than not showing it.
|
||||||
|
*/
|
||||||
|
const hidden = useCallback(
|
||||||
|
(a: ParkingArea) => isAnonymous && areaRequiresAccount(a),
|
||||||
|
[isAnonymous],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** What actually gets drawn and tapped. */
|
||||||
|
const visibleAreas = useMemo(() => areas.filter((a) => !hidden(a)), [areas, hidden]);
|
||||||
|
|
||||||
/** Open an area, carrying the pin along if we have one. */
|
/** Open an area, carrying the pin along if we have one. */
|
||||||
const openArea = useCallback(
|
const openArea = useCallback(
|
||||||
(area: ParkingArea, at?: ParkedSpot) => {
|
(area: ParkingArea, at?: ParkedSpot) => {
|
||||||
|
|
@ -298,8 +316,12 @@ export function MapScreen() {
|
||||||
// Persist immediately — the pin is worth keeping even if you never start a
|
// Persist immediately — the pin is worth keeping even if you never start a
|
||||||
// timer, and even if you back out of the screen we're about to open.
|
// timer, and even if you back out of the screen we're about to open.
|
||||||
void pinParkedSpot(at);
|
void pinParkedSpot(at);
|
||||||
|
// Detect against every area, including the ones hidden from this user, so
|
||||||
|
// standing in a paid lot gets an explanation rather than "nothing found".
|
||||||
const found = areaAt([c.longitude, c.latitude], areas);
|
const found = areaAt([c.longitude, c.latitude], areas);
|
||||||
if (found) {
|
if (found && hidden(found)) {
|
||||||
|
setStatus(`Pinned. ${found.name} is a paid city lot — sign in to park there.`);
|
||||||
|
} else if (found) {
|
||||||
setStatus(`Parked at ${found.name}`);
|
setStatus(`Parked at ${found.name}`);
|
||||||
openArea(found, at);
|
openArea(found, at);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -307,7 +329,7 @@ export function MapScreen() {
|
||||||
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
|
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[areas, openArea],
|
[areas, hidden, openArea],
|
||||||
);
|
);
|
||||||
|
|
||||||
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
|
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
|
||||||
|
|
@ -362,21 +384,21 @@ export function MapScreen() {
|
||||||
/** A tap on a coloured segment: the other start flow, no pin involved. */
|
/** A tap on a coloured segment: the other start flow, no pin involved. */
|
||||||
const onAreaPress = (e: any) => {
|
const onAreaPress = (e: any) => {
|
||||||
const id = e?.features?.[0]?.properties?.id;
|
const id = e?.features?.[0]?.properties?.id;
|
||||||
const found = areas.find((a) => a.id === id);
|
const found = visibleAreas.find((a) => a.id === id);
|
||||||
if (found) openArea(found, spot ?? undefined);
|
if (found) openArea(found, spot ?? undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const areaFeatures = useMemo(
|
const areaFeatures = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: areas.map((a) => ({
|
features: visibleAreas.map((a) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
id: a.id,
|
id: a.id,
|
||||||
geometry: a.geometry,
|
geometry: a.geometry,
|
||||||
properties: { id: a.id, color: a.color, kind: a.kind },
|
properties: { id: a.id, color: a.color, kind: a.kind },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[areas],
|
[visibleAreas],
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotFeature = useMemo(
|
const spotFeature = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,9 @@ export async function buildApp(opts: BuildOptions) {
|
||||||
color: String(raw.color ?? '#888888'),
|
color: String(raw.color ?? '#888888'),
|
||||||
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
||||||
geometry: g,
|
geometry: g,
|
||||||
|
// Absent means "let the client decide by category"; only an explicit
|
||||||
|
// boolean overrides a specific lot.
|
||||||
|
requiresAccount: typeof raw.requiresAccount === 'boolean' ? raw.requiresAccount : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
|
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ export interface ParkingArea {
|
||||||
shape: 'line' | 'polygon';
|
shape: 'line' | 'polygon';
|
||||||
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
||||||
geometry: unknown;
|
geometry: unknown;
|
||||||
|
/**
|
||||||
|
* Overrides the client's by-category default (paid city lots need a
|
||||||
|
* ParkSmarter account, free time-limited streets don't). Null means "use the
|
||||||
|
* default"; set it only to correct a specific lot.
|
||||||
|
*/
|
||||||
|
requiresAccount: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,6 +147,16 @@ export class LabelDb {
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// CREATE TABLE IF NOT EXISTS won't add a column to a table that already
|
||||||
|
// exists, so added columns need an explicit migration.
|
||||||
|
this.addColumnIfMissing('parking_areas', 'requires_account', 'INTEGER');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addColumnIfMissing(table: string, column: string, type: string): void {
|
||||||
|
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||||
|
if (cols.some((c) => c.name === column)) return;
|
||||||
|
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------ city parking-map areas */
|
/* ------------------------------------------------ city parking-map areas */
|
||||||
|
|
@ -159,6 +175,8 @@ export class LabelDb {
|
||||||
color: r.color,
|
color: r.color,
|
||||||
shape: r.shape as 'line' | 'polygon',
|
shape: r.shape as 'line' | 'polygon',
|
||||||
geometry: JSON.parse(r.geometry),
|
geometry: JSON.parse(r.geometry),
|
||||||
|
// SQLite has no boolean; null stays null so the client applies its default.
|
||||||
|
requiresAccount: r.requires_account == null ? null : !!r.requires_account,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,13 +200,20 @@ export class LabelDb {
|
||||||
replaceAreas(areas: ParkingArea[]): number {
|
replaceAreas(areas: ParkingArea[]): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const insert = this.db.prepare(
|
const insert = this.db.prepare(
|
||||||
`INSERT INTO parking_areas (id, kind, name, label, legend, hours, color, shape, geometry, updated_at)
|
`INSERT INTO parking_areas
|
||||||
VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`,
|
(id, kind, name, label, legend, hours, color, shape, geometry, requires_account, updated_at)
|
||||||
|
VALUES
|
||||||
|
(@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @requiresAccount, @updatedAt)`,
|
||||||
);
|
);
|
||||||
this.db.transaction((items: ParkingArea[]) => {
|
this.db.transaction((items: ParkingArea[]) => {
|
||||||
this.db.prepare('DELETE FROM parking_areas').run();
|
this.db.prepare('DELETE FROM parking_areas').run();
|
||||||
for (const a of items) {
|
for (const a of items) {
|
||||||
insert.run({ ...a, geometry: JSON.stringify(a.geometry), updatedAt: now });
|
insert.run({
|
||||||
|
...a,
|
||||||
|
geometry: JSON.stringify(a.geometry),
|
||||||
|
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})(areas);
|
})(areas);
|
||||||
return areas.length;
|
return areas.length;
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,58 @@ test('polygons are accepted; bad kinds and geometries are rejected', async () =>
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('requiresAccount is null unless explicitly overridden', async () => {
|
||||||
|
const app = await make();
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/areas',
|
||||||
|
headers: auth,
|
||||||
|
payload: {
|
||||||
|
areas: [
|
||||||
|
line('sp-001'), // no override -> client decides by category
|
||||||
|
{ ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account
|
||||||
|
{ ...line('sp-003'), requiresAccount: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||||
|
const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount]));
|
||||||
|
assert.equal(by['sp-001'], null, 'no override should stay null, not become false');
|
||||||
|
assert.equal(by['sp-002'], false);
|
||||||
|
assert.equal(by['sp-003'], true);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a database created before requires_account existed still works', async () => {
|
||||||
|
// Simulate an older deployment: build the table without the column, then let
|
||||||
|
// the migration add it. CREATE TABLE IF NOT EXISTS alone would not.
|
||||||
|
const Database = (await import('better-sqlite3')).default;
|
||||||
|
const file = `/tmp/bbp-migrate-${process.pid}.db`;
|
||||||
|
const raw = new Database(file);
|
||||||
|
raw.exec(`CREATE TABLE 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)`);
|
||||||
|
raw.close();
|
||||||
|
|
||||||
|
const app = await make({ dbPath: file });
|
||||||
|
const r = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/areas',
|
||||||
|
headers: auth,
|
||||||
|
payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.statusCode, 200);
|
||||||
|
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||||
|
assert.equal(areas[0].requiresAccount, true);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
(await import('node:fs')).rmSync(file, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('overlay defaults to identity and round-trips', async () => {
|
test('overlay defaults to identity and round-trips', async () => {
|
||||||
const app = await make();
|
const app = await make();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue