The DOBROVOZIM handbook

How it all works.Part by part.

A guided tour of the whole system — the iPhone app that measures, the Go backend that aggregates, the web portal and desktop console that put the map in your hands, plus the deployment and the tests that hold it all together.

A local handbook · every portal page is one click away.

THE PIPELINE / END TO END

From bump to map.

A measurement crosses four components before it colors a street — phone, backend, database, map.

Smooth Uneven Rough
Handbook illustration · Illustrative map
One measurement, four components
Measured at the edge, on your iPhone
Published to a map anyone can open

On this page

Eight stops.
One system.

The handbook follows a measurement on its whole journey. Each stop explains what that part does and which features live inside it.

Part 00 · The big picture

Your phone knows
how the road feels.Now everyone can see it.

DOBROVOZIM turns everyday drives into a public road-quality atlas. No special hardware — just a phone, an app, and the roads you already travel.

The system is a crowdsourced telemetry platform. Phones measure vertical road vibration — the root-mean-square (RMS) of cleaned accelerometer data — a Go backend quantizes those points into map squares and serves them as GeoJSON aggregates, and the web, desktop and mobile map clients render the result over OpenStreetMap. Every component below exists to move one measurement from a pothole to a colored cell on a map.

The journey of one measurement

iPhone appsenses & buffers
ZIP of CSV
every 5 min
Go backendvalidates & quantizes
bulk COPY
one transaction
PostgreSQL + PostGIS10 m squares
Web portalbrowser, /web/
GeoJSON
public layer
Go backendaggregates & serves
REST + QWebChannel
live & admin
Desktop consolePySide6 + Leaflet
  1. Register.

    Create an account, register your iPhone and car, and wait for an administrator's approval.

  2. Drive.

    Start recording and take your usual route — the app keeps measuring even with the screen locked.

  3. Measure.

    The phone folds 25 Hz motion into 2-second RMS windows that describe how bumpy each stretch was.

  4. Upload.

    Every five minutes the data lands as a ZIP on the backend, which bins it into 10 m map squares.

  5. See it.

    Aggregates surface on the public map and the desktop console, colored from smooth green to rough red.

ComponentFolderWhat it is
iOS client ios/ SwiftUI app: senses, caches, exports and uploads RMS data; in-app accounts + device registration.
Web portal web/ Vanilla HTML/CSS/JS (no build step), served by the backend at /web/: accounts, device registration, public map, admin consoles.
Backend backend/ Go API + PostGIS: ingestion, spatial aggregation, public layer, accounts & roles, admin surface, static hosting.
Desktop viewer desktop/ PySide6 (QtWebEngine/Leaflet) admin & analysis client for the live and public layers.

How to read this handbookEach part below is written to stand on its own — jump in anywhere. Feature lists describe the system as built; the deeper reference documents (API contract, architecture, runbooks) are linked at the very end.

Part 01 · Shared rules

One contract.
Every component obeys it.The glue between the parts.

Before the individual parts, a handful of cross-cutting rules define who may do what, how sessions live, how errors speak and how colors stay honest.

1.1 · IDENTITIES

Five roles, one hierarchy.

Roles are plain wire strings (snake_case) checked on every guarded call — the same five everywhere, from the iPhone to the desktop.

  • superuser — everything; the only role that may change account roles, reset passwords, delete accounts, toggle read-only and read the audit trail. Created exactly once via the bootstrap endpoint.
  • administrator — device whitelist, approval queues, directory, users list + ban, layer assignment, public-layer rebuild; sees the live layer.
  • data_supplier — the self-registration default: may upload measurements and submit device-registration requests.
  • analyst — read-only map access: the public layer plus whatever layers are assigned to the account; cannot upload.
  • guest — minimal read access, public layers only.
1.2 · SESSIONS

Sessions, not secrets.

Username/password sessions with opaque bearer tokens replaced every legacy admin key — old tokens parse but grant nothing.

  • Authorization: Bearer <token> on every guarded call; the login field accepts username or email, case-insensitively.
  • Tokens are stored sha256-hashed server-side, expire after a fixed 30 days, and several may be active at once; passwords are bcrypt-hashed.
  • Changing your password revokes your other sessions; an admin-side reset revokes all of them.
  • Each client persists its session its own way: web → localStorage, desktop → QSettings plist, iOS → Keychain behind a Face ID prompt.
  • A uniform 401 rule everywhere: any rejected token drops the UI back to the login flow.
