From d9927fc6dbda992814463737625a597956f669ab Mon Sep 17 00:00:00 2001 From: InfoX Date: Sat, 3 Oct 2026 19:56:49 +0200 Subject: [PATCH] Add Docker files and remove DEMO content. --- .dockerignore | 6 ++ Dockerfile | 17 +++++ README.md | 68 ++++++++++++++++++- docker-compose.yml | 40 +++++++++++ docker/Caddyfile | 5 ++ docker/nginx.conf | 56 ++++++++++++++++ index.html | 19 +++++- package.json | 3 +- scripts/offline-e2e.mjs | 50 ++++++++++++++ scripts/sensors-e2e.mjs | 4 +- scripts/smoke.mjs | 4 +- scripts/vet-e2e.mjs | 2 +- src/App.tsx | 59 ++++++++++------ src/components/AboutView.tsx | 27 +++++++- src/components/AppLoader.tsx | 18 +++++ src/components/DashQueue.tsx | 2 +- src/components/LoginView.tsx | 9 ++- src/components/MapView.tsx | 19 ++++-- src/components/PlaceDetail.tsx | 49 +++++--------- src/components/PlacesView.tsx | 5 +- src/components/ReportCard.tsx | 1 - src/components/SettingsPanel.tsx | 5 -- src/components/StatusBanners.tsx | 90 +++++++++++++++++++++++++ src/components/ui.tsx | 2 +- src/config.ts | 15 ++++- src/global.d.ts | 2 + src/lib/assess.ts | 2 +- src/lib/civic.ts | 13 ++-- src/lib/demo.ts | 42 ------------ src/lib/fetchProgress.ts | 33 +++++++++ src/lib/i18n.ts | 68 ++++++++++++++----- src/lib/offline.test.ts | 90 +++++++++++++++++++++++++ src/lib/offline.ts | 112 +++++++++++++++++++++++++++++++ src/lib/settings.ts | 5 +- src/lib/transit.ts | 27 +------- src/lib/types.ts | 8 +-- src/lib/useOffline.ts | 83 +++++++++++++++++++++++ src/lib/useTransit.ts | 1 + src/styles/app.css | 32 +++++++++ vite.config.ts | 32 +++++++-- 40 files changed, 938 insertions(+), 187 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/Caddyfile create mode 100644 docker/nginx.conf create mode 100644 scripts/offline-e2e.mjs create mode 100644 src/components/AppLoader.tsx create mode 100644 src/components/StatusBanners.tsx create mode 100644 src/global.d.ts delete mode 100644 src/lib/demo.ts create mode 100644 src/lib/fetchProgress.ts create mode 100644 src/lib/offline.test.ts create mode 100644 src/lib/offline.ts create mode 100644 src/lib/useOffline.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1068dbe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.DS_Store +scripts/.cache +*.pdf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b85fbf6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# syntax=docker/dockerfile:1 + +# ---- build: type-check and bundle the PWA (the places snapshot and timetable in public/data are part of the repo) ---- +FROM node:24-slim AS build +WORKDIR /app +RUN npm install -g pnpm@11.22.0 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +# ---- serve: static files + the /rt/ proxy for live transit data (see docker/nginx.conf) ---- +FROM nginx:1.27-alpine +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 diff --git a/README.md b/README.md index 8395514..7bb9d9c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ pnpm dev # or: pnpm build && pnpm preview pnpm test # unit tests (parsing, verdicts, routing, reach, moderation) pnpm smoke # end-to-end flows against `pnpm preview` on :4173 pnpm a11y # axe-core WCAG 2.2 AA scan of every main view -pnpm data:build # re-fetch OSM + ZTP data into public/data/kk-snapshot.json +pnpm data:build # re-fetch OSM + ZTP data into public/data/kk-snapshot.json and rebuild the timetable (kk-transit.json) +pnpm transit:build # only rebuild the timetable from the cached GTFS zips +pnpm offline # offline end-to-end check (needs `pnpm preview`): saves the app + map, cuts the network, reloads ``` Demo sign-in has no passwords: pick **Anna** (wheelchair), **Piotr** (stroller) or **Maria** (city @@ -67,6 +69,69 @@ Without it the app says "timetable only" and shows no delays; it never pretends The ZTP timetable has **no low-floor flag** (`wheelchair_accessible` is 0 for all 57k trips in the area) and the stops' `wheelchair_boarding` is empty, so the app cannot promise an accessible vehicle and says so. +## Deploy to production (Docker Compose) + +The app is static files plus one small proxy rule, so production is a single nginx container (`Dockerfile`, `docker/nginx.conf`). +There is no database and no server-side state: accounts, settings, reports and decisions live in each user's browser. + +**1. Settings you may want to change first** (compile-time constants in `src/config.ts`): + +| Constant | Meaning | +| --- | --- | +| `APP_NAME` | The app's name everywhere: header, page title, PWA name, export file names. | +| `SHOW_PROTOTYPE_BANNER` | The "prototype" strip above the header. `false` by default. | + +**2. Build and start** (Docker with Compose v2): + +```bash +git clone && cd +docker compose up -d --build # serves http://localhost:8080 +docker compose ps # "healthy" when /healthz answers +docker compose logs -f app +``` + +Pick another port or bind address with `APP_BIND` (e.g. `APP_BIND=127.0.0.1:8080` in a `.env` file next to `docker-compose.yml`). + +**3. HTTPS is required.** Browsers only allow location (GPS), the service worker (offline mode) and "install app" on `https://` +(or `localhost`). Two ways to get it: + +- *Your own reverse proxy* (Traefik, nginx, a load balancer): point it at the `app` container's port 80 and let it terminate TLS. + Set `APP_BIND=127.0.0.1:8080` so the container is not reachable directly. Do not strip or cache `/sw.js`. +- *Caddy, included* (certificate issued and renewed automatically): create `.env`, point the DNS A/AAAA record of the host at the + server, open ports 80 and 443, then: + + ```bash + printf 'DOMAIN=gdziewjade.example.org\nAPP_BIND=127.0.0.1:8080\n' > .env + docker compose --profile tls up -d --build + ``` + +**4. What the container does** + +- Serves the built app with gzip, a single-page-app fallback, and cache rules that make updates reach installed copies: + `index.html`, `sw.js`, the manifest and `data/*` are always revalidated; `assets/*` (hashed names) are cached for a year. +- Proxies `/rt/*` to `gtfs.ztp.krakow.pl` (live delays; ZTP sends no CORS headers) with a 15 s cache, so a crowd of users makes one + upstream request per feed per 15 s. If ZTP is down the app falls back to the timetable and says delays are unavailable. +- `GET /healthz` returns `ok` (used by the Compose health check). The filesystem is read-only. + +**5. Updating** + +```bash +git pull +docker compose up -d --build # rebuilds the image; installed apps pick up the new version on their next visit +``` + +To refresh the places and timetable data, run `pnpm data:build` on a machine with internet access (Overpass can rate-limit; the script +retries and rotates mirrors), commit `public/data/kk-snapshot.json` and `public/data/kk-transit.json`, then rebuild as above. +The GTFS feed changes every few days; rebuilding monthly keeps the offline copy reasonably fresh. + +**6. Before going public** + +- Map tiles come from `tile.openstreetmap.org`, whose usage policy discourages heavy and bulk use. The offline map pack downloads about + 1,000 tiles per new device. For real traffic run your own tile server (or a commercial provider) and change the tile URL in + `src/components/MapView.tsx`, `vite.config.ts` (cache rule) and `src/lib/offline.ts`. +- Confirm the licence of the ZTP Kraków data (otwartedane.um.krakow.pl) and keep the OpenStreetMap attribution (ODbL). +- Sign-in is a demo (no passwords, no server). Put real authentication in front before trusting the city-office panel. + ## Data and honesty rules - **OpenStreetMap** (ODbL) via Overpass: places, steps, ramps, kerbs, surfaces, benches, toilets. @@ -79,7 +144,6 @@ is empty, so the app cannot promise an accessible vehicle and says so. edit-date data "confirmed". - Absence of data → "No data". User report vs OSM disagreement → "Conflicting data" with both sides shown. Refresh failure → last snapshot date plus a warning (demo switch in *Data → Demo controls*). -- Four fictional places and three sample reports are labelled **DEMO** and can be hidden in Settings. ## Architecture diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a983468 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# Production: `docker compose up -d --build` (with HTTPS: `docker compose --profile tls up -d --build`, see README) +services: + app: + build: . + image: gdziewjade:latest + restart: unless-stopped + # host:container. Behind Caddy or another proxy use APP_BIND=127.0.0.1:8080 so only the proxy can reach it. + ports: + - "${APP_BIND:-8080}:80" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"] + interval: 30s + timeout: 3s + retries: 3 + read_only: true + tmpfs: + - /var/cache/nginx + - /var/run + - /tmp + + caddy: + profiles: ["tls"] + image: caddy:2-alpine + restart: unless-stopped + depends_on: + app: + condition: service_healthy + ports: + - "80:80" + - "443:443" + environment: + DOMAIN: ${DOMAIN:-localhost} # set DOMAIN=gdziewjade.example.org in .env for a real certificate + volumes: + - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..bda9545 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,5 @@ +# Optional TLS front (compose profile "tls"): Caddy gets and renews the certificate for $DOMAIN by itself. +# Geolocation, the service worker and "install app" all require HTTPS outside localhost. +{$DOMAIN} { + reverse_proxy app:80 +} diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..3395d20 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,56 @@ +# Cache for live GTFS-Realtime answers: the feeds change about every 30 s, so 15 s protects ZTP from a flood of identical requests. +proxy_cache_path /var/cache/nginx/rt levels=1 keys_zone=rt:1m max_size=20m inactive=2m use_temp_path=off; + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_min_length 512; + gzip_types application/json application/javascript text/javascript text/css image/svg+xml application/manifest+json application/wasm; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Live transit delays. ZTP sends no CORS headers, so the browser cannot call it directly; the app asks for /rt/* on its own origin. + location /rt/ { + resolver 127.0.0.11 1.1.1.1 valid=300s ipv6=off; # resolved at request time: a ZTP outage must not stop nginx from starting + set $ztp gtfs.ztp.krakow.pl; + rewrite ^/rt/(.*)$ /$1 break; + proxy_pass https://$ztp; + proxy_ssl_server_name on; + proxy_set_header Host $ztp; + proxy_connect_timeout 3s; + proxy_read_timeout 6s; + proxy_cache rt; + proxy_cache_valid 200 15s; + proxy_ignore_headers Cache-Control Expires Set-Cookie; # cache by our rule below, whatever the upstream says + proxy_cache_use_stale error timeout updating; + add_header Cache-Control "no-store" always; + } + + # The service worker and the app shell must always be revalidated, or an update would never reach installed apps. + location = /sw.js { add_header Cache-Control "no-cache"; try_files $uri =404; } + location = /index.html { add_header Cache-Control "no-cache"; } + location = /manifest.webmanifest { add_header Cache-Control "no-cache"; types { application/manifest+json webmanifest; } } + + # Hashed build output never changes under the same name. + location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } + location ~ ^/workbox-.*\.js$ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } + + # Data files keep their names: revalidate (the service worker precaches them by content hash). + location /data/ { add_header Cache-Control "no-cache"; try_files $uri =404; } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/index.html b/index.html index ce6d20a..138b6dc 100644 --- a/index.html +++ b/index.html @@ -8,9 +8,26 @@ %APP_NAME% + -
+
+
+ + %APP_NAME% + +
+
diff --git a/package.json b/package.json index 73eb175..f0208dc 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "icons": "node scripts/make-icons.mjs", "preview:https": "HTTPS=1 vite preview --host --port 4443", "dev:https": "HTTPS=1 vite --host --port 5443", - "sensors": "node scripts/sensors-e2e.mjs" + "sensors": "node scripts/sensors-e2e.mjs", + "offline": "node scripts/offline-e2e.mjs" }, "dependencies": { "@huggingface/transformers": "^4.3.0", diff --git a/scripts/offline-e2e.mjs b/scripts/offline-e2e.mjs new file mode 100644 index 0000000..ce36fe2 --- /dev/null +++ b/scripts/offline-e2e.mjs @@ -0,0 +1,50 @@ +// Offline end-to-end check against `pnpm preview` (production build, service worker on). Run: node scripts/offline-e2e.mjs +// 1) first visit saves the app + the map pack, 2) with the network cut the app reloads, shows the offline notice and still routes. +import { chromium } from 'playwright-core'; +const URL = process.env.URL ?? 'http://localhost:4173/'; +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: 'pl-PL', isMobile: true, hasTouch: true }); +const page = await ctx.newPage(); +let step = 0; +const ok = (cond, msg) => { step++; if (!cond) { console.error(`FAIL ${step}: ${msg}`); process.exitCode = 1; } else console.log(`ok ${step}: ${msg}`); }; +const caches = () => page.evaluate(async () => { const o = {}; for (const n of await window.caches.keys()) o[n] = (await (await window.caches.open(n)).keys()).length; return o; }); + +await page.goto(URL); +await page.click('text=Zaloguj jako Anna Kowalska'); +await page.waitForSelector('#q', { state: 'attached', timeout: 60_000 }); +ok(await page.evaluate(() => !!navigator.serviceWorker.controller), 'service worker controls the page'); +let tiles = 0; +for (let i = 0; i < 150 && tiles < 1000; i++) { tiles = (await caches())['osm-tiles'] ?? 0; if (tiles < 1000) await page.waitForTimeout(1000); } +ok(tiles >= 1000, `offline map pack saved (${tiles} tiles)`); +let rec = null; +for (let i = 0; i < 60 && !rec; i++) { rec = JSON.parse((await page.evaluate(() => localStorage.getItem('kbb.offline'))) ?? 'null'); if (!rec) await page.waitForTimeout(500); } +ok(rec && rec.failed === 0 && rec.tiles >= 1000, 'pack record stored'); + +await ctx.setOffline(true); +await page.waitForSelector('.kbb-banner--warning', { timeout: 5000 }); +ok(/Trasy na żywo/.test(await page.locator('.kbb-banner--warning').innerText()), 'offline notice says live routes are unavailable'); +await page.click('.kbb-banner--warning .kbb-banner__close'); +ok((await page.locator('.kbb-banner--warning').count()) === 0, 'offline notice can be dismissed'); + +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForSelector('#q', { state: 'attached', timeout: 30_000 }); +ok(true, 'app reloads and opens with no network'); +ok((await page.locator('.kbb-banner--warning').count()) === 1, 'offline notice shown again after the reload'); + +await page.click('nav >> text=Trasa'); +await page.fill('#route-from', 'Sukiennice'); +await page.fill('#route-to', 'Zamek Królewski na Wawelu'); +await page.click('button[type=submit]:has-text("Wyznacz")'); +await page.waitForSelector('.kbb-modes', { timeout: 10_000 }); +ok(true, 'walking route is computed offline'); +await page.click('.kbb-mode:has-text("Komunikacja")'); +// scoped to the planner: the offline banner also says "Brak połączenia", and the loader shares the #tr-title heading +const planner = page.locator('section[aria-labelledby=tr-title]'); +await planner.locator('.kbb-jhead, .usa-alert').first().waitFor({ timeout: 20_000 }); +const note = await page.locator('#tr-title + p').innerText(); +ok(/Według rozkładu/.test(note), `transit planner answers from the saved timetable and says delays are unavailable ("${note.slice(0, 60)}")`); + +await ctx.setOffline(false); +await page.waitForSelector('.kbb-banner--success', { timeout: 5000 }); +ok(true, 'back-online notice appears'); +await browser.close(); diff --git a/scripts/sensors-e2e.mjs b/scripts/sensors-e2e.mjs index 2a0beee..a10b70e 100644 --- a/scripts/sensors-e2e.mjs +++ b/scripts/sensors-e2e.mjs @@ -9,7 +9,7 @@ async function run(kind) { const page = await browser.newPage({ viewport: { width: 1000, height: 900 }, locale: 'pl-PL' }); await page.goto(URL, { waitUntil: 'networkidle' }); await page.click('text=Zaloguj jako Anna Kowalska'); - await page.fill('#q', 'Pracownia Przykładowa'); + await page.fill('#q', 'Sukiennice'); await page.click('button[id^=place-]'); await page.click('text=Popraw lub uzupełnij >> nth=0'); await page.selectOption('#rep-attr', 'ramp'); @@ -35,7 +35,7 @@ await run('motion'); const page = await browser.newPage({ locale: 'pl-PL' }); await page.goto(URL, { waitUntil: 'networkidle' }); await page.click('text=Zaloguj jako Anna Kowalska'); -await page.fill('#q', 'Pracownia Przykładowa'); +await page.fill('#q', 'Sukiennice'); await page.click('button[id^=place-]'); await page.click('text=Popraw lub uzupełnij >> nth=0'); await page.selectOption('#rep-attr', 'ramp'); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 3cc8a42..53f19dd 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -17,7 +17,7 @@ await page.click('text=Zaloguj jako Anna Kowalska'); await page.waitForSelector('#q'); const total = Number(((await page.locator('text=Wyniki:').first().innerText()).match(/\d+/) ?? ['0'])[0]); ok(total > 4000, `places list loaded (${total} places)`); -await page.fill('#q', 'Pracownia Przykładowa'); +await page.fill('#q', 'Sukiennice'); await page.click('button[id^=place-]'); await page.waitForSelector('text=Szczegóły dostępności'); await page.click('text=Popraw lub uzupełnij >> nth=0'); @@ -38,7 +38,7 @@ await page.click('text=Wyloguj'); await page.click('text=Zaloguj jako Maria Wiśniewska'); await page.waitForSelector('[role=tablist]'); const pending = await page.locator('button:has-text("Zatwierdź")').count(); -ok(pending >= 3, `moderation queue (first tab) has ${pending} pending reports`); +ok(pending >= 1, `moderation queue (first tab) shows the report filed above (${pending} pending)`); await page.locator('button:has-text("Zatwierdź")').first().click(); ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending - 1, 'accepting removes it from the queue'); await page.click('button:has-text("Cofnij"):not(:has-text("decyzję"))'); diff --git a/scripts/vet-e2e.mjs b/scripts/vet-e2e.mjs index d855a34..f8a3beb 100644 --- a/scripts/vet-e2e.mjs +++ b/scripts/vet-e2e.mjs @@ -10,7 +10,7 @@ const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.goto(URL, { waitUntil: 'networkidle' }); await page.click('text=Zaloguj jako Anna Kowalska'); -await page.fill('#q', 'Pracownia Przykładowa'); +await page.fill('#q', 'Sukiennice'); await page.click('button[id^=place-]'); await page.waitForSelector('text=Szczegóły dostępności'); await page.click('text=Popraw lub uzupełnij >> nth=0'); diff --git a/src/App.tsx b/src/App.tsx index 6425faf..055c41a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,6 @@ import { Alert, Icon } from './components/ui'; import { assessPlace, buildIndex } from './lib/assess'; import { findAccount, userKey, type Account } from './lib/auth'; import { computeHotspots, computeStats, download, toCsv, toGeoJson, type Hotspot } from './lib/civic'; -import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo'; import type { LatLon } from './lib/geo'; import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n'; import { buildLabels } from './lib/labels'; @@ -29,7 +28,11 @@ 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'; -import { APP_SLUG } from './config'; +import { APP_SLUG, SHOW_PROTOTYPE_BANNER } from './config'; +import { AppLoader, type LoadState } from './components/AppLoader'; +import { StatusBanners } from './components/StatusBanners'; +import { fetchJson } from './lib/fetchProgress'; +import { useOfflinePack } from './lib/useOffline'; import { useJourneys } from './lib/useTransit'; import { loadTransitIndex } from './lib/transit'; import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store'; @@ -93,22 +96,28 @@ export default function App() { // ---- data ---- const [snap, setSnap] = useState(null); const [loadError, setLoadError] = useState(false); + const [load, setLoad] = useState({ stage: 'download', pct: 0 }); 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; }) - .then(setSnap) + fetchJson(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, (f) => setLoad({ stage: 'download', pct: Math.round(f * 75) }), ctrl.signal, typeof __DATA_BYTES__ === 'undefined' ? 0 : __DATA_BYTES__.snapshot) + .then(async (s) => { + setLoad({ stage: 'prepare', pct: 85 }); + await new Promise((r) => setTimeout(r, 0)); // let the "preparing" step paint before the synchronous index / graph build + setSnap(s); + }) .catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); }); return () => ctrl.abort(); }, []); + // the offline map pack (tiles) is saved once someone is signed in and the data is here + const pack = useOfflinePack(snap?.meta.bbox ?? null, !!account && !!snap); const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]); const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]); const [overrides, setOverrides] = useState>({}); const realPlaces = useMemo(() => (snap ? snap.places.map((p) => withCityOffer(overrides[p.id] ?? p)) : []), [snap, overrides]); - const places = useMemo(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]); + const places = realPlaces; const reports = useMemo( - () => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)), + () => userReports.map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)), [userReports, decisions], ); const assessments = useMemo(() => { @@ -117,7 +126,7 @@ export default function App() { return m; }, [places, profile, index, reports]); const labels = useMemo(() => buildLabels(places), [places]); - const placeById = useMemo(() => new Map([...DEMO_PLACES, ...realPlaces].map((p) => [p.id, p])), [realPlaces]); + const placeById = useMemo(() => new Map(realPlaces.map((p) => [p.id, p])), [realPlaces]); const previewCounts = useCallback((p: Profile): Record => { const c: Record = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 }; @@ -204,6 +213,10 @@ export default function App() { ...(photo ? { photoId: id } : {}), vetting: { state: 'running' }, }; if (photo) await putPhoto(id, photo.dataUrl); + if (photo && !navigator.onLine) { + setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'offline' } }]); + return; + } // 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' } }]); @@ -342,7 +355,7 @@ export default function App() { if (!account) { return ( -
+
); } @@ -352,16 +365,18 @@ export default function App() {
{t('skip')} -
-
-
-

- {t('banner.proto')}{' '} - {snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''} -

+ {SHOW_PROTOTYPE_BANNER && ( +
+
+
+

+ {t('banner.proto')}{' '} + {snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''} +

+
-
-
+ + )}
@@ -404,6 +419,8 @@ export default function App() {
+ + {guiding && view === 'route' && route && route.status !== 'none' && ( setGuiding(false)} onPos={setUserPos} onReroute={reroute} /> )} @@ -411,7 +428,7 @@ export default function App() {
{loadError &&
{t('common.loadError')}
} {!loadError && !ready && !needsProfile && view !== 'profile' && ( -
{t('common.loading')}
+ )} {(view === 'profile' || needsProfile) && ( @@ -436,7 +453,7 @@ export default function App() { {view === 'about' && !needsProfile && snap && ( )} @@ -476,6 +493,7 @@ export default function App() { reports={reports} snapshotDate={snap.meta.fetchedAt.slice(0, 10)} refresh={refresh} + online={online} 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)} @@ -521,7 +539,6 @@ export default function App() { focus={focus} visible={sheet} bbox={snap.meta.bbox} - online={online} basemap={basemap} onBasemap={setBasemap} userPos={view === 'route' && guiding ? userPos : null} diff --git a/src/components/AboutView.tsx b/src/components/AboutView.tsx index 0e27629..cd78175 100644 --- a/src/components/AboutView.tsx +++ b/src/components/AboutView.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode } from 'react'; import { useI18n } from '../lib/i18n'; import type { Confidence } from '../lib/types'; +import type { PackState } from '../lib/useOffline'; import { Alert, ConfidenceTag } from './ui'; function AccordionSection({ id, title, children, open, toggle }: { id: string; title: string; children: ReactNode; open: string; toggle: (id: string) => void }) { @@ -26,9 +27,11 @@ interface Props { onSimulate: (v: boolean) => void; onReset: () => void; resetDone: boolean; + pack: PackState; + online: boolean; } -export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSimulate, onReset, resetDone }: Props) { +export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSimulate, onReset, resetDone, pack, online }: Props) { const { t, date } = useI18n(); const [open, setOpen] = useState('data'); const toggle = (id: string) => setOpen((cur) => (cur === id ? '' : id)); @@ -41,6 +44,28 @@ export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSi {t('banner.data', { date: date(snapshotDate) })} {Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ')}

+
+

{t('pack.title')}

+

+ {pack.phase === 'unsupported' ? t('pack.status.unsupported') + : pack.phase === 'downloading' ? t('pack.status.downloading') + : pack.record ? t('pack.status.ready', { tiles: pack.record.tiles, date: date(pack.record.at.slice(0, 10)) }) + : t('pack.status.none')} +

+ {pack.phase === 'downloading' && ( + + )} + {pack.phase !== 'unsupported' && pack.phase !== 'downloading' && ( +
+ + {pack.record && } +
+ )} + {pack.record &&

{t('pack.removeNote')}

} +
+

{t('about.data.body1')}

diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx new file mode 100644 index 0000000..8ef7ea4 --- /dev/null +++ b/src/components/AppLoader.tsx @@ -0,0 +1,18 @@ +import { useI18n, type Key } from '../lib/i18n'; +import { BrandMark } from './BrandMark'; + +export interface LoadState { stage: 'download' | 'prepare'; pct: number } + +/** First-load splash: what is happening and how far along it is. */ +export function AppLoader({ load }: { load: LoadState }) { + const { t } = useI18n(); + return ( +
+ +

{t('app.name')}

+ {load.pct}% +

{t(`load.${load.stage}` as Key)} {load.pct}%

+

{t('load.note')}

+
+ ); +} diff --git a/src/components/DashQueue.tsx b/src/components/DashQueue.tsx index f166d26..379001c 100644 --- a/src/components/DashQueue.tsx +++ b/src/components/DashQueue.tsx @@ -114,7 +114,7 @@ export function DashQueue({ reports, placeById, onDecide, onUndo, onOpenPlace }: {t(`dash.queue.status.${r.status === 'accepted' ? 'accepted' : 'rejected'}` as Key)} {r.reviewedBy ? ` (${r.reviewedBy})` : ''} - {!r.demo && } + ))} diff --git a/src/components/LoginView.tsx b/src/components/LoginView.tsx index aaea0ad..1f4f32e 100644 --- a/src/components/LoginView.tsx +++ b/src/components/LoginView.tsx @@ -2,9 +2,10 @@ import { ACCOUNTS, GUEST, type Account } from '../lib/auth'; import { useI18n, type Key } from '../lib/i18n'; import type { Lang } from '../lib/types'; import { LangToggle } from './LangToggle'; +import type { LoadState } from './AppLoader'; import { Alert, Icon } from './ui'; -export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => void; lang: Lang; onLang: (l: Lang) => void }) { +export function LoginView({ onPick, lang, onLang, loading }: { onPick: (a: Account) => void; lang: Lang; onLang: (l: Lang) => void; loading?: LoadState | null }) { const { t } = useI18n(); return (
@@ -17,6 +18,12 @@ export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => vo

{t('auth.title')}

{t('app.tagline')}

{t('auth.intro')} + {loading && ( +
+

{t(loading.stage === 'download' ? 'load.download' : 'load.prepare')} {loading.pct}%

+ {loading.pct}% +
+ )}
    {ACCOUNTS.map((a) => ( diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 29e7bbf..7faf07d 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -76,7 +76,6 @@ interface Props { /** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */ visible: unknown; bbox: [number, number, number, number]; - online: boolean; basemap: 'map' | 'satellite'; onBasemap: (b: 'map' | 'satellite') => void; userPos: LatLon | null; @@ -97,7 +96,7 @@ export interface JourneyOverlay { stops: { at: LatLon; label: string }[]; } -export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey, sheet }: Props) { +export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey, sheet }: Props) { const { t, lang } = useI18n(); const ref = useRef(null); const mapRef = useRef(null); @@ -113,6 +112,9 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route rmRef.current = reduceMotion; const reduced = () => rmRef.current || window.matchMedia('(prefers-reduced-motion: reduce)').matches; const [ready, setReady] = useState(false); + // first paint of the map: full-area loader; afterwards a thin bar whenever tiles are still arriving + const [firstPaint, setFirstPaint] = useState(true); + const [tilesLoading, setTilesLoading] = useState(false); // The sheet hides the lower part of the map. Camera padding makes "centre" mean the middle of the part you can see, so a route fit // or a focused place lands there. The inset comes from the sheet's target size (not from the DOM), so it is right while the sheet @@ -212,10 +214,16 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route setReady(true); }); mapRef.current = map; + // offline, tiles outside the saved pack fail to load: expected, not worth a console error each + map.on('error', () => undefined); + let busyTimer = 0; + map.on('dataloading', () => { window.clearTimeout(busyTimer); busyTimer = window.setTimeout(() => setTilesLoading(true), 250); }); + const splashGuard = window.setTimeout(() => setFirstPaint(false), 8000); // never leave the loader up if 'idle' does not come + map.on('idle', () => { window.clearTimeout(busyTimer); setTilesLoading(false); setFirstPaint(false); }); const ro = new ResizeObserver(() => { map.resize(); if (!map.isMoving()) syncInsetRef.current(map); }); ro.observe(ref.current); - return () => { ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); }; + return () => { window.clearTimeout(splashGuard); window.clearTimeout(busyTimer); ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -446,9 +454,12 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
+ {firstPaint && ( +
+ )} + {!firstPaint && tilesLoading &&
{t('load.mapTiles')}
}

{t('map.alt')}

- {!online &&

{t('map.offline')}

} {showPlaces && (
{t('map.legend')} diff --git a/src/components/PlaceDetail.tsx b/src/components/PlaceDetail.tsx index 28d78fc..45ff4ac 100644 --- a/src/components/PlaceDetail.tsx +++ b/src/components/PlaceDetail.tsx @@ -1,7 +1,6 @@ import { forwardRef, useState } from 'react'; import { googleMapsPlaceUrl } from '../lib/links'; import { useI18n, type Key } from '../lib/i18n'; -import { DEMO_PLACES } from '../lib/demo'; import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types'; import { Alert, FactCard, Icon, VERDICT_STYLE } from './ui'; import { ReportForm } from './ReportForm'; @@ -23,6 +22,8 @@ interface Props { reports: UserReport[]; snapshotDate: string; refresh: RefreshState; + /** Live refresh from OpenStreetMap needs a connection. */ + online: boolean; saved: boolean; onToggleSave: () => void; onCard: () => void; @@ -49,7 +50,6 @@ export const PlaceDetail = forwardRef(function PlaceD const [reporting, setReporting] = useState(null); const [thanks, setThanks] = useState(false); const { place, assessment } = p; - const demo = DEMO_PLACES.find((d) => d.id === place.id); const address = [place.tags['addr:street'], place.tags['addr:housenumber']].filter(Boolean).join(' '); const gmaps = googleMapsPlaceUrl([place.lat, place.lon]); const [reachOpen, setReachOpen] = useState(false); @@ -78,15 +78,6 @@ export const PlaceDetail = forwardRef(function PlaceD

)} - {demo && ( -
- {t('detail.demo')} -

- {t('detail.demoNote', { state: t(`verdict.${demoState(demo.demoState)}`) })} -

-
- )} -
@@ -191,7 +182,7 @@ export const PlaceDetail = forwardRef(function PlaceD {mine.map((r) => (
  • - {!r.demo && } +
  • ))} @@ -202,25 +193,19 @@ export const PlaceDetail = forwardRef(function PlaceD

    {t('detail.tools')}

      - {place.demo ? ( -
    • {t('detail.refreshDemo')}

    • - ) : ( - <> -
    • - -
    • -
    • - - - {t('detail.openOsm')} - - -
    • - - )} +
    • + +
    • +
    • + + + {t('detail.openOsm')} + + +
    -
    - set('showDemoPlaces', e.target.checked)} aria-describedby="set-demo-h" /> - -
    -

    {t('set.demoPlaces.help')}