Compare commits

...
Author SHA1 Message Date
kamilkrzywda f01c3f417c Merge pull request 'v0.50.0 — queued/planned route dotted line yellow-ish instead of gray (#98)' (#103) from feat/queued-route-color into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 10:29:13 +02:00
kamilkrzywda c3339f0e40 Make queued/planned route dotted line amber/gold instead of gray (ISS-098)
CI / build + unit + e2e (pull_request) Successful in 10m51s
Extract the queue-line color from an inline literal to exported
QUEUE_ROUTE_LINE_COLOR and retune it #64748b -> #d4a017. Dashed style
and width unchanged. Unit test pins the constant; existing e2e specs
(queue.spec.ts, relocation.spec.ts) already cover the rendering path.
2026-09-22 08:02:10 +00:00
kamilkrzywda d215203988 Merge pull request 'v0.49.0 — airport popup Relocate works for flying planes via queue (#96)' (#102) from feat/relocate-queue into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 09:48:50 +02:00
kamilkrzywda 11fc6f9d5c Airport popup Relocate works for flying/repairing planes via queue (ISS-096, v0.49.0)
CI / build + unit + e2e (pull_request) Successful in 12m56s
The airport popup's Relocate row now lists ALL planes instead of only
grounded ones; the click handler picks the endpoint by the selected
plane's current status — available → /fly (unchanged), flying/repairing
→ /queue so the destination is appended to the flight queue and chains
on landing. No-planes fallback reworded to 'No planes to relocate.'

E2E +1: relocation.spec.ts dispatches the starter on a leg via API,
slows the sim to 1x, opens the popup for a different airport, selects
the flying plane and clicks Relocate here — asserts the queue contains
the clicked ICAO while the plane still flies its original destination.
2026-09-22 07:32:31 +00:00
kamilkrzywda 8f32d5bf81 Merge pull request 'v0.48.0 — show leg distance (km) on job list and on-board contracts (#95)' (#100) from feat/leg-distance into master
CI / build + unit + e2e (push) Successful in 9m5s
2026-09-22 09:10:37 +02:00
5 changed files with 122 additions and 16 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## v0.50.0 — 2026-09-22 08:00
- **Queued/planned route line is now amber/gold instead of gray (ISS-098).** The dotted queue line in `components/Map.tsx` (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate `#64748b`. It is now an amber/gold `#d4a017`, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant `QUEUE_ROUTE_LINE_COLOR` so it is testable; the dashed/dotted style (`line-dasharray [0.1, 1.5]`) and width (3) are unchanged
- **Tests.** Unit +1: new `components/map.test.ts` pins `QUEUE_ROUTE_LINE_COLOR === '#d4a017'`. No new e2e spec — the existing queued-route rendering paths (`e2e/queue.spec.ts`, `e2e/relocation.spec.ts`) already exercise this layer, and a pure color swap needs no behavioral coverage
## v0.49.0 — 2026-09-22 07:25
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
## v0.48.0 — 2026-09-22 06:55
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
+19 -12
View File
@@ -32,6 +32,8 @@ const ROUTES_LEGS_LAYER_ID = 'routes-legs';
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
// ISS-098 (v0.50.0): amber/gold — planned legs read as "queued", visible on light and dark tiles alike.
export const QUEUE_ROUTE_LINE_COLOR = '#d4a017';
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
@@ -231,21 +233,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
// deterministic generated weather — no gameplay effects per PLAN.md.
const w = weatherFor(p.icao);
const localTime = approxLocalTime(p.lon, Date.now());
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
const st = stateRef.current;
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
const planes = st ? st.planes : [];
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
const relocateHtml =
groundedPlanes.length > 0
planes.length > 0
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
`<option value="">Select a plane\u2026</option>` +
groundedPlanes.map(
planes.map(
(pl) =>
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
).join('') +
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
const popup = new maplibregl.Popup({ offset: 10 })
.setLngLat(f.geometry.coordinates as [number, number])
.setHTML(
@@ -263,7 +266,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const el = popup.getElement();
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
// The status span is always in the popup HTML (even with no grounded planes), so it
// The status span is always in the popup HTML (even with no planes), so it
// exists whenever the select/button do.
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
if (select && button && statusEl) {
@@ -276,7 +279,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
button.disabled = true;
statusEl.textContent = 'Relocating\u2026';
try {
const res = await fetch(`/api/planes/${planeId}/fly`, {
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
// planes fly now, flying/repairing ones get the leg queued for after landing.
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destIcao: p.icao }),
@@ -285,7 +292,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
statusEl.textContent = 'Relocation started.';
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
} catch (err) {
statusEl.textContent = err instanceof Error ? err.message : String(err);
button.disabled = false; // allow retry / picking another plane
@@ -377,8 +384,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
});
}
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
// layers so a hovered trace still draws on top of it. Amber/gold + fine dots reads as
// "planned/future", distinct from the solid bright-amber active leg and the [2,2] yellow hover dash.
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
type: 'geojson',
@@ -391,7 +398,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
type: 'line',
source: QUEUE_ROUTES_SOURCE_ID,
paint: {
'line-color': '#64748b', // muted slate — planned legs, not actively flying
'line-color': QUEUE_ROUTE_LINE_COLOR, // amber/gold — planned legs, not actively flying (ISS-098)
'line-width': 3,
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
},
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { QUEUE_ROUTE_LINE_COLOR } from './Map';
// ISS-098 (v0.50.0): the dotted queued/planned route line is amber/gold, not gray — pinned so a
// future retune is deliberate. E2E already exercises the rendering path (queue.spec.ts, relocation.spec.ts).
describe('queued route line color', () => {
it('pins the amber/gold queue-line color readable on light and dark tiles', () => {
expect(QUEUE_ROUTE_LINE_COLOR).toBe('#d4a017');
});
});
+83 -3
View File
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
// the existing e2e/map.spec.ts click tests, not here.
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
@@ -15,7 +16,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
}
async function getState(page: Page): Promise<StateBody> {
@@ -195,3 +196,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
});
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
// shortest leg takes minutes of real time) — "still flying its original destination" below is
// deterministic regardless of how fast the UI moves.
const nearest = await nearestToHub(page);
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
await setScale(page, 1);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.ok()).toBeTruthy();
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
await page.goto('/');
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}, { timeout: 15_000 }).toBeGreaterThan(0);
await expect.poll(async () =>
page.evaluate(() => {
const m = (window as any).__flightMap;
if (!m || !m.getSource('planes')) return false;
try {
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
} catch {
return false; // source not ready yet — keep polling
}
}),
{ timeout: 15_000 },
).toBe(true);
const pt = await page.evaluate(({ lon, lat }) => {
const map = (window as any).__flightMap;
const p = map.project([lon, lat]);
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, POPUP_AIRPORT);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText(POPUP_AIRPORT.icao);
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
// is not grounded; while flying its option shows the DEPARTURE airport.
const planeSel = page.getByTestId('airport-popup-plane');
await expect(planeSel).toBeVisible();
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
const goBtn = page.getByTestId('airport-popup-relocate');
await expect(goBtn).toBeDisabled(); // placeholder selected
await planeSel.selectOption('plane-starter');
await expect(goBtn).toBeEnabled();
await goBtn.click();
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
// the flight queue while the plane keeps flying its original destination.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.48.0",
"version": "0.50.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",