Compare commits

..

No commits in common. "main" and "v0.6.2" have entirely different histories.
main ... v0.6.2

9 changed files with 41 additions and 284 deletions

View file

@ -56,19 +56,11 @@ 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.
**The free areas never touch ParkSmarter.** They live in the local database (bundled with **None of it touches ParkSmarter.** The areas live in the local database (bundled with the
the app, refreshed from the zone-labels server, cached on-device), the countdown is the app, refreshed from the zone-labels server, cached on-device), the countdown is the phone's
phone's own clock, and the notification is the same foreground service every other session own clock, and the notification is the same foreground service every other session uses. So
uses. So tracking your time on a free city spot works with no account, no signal, no tracking your time on a city spot works with no account, no signal, no payment, and in
payment, and in Anonymous Mode. Anonymous Mode. Two ways to start:
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

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking", "name": "BigBrainParking",
"slug": "bigbrainparking", "slug": "bigbrainparking",
"scheme": "bigbrainparking", "scheme": "bigbrainparking",
"version": "0.6.4", "version": "0.6.2",
"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": 25, "versionCode": 23,
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",

View file

@ -40,11 +40,6 @@ 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 {
@ -81,19 +76,6 @@ 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.
* *

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@ -7,20 +7,6 @@ export interface Coords {
longitude: number; longitude: number;
} }
export interface UseLocationOptions {
/**
* Re-read the OS fix this often, in ms. Omit (or 0) for a single fix at mount.
* A fix goes stale as soon as you drive a block, so any screen that shows
* "where am I" for more than a moment wants this.
*/
intervalMs?: number;
/**
* Poll only while true. Callers pass screen focus AND app foreground: polling
* a map nobody is looking at spends battery on an answer no one reads.
*/
active?: boolean;
}
const LAST_LOC_KEY = 'ps_last_location'; const LAST_LOC_KEY = 'ps_last_location';
/** Persist the most recent fix so the "near my last location" button works cold. */ /** Persist the most recent fix so the "near my last location" button works cold. */
@ -35,54 +21,32 @@ export async function getLastKnownSavedLocation(): Promise<Coords | null> {
/** /**
* Foreground location. On GrapheneOS this uses the OS location provider directly * Foreground location. On GrapheneOS this uses the OS location provider directly
* (no Google Play Services). We prefer a fast last-known fix, then refine. * (no Google Play Services). We prefer a fast last-known fix, then refine.
*
* `updatedAt` is when `coords` was actually read, so callers can tell a fresh fix
* from one that has been sitting there since the screen opened.
*/ */
export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) { export function useLocation() {
const [coords, setCoords] = useState<Coords | null>(null); const [coords, setCoords] = useState<Coords | null>(null);
const [updatedAt, setUpdatedAt] = useState(0);
const [granted, setGranted] = useState<boolean | null>(null); const [granted, setGranted] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Read inside refresh() without making it a dependency — refresh is the
// interval's callback, and a changing identity would restart the timer on
// every fix, so it would never actually reach the interval.
const haveFix = useRef(false);
const permitted = useRef(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
if (!permitted.current) { const { status } = await Location.requestForegroundPermissionsAsync();
const { status } = await Location.requestForegroundPermissionsAsync(); const ok = status === 'granted';
const ok = status === 'granted'; setGranted(ok);
permitted.current = ok; if (!ok) {
setGranted(ok); setError('Location permission denied.');
if (!ok) { return null;
setError('Location permission denied.');
return null;
}
} }
// Only worth it before we have anything to show: on a later poll the const last = await Location.getLastKnownPositionAsync();
// last-known fix is usually older than the one we already hold, and if (last) {
// publishing it would make the dot jump backwards. const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
if (!haveFix.current) { setCoords(c);
const last = await Location.getLastKnownPositionAsync(); void saveLastLocation(c);
if (last) {
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
haveFix.current = true;
setCoords(c);
setUpdatedAt(Date.now());
void saveLastLocation(c);
}
} }
const cur = await Location.getCurrentPositionAsync({ const cur = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced, accuracy: Location.Accuracy.Balanced,
}); });
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude }; const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
haveFix.current = true;
setCoords(c); setCoords(c);
setUpdatedAt(Date.now());
setError(null);
void saveLastLocation(c); void saveLastLocation(c);
return c; return c;
} catch (e: any) { } catch (e: any) {
@ -92,19 +56,8 @@ export function useLocation({ intervalMs = 0, active = true }: UseLocationOption
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!active) return;
// Denied is denied — polling it every 30s just burns wake-ups to be told no.
if (granted === false) return;
// Re-activating (screen focused, app foregrounded) is exactly when the held
// fix is most likely to be stale, so read one straight away rather than
// waiting out a whole interval.
void refresh(); void refresh();
if (!intervalMs) return; }, [refresh]);
const id = setInterval(() => {
void refresh();
}, intervalMs);
return () => clearInterval(id);
}, [active, granted, intervalMs, refresh]);
return { coords, updatedAt, granted, error, refresh }; return { coords, granted, error, refresh };
} }