1.3 · ERRORS

Errors that speak the same language.

Every failure answers the canonical envelope {"error":{"code","message"}} with a code from one fixed set — so all four clients can render it meaningfully.

unauthorizedforbiddenbannedread_onlydevice_not_owned invalid_credentialsusername_takenemail_takenweak_passwordbad_usernamebad_email not_foundalready_decidedlast_superuseralready_bootstrappedresult_too_largebbox_too_largerate_limitedtoo_many_attempts

Client UX mapping: banned → red persistent banner (reads keep working) · read_only → orange banner · device_not_owned → opens the in-app registration flow · 401 → re-login.

1.4 · READ-ONLY MODE

A switch that pauses the firehose.

A DB-backed flag (backend_flags.read_only) so every replica sees the same state — toggled by the superuser, shown as a badge on the desktop.

  • Blocks only data writes: POST /api/v1/upload and POST /api/v1/web/registrations answer 409 read_only.
  • Reads, logins and every other admin action keep working while the mode is on.
  • Administrator and superuser can read the state; only the superuser flips it.
1.5 · SPEED BUCKETS

Same bump, different speed.

A road feels different at 30 and at 130 km/h, so every aggregate is split into four speed buckets — computed at query time, never re-ingested.

  • Stored values are m/s (despite the historical column name) and are converted with ×3.6 only when a query runs.
  • Buckets are low-inclusive / high-exclusive, so boundaries never double-count.
  • Because bucketing happens at query time, re-bucketing requires zero re-uploads.
1.6 · COLOR PARITY

One scale, two implementations, zero drift.

The color scales are defined once and implemented exactly twice — Python for the desktop, JavaScript for the web — with a cross-check test pinning them together.

  • desktop/app/colormap.py and web/colormap.js must produce identical stop tables.
  • Five scales: green-red, green-yellow-red, viridis, blues, grayscale.
  • The parity test fails the build if the two ever drift apart.
The five roles at a glance

superuser

Everything, everywhere — the only role that can mint roles, reset passwords, delete accounts, flip read-only and read the audit trail.

exactly one · bootstrapped

administrator

Runs the intake: whitelist, approval queues, directory, bans, layer assignment and public-layer rebuilds; sees the live layer.

operations

data_supplier

The default on signup: uploads measurements and submits device-registration requests for approval.

the drivers

analyst

Read-only map access — the public layer plus whatever layers are assigned to the account. Never uploads.

read-only

guest

Minimal read access: public layers only, nothing else.

look around
BucketSpeed rangeTypical driving
00 – 40 km/hParked, crawling, dense city traffic.
140 – 80 km/hUrban and suburban arterials.
280 – 130 km/hMain roads and highway cruising.
3130 km/h and upFast highway running.

Part 02 · The sensor in your pocket

An iPhone is all
the hardware there is.Measure, cache, upload.

Native SwiftUI (iOS 16+) with zero external dependencies — CoreMotion, CoreLocation, MapKit, LocalAuthentication, Security, SQLite3 and libcompression. It runs on real devices only, because motion sensors are the point.

2.1 · MEASUREMENT ENGINE

Turning vibration into numbers.

  • 25 Hz motion sampling — accelerometer + gyroscope via CMDeviceMotion, each sample stamped with the most recent GPS fix (~1 Hz, BestForNavigation accuracy).
  • Gyro-orientation cleaning — the attitude quaternion rotates device-frame acceleration into the world frame; only the vertical (z) component becomes the roughness signal. At rest, RMS ≈ 0.
  • Raw cache — an in-memory buffer flushed to SQLite every 5 s (ts, GPS lat/lon/altitude/accuracy/speed, gyro, accelerometer, vertical, horizontal).
  • 2 s RMS windows — Σv² per window → rms = √(Σv²/n), with the window midpoint, mean position and mean speed, written every 10 s.
  • 60-minute retention — the database is a rolling cache, not an archive; older rows are pruned on every flush.
  • No measurements below 7 km/h — the speed gate also invalidates the partial window, so a slow stretch can never bridge two windows. The UI shows “PAUSED < 7 KM/H”.
  • Walk mode — a persisted toggle removes the speed gate entirely for pedestrian measurements.

The temporary SQLite file lives in the app's tmp directory (the OS may purge it); its path is shown in the UI.

2.2 · RECORDING UX

