29 KiB
29 KiB
Changelog
v0.7.0 — 2026-09-17 12:27
- Server-authoritative sim engine (PLAN.md "Simulation engine" increment 2) in new
lib/sim/— no frontend/UI changes; dispatch of planes lands next increment, but all the clock/market/finalization machinery it builds on is live and tested now - Tick-on-read game clock (
lib/sim/engine.ts, NO background timer): every read/action endpoint callstickNow()first (GET /api/state, POST /api/sim — and any future action endpoint); the tick advancesclockMsby(real ms since last tick) × timeScale. Newsim_state.last_real_tick_mscolumn stores the real-time anchor (seed sets it to the seed moment). Clamp:MAX_TICK_ELAPSED_MS = 2000— a single tick counts at most 2 s of real absence, chosen above a normal ~1 s poll interval (active play advances ~real time × scale) but small enough that one stale read after an absence moves game time by ≤ 2 s × scale (≤ 2 min at max scale 60), so the sim only meaningfully progresses while actively polled. While paused the clock is frozen butlastRealTickMskeeps tracking real time, so unpausing resumes with no catch-up jump. Ticks are serialized through a module-level promise lock (concurrent Next.js requests can't double-advance) and each tick persists via the existing sql.js atomic export - Analytic movement (
lib/sim/movement.ts, pure functions of the sim clock — no incremental stepping):travelDurationMs(distanceKm, speedKmh),arrivalTimeFor(departureSimTime, distanceKm, speedKmh),progressAt(...)(clamped 0–1),positionAt(fromLat, fromLon, toLat, toLon, t)(great-circle slerp — exact endpoints, stays on the arc),fuelAt(startFuel, burnPerKm, distanceKm, progress)(start − burn·distanceSoFar, clamped at 0; range exhaustion not modeled yet). New nullableplanes.departure_sim_timecolumn carries the flight context (route comes from the bound contract, speed/burn from the catalog by model); while flying,fuelis frozen at its departure value and GET /api/state reports live lat/lon/fuel computed from the clock - Arrival finalization (in-tick): a flying plane whose
arrivalTime ≤ clockgets its contract marked completed, the exact reward added toaccount.cash, reputation +=1 + 0.0005 × km(rounded to 2 dp — ~1.15 rep for a 300 km hop, ~2.5 for a 3000 km leg; slow growth from the starting 10), and the plane grounded at the destination with remaining fuel +departureSimTime = null. Idempotent by construction: finalization leaves the 'flying' state, so an award can never be paid twice (unit-tested) - Contract expiry (in-tick): any
opencontract withdeadlineSimTime ≤ clockMs→ status'expired'(new enum value in schema/DDL/snapshot/OpenAPI); accepted/in-flight contracts never expire — they complete. Expired contracts leave the available set the client sees - Market spawn (in-tick, after expiry): if open contracts <
MARKET_TARGET_OPEN_CONTRACTS = 10, new ones are generated to top it up using the SAME builder as the first-run seed —generateContract({id, airports, rng, nowMs, deadlineOffsetMs?})extracted frombuildSeed(same RNG call order, reward formula, cargo flavor, 6–72h game-time deadlines; spawn uses crypto-random UUID ids and the sim clock as "now"). Injectable RNG for deterministic tests - Time controls endpoint: new
POST /api/sim— body{ timeScale?: number, paused?: boolean }; unknown fields / non-number timeScale / non-boolean paused → 400; timeScale clamped to [1, 60]; ticks the clock first so the returned simState already reflects current game time. Documented inlib/openapi.ts(new SimControlsRequest + Error component schemas; ContractState status enum gains 'expired'; /api/state description notes tick-on-read) - DB migration for existing installs:
openGameDb()now runs a guardedALTER TABLE ADD COLUMNstep (PRAGMA table_info check) addingplanes.departure_sim_timeandsim_state.last_real_tick_msto pre-v0.7.0 files, then fixes up the ALTER-defaultlast_real_tick_ms = 0to "now" so old DBs resume cleanly instead of taking one clamped catch-up jump - e2e test hook (test-only affordance): new
POST /api/test/expire-contract, enabled ONLY whenFLIGHT_TEST_HOOKS=1(set in docker-compose.yml for the e2e instance; 404 everywhere else) — with{contractId}it moves that open contract's deadline ~10 game-seconds out, without a body it creates a fresh short-deadline open contract; lets the suite observe expiry at scale 60 instead of waiting hours - Tests: unit — new
lib/sim/movement.test.ts(duration/arrival math, progress boundaries incl. just-before-arrival, slerp endpoints/on-arc/equator-exact/same-point, fuel burn + zero clamp) andlib/sim/engine.test.ts(fake-timer clock advance × timeScale, pause freeze + clean resume, 1-hour-gap clamp to exactly one tick, same-ms no-op; expiry flips only open past-deadline contracts incl. the inclusive edge; finalize awards cash+rep exactly once with right remaining fuel and does nothing pre-arrival or without route info; spawn tops up to target via the shared builder — deterministic for an injected RNG);lib/game/seed.test.tsgains generateContract coverage (formula, window, determinism, deadlineOffsetMs, buildSeed ≡ builder output) and the simState assertion now includes lastRealTickMs; newapp/api/sim/route.test.ts(200 shape, clamp to [1,60], pause freeze across ticks, 400 matrix);lib/db/client.test.tsgains a legacy-DB migration test; e2e — newe2e/sim.spec.ts: scale-60 clock advance bounded per tick by the clamp, exact freeze while paused, expiry of a hook-shortened deadline + market staying ≥ target (both mutate and create hook modes), POST /api/sim validation/clamping - Known interaction with v0.6.0 e2e:
e2e/state.spec.tsrestart-persistence test previously asserted the sim clock was byte-identical across a container restart — impossible under tick-on-read by design (every GET advances ≤ one clamped tick). The assertion now checks persistence properly: account/planes/contracts still byte-identical, and post-restartclockMs ≥ beforebounded byMAX_TICK_ELAPSED_MS × timeScale.e2e/api.spec.tsendpoint list updated to four - Verified: unit suite 47 → 86 green;
tsc --noEmitclean; e2e 11 → 16 green against the rebuilt compose instance (incl. restart-persistence); live curl of POST /api/sim + GET /api/state shows the clock advancing at scale 60 then frozen on pause
v0.6.0 — 2026-09-17 11:49
- Data foundation (PLAN.md "Simulation engine" increment 1): Drizzle ORM + SQLite via sql.js (WASM) — chosen over better-sqlite3 because it needs zero native compilation, so the existing oven/bun-alpine install stages and node:22-alpine runtime work unchanged; deps
drizzle-orm@0.45,sql.js@1.14(+ dev-only@types/sql.js) - Schema (
lib/db/schema.ts, DDL mirrored inlib/db/client.ts):planes(id, model, status available|flying, fuel/maxFuel, locationIcao, contractId?, lat/lon),contracts(id, origin/dest ICAO, reward, cargoType/cargoCount, deadlineSimTime, status open|accepted|completed, planeId?, createdSimTime), single-rowaccount(cash, reputation) andsim_state(clockMs, timeScale 1–60, paused) - First-run seed (
lib/game/seed.ts) — purebuildSeed({airports, rng, nowMs}), fully deterministic with an injected RNG (mulberry32 PRNG exported for tests): starter plane ATR 72-600 (in-code catalog constant inlib/game/catalog.ts: maxFuel 6300 L, burnPerKm 1.75 L/km, speedKmh 510 — structured so more types can be added) grounded full-tank at Frankfurt (EDDF; EDDM is Munich), 8–12 open contracts between random distinct CSV airports with rewardround(500 + 2.5 · haversineKm)(documented simple formula; real revenue/cost modeling is a later increment), cargo flavor (Passengers ≤68 / pallets ≤50), deadline = now + 6–72h game time; account starts at cash 20 000 / reputation 10; simState clockMs = real now, timeScale 1, paused false - Persistence: DB file
db/flight.dbon the host (gitignored) volume-mounted in docker-compose as./db:/app/db— a dedicated dir, NOT over/app/data(which would mask airports_eu.csv); sql.js keeps the DB in memory, sopersist()exports the bytes and writes them atomically (tmp file + rename) after every mutation; seeding happens lazily on first access when the account table is empty;FLIGHT_DB_PATHenv override lets tests point at temp files - New read-only
GET /api/state(app/api/state/route.ts): full snapshot{account, simState, planes[], contracts[]}for client polling — plane/contract shapes include airport names (locationName / originName / destName) resolved from the CSV; no tick loop yet, state is static after seed - OpenAPI:
/api/statedocumented with component schemas StateSnapshot/AccountState/SimState/PlaneState/ContractState (lib/openapi.ts) next.config.ts:serverExternalPackages: ['sql.js']— the WASM blob resolves relative to sql.js's dist dir, so it must not be bundled into a .next chunk; known drizzle-orm 0.45.x quirk worked around in code (commented): the sql-js driver drops the result mapper for relationaldb.query.*queries and returns raw snake_case rows, so all reads use plainselect()- Tests: unit — new
lib/geo.test.ts(haversine),lib/game/seed.test.ts(determinism with fixed RNG, 8–12 count, distinct endpoints, reward formula vs haversine, deadline window, hub + fallback, account/simState values),lib/db/client.test.ts(first-run seed + on-disk file, no re-seed on reopen, persist() round-trip) andapp/api/state/route.test.ts(200 + exact top-level shape against a temp DB); openapi tests updated for the third endpoint; e2e — newe2e/state.spec.ts: snapshot shape from the live origin, plus restart-persistence test that records cash/clockMs/ids, runsdocker compose restart flight, waits for readiness and asserts everything unchanged (PLAN.md "sim clock persisted across sessions"); playwright.config gainsworkers: 1so the mid-run container restart can't race the map tests - Verified: unit suite 47/47;
tsc --noEmitclean; e2e 11/11 green against the rebuilt compose instance (incl. both new state tests); live curl/api/state→ Frankfurt starter plane + 11 contracts,/api/airportsstill serves 242 features after the mount change; persistence across a fulldocker compose up -d --buildverified manually (clockMs/cash/contract count identical before/after)
v0.5.0 — 2026-09-17 10:15
- New
GET /api/version(app/api/version/route.ts): returns{ "version": "<semver>" }, version read from package.json via a server-side import (same single-source-of-truth pattern as the header tag) - OpenAPI 3.1 spec in code:
lib/openapi.tsexports an object documenting exactly the two public endpoints —GET /api/airports(GeoJSON FeatureCollection, with component schemas for feature collection/feature/point geometry/properties) andGET /api/version;info.versionis imported from package.json so it stays in sync automatically;servers: [{ url: "" }]means "current origin" - New
GET /api/openapi(app/api/openapi/route.ts) serves that spec as JSON — machine-readable API docs for AI agents and external tooling (no interactive docs page, no new dependencies) - AGENTS.md gains a short "## API" section pointing agents at the live base URL and the spec at
https://flight.dev.kkhost.pl/api/openapi, noting the spec is generated fromlib/openapi.tsand must be updated whenever endpoints change; this commit also includes the pending local note in AGENTS.md's "## Other" section (use pty_* tools for longer terminal tasks) - Tests: unit — new
app/api/version/route.test.ts(200 + JSON version equals package.json's, exact body shape) andlib/openapi.test.ts(both paths present, info.version in sync with package.json, empty server url, airports schema refs the FeatureCollection/Feature/Properties component schemas with name/icao/lat/lon); e2e — newe2e/api.spec.ts: fetches/api/versionfrom the live origin and asserts 200 + version matching package.json read at test time; fetches/api/openapiand checks content-type, title, info.version and both paths
v0.4.1 — 2026-09-17 08:50
- Map-asset pack (~100 MB, 780 files) removed from git and from the Docker image:
git rm -r --cached public/{styles,fonts,sprites}(files kept on disk),.gitignorenow excludes exactly the generated paths (public/styles/,public/fonts/,public/sprites/,public/map-assets-manifest.json) — not all ofpublic/;.dockerignoregets the same four entries; the v0.4.0 runtime-stageCOPY --from=build /app/public ./publicline is gone from the Dockerfile - The pack now reaches the container via a docker-compose volume mount:
./public:/app/publicon theflightservice — host dir present with assets → served locally; absent/empty → 404s → runtime fallback (below). Establishes the pattern for the future multi-GB tile file (v0.5.0) that must never go into git scripts/download-map-assets.mjsnow writespublic/map-assets-manifest.jsonas its LAST step ({name, generatedAt ISO, styles: [ids]}) so the manifest's presence proves the whole pack is in place; new npm script"assets:download": "node scripts/download-map-assets.mjs"- Runtime fallback for fresh clones without the pack:
lib/mapStyles.tsgains purestyleUrlFor(id, localAssetsAvailable)→/styles/{id}.jsonorhttps://tiles.openfreemap.org/styles/{id}, plus asynclocalAssetsAvailable()— fetches/map-assets-manifest.jsononce (module-level cached promise; any non-200/network error → false); the single manifest probe is designed so a future self-hosted tile pack plugs into the same availability mechanism without rework components/Map.tsx: map creation in the mount effect is deferred until the probe resolves (one cheap local request beforenew Map), and the persisted-style restore was folded into that same post-mount flow — localStorage is still read only after mount (SSR-safe), applied at creation time so there's no fiord→saved flash; style switcher changes reuse the cached probe viastyleUrlFor; all v0.4.0 behavior intact (fiord default, persistence key, idempotentensureAirportsLayeron everystyledata, single/api/airportsfetch,window.__flightMap)- Tests: unit — new
styleUrlForcases (local path / exact remote OFM URL) andlocalAssetsAvailablecases (false on non-200, true + cached with exactly one fetch across awaits via module reset, false on network error); e2e — new test "falls back to remote OFM styles when the local asset pack is absent" routes/map-assets-manifest.json→ 404, lets*.openfreemap.org/styles/**through while aborting all other OFM traffic, and asserts the map initializes from the REMOTE style (glyphs still point at openfreemap.org), the airport count renders, and a UKBB marker click opens its popup — proving fresh-clone-without-assets usability end-to-end - Verified: unit suite 21/21;
tsc --noEmitclean; e2e suite green against the rebuilt compose instance with assets served from the bind mount (curl:/styles/fiord.json, a font.pbf,/sprites/ofm.jsonall 200); note the asset blobs remain in git history — no history rewrite was performed
v0.4.0 — 2026-09-17 08:22
- Self-hosted all OpenFreeMap map-styling assets (tiles stay remote): new
scripts/download-map-assets.mjsfetches the 5 OFM styles with a browser UA, rewrites each viarewriteStyle, and extracts the font/sprite tarballs intopublic/— committed so the image is self-contained:public/styles/{liberty,bright,positron,dark,fiord}.json(0.15 MB),public/fonts/Noto Sans {Regular,Italic,Bold}/*.pbf(768 files, 99.76 MB — the font payload),public/sprites/ofm{.json,.png,@2x.json,@2x.png}(0.21 MB); total public/ = 100.13 MB - New shared module
lib/mapStyles.ts:STYLESregistry (5 ids + labels),DEFAULT_STYLE_ID = 'fiord', and purerewriteStyle()— glyphshttps://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf→/fonts/{fontstack}/{range}.pbf, spritehttps://tiles.openfreemap.org/sprites/ofm_f384/ofm→/sprites/ofm; the vector tile url (/planet) and ne2_shaded raster tiles (/natural_earth/ne2sr/...) are left remote; every source lacking an attribution gets"© OpenStreetMap contributors © OpenFreeMap"(OFM styles ship none; ODbL requires visible OSM credit) components/Map.tsx: map now loads the local/styles/fiord.jsonby default; new top-bar style switcher (<select data-testid="style-switch">, subtle styling inapp/globals.css) listing all 5 styles, persisted to localStorage keyflight-ops-style; SSR-safe — state initializes toDEFAULT_STYLE_IDand the saved choice is read/applied only in a post-mount effect (no localStorage reads during render/hydration)components/Map.tsx: airports layer now survives style switches —/api/airportsis fetched at most once per page life (promise cached in a ref; count set exactly once), and an idempotentensureAirportsLayer()re-adds the source+layer if missing on EVERYstyledataevent (initial load AND after eachsetStyle, which wipes sources/layers not in the new style); the click→popup handler is re-wired with off+on using a stable reference since MapLibre layer-scoped listeners survive setStyle and would otherwise duplicateDockerfile: runtime stage now copiespublic/from the build stage (COPY --from=build /app/public ./public) sonext startserves the self-hosted assets;.dockerignorealready lets public/ through- Tests: new unit suite
lib/mapStyles.test.ts(registry shape, fiord default, rewriteStyle against an inline OFM fixture — local glyphs/sprite, remote tiles preserved, attribution added/preserved, input not mutated);e2e/map.spec.ts: openfreemap mock simplified to abort ALL requests from that host (only tiles remain there), new test asserting the switcher has 5 options with Fiord default, switching changes the applied style's layer set AND the airports layer re-renders after the switch + exactly one popup on marker click (guards the setStyle-wipes-layers and duplicate-listener regressions), plus a reload-persistence test for the localStorage restore path - Verified: unit suite 16/16;
tsc --noEmitclean; e2e suite 6/6 green against the rebuilt compose instance (all styles/fonts/sprites served locally, fiord default, switch + persistence verified in a real browser)
v0.3.1 — 2026-09-17 06:35
- Header now shows the app version between the "Flight Ops" title and the airport count (
<small>v{version}</small>incomponents/Map.tsx), sourced dynamically frompackage.jsonvia a JSON import — future version bumps show up automatically with no code change; subtle 11px muted style added toapp/globals.css - Tests: new unit suite
components/version.test.ts(valid semver in package.json + guard that the header sources the version from package.json rather than hardcoding it);e2e/map.spec.ts: new test asserting the header showsv{version}with the expected value read frompackage.jsonat test time, so it stays in sync automatically - Verified: unit suite 10/10;
tsc --noEmitclean; e2e suite 4/4 green against the rebuilt compose instance
v0.3.0 — 2026-09-17 00:32
- Removed the zoom-driven camera tilt (
lib/camera.tspitch curve + the mapmovehandler): the map is now always flat/top-down with standard pan/zoom only; deletedlib/camera.tsand its unit tests - Removed terrain: no more DEM raster source /
map.setTerrain, and the?terrain=0/NEXT_PUBLIC_TERRAINtoggles are gone — pure 2D view, no 3D elements - MAX_ZOOM 14 → 11: at European latitudes (~52°N) z11 ≈ 47 m/px, so the viewport spans ~90–135 km across a typical desktop width (≈100 km on screen); street-level zoom had no gameplay value
e2e/map.spec.ts: APP_URL is now/(terrain flag removed), MAX_ZOOM mirror updated to 11; PLAN.md map section updated to flat-2D-only- Verified: unit suite 8/8;
tsc --noEmitclean; e2e suite 3/3 green against the rebuilt compose instance (flat view, wheel zoom clamped to [3, 11])
v0.2.9 — 2026-09-17 00:21
data/airports_eu.csv: multi-airport metro areas reduced to one dot each on the map — Paris→CDG, London→LHR, Moscow→SVO, Milan→MXP, Istanbul→IST, Warsaw→WAW, Brussels→BRU (Rhine-Ruhr keeps both DUS and CGN as distinct cities); dropped 18 satellite airports incl. Orly, Gatwick, Luton, Stansted, Domodedovo, Vnukovo, Zhukovsky, Linate, Caravaggio, Atatürk, Modlin, Charleroi, Weeze, Prestwick, Treviso, Ciampino, Băneasa — 260 → 242 rows;/api/airportsand the circle layer pick it up with no code changes- Verified: unit suite 8/8;
tsc --noEmitclean; e2e suite 3/3 green against the rebuilt compose instance (/api/airportsserves 242 features)
v0.2.8 — 2026-09-16 23:53
data/airports_eu.csv: reduced the dataset to onlylarge_airportrows (dropped all 731 medium_airport + 54 seaplane_base) — the map now shows just the 260 biggest EU airports;/api/airportsand the circle layer pick it up with no code changese2e/map.spec.ts: marker-click test repointed from ESSV Visby Airport (a medium_airport, now removed) to UKBB Boryspil International Airport — with only large_airport rows left it is the most isolated marker inside the initial zoom-4 viewport: its nearest surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom (Kyiv Zhuliany, geographically closer, was a medium and got cut)- Verified: unit suite 12/12;
tsc --noEmitclean; e2e suite 3/3 green against the rebuilt compose instance (/api/airportsserves 260 features)
v0.2.7 — 2026-09-16 23:39
data/airports_eu.csv: removed all 6400small_airportrows from the OurAirports EU export — the map now shows only the 1045 bigger airports (260 large + 731 medium + 54 seaplane_base);/api/airportsand the circle layer pick it up with no code changese2e/map.spec.ts: marker-click test repointed from EKRN Bornholm Airport (a small_airport, now removed) to ESSV Visby Airport on Gotland — its nearest remaining airport is Stockholm Skavsta across the Baltic Sea (~59 px away at the initial zoom), so its marker cannot be overlapped by a neighbouring circle- Verified: unit suite 12/12; e2e suite 3/3 green against the rebuilt compose instance (
/api/airportsserves 1045 features)
v0.2.6 — 2026-09-16 22:41
Dockerfile: install stages switched from npm to bun (oven/bun:1.4.2-alpine,bun install --frozen-lockfile);package-lock.jsonreplaced bybun.lock; no BuildKittype=cachemounts — they don't persist across builds on this daemon, and bun install is fast enough (~3 s cold) that cross-build caching isn't worth itDockerfile: newprod-depsstage (bun install --production) so the runtime image carries prod-onlynode_modules— devDeps (typescript, vitest, @types) are no longer shipped; build and runtime stages otherwise unchanged (still node:22-alpine, full.next, no standalone output).dockerignore: excludes docs/deployment files (*.md,Dockerfile*,docker-compose*) from the build context so changelog-only commits don't invalidate the COPY layer and re-runnext build- Verified: full cold build 77 s → 58 s; e2e suite 3/3 green against the rebuilt compose instance
v0.2.5 — 2026-09-16 21:22
AGENTS.md: new "E2E tests (mandatory)" section codifies the e2e workflow — the compose instance behind Traefik (https://flight.dev.kkhost.pl) is always the app under test, never a localnext devon :3000; deploy current code first withdocker compose up -d --build; browsers run through the shared remote Playwright server at192.168.1.4:3000, and plainnpx playwright testhandles deploy + readiness + remote connectplaywright.config.ts: dropped theE2E_LOCAL=1local-dev path accordingly — no more local :3000 dev instance for e2e; the default (and only) mode is browsers on the shared remote Playwright server against the prod build behind Traefik, withwebServerrunningdocker compose up -d --build;E2E_REMOTE=1and explicitPW_TEST_CONNECT_WS_ENDPOINT/E2E_BASE_URLoverrides are unchanged
v0.2.4 — 2026-09-16 21:06
playwright.config.ts: default e2e mode now targets the prod build behind Traefik (https://flight.dev.kkhost.pl) —baseURLdefaults to that URL andwebServerrunsdocker compose up -d --build(300 s timeout) instead ofnpm run dev, becausenext devdoes not render for non-loopback clients: Turbopack's HMR WebSocket fails from the remote browsers, so the page loads blankE2E_LOCAL=1keeps the local iteration path (browsers on this box,webServerauto-startsnpm run dev, baseURLlocalhost:3000) as a fallback when the proxy or compose stack is down;E2E_REMOTE=1and explicitPW_TEST_CONNECT_WS_ENDPOINT/E2E_BASE_URLoverrides are unchanged
v0.2.3 — 2026-09-16 20:30
playwright.config.ts: e2e now launches browsers on the shared remote Playwright server (Docker,ws://192.168.1.4:3000/) by default — when no env vars are set it exportsPW_TEST_CONNECT_WS_ENDPOINTpointing at that box; in this modebaseURLdefaults to this box's LAN IP (http://192.168.1.26:3000) because pages load from the remote browser's perspective, while the localwebServerstill auto-startsnpm run devE2E_LOCAL=1restores fully-local runs (browsers on this box,localhost:3000baseURL) as a fallback when the remote server is down;E2E_REMOTE=1keeps its previous behavior for runners on other machines — skipswebServerentirely and pairs withE2E_BASE_URL; an explicitPW_TEST_CONNECT_WS_ENDPOINTalways wins over the default
v0.2.2 — 2026-09-16 20:05
playwright.config.ts: e2e suite can now run on a different machine than the app — withE2E_REMOTE=1the localwebServerblock is skipped entirely andbaseURLdefaults tohttp://flight.dev.kkhost.pl;E2E_BASE_URLoverridesbaseURLin both modes; with no env vars set behavior is unchanged (localwebServerauto-startsnpm run dev)
v0.2.1 — 2026-09-16 19:49
- Clamp map zoom to the useful gameplay range:
minZoom: 3/maxZoom: 14on the MapLibre options incomponents/Map.tsx(exported asMIN_ZOOM/MAX_ZOOM) — wheel travel from the Europe overview is now ~10 levels instead of ~16, and street-level zoom past the z12 pitch transition no longer exists - e2e: new test that wheels well past both limits via
page.mouse.wheel(basemap tile fetches blocked for speed/determinism; clamping is pure camera behavior) and asserts viaexpect.pollon the map instance thatgetZoom()settles at 14 / 3, i.e. clamped within [3, 14]
v0.2.0 — 2026-09-16 18:32
- Docker deployment: multi-stage
Dockerfile(node:22-alpine; deps → build → runtime) runningnext starton port 3000; nooutput: 'standalone'— the runtime stage keeps fullnode_modulesbecause/api/mlworker/[file]reads maplibre-gl dist files from disk at runtime (and/api/airportsreadsdata/*.csv) .dockerignore: node_modules, .next, data/*.db, test-results, *.tsbuildinfo, plus .git/.env/.codenomad and the e2e harness (@playwright/testis a host-global install, not a project dependency — Next's build-time type check would fail on it inside the image)docker-compose.yml: single serviceflight, no published ports, joins external networkdev_netwith Traefik labels routingHost(flight.dev.kkhost.pl)→ port 3000 (plain HTTP entrypointweb; TLS terminated upstream)- Verified: container builds and runs; router registered in dev_traefik rawdata;
curl -H "Host: flight.dev.kkhost.pl" http://localhost/→ 200,/api/airports→ 200 GeoJSON (7445 features), mlworker .mjs files served from node_modules inside the container; unit suite 12/12 and e2e suite 2/2 still green
v0.1.0 — 2026-09-16 18:24
- Scaffolded Next.js (App Router) + TypeScript project; dev server on port 3000 (
npm run dev) - Full-viewport MapLibre GL map with OpenFreeMap "liberty" vector basemap (attribution included via style)
- Zoom-driven camera controller: top-down at zoom ≤ 6, smoothstep tilt to 45° pitch by zoom ≥ 12; pitch is a pure function of zoom so it never fights pan/zoom gestures
- Lite 3D terrain from AWS terrarium raster-DEM tiles (
map.setTerrain); toggleable off via?terrain=0orNEXT_PUBLIC_TERRAIN=0(off in e2e for deterministic headless rendering) - EU airport seed data: OurAirports CSV (official mirror at davidmegginson.github.io/ourairports-data — the legacy ourairports.com/data URL now 404s) filtered to continent=EU with types small/medium/large_airport + seaplane_base → committed as
data/airports_eu.csv(7445 airports; heliports, closed and balloon sites excluded — rationale documented inlib/airports.ts) GET /api/airports: server-side CSV parse with in-memory cache, returns GeoJSON FeatureCollection (properties: name, icao, lat, lon)- Client renders airports as a GeoJSON circle layer (no DOM markers); click → popup with name + ICAO; header shows loaded airport count (
data-testid="airport-count") - MapLibre worker served from node_modules via
/api/mlworker/*route — bundlers break the URL MapLibre derives for its worker, which silently hangs GeoJSON source updates - Tests: vitest unit suite (CSV parse/filter, GeoJSON build, pitch curve, API handler) + self-contained Playwright e2e suite (
webServerauto-startsnpm run dev; page load + airport count + marker click popup)