View file

@ -5,8 +5,7 @@ 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, areaRequiresAccount } from '@/api/parkingAreas'; import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
import { useAuth } from '@/auth/AuthContext';
import { import {
endActiveParking, endActiveParking,
extendAreaParking, extendAreaParking,
@ -44,7 +43,6 @@ 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);
@ -123,18 +121,7 @@ export function CityAreaScreen() {
</View> </View>
) : null} ) : null}
{isAnonymous && areaRequiresAccount(area) ? ( {parkedHere && active ? (
<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>

View file

@ -1,13 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
ActivityIndicator,
Alert,
AppState,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { import {
MapView, MapView,
@ -26,12 +18,7 @@ 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 { import { getAdjustedAreas, refreshAreas, type ParkingArea } from '@/api/parkingAreas';
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,
@ -53,20 +40,6 @@ const MAP_STYLE_DARK =
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 }; const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
const DEFAULT_ZOOM = 14; const DEFAULT_ZOOM = 14;
/**
* How often to re-read the GPS while the map is the screen you're looking at.
* A single fix at mount goes stale the moment you walk a block, which is the
* whole time you'd be looking at this screen.
*/
const GPS_REFRESH_MS = 30_000;
/**
* How old a fix can be before "Park here" / "My location" stops trusting it and
* goes and asks again. A poll and a half, so a fix arriving on schedule is never
* treated as stale.
*/
const FIX_MAX_AGE_MS = 45_000;
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
/** /**
@ -107,19 +80,7 @@ export function MapScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { mode } = useTheme(); const { mode } = useTheme();
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT; const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
// Poll the GPS while this screen is actually in front of someone. Focus alone const { coords, refresh } = useLocation();
// isn't enough: a backgrounded app stays "focused" on its last tab, and Android
// won't give a foreground app's location out to one that isn't.
const [focused, setFocused] = useState(true);
const [foreground, setForeground] = useState(AppState.currentState === 'active');
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => setForeground(next === 'active'));
return () => sub.remove();
}, []);
const { coords, updatedAt, refresh } = useLocation({
intervalMs: GPS_REFRESH_MS,
active: focused && foreground,
});
const { isAnonymous } = useAuth(); const { isAnonymous } = useAuth();
const [zones, setZones] = useState<Zone[]>([]); const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.'); const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
@ -147,9 +108,7 @@ export function MapScreen() {
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null); const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
// The native UserLocation dot has its own GPS feed — capture it so "My // The native UserLocation dot has its own GPS feed — capture it so "My
// location" works even when expo-location can't get a fix (e.g. indoors). // location" works even when expo-location can't get a fix (e.g. indoors).
// Stamped, because that feed goes quiet whenever the map isn't drawing and a const nativeFix = useRef<Coords | null>(null);
// silently stale fix is worse than no fix.
const nativeFix = useRef<(Coords & { at: number }) | null>(null);
// Look up the LAST session's parking-lot coordinate (the meter's own location // Look up the LAST session's parking-lot coordinate (the meter's own location
// from history — never the user's GPS). Used to open the map and by "Last lot". // from history — never the user's GPS). Used to open the map and by "Last lot".
@ -252,32 +211,13 @@ export function MapScreen() {
}, []); }, []);
// Re-read the pin on every focus: the session may have ended on another screen // Re-read the pin on every focus: the session may have ended on another screen
// (or from the notification), which clears it. Focus also gates the GPS poll. // (or from the notification), which clears it.
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void getParkedPin().then(setSpot); void getParkedPin().then(setSpot);
setFocused(true);
return () => setFocused(false);
}, []), }, []),
); );
/**
* The freshest fix we can get, in the order it's cheapest to get it: the map's
* own dot, then the polled expo fix, then a forced read. Whichever we hold is
* only used if it's recent the point of the poll is that "where am I" answers
* with where you are now, not where you were when the screen opened.
*/
const bestFix = useCallback(async (): Promise<Coords | null> => {
const now = Date.now();
const n = nativeFix.current;
if (n && now - n.at < FIX_MAX_AGE_MS) return { latitude: n.latitude, longitude: n.longitude };
if (coords && now - updatedAt < FIX_MAX_AGE_MS) return coords;
const fresh = await refresh();
if (fresh) return fresh;
// Nothing current and nothing new — a stale fix still beats no answer.
return n ? { latitude: n.latitude, longitude: n.longitude } : coords;
}, [coords, updatedAt, refresh]);
// Search whatever the map is currently centered on. This only ever sends the // Search whatever the map is currently centered on. This only ever sends the
// map's center point — never the device GPS. (If you want to search your own // map's center point — never the device GPS. (If you want to search your own
// location, tap "My location" to center there first, then Search this area.) // location, tap "My location" to center there first, then Search this area.)
@ -301,9 +241,10 @@ export function MapScreen() {
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area'); await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
}; };
// Recenter on the live GPS fix (if available) and search there. // Recenter on the live GPS fix (if available) and search there. Prefer the
// native map fix (the blue dot), then expo-location, then a forced refresh.
const goToMyLocation = async () => { const goToMyLocation = async () => {
const c = await bestFix(); const c = nativeFix.current ?? coords ?? (await refresh());
if (!c) { if (!c) {
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).'); setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
return; return;
@ -341,19 +282,6 @@ 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) => {
@ -370,12 +298,8 @@ 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 && hidden(found)) { if (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 {
@ -383,7 +307,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, hidden, openArea], [areas, 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
@ -395,7 +319,7 @@ export function MapScreen() {
setStatus('Pin cancelled.'); setStatus('Pin cancelled.');
return; return;
} }
const c = await bestFix(); const c = nativeFix.current ?? coords ?? (await refresh());
if (!c) { if (!c) {
setPinning(true); setPinning(true);
setStatus('No GPS fix — tap the map where you parked.'); setStatus('No GPS fix — tap the map where you parked.');
@ -438,21 +362,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 = visibleAreas.find((a) => a.id === id); const found = areas.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: visibleAreas.map((a) => ({ features: areas.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 },
})), })),
}), }),
[visibleAreas], [areas],
); );
const spotFeature = useMemo( const spotFeature = useMemo(
@ -512,7 +436,6 @@ export function MapScreen() {
nativeFix.current = { nativeFix.current = {
latitude: loc.coords.latitude, latitude: loc.coords.latitude,
longitude: loc.coords.longitude, longitude: loc.coords.longitude,
at: Date.now(),
}; };
} }
}} }}

View file

@ -197,9 +197,6 @@ 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() };

View file

@ -33,12 +33,6 @@ 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;
} }
/** /**
@ -147,16 +141,6 @@ 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 */
@ -175,8 +159,6 @@ 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,
})); }));
} }
@ -200,20 +182,13 @@ 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 `INSERT INTO parking_areas (id, kind, name, label, legend, hours, color, shape, geometry, updated_at)
(id, kind, name, label, legend, hours, color, shape, geometry, requires_account, updated_at) VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`,
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({ insert.run({ ...a, geometry: JSON.stringify(a.geometry), updatedAt: now });
...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;

View file

@ -140,58 +140,6 @@ 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();