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

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:
Erik 2026-08-13 06:02:23 +00:00
parent 6af3dad762
commit 4217e88338
8 changed files with 159 additions and 18 deletions

View file

@ -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:
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
app, refreshed from the zone-labels server, cached on-device), the countdown is the phone's
own clock, and the notification is the same foreground service every other session uses. So
tracking your time on a city spot works with no account, no signal, no payment, and in
Anonymous Mode. Two ways to start:
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
phone's own clock, and the notification is the same foreground service every other session
uses. So tracking your time on a free city spot works with no account, no signal, no
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
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.6.2",
"version": "0.6.3",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
"versionCode": 23,
"versionCode": 24,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",

View file

@ -40,6 +40,11 @@ export interface ParkingArea {
color: string;
shape: 'line' | 'polygon';
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 {
@ -76,6 +81,19 @@ export function areaIsFree(kind: AreaKind): boolean {
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.
*

View file

@ -5,7 +5,8 @@ import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/nativ
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { useTheme } from '@/theme/ThemeContext';
import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
import { useAuth } from '@/auth/AuthContext';
import {
endActiveParking,
extendAreaParking,
@ -43,6 +44,7 @@ export function CityAreaScreen() {
const { area, spot } = useRoute<AreaRoute>().params;
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const { isAnonymous, requireLogin } = useAuth();
const [active, setActive] = useState<ActiveParking | null>(null);
const options = areaDurationOptions(area);
@ -121,7 +123,18 @@ export function CityAreaScreen() {
</View>
) : 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}>
<Text style={s.sectionTitle}>Tracking now</Text>
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>

View file

@ -18,7 +18,12 @@ import { ps } from '@/api/client';
import { useLocation, type Coords } from '@/features/location/useLocation';
import { useAuth } from '@/auth/AuthContext';
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 {
getParkedPin,
@ -282,6 +287,19 @@ export function MapScreen() {
/* -------------------------------------------------- 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. */
const openArea = useCallback(
(area: ParkingArea, at?: ParkedSpot) => {
@ -298,8 +316,12 @@ export function MapScreen() {
// 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.
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);
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}`);
openArea(found, at);
} else {
@ -307,7 +329,7 @@ export function MapScreen() {
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
@ -362,21 +384,21 @@ export function MapScreen() {
/** A tap on a coloured segment: the other start flow, no pin involved. */
const onAreaPress = (e: any) => {
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);
};
const areaFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: areas.map((a) => ({
features: visibleAreas.map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { id: a.id, color: a.color, kind: a.kind },
})),
}),
[areas],
[visibleAreas],
);
const spotFeature = useMemo(

View file

@ -197,6 +197,9 @@ export async function buildApp(opts: BuildOptions) {
color: String(raw.color ?? '#888888'),
shape: g.type === 'Polygon' ? 'polygon' : 'line',
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() };

View file

@ -33,6 +33,12 @@ export interface ParkingArea {
shape: 'line' | 'polygon';
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
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
);
`);
// 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 */
@ -159,6 +175,8 @@ export class LabelDb {
color: r.color,
shape: r.shape as 'line' | 'polygon',
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 {
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)`,
`INSERT INTO parking_areas
(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.prepare('DELETE FROM parking_areas').run();
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);
return areas.length;

View file

@ -140,6 +140,58 @@ test('polygons are accepted; bad kinds and geometries are rejected', async () =>
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 () => {
const app = await make();