Start, lock, forget.

  • Start / Stop buttons with a live RMS display and a paused-state badge.
  • The screen never locks while measuring — the idle timer is disabled during a session.
  • Background collection — the location background mode keeps the process alive, so 25 Hz sampling continues off-screen (the blue iOS location indicator). When-In-Use permission is sufficient.
  • Honest caveats, documented in-app docs: force-quitting stops collection; long GPS outages may let iOS suspend the app.
2.3 · MAP

Your drive, colored as you go.

  • OpenStreetMap basemap — OSM tiles replace Apple's (attribution shown; HTTPS per the OSM tile policy).
  • RMS track lines — one polyline per consecutive pair of RMS windows, 5 px, colored green → red (hue 120° → 0°) by rms / maxRMS(last 10 min).
  • Follows the current position — the map re-centers as you move.
2.4 · EXPORT & UPLOAD

ZIP it, send it, delete it.

  • Export & Share — streams both tables into dobrovozim_<timestamp>.zip (deflate with a store fallback), shared through the system share sheet.
  • Automatic upload every 5 minutes while measuring, one final attempt on Stop, plus a manual Send now button — 60 s timeout.
  • Slim payload — rms_windows.csv in the 6-column ts_epoch,lat,lon,rms_ms2,samples,speed_mps layout + client_id.txt: −39 % after deflate, all information preserved. Raw 25 Hz rows are never uploaded.
  • Delete-on-success — any 2xx deletes exactly the sent rows; rows flushed during the upload stay for the next cycle; failures retry on the next cycle. No data loss.
  • Map continuity — uploaded rows stay visible on the 10-minute track via an in-memory buffer.
  • Plain HTTP allowed (ATS exception) so LAN backends like http://192.168.1.10:8080 work; the URL must be the full upload endpoint.
2.5 · DEVICE IDENTITY

One ID, for the device's whole life.

  • A fixed, non-editable Device ID is generated once and stored in the iOS Keychain — it survives restarts, app updates and even uninstall/reinstall. Only a device erase purges it.
  • The ID goes into client_id.txt and the X-Client-Id header of every upload.
  • Shown in Settings with a Copy button, ready for the registration form.
2.6 · ACCOUNTS & SECURITY

Your session, behind your face.

  • Login screen — username-or-email + password; the token goes to the Keychain, the user snapshot to UserDefaults; distinct errors for every account failure code.
  • Signup screen — creates a data_supplier account and auto-logs-in with the same credentials.
  • Face ID session unlock — at launch the stored token loads only after a successful biometric prompt; no biometry means no prompt and a direct load.
  • Session freshness — GET /auth/me re-syncs role and ban state after each unlock; a stale token is dropped locally before it can be unlocked.
  • Role gating client-side — registration and uploads are offered only to supplier/admin/superuser roles; analysts and guests get a friendly message naming their role.
  • Ban state — banned users can still log in and read; a persistent red banner explains that sending is disabled. Read-only mode shows an orange banner.
  • Logout — revokes server-side (best effort) and deletes the keychain token and cached identity.
2.7 · IN-APP REGISTRATION

