app: zone-labels client + admin labeling UX (Phase B) + default map = downtown Sandpoint

- zoneLabels client (cached fetch wrapper) hitting bigbrainparking.mowden.top;
  adminStore keeps the admin password in secure-store.
- Admin screen (verify + save password) reachable from Account → Admin.
- MeterDetail shows a label badge and, for admins, 2h/3h/4h/Pay-now/Clear
  labeling controls that PUT/DELETE to the API.
- app.json extra.zoneLabelsApiUrl.
- Map default view is now downtown Sandpoint (48.2766,-116.5533 @ z14) instead
  of the continental-US fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-24 18:18:35 +00:00
parent c14081d85f
commit eeaf09ea0e
8 changed files with 384 additions and 4 deletions

View file

@ -44,6 +44,7 @@
], ],
"extra": { "extra": {
"psEnvironment": "prodv2", "psEnvironment": "prodv2",
"zoneLabelsApiUrl": "https://bigbrainparking.mowden.top",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty", "mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", "mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
"unifiedPushDefaultDistributor": "io.heckel.ntfy", "unifiedPushDefaultDistributor": "io.heckel.ntfy",

18
app/src/api/adminStore.ts Normal file
View file

@ -0,0 +1,18 @@
import * as SecureStore from 'expo-secure-store';
/**
* The zone-labels admin password, kept in the OS keystore (never AsyncStorage).
* Its presence is what unlocks the labeling controls; it's sent as a Bearer token
* to the bigbrainparking.mowden.top API on writes.
*/
const ADMIN_KEY = 'ps_admin_token';
export function getAdminToken(): Promise<string | null> {
return SecureStore.getItemAsync(ADMIN_KEY);
}
export function setAdminToken(token: string | null): Promise<void> {
return token
? SecureStore.setItemAsync(ADMIN_KEY, token)
: SecureStore.deleteItemAsync(ADMIN_KEY);
}

142
app/src/api/zoneLabels.ts Normal file
View file

@ -0,0 +1,142 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAdminToken } from './adminStore';
/**
* Client for the BigBrainParking zone-labels API (bigbrainparking.mowden.top).
* Reads are public and cached locally so check-in works offline; writes require
* the admin token. This is a plain fetch wrapper the ParkSmarter HttpClient is
* too domain-specific (its own auth headers / rolling tokens) to reuse here.
*/
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
export interface ZoneLabel {
zoneId: string;
customerId: string | null;
zoneName: string | null;
kind: LabelKind;
note: string | null;
updatedAt: number;
updatedBy: string | null;
}
const CACHE_KEY = 'ps_zone_labels';
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
export class ZoneLabelsError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'ZoneLabelsError';
}
}
/** Hours of free parking for a kind, or null for pay-immediate. */
export function labelHours(kind: LabelKind): number | null {
return kind === 'free_2h' ? 2 : kind === 'free_3h' ? 3 : kind === 'free_4h' ? 4 : null;
}
/** Short human label for a kind. */
export function labelText(kind: LabelKind): string {
switch (kind) {
case 'free_2h':
return 'Free · 2h limit';
case 'free_3h':
return 'Free · 3h limit';
case 'free_4h':
return 'Free · 4h limit';
case 'pay_immediate':
return 'Pay immediately';
}
}
let memCache: Record<string, ZoneLabel> | null = null;
async function loadCache(): Promise<Record<string, ZoneLabel>> {
if (memCache) return memCache;
const raw = await AsyncStorage.getItem(CACHE_KEY);
memCache = raw ? (JSON.parse(raw) as Record<string, ZoneLabel>) : {};
return memCache;
}
async function saveCache(map: Record<string, ZoneLabel>): Promise<void> {
memCache = map;
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(map));
}
function ensureConfigured(): void {
if (!BASE_URL) throw new ZoneLabelsError('zoneLabelsApiUrl is not configured');
}
async function authHeaders(): Promise<Record<string, string>> {
const token = await getAdminToken();
if (!token) throw new ZoneLabelsError('not authenticated as admin');
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
/** Pull all labels and refresh the local cache. */
export async function refreshLabels(): Promise<void> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/labels`);
if (!res.ok) throw new ZoneLabelsError('labels fetch failed', res.status);
const body = (await res.json()) as { labels?: ZoneLabel[] };
const map: Record<string, ZoneLabel> = {};
for (const l of body.labels ?? []) map[String(l.zoneId)] = l;
await saveCache(map);
}
/** Cached label for a zone, or null. Never hits the network. */
export async function getCachedLabel(
zoneId: number | string | null | undefined,
): Promise<ZoneLabel | null> {
if (zoneId == null) return null;
const map = await loadCache();
return map[String(zoneId)] ?? null;
}
export async function setLabel(
zoneId: number | string,
kind: LabelKind,
meta: { zoneName?: string | null; customerId?: string | number | null } = {},
): Promise<ZoneLabel> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify({
kind,
zoneName: meta.zoneName ?? null,
customerId: meta.customerId ?? null,
}),
});
if (!res.ok) throw new ZoneLabelsError('set label failed', res.status);
const label = (await res.json()) as ZoneLabel;
const map = await loadCache();
map[String(zoneId)] = label;
await saveCache(map);
return label;
}
export async function deleteLabel(zoneId: number | string): Promise<void> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
method: 'DELETE',
headers: await authHeaders(),
});
if (!res.ok && res.status !== 404) throw new ZoneLabelsError('delete failed', res.status);
const map = await loadCache();
delete map[String(zoneId)];
await saveCache(map);
}
/** Verify a candidate admin token against the server (for the settings "Test" button). */
export async function verifyAdmin(token: string): Promise<boolean> {
ensureConfigured();
const res = await fetch(`${BASE_URL}/api/whoami`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.ok;
}

View file

@ -23,6 +23,7 @@ import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { StartSessionScreen } from '@/screens/StartSessionScreen'; import { StartSessionScreen } from '@/screens/StartSessionScreen';
import { SessionDetailScreen } from '@/screens/SessionDetailScreen'; import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
import { AdminScreen } from '@/screens/AdminScreen';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import { useSessionStatusSync } from '@/notifications/sessionStatus'; import { useSessionStatusSync } from '@/notifications/sessionStatus';
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
@ -38,6 +39,7 @@ export type RootStackParamList = {
PaymentMethods: undefined; PaymentMethods: undefined;
Notifications: undefined; Notifications: undefined;
Diagnostics: undefined; Diagnostics: undefined;
Admin: undefined;
}; };
export type TabParamList = { export type TabParamList = {
@ -148,6 +150,7 @@ export function RootNavigator() {
component={DiagnosticsScreen} component={DiagnosticsScreen}
options={{ title: 'Diagnostics' }} options={{ title: 'Diagnostics' }}
/> />
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
</Stack.Navigator> </Stack.Navigator>
) : ( ) : (
<LoginScreen /> <LoginScreen />

View file

@ -59,6 +59,11 @@ export function AccountScreen() {
<Switch value={mode === 'dark'} onValueChange={toggle} /> <Switch value={mode === 'dark'} onValueChange={toggle} />
</View> </View>
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} /> <Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
<Item
icon="shield-checkmark"
label="Admin (zone labeling)"
onPress={() => navigation.navigate('Admin')}
/>
</View> </View>
<TouchableOpacity <TouchableOpacity

View file

@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useTheme } from '@/theme/ThemeContext';
import { getAdminToken, setAdminToken } from '@/api/adminStore';
import { verifyAdmin } from '@/api/zoneLabels';
/**
* Enter the zone-labels admin password. Once verified + saved it unlocks the
* labeling controls on the meter detail screen. Stored in the OS keystore.
*/
export function AdminScreen() {
const { colors } = useTheme();
const [value, setValue] = useState('');
const [authed, setAuthed] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
void getAdminToken().then((t) => setAuthed(!!t));
}, []);
const saveAndTest = async () => {
const token = value.trim();
if (!token) return;
setBusy(true);
try {
const ok = await verifyAdmin(token);
if (!ok) {
Alert.alert('Not accepted', 'That password was rejected by the server.');
return;
}
await setAdminToken(token);
setAuthed(true);
setValue('');
Alert.alert('Admin enabled', 'You can now label zones from the meter screen.');
} catch (e: any) {
Alert.alert('Could not verify', e?.message ?? 'Check your connection and try again.');
} finally {
setBusy(false);
}
};
const signOut = async () => {
await setAdminToken(null);
setAuthed(false);
Alert.alert('Signed out', 'Admin labeling is now disabled on this device.');
};
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={[styles.section, { color: colors.subtext }]}>
Zone labeling {authed ? '· enabled' : '· disabled'}
</Text>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.help, { color: colors.subtext }]}>
The admin password lets you tag zones as free (2h/3h/4h) or pay-immediately.
It's stored securely on this device and sent only to
bigbrainparking.mowden.top.
</Text>
<TextInput
style={[styles.input, { color: colors.text, borderColor: colors.border }]}
placeholder={authed ? 'Enter a new password to replace' : 'Admin password'}
placeholderTextColor={colors.subtext}
value={value}
onChangeText={setValue}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary, opacity: value.trim() ? 1 : 0.5 }]}
onPress={saveAndTest}
disabled={!value.trim() || busy}
>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Verify &amp; save</Text>
)}
</TouchableOpacity>
</View>
{authed ? (
<TouchableOpacity style={[styles.signout, { borderColor: colors.danger }]} onPress={signOut}>
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out of admin</Text>
</TouchableOpacity>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
section: { marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' },
card: { borderRadius: 12, padding: 16, gap: 12 },
help: { fontSize: 13, lineHeight: 19 },
input: { borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
button: { borderRadius: 10, padding: 14, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
signout: { marginTop: 24, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
});

View file

@ -24,9 +24,9 @@ const MAP_STYLE_DARK =
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ?? (Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// Fallback view when we have no GPS and no cached location (continental US). // Default view when we have no last-session lot: all of downtown Sandpoint, ID.
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 }; const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
const DEFAULT_ZOOM = 4; const DEFAULT_ZOOM = 14;
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { RouteProp } from '@react-navigation/native'; import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native';
@ -7,6 +7,25 @@ import type { RootStackParamList } from '@/navigation/RootNavigator';
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore'; import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import type { SpacePolicy } from 'parksmarter-client'; import type { SpacePolicy } from 'parksmarter-client';
import { getAdminToken } from '@/api/adminStore';
import {
deleteLabel,
getCachedLabel,
labelText,
refreshLabels,
setLabel,
type LabelKind,
type ZoneLabel,
} from '@/api/zoneLabels';
/** Admin labeling buttons — kind, or 'clear' to remove. */
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
{ label: '2h', kind: 'free_2h' },
{ label: '3h', kind: 'free_3h' },
{ label: '4h', kind: 'free_4h' },
{ label: 'Pay now', kind: 'pay_immediate' },
{ label: 'Clear', kind: 'clear' },
];
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>; type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
@ -69,6 +88,44 @@ export function MeterDetailScreen() {
const { params } = useRoute<DetailRoute>(); const { params } = useRoute<DetailRoute>();
const z = params.zone; const z = params.zone;
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [label, setLabelState] = useState<ZoneLabel | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [savingKind, setSavingKind] = useState<string | null>(null);
useEffect(() => {
let alive = true;
void getAdminToken().then((t) => alive && setIsAdmin(!!t));
void getCachedLabel(z.ZoneId).then((l) => alive && setLabelState(l));
// Refresh from the server, then re-read this zone's label.
void refreshLabels()
.then(() => getCachedLabel(z.ZoneId))
.then((l) => alive && setLabelState(l))
.catch(() => {});
return () => {
alive = false;
};
}, [z.ZoneId]);
const applyLabel = async (kind: LabelKind | 'clear') => {
if (z.ZoneId == null) return;
setSavingKind(kind);
try {
if (kind === 'clear') {
await deleteLabel(z.ZoneId);
setLabelState(null);
} else {
const l = await setLabel(z.ZoneId, kind, {
zoneName: z.ZoneName ?? null,
customerId: z.CustomerId ?? null,
});
setLabelState(l);
}
} catch (e: any) {
Alert.alert('Could not save label', e?.message ?? 'error');
} finally {
setSavingKind(null);
}
};
const onSave = async () => { const onSave = async () => {
await saveKiosk(toSavedKiosk(z)); await saveKiosk(toSavedKiosk(z));
@ -98,6 +155,17 @@ export function MeterDetailScreen() {
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text> <Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
) : null} ) : null}
<View
style={[
styles.badge,
{ backgroundColor: label ? (label.kind === 'pay_immediate' ? '#8a4b00' : '#1b5e20') : colors.card },
]}
>
<Text style={[styles.badgeText, { color: label ? '#fff' : colors.subtext }]}>
{label ? labelText(label.kind) : 'Unlabeled'}
</Text>
</View>
<View style={styles.row}> <View style={styles.row}>
<Field label="Scanner code" value={z.ScannerCode} /> <Field label="Scanner code" value={z.ScannerCode} />
<Field label="Serial" value={z.TerminalSerNo} /> <Field label="Serial" value={z.TerminalSerNo} />
@ -136,6 +204,36 @@ export function MeterDetailScreen() {
</View> </View>
) : null} ) : null}
{isAdmin ? (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>Label this zone (admin)</Text>
<View style={styles.chipRow}>
{LABEL_CHOICES.map((c) => {
const active = label?.kind === c.kind;
return (
<TouchableOpacity
key={c.label}
style={[
styles.chip,
{
borderColor: colors.border,
backgroundColor: active ? colors.primary : 'transparent',
opacity: savingKind != null && !active ? 0.5 : 1,
},
]}
onPress={() => applyLabel(c.kind)}
disabled={savingKind != null}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600' }}>
{c.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
) : null}
<TouchableOpacity <TouchableOpacity
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]} style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
onPress={onSave} onPress={onSave}
@ -171,4 +269,8 @@ const styles = StyleSheet.create({
policyRate: { fontSize: 13, fontWeight: '600' }, policyRate: { fontSize: 13, fontWeight: '600' },
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' }, button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
buttonText: { fontWeight: '600', fontSize: 16 }, buttonText: { fontWeight: '600', fontSize: 16 },
badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4 },
badgeText: { fontSize: 13, fontWeight: '700' },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
}); });