2026-10-03 13:33:50 +02:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
import { AboutView } from './components/AboutView';
|
2026-10-03 14:29:28 +02:00
|
|
|
import { AccessCard } from './components/AccessCard';
|
|
|
|
|
import { DashboardView } from './components/DashboardView';
|
|
|
|
|
import { LoginView } from './components/LoginView';
|
2026-10-03 19:14:41 +02:00
|
|
|
import { MapView, type JourneyOverlay } from './components/MapView';
|
2026-10-03 14:29:28 +02:00
|
|
|
import { PlaceDetail, type ReachState, type RefreshState } from './components/PlaceDetail';
|
2026-10-03 13:33:50 +02:00
|
|
|
import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './components/PlacesView';
|
|
|
|
|
import { ProfileView } from './components/ProfileView';
|
|
|
|
|
import { RouteView, type RouteForm } from './components/RouteView';
|
2026-10-03 17:33:13 +02:00
|
|
|
import { LangToggle } from './components/LangToggle';
|
2026-10-03 14:29:28 +02:00
|
|
|
import { SettingsPanel } from './components/SettingsPanel';
|
2026-10-03 15:02:13 +02:00
|
|
|
import { SheetHandle, type Sheet } from './components/SheetHandle';
|
2026-10-03 13:33:50 +02:00
|
|
|
import { Alert, Icon } from './components/ui';
|
|
|
|
|
import { assessPlace, buildIndex } from './lib/assess';
|
2026-10-03 14:29:28 +02:00
|
|
|
import { findAccount, userKey, type Account } from './lib/auth';
|
|
|
|
|
import { computeHotspots, computeStats, download, toCsv, toGeoJson, type Hotspot } from './lib/civic';
|
2026-10-03 13:33:50 +02:00
|
|
|
import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo';
|
|
|
|
|
import type { LatLon } from './lib/geo';
|
2026-10-03 14:29:28 +02:00
|
|
|
import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n';
|
2026-10-03 13:33:50 +02:00
|
|
|
import { buildLabels } from './lib/labels';
|
|
|
|
|
import { refreshPlace } from './lib/live';
|
2026-10-03 17:33:13 +02:00
|
|
|
import { withCityOffer } from './lib/osmPlace';
|
2026-10-03 14:29:28 +02:00
|
|
|
import { DEFAULT_PROFILE, isProfile } from './lib/profile';
|
|
|
|
|
import { computeReach, placesInReach, type Reach } from './lib/reach';
|
|
|
|
|
import { buildGraph, type RouteOption } from './lib/route';
|
|
|
|
|
import { deletePhoto, putPhoto, type ProcessedPhoto } from './lib/photo';
|
|
|
|
|
import { NoCreditsError, vetReport } from './lib/vet';
|
|
|
|
|
import { GuideBanner } from './components/GuideBanner';
|
|
|
|
|
import { findAlternatives } from './lib/route';
|
|
|
|
|
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
|
2026-10-03 19:14:41 +02:00
|
|
|
import { APP_SLUG } from './config';
|
|
|
|
|
import { useJourneys } from './lib/useTransit';
|
|
|
|
|
import { loadTransitIndex } from './lib/transit';
|
2026-10-03 15:02:13 +02:00
|
|
|
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
|
2026-10-03 14:29:28 +02:00
|
|
|
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
|
|
|
|
|
|
|
|
|
|
type View = 'places' | 'route' | 'dashboard' | 'about' | 'profile';
|
|
|
|
|
type Decision = { status: 'accepted' | 'rejected'; reviewedAt: string; reviewedBy: string };
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
const isLang = (v: unknown): v is Lang => v === 'pl' || v === 'en';
|
|
|
|
|
const detectLang = (): Lang => (navigator.language.toLowerCase().startsWith('pl') ? 'pl' : 'en');
|
2026-10-03 14:29:28 +02:00
|
|
|
const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v === 'string';
|
|
|
|
|
const isStrArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
|
|
|
const isObj = <T extends object>(v: unknown): v is T => typeof v === 'object' && v !== null && !Array.isArray(v);
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
export default function App() {
|
2026-10-03 14:29:28 +02:00
|
|
|
// ---- identity (demo) ----
|
|
|
|
|
const [loginLang, setLoginLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
|
|
|
|
|
const [session, setSession] = useLocalState<string | null>('kbb.session', null, isStrOrNull);
|
|
|
|
|
const account: Account | null = findAccount(session);
|
|
|
|
|
const uid = account?.id ?? 'none';
|
|
|
|
|
const [settings, setSettings] = useLocalState<Settings>(userKey(uid, 'settings'), defaultSettings(loginLang), isSettings);
|
|
|
|
|
const [profile, setProfile] = useLocalState<Profile | null>(userKey(uid, 'profile'), null, (v): v is Profile | null => v === null || isProfile(v));
|
|
|
|
|
const [favorites, setFavorites] = useLocalState<string[]>(userKey(uid, 'favorites'), [], isStrArray);
|
2026-10-03 13:33:50 +02:00
|
|
|
const [userReports, setUserReports] = useLocalState<UserReport[]>('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v));
|
2026-10-03 14:29:28 +02:00
|
|
|
const [decisions, setDecisions] = useLocalState<Record<string, Decision>>('kbb.decisions', {}, isObj<Record<string, Decision>>);
|
|
|
|
|
const [demand, setDemand] = useLocalState<Record<string, number>>('kbb.demand', {}, isObj<Record<string, number>>);
|
|
|
|
|
|
|
|
|
|
const lang: Lang = account ? settings.lang : loginLang;
|
|
|
|
|
const [view, setView] = useState<View>('places');
|
2026-10-03 15:02:13 +02:00
|
|
|
// Mobile portrait: the map fills the screen and the panel is a bottom sheet (peek / half / full).
|
|
|
|
|
const [sheet, setSheet] = useState<Sheet>('half');
|
|
|
|
|
const sheetMode = useMedia('(max-width: 63.99em) and (orientation: portrait)');
|
|
|
|
|
const setPane = useCallback((p: 'list' | 'map') => setSheet(p === 'map' ? 'peek' : 'half'), []);
|
2026-10-03 13:33:50 +02:00
|
|
|
const online = useOnline();
|
|
|
|
|
|
|
|
|
|
const i18n = useMemo<I18n>(() => {
|
|
|
|
|
const { t, tm } = makeT(lang);
|
2026-10-03 14:29:28 +02:00
|
|
|
return { lang, setLang: (l) => (account ? setSettings((s) => ({ ...s, lang: l })) : setLoginLang(l)), t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') };
|
|
|
|
|
}, [lang, account, setSettings, setLoginLang]);
|
2026-10-03 13:33:50 +02:00
|
|
|
const { t } = i18n;
|
2026-10-03 14:29:28 +02:00
|
|
|
useEffect(() => { document.title = t('app.name'); }, [t]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (account) { applySettings(settings); setLoginLang(settings.lang); }
|
|
|
|
|
else { document.documentElement.lang = loginLang; document.documentElement.style.fontSize = ''; document.documentElement.dataset.contrast = 'default'; }
|
|
|
|
|
}, [account, settings, loginLang, setLoginLang]);
|
|
|
|
|
|
|
|
|
|
// first login: seed the profile from the account's persona
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!account || profile) return;
|
|
|
|
|
const kind = account.preset ?? (account.role === 'official' ? 'wheelchair' : null);
|
|
|
|
|
if (kind) setProfile(DEFAULT_PROFILE[kind]);
|
|
|
|
|
}, [account, profile, setProfile]);
|
|
|
|
|
|
|
|
|
|
const signIn = (a: Account) => {
|
|
|
|
|
setSession(a.id);
|
|
|
|
|
setView(a.role === 'official' ? 'dashboard' : 'places');
|
|
|
|
|
setPane('list');
|
|
|
|
|
};
|
|
|
|
|
const signOut = () => { setSession(null); setView('places'); setSelectedId(null); };
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
// ---- data ----
|
|
|
|
|
const [snap, setSnap] = useState<Snapshot | null>(null);
|
|
|
|
|
const [loadError, setLoadError] = useState(false);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const ctrl = new AbortController();
|
|
|
|
|
fetch(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, { signal: ctrl.signal })
|
|
|
|
|
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise<Snapshot>; })
|
|
|
|
|
.then(setSnap)
|
|
|
|
|
.catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); });
|
|
|
|
|
return () => ctrl.abort();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
|
|
|
|
|
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
|
|
|
|
|
const [overrides, setOverrides] = useState<Record<string, Place>>({});
|
2026-10-03 17:33:13 +02:00
|
|
|
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => withCityOffer(overrides[p.id] ?? p)) : []), [snap, overrides]);
|
2026-10-03 14:29:28 +02:00
|
|
|
const places = useMemo<Place[]>(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]);
|
|
|
|
|
const reports = useMemo<UserReport[]>(
|
|
|
|
|
() => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)),
|
|
|
|
|
[userReports, decisions],
|
2026-10-03 13:33:50 +02:00
|
|
|
);
|
|
|
|
|
const assessments = useMemo(() => {
|
2026-10-03 14:29:28 +02:00
|
|
|
const m = new Map<string, Assessment>();
|
2026-10-03 13:33:50 +02:00
|
|
|
if (index && profile) for (const p of places) m.set(p.id, assessPlace(p, profile, index, reports));
|
|
|
|
|
return m;
|
|
|
|
|
}, [places, profile, index, reports]);
|
|
|
|
|
const labels = useMemo(() => buildLabels(places), [places]);
|
2026-10-03 14:29:28 +02:00
|
|
|
const placeById = useMemo(() => new Map([...DEMO_PLACES, ...realPlaces].map((p) => [p.id, p])), [realPlaces]);
|
|
|
|
|
|
|
|
|
|
const previewCounts = useCallback((p: Profile): Record<Verdict, number> => {
|
|
|
|
|
const c: Record<Verdict, number> = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 };
|
|
|
|
|
if (index) for (const pl of realPlaces) c[assessPlace(pl, p, index, reports).verdict]++;
|
|
|
|
|
return c;
|
|
|
|
|
}, [index, realPlaces, reports]);
|
2026-10-03 13:33:50 +02:00
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
// ---- places view ----
|
2026-10-03 13:33:50 +02:00
|
|
|
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
2026-10-03 14:29:28 +02:00
|
|
|
const favSet = useMemo(() => new Set(favorites), [favorites]);
|
|
|
|
|
const results = useMemo(() => filterPlaces(places, assessments, filters, favSet), [places, assessments, filters, favSet]);
|
2026-10-03 17:33:13 +02:00
|
|
|
const catCounts = useMemo(() => {
|
|
|
|
|
const c: Record<string, number> = {};
|
|
|
|
|
for (const p of filterPlaces(places, assessments, { ...filters, cat: 'all' }, favSet)) { c[p.cat] = (c[p.cat] ?? 0) + 1; c.all = (c.all ?? 0) + 1; }
|
|
|
|
|
return c;
|
|
|
|
|
}, [places, assessments, filters, favSet]);
|
2026-10-03 13:33:50 +02:00
|
|
|
const mapPlaces = useMemo(
|
|
|
|
|
() => results.flatMap((place) => { const a = assessments.get(place.id); return a ? [{ place, verdict: a.verdict }] : []; }),
|
|
|
|
|
[results, assessments],
|
|
|
|
|
);
|
|
|
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
2026-10-03 14:29:28 +02:00
|
|
|
const selected = selectedId ? placeById.get(selectedId) ?? null : null;
|
2026-10-03 13:33:50 +02:00
|
|
|
const [refresh, setRefresh] = useState<RefreshState>({ kind: 'idle' });
|
|
|
|
|
const [simulateOutage, setSimulateOutage] = useState(false);
|
|
|
|
|
const detailHeading = useRef<HTMLHeadingElement>(null);
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
// ---- reach ----
|
|
|
|
|
const [reachState, setReachState] = useState<ReachState>({ minutes: null, summary: null });
|
|
|
|
|
const [reachLines, setReachLines] = useState<[LatLon, LatLon][] | null>(null);
|
|
|
|
|
const snapCache = useRef(new Map<string, number | null>());
|
|
|
|
|
const clearReach = useCallback(() => { setReachState({ minutes: null, summary: null }); setReachLines(null); }, []);
|
|
|
|
|
const doReach = (place: Place, minutes: number) => {
|
|
|
|
|
if (!graph || !profile) return;
|
|
|
|
|
const r: Reach | null = computeReach(graph, [place.lat, place.lon], profile, minutes);
|
|
|
|
|
if (!r) { setReachState({ minutes, summary: 'stuck' }); setReachLines(null); return; }
|
|
|
|
|
const inReach = placesInReach(graph, r, realPlaces.filter((p) => p.id !== place.id), snapCache.current);
|
|
|
|
|
const fit = inReach.filter((p) => { const v = assessments.get(p.id)?.verdict; return v === 'match' || v === 'partial'; }).length;
|
|
|
|
|
setReachState({ minutes, summary: { n: inReach.length, fit } });
|
|
|
|
|
setReachLines(r.lines);
|
2026-10-03 15:02:13 +02:00
|
|
|
setSheet('half');
|
2026-10-03 14:29:28 +02:00
|
|
|
};
|
|
|
|
|
|
2026-10-03 13:33:50 +02:00
|
|
|
const select = useCallback((id: string | null) => {
|
|
|
|
|
setSelectedId(id);
|
|
|
|
|
setRefresh({ kind: 'idle' });
|
2026-10-03 14:29:28 +02:00
|
|
|
clearReach();
|
|
|
|
|
if (id) {
|
|
|
|
|
setPane('list');
|
|
|
|
|
if (!id.startsWith('demo-')) setDemand((d) => ({ ...d, [id]: (d[id] ?? 0) + 1 }));
|
|
|
|
|
}
|
|
|
|
|
}, [clearReach, setDemand]);
|
2026-10-03 13:33:50 +02:00
|
|
|
useEffect(() => { if (selectedId) detailHeading.current?.focus(); }, [selectedId]);
|
2026-10-03 14:29:28 +02:00
|
|
|
useEffect(() => { clearReach(); }, [profile, clearReach]);
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
const back = () => {
|
|
|
|
|
const id = selectedId;
|
|
|
|
|
select(null);
|
|
|
|
|
if (id) setTimeout(() => document.getElementById(`place-${id}`)?.focus(), 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const doRefresh = async (p: Place) => {
|
|
|
|
|
setRefresh({ kind: 'loading' });
|
|
|
|
|
try {
|
|
|
|
|
if (simulateOutage) throw new Error('simulated outage');
|
|
|
|
|
const fresh = await refreshPlace(p.id);
|
2026-10-03 14:29:28 +02:00
|
|
|
setOverrides((o) => ({ ...o, [p.id]: { ...(p.ext ? { ext: p.ext } : {}), ...fresh } }));
|
2026-10-03 13:33:50 +02:00
|
|
|
setRefresh({ kind: 'ok', at: new Date().toISOString() });
|
|
|
|
|
} catch {
|
|
|
|
|
setRefresh({ kind: 'fail' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
const patchReport = (id: string, patch: Partial<UserReport>) =>
|
|
|
|
|
setUserReports((cur) => cur.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
|
|
|
// a vetting that was running when the tab closed will never finish
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setUserReports((cur) => (cur.some((r) => r.vetting?.state === 'running') ? cur.map((r) => (r.vetting?.state === 'running' ? { ...r, vetting: { state: 'failed', reason: 'error' } } : r)) : cur));
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
const addReport = async (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => {
|
|
|
|
|
const id = newId();
|
|
|
|
|
const rep: UserReport = {
|
|
|
|
|
...r, id, createdAt: new Date().toISOString(), authorId: account?.id ?? 'guest',
|
|
|
|
|
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
|
|
|
|
|
};
|
|
|
|
|
if (photo) await putPhoto(id, photo.dataUrl);
|
2026-10-03 15:02:13 +02:00
|
|
|
// the on-device model is ~90 MB: ask before downloading it on a metered link
|
|
|
|
|
if (photo && isMetered() && !window.confirm(t('vet.metered'))) {
|
|
|
|
|
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-03 14:29:28 +02:00
|
|
|
setUserReports((cur) => [...cur, rep]);
|
|
|
|
|
try {
|
|
|
|
|
const result = await vetReport(rep, photo ? { dataUrl: photo.dataUrl, blur: photo.blur } : null);
|
|
|
|
|
patchReport(id, { vetting: { state: 'done', result } });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
patchReport(id, { vetting: { state: 'failed', reason: e instanceof NoCreditsError ? 'no-credits' : 'error' } });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const [cardId, setCardId] = useState<string | null>(null);
|
2026-10-03 13:33:50 +02:00
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
// ---- route ----
|
2026-10-03 13:33:50 +02:00
|
|
|
const [routeForm, setRouteForm] = useState<RouteForm>({ fromText: '', toText: '', fromPoint: null });
|
2026-10-03 14:29:28 +02:00
|
|
|
const [routeOptions, setRouteOptions] = useState<RouteOption[] | null>(null);
|
|
|
|
|
const [routeSel, setRouteSel] = useState(0);
|
|
|
|
|
const [guiding, setGuiding] = useState(false);
|
|
|
|
|
const [userPos, setUserPos] = useState<LatLon | null>(null);
|
|
|
|
|
const [basemap, setBasemap] = useLocalState<'map' | 'satellite'>('kbb.basemap', 'map', (v): v is 'map' | 'satellite' => v === 'map' || v === 'satellite');
|
|
|
|
|
const route = routeOptions?.[routeSel]?.result ?? null;
|
|
|
|
|
const altRoutes = useMemo(() => (routeOptions ?? []).filter((_, i) => i !== routeSel).map((o) => o.result.coords), [routeOptions, routeSel]);
|
2026-10-03 13:33:50 +02:00
|
|
|
const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null);
|
|
|
|
|
const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null);
|
2026-10-03 19:14:41 +02:00
|
|
|
const [routeTab, setRouteTab] = useState<'walk' | 'transit'>('walk');
|
|
|
|
|
const transitStops = useMemo(() => realPlaces.filter((p) => p.cat === 'transit'), [realPlaces]);
|
|
|
|
|
// warm the timetable as soon as a route exists, so the "Komunikacja" tab is usually instant (skipped on metered links)
|
|
|
|
|
useEffect(() => { if (routeEnds && !isMetered()) void loadTransitIndex().catch(() => undefined); }, [routeEnds]);
|
|
|
|
|
const planner = useJourneys({ enabled: view === 'route' && routeTab === 'transit', ends: routeEnds, graph, profile, stops: transitStops });
|
|
|
|
|
const journey = useMemo<JourneyOverlay | null>(() => {
|
|
|
|
|
const j = view === 'route' && routeTab === 'transit' && planner.status === 'ready' ? planner.journeys[planner.selected] : undefined;
|
|
|
|
|
if (!j) return null;
|
|
|
|
|
return {
|
|
|
|
|
walks: [j.walkTo.coords, ...(j.transfer ? [j.transfer.walk.coords] : []), j.walkFrom.coords],
|
|
|
|
|
rides: j.rides.map((r) => ({ path: r.path, mode: r.dep.route.mode })),
|
|
|
|
|
stops: j.rides.flatMap((r) => [{ at: [r.from.lat, r.from.lon] as LatLon, label: `${r.dep.line} · ${r.from.name ?? ''}` }, { at: [r.to.lat, r.to.lon] as LatLon, label: `${r.dep.line} · ${r.to.name ?? ''}` }]),
|
|
|
|
|
};
|
|
|
|
|
}, [view, routeTab, planner]);
|
2026-10-03 14:29:28 +02:00
|
|
|
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
|
|
|
|
|
setRouteOptions(o);
|
2026-10-03 19:14:41 +02:00
|
|
|
setRouteSel(Math.max(0, o?.findIndex((x) => x.recommended) ?? 0)); // the recommended variant is the default
|
2026-10-03 13:33:50 +02:00
|
|
|
setRouteEnds(from && to ? { from, to } : null);
|
2026-10-03 15:02:13 +02:00
|
|
|
if (o?.[0] && o[0].result.status !== 'none') setSheet('half');
|
2026-10-03 14:29:28 +02:00
|
|
|
};
|
|
|
|
|
useEffect(() => { setRouteOptions(null); setRouteEnds(null); setGuiding(false); }, [profile]);
|
|
|
|
|
useEffect(() => { if (view !== 'route' || !route || route.status === 'none') setGuiding(false); }, [view, route]);
|
|
|
|
|
const reroute = (from: LatLon) => {
|
|
|
|
|
if (!graph || !profile || !routeEnds) return;
|
|
|
|
|
const o = findAlternatives(graph, from, routeEnds.to, profile);
|
|
|
|
|
if (!o.length || o[0]!.result.status === 'none') return;
|
|
|
|
|
setRouteOptions(o);
|
|
|
|
|
setRouteSel(Math.max(0, o.findIndex((x) => x.recommended)));
|
|
|
|
|
setRouteEnds({ from, to: routeEnds.to });
|
2026-10-03 13:33:50 +02:00
|
|
|
};
|
|
|
|
|
const routeTo = (p: Place) => {
|
|
|
|
|
setRouteForm((f) => ({ ...f, toText: labels.labelOf.get(p.id) ?? p.name ?? '' }));
|
2026-10-03 14:29:28 +02:00
|
|
|
setRouteOptions(null);
|
2026-10-03 13:33:50 +02:00
|
|
|
setRouteEnds(null);
|
|
|
|
|
setView('route');
|
|
|
|
|
setPane('list');
|
|
|
|
|
};
|
2026-10-03 14:29:28 +02:00
|
|
|
// ---- city panel ----
|
|
|
|
|
const stats = useMemo(() => computeStats(places, assessments), [places, assessments]);
|
|
|
|
|
const hotspots = useMemo(() => (snap && index ? computeHotspots(snap, index, realPlaces) : []), [snap, index, realPlaces]);
|
|
|
|
|
const [hotMode, setHotMode] = useState(false);
|
|
|
|
|
const [hotFocus, setHotFocus] = useState<Hotspot | null>(null);
|
|
|
|
|
const [exportMsg, setExportMsg] = useState<string | null>(null);
|
|
|
|
|
const demandList = useMemo(
|
|
|
|
|
() => Object.entries(demand)
|
|
|
|
|
.flatMap(([id, views]) => { const p = placeById.get(id); return p && assessments.get(id)?.verdict === 'incomplete' ? [{ place: p, views }] : []; })
|
|
|
|
|
.sort((a, b) => b.views - a.views).slice(0, 5),
|
|
|
|
|
[demand, placeById, assessments],
|
|
|
|
|
);
|
2026-10-03 17:08:38 +02:00
|
|
|
const decide = (ids: string[], status: 'accepted' | 'rejected') => {
|
|
|
|
|
const stamp = { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' };
|
|
|
|
|
setDecisions((d) => ({ ...d, ...Object.fromEntries(ids.map((id) => [id, stamp])) }));
|
|
|
|
|
};
|
|
|
|
|
const undoDecisions = (ids: string[]) =>
|
|
|
|
|
setDecisions((d) => Object.fromEntries(Object.entries(d).filter(([id]) => !ids.includes(id))));
|
|
|
|
|
const pendingCount = useMemo(() => reports.filter((r) => !r.status || r.status === 'pending').length, [reports]);
|
2026-10-03 14:29:28 +02:00
|
|
|
const doExport = (kind: 'csv' | 'geojson') => {
|
|
|
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
2026-10-03 19:14:41 +02:00
|
|
|
const name = `${APP_SLUG}-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
|
2026-10-03 14:29:28 +02:00
|
|
|
if (kind === 'csv') download(name, 'text/csv', toCsv(realPlaces, assessments));
|
|
|
|
|
else download(name, 'application/geo+json', JSON.stringify(toGeoJson(realPlaces, assessments)));
|
|
|
|
|
setExportMsg(t('dash.export.done', { name }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---- settings export / import ----
|
|
|
|
|
const [settingsMsg, setSettingsMsg] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
|
const exportSettings = () => download('kbb-settings.json', 'application/json', JSON.stringify({ app: 'kbb', v: 1, profile, settings, favorites }, null, 2));
|
|
|
|
|
const importSettings = async (file: File) => {
|
|
|
|
|
try {
|
|
|
|
|
const j: unknown = JSON.parse(await file.text());
|
|
|
|
|
if (!isObj<Record<string, unknown>>(j) || j.app !== 'kbb' || !isSettings(j.settings) || !(j.profile === null || isProfile(j.profile)) || !isStrArray(j.favorites)) throw new Error('shape');
|
|
|
|
|
setSettings(j.settings);
|
|
|
|
|
setProfile(j.profile);
|
|
|
|
|
setFavorites(j.favorites);
|
|
|
|
|
setSettingsMsg({ kind: 'success', text: t('set.import.ok') });
|
|
|
|
|
} catch {
|
|
|
|
|
setSettingsMsg({ kind: 'error', text: t('set.import.bad') });
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
// ---- shell ----
|
|
|
|
|
const mainRef = useRef<HTMLElement>(null);
|
|
|
|
|
const first = useRef(true);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (first.current) { first.current = false; return; }
|
|
|
|
|
mainRef.current?.focus();
|
|
|
|
|
}, [view]);
|
|
|
|
|
|
|
|
|
|
const [resetDone, setResetDone] = useState(false);
|
|
|
|
|
const reset = () => {
|
2026-10-03 14:29:28 +02:00
|
|
|
const keys = Object.keys(localStorage).filter((k) => k.startsWith('kbb.') && k !== 'kbb.lang');
|
|
|
|
|
clearLocal(keys);
|
2026-10-03 13:33:50 +02:00
|
|
|
setResetDone(true);
|
2026-10-03 14:29:28 +02:00
|
|
|
window.setTimeout(() => window.location.reload(), 600);
|
2026-10-03 13:33:50 +02:00
|
|
|
};
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
const official = account?.role === 'official';
|
2026-10-03 17:08:38 +02:00
|
|
|
const nav: { id: View; label: string; icon: string; badge?: number }[] = [
|
2026-10-03 15:02:13 +02:00
|
|
|
{ id: 'places', label: t('nav.places'), icon: 'location_on' },
|
|
|
|
|
{ id: 'route', label: t('nav.route'), icon: 'directions_walk' },
|
2026-10-03 17:08:38 +02:00
|
|
|
...(official ? [{ id: 'dashboard' as View, label: t(sheetMode ? 'nav.dashboard.short' : 'nav.dashboard'), icon: 'assessment', badge: pendingCount }] : []),
|
2026-10-03 15:02:13 +02:00
|
|
|
{ id: 'about', label: t('nav.about'), icon: 'info' },
|
|
|
|
|
{ id: 'profile', label: t('nav.profile'), icon: 'account_circle' },
|
2026-10-03 13:33:50 +02:00
|
|
|
];
|
2026-10-03 14:29:28 +02:00
|
|
|
const needsProfile = !!account && !profile;
|
|
|
|
|
const split = !!account && !needsProfile && (view === 'places' || view === 'route');
|
2026-10-03 13:33:50 +02:00
|
|
|
const ready = snap && index && graph && profile;
|
2026-10-03 14:29:28 +02:00
|
|
|
const card = cardId ? placeById.get(cardId) : null;
|
|
|
|
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
return (
|
|
|
|
|
<I18nContext.Provider value={i18n}>
|
|
|
|
|
<div className="kbb-app"><main id="main" className="kbb-main"><LoginView onPick={signIn} lang={loginLang} onLang={setLoginLang} /></main></div>
|
|
|
|
|
</I18nContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<I18nContext.Provider value={i18n}>
|
2026-10-03 15:02:13 +02:00
|
|
|
<div className={`kbb-app ${guiding && view === 'route' ? 'is-guiding' : ''}`}>
|
2026-10-03 13:33:50 +02:00
|
|
|
<a className="usa-skipnav" href="#main">{t('skip')}</a>
|
|
|
|
|
|
2026-10-03 15:02:13 +02:00
|
|
|
<section className="kbb-proto usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
|
2026-10-03 13:33:50 +02:00
|
|
|
<div className="usa-alert">
|
|
|
|
|
<div className="usa-alert__body">
|
|
|
|
|
<p className="usa-alert__text">
|
|
|
|
|
<strong>{t('banner.proto')}</strong>{' '}
|
2026-10-03 14:29:28 +02:00
|
|
|
<span className="kbb-banner-detail">{snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}</span>
|
2026-10-03 13:33:50 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<header className="bg-white border-bottom-1px border-base-lighter">
|
2026-10-03 14:29:28 +02:00
|
|
|
<div className="kbb-head">
|
|
|
|
|
<p className="margin-0 display-flex flex-align-center text-bold text-primary-darker kbb-head__brand">
|
2026-10-03 13:33:50 +02:00
|
|
|
<Icon name="accessible_forward" size={4} className="margin-right-1" />
|
|
|
|
|
{t('app.name')}
|
|
|
|
|
</p>
|
2026-10-03 14:29:28 +02:00
|
|
|
<nav aria-label={t('nav.label')} className="kbb-head__nav">
|
2026-10-03 15:02:13 +02:00
|
|
|
<ul className="kbb-nav usa-button-group usa-button-group--segmented margin-0 padding-0">
|
2026-10-03 14:29:28 +02:00
|
|
|
{nav.map((n) => (
|
|
|
|
|
<li className="usa-button-group__item" key={n.id}>
|
|
|
|
|
<button
|
|
|
|
|
type="button" aria-current={view === n.id ? 'page' : undefined}
|
2026-10-03 15:02:13 +02:00
|
|
|
className={`kbb-nav__btn usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
|
2026-10-03 14:29:28 +02:00
|
|
|
onClick={() => setView(n.id)}
|
|
|
|
|
>
|
2026-10-03 15:02:13 +02:00
|
|
|
<Icon name={n.icon} size={3} className="kbb-nav__icon" />
|
|
|
|
|
<span>{n.label}</span>
|
2026-10-03 17:08:38 +02:00
|
|
|
{n.badge ? (
|
|
|
|
|
<>
|
|
|
|
|
<span className="kbb-nav__badge" aria-hidden="true">{n.badge}</span>
|
|
|
|
|
<span className="usa-sr-only">{t('dash.nav.pending', { n: n.badge })}</span>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
2026-10-03 14:29:28 +02:00
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</nav>
|
|
|
|
|
<div className="kbb-head__tools">
|
|
|
|
|
<span className="font-sans-xs text-base-dark kbb-head__user">
|
|
|
|
|
<Icon name="accessibility_new" size={3} className="margin-right-05" />
|
|
|
|
|
{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}
|
|
|
|
|
</span>
|
2026-10-03 17:33:13 +02:00
|
|
|
<button type="button" className="kbb-signout" onClick={signOut} aria-label={t('auth.signOut')} title={t('auth.signOut')}>
|
|
|
|
|
<Icon name="logout" size={3} />
|
|
|
|
|
</button>
|
|
|
|
|
<LangToggle lang={lang} onLang={i18n.setLang} />
|
2026-10-03 14:29:28 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-10-03 13:33:50 +02:00
|
|
|
</header>
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
{guiding && view === 'route' && route && route.status !== 'none' && (
|
|
|
|
|
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
|
|
|
|
|
)}
|
|
|
|
|
|
2026-10-03 15:02:13 +02:00
|
|
|
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${sheet}` : ''}`}>
|
2026-10-03 13:33:50 +02:00
|
|
|
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
|
2026-10-03 14:29:28 +02:00
|
|
|
{!loadError && !ready && !needsProfile && view !== 'profile' && (
|
2026-10-03 13:33:50 +02:00
|
|
|
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{(view === 'profile' || needsProfile) && (
|
|
|
|
|
<ProfileView
|
2026-10-03 14:29:28 +02:00
|
|
|
key={account.id + String(!!profile)}
|
|
|
|
|
account={account}
|
|
|
|
|
profile={profile}
|
|
|
|
|
onChange={setProfile}
|
|
|
|
|
onDone={() => setView('places')}
|
|
|
|
|
onSignOut={signOut}
|
|
|
|
|
preview={previewCounts}
|
|
|
|
|
>
|
|
|
|
|
{!needsProfile && (
|
|
|
|
|
<SettingsPanel
|
|
|
|
|
settings={settings} onSettings={setSettings} favoritesCount={favorites.length}
|
|
|
|
|
onExport={exportSettings} onImport={(f) => void importSettings(f)} message={settingsMsg}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</ProfileView>
|
2026-10-03 13:33:50 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{view === 'about' && !needsProfile && snap && (
|
|
|
|
|
<AboutView
|
2026-10-03 14:29:28 +02:00
|
|
|
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts} cityData={snap.meta.cityData}
|
2026-10-03 13:33:50 +02:00
|
|
|
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
{view === 'dashboard' && !needsProfile && (
|
|
|
|
|
official ? (
|
|
|
|
|
ready && (
|
|
|
|
|
<DashboardView
|
|
|
|
|
stats={stats} hotspots={hotspots} reports={reports} placeById={placeById} demand={demandList}
|
|
|
|
|
officialName={t(`auth.${account.id}` as Key)}
|
2026-10-03 17:08:38 +02:00
|
|
|
onDecide={decide} onUndo={undoDecisions} onOpenPlace={(id) => { setView('places'); select(id); }} onExport={doExport} exportMsg={exportMsg}
|
2026-10-03 14:29:28 +02:00
|
|
|
onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid-container padding-y-4 kbb-narrow"><Alert kind="warning" heading={t('dash.title')}>{t('dash.role')}</Alert></div>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
|
2026-10-03 13:33:50 +02:00
|
|
|
{split && ready && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="kbb-panel">
|
2026-10-03 15:02:13 +02:00
|
|
|
{sheetMode && <SheetHandle sheet={sheet} onSheet={setSheet} />}
|
|
|
|
|
<div className="kbb-panel__body" {...(sheetMode && sheet === 'peek' ? { inert: true } : {})}>
|
2026-10-03 14:29:28 +02:00
|
|
|
{view === 'places' && hotMode && (
|
|
|
|
|
<div className="margin-bottom-2">
|
|
|
|
|
<Alert kind="info" slim>
|
|
|
|
|
{t('dash.hot.mapBanner', { n: hotspots.length })}{' '}
|
|
|
|
|
<button type="button" className="usa-button usa-button--unstyled" onClick={() => { setHotMode(false); setHotFocus(null); }}>{t('dash.hot.clear')}</button>
|
|
|
|
|
</Alert>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-10-03 13:33:50 +02:00
|
|
|
{view === 'places' && (selected ? (
|
|
|
|
|
<PlaceDetail
|
|
|
|
|
ref={detailHeading}
|
|
|
|
|
place={selected}
|
2026-10-03 14:29:28 +02:00
|
|
|
assessment={assessments.get(selected.id) ?? assessPlace(selected, profile, index, reports)}
|
2026-10-03 13:33:50 +02:00
|
|
|
reports={reports}
|
|
|
|
|
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
|
|
|
|
|
refresh={refresh}
|
2026-10-03 14:29:28 +02:00
|
|
|
saved={favSet.has(selected.id)}
|
|
|
|
|
onToggleSave={() => setFavorites((f) => (f.includes(selected.id) ? f.filter((x) => x !== selected.id) : [...f, selected.id]))}
|
|
|
|
|
onCard={() => setCardId(selected.id)}
|
|
|
|
|
reach={reachState}
|
|
|
|
|
onReach={(m) => doReach(selected, m)}
|
|
|
|
|
onClearReach={clearReach}
|
2026-10-03 13:33:50 +02:00
|
|
|
onBack={back}
|
|
|
|
|
onRouteTo={() => routeTo(selected)}
|
2026-10-03 19:14:41 +02:00
|
|
|
stops={transitStops}
|
|
|
|
|
onOpenStop={(id) => select(id)}
|
2026-10-03 13:33:50 +02:00
|
|
|
onRefresh={() => void doRefresh(selected)}
|
2026-10-03 14:29:28 +02:00
|
|
|
onAddReport={(r, photo) => void addReport(r, photo)}
|
|
|
|
|
onRemoveReport={(id) => { void deletePhoto(id); setUserReports((cur) => cur.filter((r) => r.id !== id)); }}
|
2026-10-03 13:33:50 +02:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<PlacesView
|
|
|
|
|
profile={profile} filters={filters} onFilters={setFilters} results={results}
|
2026-10-03 17:33:13 +02:00
|
|
|
assessments={assessments} counts={catCounts} onSelect={select}
|
2026-10-03 13:33:50 +02:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
{view === 'route' && (
|
|
|
|
|
<RouteView
|
|
|
|
|
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
|
2026-10-03 19:14:41 +02:00
|
|
|
tab={routeTab} onTab={setRouteTab} planner={planner} onShowTransit={() => setSheet((s) => (s === 'full' ? 'half' : s))}
|
2026-10-03 14:29:28 +02:00
|
|
|
form={routeForm} onForm={setRouteForm} options={routeOptions} ends={routeEnds} guiding={guiding} onGuide={() => { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult}
|
2026-10-03 19:14:41 +02:00
|
|
|
onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
|
2026-10-03 13:33:50 +02:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
|
2026-10-03 15:02:13 +02:00
|
|
|
</div>
|
2026-10-03 13:33:50 +02:00
|
|
|
</div>
|
|
|
|
|
<MapView
|
|
|
|
|
key={lang}
|
|
|
|
|
places={mapPlaces}
|
|
|
|
|
showPlaces={view === 'places'}
|
|
|
|
|
selectedId={view === 'places' ? selectedId : null}
|
|
|
|
|
onSelect={select}
|
2026-10-03 19:14:41 +02:00
|
|
|
route={view === 'route' && routeTab === 'walk' ? route : null}
|
|
|
|
|
journey={journey}
|
2026-10-03 19:23:37 +02:00
|
|
|
sheet={sheetMode && !(guiding && view === 'route') ? sheet : null}
|
2026-10-03 13:33:50 +02:00
|
|
|
routeEnds={view === 'route' ? routeEnds : null}
|
2026-10-03 19:14:41 +02:00
|
|
|
altRoutes={view === 'route' && routeTab === 'walk' ? altRoutes : []}
|
2026-10-03 13:33:50 +02:00
|
|
|
focus={focus}
|
2026-10-03 15:02:13 +02:00
|
|
|
visible={sheet}
|
2026-10-03 13:33:50 +02:00
|
|
|
bbox={snap.meta.bbox}
|
|
|
|
|
online={online}
|
2026-10-03 14:29:28 +02:00
|
|
|
basemap={basemap}
|
|
|
|
|
onBasemap={setBasemap}
|
|
|
|
|
userPos={view === 'route' && guiding ? userPos : null}
|
|
|
|
|
follow={guiding}
|
|
|
|
|
reach={view === 'places' ? reachLines : null}
|
|
|
|
|
hotspots={view === 'places' && hotMode ? hotspots : []}
|
|
|
|
|
hotspotFocus={hotFocus}
|
|
|
|
|
reduceMotion={settings.reduceMotion}
|
2026-10-03 13:33:50 +02:00
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</main>
|
2026-10-03 14:29:28 +02:00
|
|
|
|
|
|
|
|
{card && profile && (
|
|
|
|
|
<AccessCard
|
|
|
|
|
place={card}
|
|
|
|
|
assessment={assessments.get(card.id) ?? assessPlace(card, profile, index!, reports)}
|
|
|
|
|
profile={profile}
|
|
|
|
|
onClose={() => setCardId(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-10-03 13:33:50 +02:00
|
|
|
</div>
|
|
|
|
|
</I18nContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-03 14:29:28 +02:00
|
|
|
|