No browser required.

  • A device registration card mirrors the web portal: car make/model/year/country, a mandatory format-checked email (the administrator's contact point), optional name/surname.
  • Device model auto-detected from the hardware identifier — e.g. iPhone15,3 becomes “iPhone 14 Pro Max”.
  • device_id is the app's fixed Device ID, so an approval whitelists exactly the id the app uploads with.
  • Send → poll → notify — the app polls the request on launch, on upload rejections, and via Check now; when the decision flips, a local notification fires (banner even while backgrounded).
  • Session-aware — a 401 prompts password login first (form fields persist); denied requests can simply be sent again.

Edge processing, on purposeAll the heavy lifting happens on the phone: smoothing, windowing, quantization and compression. Only the distilled 2-second RMS windows ever leave the device — which is why a whole drive costs a few kilobytes.

Part 03 · The browser side

No build step.
No framework.Just the web, served by the backend.

Vanilla HTML/CSS/JS hosted by the backend at /web/ — a role-aware home page, accounts, the public map and the admin consoles, all sharing one small helper library.

3.1 · HUB

A home page that knows who you are.

  • Role-gated cards — every card carries data-roles; pseudo-roles * (everyone), anon (no valid session) and auth (any signed-in user) decide visibility next to the five real roles.
  • Every page stays reachable by direct URL — cards are only links, never gates.
  • Session awareness — on load the hub validates the stored token against GET /auth/me; a dead token is dropped (obsolete pre-accounts keys are swept too) and the anonymous card set returns.
  • Signed-in users see a session box — “Logged in as … (role)”, analysts additionally their assigned layers — and a Log out button that revokes the session server-side.
3.2 · ACCOUNTS

Sign in, sign up, register the car.

  • Login page — email-or-username + password; the token and user snapshot land in localStorage["dobrovozim.session"]; invalid credentials surface a visible error and store nothing; banned users are flagged.
  • Registration page, two-stage — account signup (auto-login) then the device-request form; an existing supplier session skips straight to the device form.
  • Exact field errors for every contract code; a valid form POSTs the trimmed payload — car year as an integer, optional fields dropped.
3.3 · PUBLIC PAGES

Open to everyone, no account needed.

  • Status page — public request-status lookup by reference id; renders approved / not-found / network-failure states and shows the assigned Device ID once approved. No PII ever. Auto-refreshes every 10 s while pending.
  • Public map — the anonymous RMS layer on OpenStreetMap with the color-scale combo, aggregation-statistic selector, speed-bucket filter and the desktop's opacity and background controls; collapsible controls panel; no auth at all.
  • Colors are anchored to the database-wide RMS p95 from the response's stats block, so colors stay comparable across views and zooms.
3.4 · ADMIN CONSOLES

Three consoles for the people in charge.

  • Admin approvals — the approval queue and user management for administrator | superuser: approve, deny or delete registrations with the session bearer; a wrong role answers “Insufficient role”; a mid-session 401 drops the panel and offers the login link.
  • Directory — devices & accounts overview: registrations with full details and Approve/Deny; devices with owner_username; administrators ban and edit layers; the superuser additionally changes roles, resets passwords and deletes.
  • Superuser console — the server-wide read-only switch loads and toggles here; administrator account creation; delete is refused for self.
3.5 · SHARED PLUMBING

Two small files carry the whole portal.

  • app.js — the api() wrapper: JSON encode/parse, bearer-token injection, the backend error envelope turned into an Error carrying .status/.code/.message, a friendly network-failure path, plus DOM helpers ($, el, setText, escapeHtml, fmtDate, notices…).
  • colormap.js — the five color scales with adaptive yellow placement, kept byte-identical to the desktop implementation and tested against it.
3.6 · THIS HANDBOOK

And the page you are reading.

  • features.html — a self-contained documentation page in the landing page's visual language: same palette, same typographic voice, zero dependencies.
  • It documents every component of the project and can be opened straight from disk or served alongside the portal at /web/features.html.

Privacy by constructionThe public map and the status page never touch personal data — identity lives only behind the admin sessions, and the anonymous public layer aggregates all clients without a single client_id in the payload.

Part 04 · The engine room

One Go binary.
One PostGIS database.Everything else is configuration.

Go 1.24 with stdlib routing and pgx/v5, PostgreSQL + PostGIS on :5433, the API on :8080. Dockerized; the static web portal is served straight from WEB_DIR.

4.1 · INGESTION

POST /api/v1/upload — the front door for data.

  • Input — one ZIP containing exactly one *.csv and one client_id.txt, accepted as a raw application/zip body or multipart.
  • Guards — session role supplier | administrator | superuser; 403 banned; 409 read_only; a device-ownership check — the ZIP's client id must map to an approved device owned by the session user (403 device_not_owned, which is what drives the iOS registration flow).
  • Unknown ids are auto-registered as pending and rejected with 403 client_not_allowed, including the device's status and first/last-seen timestamps.
  • CSV validation — slim 6-column header (case/order-insensitive, BOM-tolerant) or the legacy 7-column layout; |lat| ≤ 85; per-row skip accounting for every failure reason; quote-tolerant parsing; a mid-stream I/O error skips one line and continues; structural problems answer 400 invalid_zip / invalid_csv.
  • Spatial quantization — every point is binned into a ~10 m square: a constant latitude step, longitude corrected by cos(lat); the footprint is a GIST-indexed polygon so bbox queries stay index-driven.
  • Bulk insert — PostgreSQL COPY in batches inside one transaction: a failure mid-insert rolls the whole ZIP back. Atomic upload.
  • Limits — 512 MB upload cap, 64 MB session decompression budget, 100 ZIP entries, 200 000 rows → 413; 60 uploads per user per hour.
  • Response — rows received / inserted / skipped with a per-reason skip map, duration, and an audit row per accepted upload.
4.2 · BULK DUMPS

Import history without the ceremony.

  • Drop ZIP archives into dumps/ and ingest them without any client check — the archive's client id is trusted, and new ids are auto-registered as approved. Existing approval statuses are never changed by side effect.
  • Idempotent — every ingested file is recorded in dump_ingest_log keyed by name, size and mtime; re-runs skip unchanged files; --redo forces a re-ingest.
  • One broken archive never stops the rest — per-file failure reporting; the CLI still exits non-zero.
  • Two surfaces — the CLI (make ingest-dumps) and the admin API GET/POST /api/v1/ingest/dumps. Bulk ingestion is never row-capped.
4.3 · LIVE LAYER

GET /api/v1/squares — aggregates on demand.

  • Per-square mode — GeoJSON for every 10 m square intersecting the bbox, one feature per square × speed bucket: median and stdev RMS, point and sample counts, bucket label. Filters: client_id, speed_buckets.
  • Viewport-grid mode — grid_m=<meters> re-aggregates all raw points in the bbox into cells of any size (no speed split) with stat=median|mean|min|max.
  • The grid geometry contract is implemented identically in backend, desktop and web and pinned by tests on both sides.
  • Caps — a max response size (result_too_large) and a max bbox span (bbox_too_large) instead of unbounded payloads.
  • Every response carries stats — the RMS p95 and point count over the whole database, never just the viewport, so color scales stay comparable across views.
  • Access — administrator | superuser always; analysts with the live layer assigned; 401 anonymous; per-user rate limiting.
4.4 · PUBLIC LAYER

The map the whole world may read.

  • Deliberately public — GET /api/v1/public/squares needs no auth: prebuilt anonymous 100 m aggregates over all clients, with no client_id anywhere. Raw points are never exposed.
  • Fixed global grid — because the layer is persisted, its grid never shifts between rebuilds: steps are computed once from a fixed reference latitude and recorded in public_layer_meta.
  • Payload — one feature per (100 m bin × speed bucket) with mean / median / min / max / stdev plus counts; optional speed-bucket filter; an empty FeatureCollection before the first build (a valid result, not an error).
  • Atomic rebuild — everything aggregates into a staging table, then one transaction swaps old for new: a concurrent reader sees either the fully old or the fully new layer, never a half-built mixture.
  • Logged & serialized — every attempt is recorded (trigger, counts, duration, error); a second concurrent rebuild gets 409 rebuild_in_progress.
  • Scheduler — rebuilds on first startup and every 24 h by default (0 disables), on the shutdown-aware context; a failure never crashes the server.
4.5 · ACCOUNTS & SESSIONS

Bootstrap once, then live on bcrypt.

  • Bootstrap — POST /auth/bootstrap guarded by the SUPERUSER_KEY env header, and only while the users table is empty; afterwards the key can be removed from the environment entirely.
  • Self-registration — creates data_supplier accounts; any role field in the body is ignored; username 3–32 chars, unique email, password ≥ 8.
  • Login — username or email, case-insensitive; banned users can log in (their state is in the response); per-account lockout — 20 failed logins in 15 min lock the account for 10 min, answering 429 too_many_attempts with Retry-After.
  • Session lifecycle — logout revokes this session; GET /auth/me returns the user plus assigned layers; a password change revokes all other sessions of that user.
4.6 · USERS ADMINISTRATION

People management with guardrails.

  • List accounts with role and substring filters; create accounts (superuser-only — a new superuser cannot be minted this way).
  • Role change (superuser-only) refuses to demote the last active superuser → 409 last_superuser.
  • Ban / unban (admin + superuser) — a ban blocks data operations only; sessions and reads keep working.
  • Password reset (superuser-only) revokes all of that user's sessions; delete is refused for self.
  • Layer assignment — grants an analyst access to non-public layers, per account.
  • Device-owner linking — links or unlinks an approved device to a user (the legacy migration path; approvals set the owner automatically).
4.7 · WHITELIST & APPROVALS

A device's life: pending → approved.

  • clients.status lifecycle — approved (whitelisted), pending (auto-registered by a mobile upload), declined.
  • The approval queue lists pending and declined devices first, most recently active first, with first/last-seen timestamps and an attempt counter; a status filter includes all.
  • Approve makes the device's next upload succeed immediately — no restart, no re-registration. Decline keeps uploads rejected but the row visible, so a mistaken decline can be undone later.
  • Plus raw whitelist CRUD for administrators and the superuser.
4.8 · WEB REGISTRATIONS & DIRECTORY

From public form to whitelisted device.

  • Create — the registration form posts under a supplier session, rate-limited, with an ~8 KB body cap; field validation answers exact invalid_<field> errors; the request is linked to the submitting account.
  • Public status — anyone with the reference id can poll it: id, status, created date — and the Device ID once approved. PII exists only on the admin surface.
  • Admin decisions — approve (with an optional client-id override; the whitelist insert is idempotent and never overrides an existing row), deny (with an optional reason), delete (the whitelist is deliberately untouched).
  • Transactional decisions — a row is locked for update, so a second decision on a decided registration is always 409 already_decided.
  • Directory — one endpoint feeds both the web directory page and the desktop's Directory tab: every registration with full details plus every whitelist device with its owner.
4.9 · PLATFORM SERVICES

The quiet machinery around the API.

  • Layer registry — GET /api/v1/layers is public but caller-filtered: anonymous users see public layers, signed-in users see theirs too, administrators see everything.
  • Audit trail — every privileged action lands in admin_audit, written fail-closed (a failed insert fails the action); superuser-only read.
  • Health split — /health is a static liveness answer (no DB touch, so a flood cannot exhaust the pool); /health/ready carries the DB probe for deploy gates.
  • Middleware — panic recovery, CORS, request logging, and in-memory rate limiters for registrations, auth, uploads and squares.
  • Static hosting — /web redirects to /web/, which serves the portal from WEB_DIR; unknown routes answer the canonical error envelope.
4.10 · DATA MODEL

Ten tables, no surprises.

  • clients — the whitelist plus approval state and owner; uploads — one audit row per accepted upload.
  • rms_points — per-point rows with 10 m buckets and a GIST-indexed footprint; dump_ingest_log — the idempotency ledger for dumps.
  • public_layer_bins / meta / build_log — the persisted anonymous layer and its history.
  • web_registrations — PII columns plus the linked account; the accounts subsystem: users, user_sessions (sha256 token hashes), user_layers, backend_flags.
  • web_admins is legacy — the table survives (migrations never destroy data) but no code path reads it.
4.11 · CONFIGURATION & OPS

Everything is an environment variable.

  • DATABASE_URL (required), APP_ADDR, CLIENT_IDS (seed whitelist), WEB_DIR, ALLOW_ORIGIN, LOG_LEVEL.
  • Size & behavior knobs — upload caps, decompression budgets, ZIP-entry and row caps, COPY batch size, bbox span and response caps, dumps dir, public-layer refresh and reference latitude, SUPERUSER_KEY.
  • Retired ADMIN_TOKEN still parses but grants nothing.
  • Tooling — make up / smoke / test / vet / build / ingest-dumps; a synthetic-ZIP generator with deliberately bad rows; an end-to-end smoke script; integration tests against a real database.
  • Documented limitations — timestamps are stored as provided (not cross-validated); re-ingesting the same ZIP duplicates points by design.

Part 05 · The control room

A native window
onto the whole system.For admins and analysts.

PySide6 with an embedded QtWebEngine Leaflet page and a QWebChannel bridge; a stdlib-only REST client; QThread workers keep every HTTP call off the GUI thread.

5.1 · SIGN-IN & ROLES

The same account, on the desktop.

  • A modal login dialog at startup (username-or-email + password) with a hint linking to the web portal for account creation; a register dialog signs up and auto-logs-in.
  • Session persistence — token + user snapshot in QSettings, so a restart resumes the 30-day session; any 401 clears it and re-prompts; “Log out” revokes server-side.
  • Role gating — administrator | superuser get everything; suppliers and guests see the public layer and minimal UI; an analyst sees exactly the layers assigned to their account.
  • The desktop-only “Live squares (admin)” layer entry is visible to every session but disabled with a hint for roles without access — an unauthorized fetch is never even sent.
5.2 · MAP RENDERING

Cells that adapt to the data.

  • Grid-cell layer over OSM — the backend's aggregated cells drawn as a colored canvas-rendered Leaflet layer over OpenStreetMap tiles.
  • Data-adaptive green-yellow-red scale — yellow sits exactly at the 75th percentile of the displayed values (the top-25 % boundary) and red at the maximum; stop positions are recomputed from the data on every fetch.
  • Statistic selector — Median / Mean / Min / Max; changing it (or the scale or opacity) only restyles the layer, no refetch.
  • Minimum cell size — a spinbox (1–10 000 m, default 100) floors the requested grid width: zooming in never yields bins smaller than this.
  • Viewport-matched cells — the page reports a viewport-derived cell size with every bounds change, so requested cells line up with the view; zooming changes the level of detail.
  • Basemap dimming — 0–100 %, and 0 % hides the tiles completely so the cells carry the screen; the setting persists.
  • Tooltips & details — hover shows cell size, statistic and counts; clicking opens every feature property plus the polygon center and cell indices.
  • Filters & reloads — client filter and speed-bucket checkboxes; auto-load on start with saved filters; failures land in the status bar, never in modal dialogs; manual bbox entry with a graceful fallback to the map view.
5.3 · JS BRIDGE

Python and the page, talking both ways.

  • Python → JS via runJavaScript calling page globals: addGeoJSON, clearFeatures, applyStyle.
  • JS → Python over QWebChannel (vendored): the page emits map_ready, bounds_changed (with the derived cell size and steps) and feature_clicked.
  • Resilient fallbacks — missing vendor files fall back to public CDNs automatically; if even the channel shim cannot load, the bridge degrades to console-message decoding with the same public API.
  • A WebEngine quirk is fixed in-app: local content may access remote URLs, so a file:// page can still fetch OSM tiles.
5.4 · SETTINGS & PERSISTENCE

Every knob in one dialog, remembered.

  • One modal Settings dialog (File → Settings…, Ctrl+,) holds everything: the signed-in account group, backend URL with a live Check and health label, area source, client & bucket filters, style group and the admin group.
  • Persists every change immediately into QSettings keys — backend URL, filters, opacities, scale, statistic, min cell, map center and zoom; compound values are JSON-encoded, every value is coerced and validated on load, unknown keys are silently dropped.
  • The last map center and zoom are restored on start; the backend status is one click away.
5.5 · ADMIN TOOLS DOCK

Approval work, three tabs wide.

  • Devices tab — every registered device stays visible (pending, declined and approved) with a Show filter, attempt counters and first/last-seen; Approve / Decline sit behind confirmation dialogs and auto-refresh follows every decision.
  • Users tab — the accounts list with a role filter and search; Ban / Unban behind confirmation; reads keep working for banned users while data operations are blocked.
  • Directory tab — two sub-tabs: Registrations (full details + Approve/Deny — approving also links the device to the requesting account) and Devices (whitelist entries with owners).
  • Rebuild public layer — forces an immediate rebuild and shows the compact outcome (“rebuilt: 1 234 bins · 56 789 pts · 3.2 s”) or the exact failure; disabled while a rebuild is in flight.
  • Read-only mode — a superuser-only checkbox with confirmation; administrators see the same state as a badge.
  • Bulk dumps ingestion is exposed among the session-guarded admin calls.
5.6 · CONCURRENCY MODEL

Fifteen workers, one rule each.

  • 15 one-shot worker types — Health, FetchSquares, Layers, RebuildPublicLayer, ClientRequests, ClientDecision, Login, Register, Logout, Me, ReadOnly, Users, BanUser, Directory, RegistrationDecision.
  • Each runs a single HTTP call on a QThread and emits one terminal signal — the GUI never blocks on the network.
  • Token-carrying workers emit unauthorized on a 401 (instead of a generic failure) so the main window re-prompts login.
  • A Qt 6.11 interruption quirk is worked around with a mirrored Python flag.
5.7 · LAUNCHERS & TOOLING

Double-click to map.

  • DOBROVOZIM Map.command — a double-clickable macOS launcher that bootstraps the virtualenv + PySide6 on first run and keeps the Terminal open on errors.
  • run.sh — venv resolution with an older-Python fallback and a system-python warning; arguments pass through.
  • Headless mode — --offscreen for CI, with the matching Qt flags.
  • tools/validate.py — a staged gate: compile, structure, contract markers, a real headless main-window instantiation, and a live-API check with polite SKIPs.
  • Accounts smoke & probes — a self-contained offscreen accounts test against a local mock backend, plus a probe for a running local backend.

Part 06 · Shipping it

One small server.
Two tidy stacks.Ports that never argue.

The whole platform fits a single modest VPS, and every piece of configuration travels through the environment — no config files to drift.

6.1 · THE VPS

Sized for real traffic, priced for hobbyists.

  • Single-VPS Docker Compose per the deployment runbook: a Hetzner CX22/CX32-class or DigitalOcean droplet, Ubuntu 24.04, 2 vCPU and 4–8 GB of RAM.
  • The database volume is the one asset that must never be destroyed — everything else rebuilds from code.
6.2 · THE BACKEND STACK

Two containers, two ports.

  • PostGIS on :5433 and the Go API on :8080 — deliberately offset from any other local stacks.
  • The dumps/ folder is mounted read-only into the container, so bulk imports cannot rewrite their own source.
  • Deploy gates probe /health/ready (with a /health fallback for older backends).
  • The Dockerfile builds a static Go server; configuration is entirely environment-driven.
6.3 · THE ROOT COMPOSE

Room reserved for the analytics tier.

  • A separate root compose stands up the planned analytics pipeline infrastructure: TimescaleDB on :5432 and Apache Kafka in KRaft mode (single-node broker + controller) on :9092.
  • It is kept deliberately apart from the live backend's PostGIS — the high-throughput ingestion / map-matching tier may come later without touching what already works.
6.4 · CLIENTS SHIP THEMSELVES

No app store, no installer dance.

  • The web portal needs no deployment at all — it is plain static files the backend serves from WEB_DIR; a file drop is a release.
  • The desktop app is a script-launched Python program with a self-bootstrapping macOS launcher.
  • The iOS app builds from its Xcode project; the upload URL is a user setting, so pointing a phone at a new backend is a text field, not a release.
PortServiceNotes
8080Go API + web portalAll REST endpoints and the static /web/ pages.
5433PostgreSQL + PostGISThe live backend database; its volume is sacred.
5432TimescaleDBPlanned analytics tier (root compose), separate from the live backend.
9092Apache KafkaPlanned analytics tier, KRaft single-node broker + controller.

Part 07 · The safety net

Every part ships
with its own net.Verified behavior, not vibes.

Per-component suites live under tests/, and the backend keeps its historical tests beside the code. Zero npm dependencies, hermetic by default, real-database integration when it matters.

7.1 · WEB TESTS

A fake DOM for real logic.

  • Node's built-in runner with zero npm dependencies; browser IIFEs load via node:vm behind a small fake-DOM harness.
  • Covers the api() wrapper and helpers, colormap parity, the register and status flows, and the full accounts flows — login, the role-gated hub, directory, admin and superuser pages.
7.2 · BACKEND TESTS

Hermetic tables, real database when needed.

  • A standalone Go module of hermetic table-driven tests for the pure logic: speed buckets, geo/EWKB math, the config matrix, CSV parsing (including mid-stream I/O errors), registration validation and public-layer grid math.
  • In-package integration tests beside the code cover uploads, web registrations, the public layer, the grid, rate limiting, the auth flow and hardening — with a real-DB flow via TEST_DATABASE_URL.
7.3 · DESKTOP TESTS

Qt forced offscreen, thoroughly exercised.

  • pytest with Qt forced offscreen before import.
  • The colormap is cross-checked against the JavaScript implementation — the parity guarantee from Part 01, enforced.
  • Settings defaults, coercion and round-trips on a temp backend; session-store round trips with corrupt-snapshot resilience; client request-building and error mapping; worker auth routing and one-shot semantics.
7.4 · iOS TESTS

XCTest, wired into the app's project.

  • An XCTest target inside the Xcode project, run on the iOS Simulator via xcodebuild test.
  • Covers the device-model mapping, ZIP writer CRC and archive round-trip, measurement-store RMS math, flush, prune and CSV+ZIP export, the speed-gate constant and walk-mode persistence, registration payload encoding, and the uploader's no-data skip.
7.5 · THE GUARDS

Tests that protect the tests.

  • A static migration guard bans destructive SQL unless a line is explicitly tagged as reviewed.
  • The desktop ships a staged validation gate (compile → structure → contract → headless GUI → live API) and an offscreen accounts smoke test against a mock backend.
  • The web suite asserts the colormap stop tables stay byte-identical across languages.

Why it mattersThe system's most subtle contracts — the grid geometry, the color scales, the error envelope, the approval lifecycle — are exactly the parts two implementations could get wrong. Those are the parts with the loudest tests.

You have seen every part

Now see it live.

Open the road map Or start contributing — register your device
↑