/ doc / tutorials / tutorial_mission_control_dashboard.org
tutorial_mission_control_dashboard.org
   1  #+title: Tutorial: Build a Mission Control Dashboard
   2  #+author: Claude Code
   3  #+date: [2026-04-01 Wed]
   4  #+startup: indent
   5  #+options: toc:t num:t ^:{}
   6  
   7  * What You Will Learn
   8  
   9  By the end of this tutorial you will have built a real-time mission control
  10  dashboard that:
  11  
  12  - Displays live aircraft on an interactive map using the OpenSky Network API
  13  - Streams simulated mission assets over WebSocket from your own server
  14  - Consumes Boston transit vehicle positions via Server-Sent Events (MBTA API)
  15  - Shows alert notifications in a sidebar
  16  - Displays detailed telemetry for a selected asset
  17  
  18  You will learn three distinct real-time data transport patterns (polling,
  19  WebSocket, SSE) and how to integrate them into a single application using
  20  TanStack Start for routing and server functions, XState machines for connection
  21  lifecycle, and =@xstate/store= for shared application state.
  22  
  23  * Prerequisites
  24  
  25  - Node.js latest installed
  26  - A code editor (gnu Emacs recommended)
  27  - Basic familiarity with React and TypeScript (you know what a component is,
  28    what props are)
  29  - A free OpenSky Network account: https://opensky-network.org/login
  30  - A free MBTA API key: https://api-v3.mbta.com/ (click "Request an API key")
  31  - A VPS with Node.js available (for Phase 2) — any Linux server works
  32  - ~2 hours for the full tutorial (each phase is ~40 minutes)
  33  
  34  * Phase 1: Aircraft on a Map (Polling)
  35  
  36  In this phase you will fetch live aircraft positions from the OpenSky Network
  37  API every 10 seconds and render them on a map. You will use a TanStack Start
  38  server function to proxy the API call (keeping credentials server-side) and an
  39  XState machine to manage the polling lifecycle.
  40  
  41  ** Step 1: Scaffold the Project
  42  
  43  #+begin_src shell
  44    npx @tanstack/cli@latest create mission-control
  45  #+end_src
  46  
  47  #+begin_example
  48  ✔ Project created
  49  #+end_example
  50  
  51  Check that you can start the default app
  52  #+begin_src bash
  53    cd mission-control
  54    npm run dev
  55  #+end_src
  56  
  57  #+caption: open localhost:3000
  58  #+name: screenshot default app
  59  [[../../assets/TanStack Start Starter.png]]
  60  
  61  # image and link preview C-c C-x C-v (org-link-preview)
  62  The CLI scaffolds a TanStack Start project with file-based routing already
  63  configured. Your project structure looks like this:
  64  
  65  #+begin_example
  66  mission-control/
  67  ├── src/
  68  │   ├── routes/
  69  │   │   ├── __root.tsx        ← root layout (HTML shell)
  70  │   │   └── index.tsx         ← home page route
  71  │   ├── router.tsx            ← router configuration
  72  │   └── routeTree.gen.ts      ← auto-generated route tree
  73  ├── vite.config.ts
  74  ├── tsconfig.json
  75  └── package.json
  76  #+end_example
  77  
  78  Install the mapping library, XState, and the store:
  79  
  80  #+begin_src bash
  81    npm install leaflet react-leaflet xstate @xstate/react @xstate/store
  82    npm install -D @types/leaflet
  83  #+end_src
  84  
  85  We install three XState packages: =xstate= + =@xstate/react= for state machines
  86  (connection lifecycle), and =@xstate/store= for shared application state. See
  87  [[file:../decisions/adr_001_state_store_selection.org][ADR-001]] for why we chose =@xstate/store= over Zustand or React Context.
  88  
  89  ** Step 2: Set Up the Root Layout
  90  
  91  Open =src/routes/__root.tsx=. This is the HTML shell for your entire app.
  92  Replace its contents:
  93  
  94  #+begin_src js
  95    // src/routes/__root.tsx
  96    /// <reference types="vite/client" />
  97    import type { ReactNode } from "react";
  98    import {
  99      Outlet,
 100      createRootRoute,
 101      HeadContent,
 102      Scripts,
 103    } from "@tanstack/react-router";
 104  
 105    export const Route = createRootRoute({
 106      head: () => ({
 107        meta: [
 108          { charSet: "utf-8" },
 109          { name: "viewport", content: "width=device-width, initial-scale=1" },
 110          { title: "Mission Control Dashboard" },
 111        ],
 112        links: [
 113          {
 114            rel: "stylesheet",
 115            href: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",
 116          },
 117        ],
 118      }),
 119      component: RootComponent,
 120    });
 121  
 122    function RootComponent() {
 123      return (
 124        <RootDocument>
 125          <Outlet />
 126        </RootDocument>
 127      );
 128    }
 129  
 130    function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
 131      return (
 132        <html>
 133          <head>
 134            <HeadContent />
 135          </head>
 136          <body style={{ margin: 0 }}>
 137            {children}
 138            <Scripts />
 139          </body>
 140        </html>
 141      );
 142    }
 143  #+end_src
 144  
 145  TanStack Start uses =__root.tsx= as the layout wrapper for every page. The
 146  =<Outlet />= component renders whichever child route matches the current URL.
 147  The =<HeadContent />= component injects the meta tags and links you defined in
 148  the =head= function. The =<Scripts />= component handles hydration.
 149  
 150  ** Step 3: Create the Map Component
 151  
 152  *Goal:* render an empty interactive map centered on Switzerland, living
 153  inside the =Header= / =Footer= chrome that Step 2 wired into =__root.tsx=.
 154  No markers yet. By the end of this step you should be able to pan and zoom
 155  an OpenStreetMap tile layer in your browser.
 156  
 157  This step looks like "drop in a component", but three small decisions hide
 158  inside it. We are going to make each one explicitly, because the reasoning
 159  will come back in Phases 2 and 3.
 160  
 161  If you want the full conceptual model behind what you are about to write
 162  — the render hierarchy, how react-leaflet bridges declarative React to
 163  imperative Leaflet, and where the SSR boundary sits — read the companion
 164  explanation alongside this step:
 165  [[file:../explanation/explanation_step3_map_component.org][Anatomy of the Map Component]]. The tutorial below is the
 166  /doing/; that document is the /understanding/. Either order works — some
 167  learners prefer to read the explanation first, others prefer to build
 168  first and then read the explanation to consolidate.
 169  
 170  *** Decision 1: Where does the map live in the layout?
 171  
 172  Your =__root.tsx= already renders =<Header />= and =<Footer />= around every
 173  route. That means you have two options:
 174  
 175  - *Full-bleed.* Replace =__root.tsx= so the map takes the entire viewport
 176    (=height: 100vh=). Simpler CSS, but you throw away the Header you just
 177    set up.
 178  - *Keep the chrome.* Leave =__root.tsx= alone and size the map to the
 179    /remaining/ viewport height — roughly =calc(100vh - <header+footer>)=.
 180  
 181  We will keep the chrome. In Phase 2 the Header will host live connection
 182  indicators (WebSocket "connected" / "reconnecting" badges) and in Phase 3
 183  it will carry an SSE status light. Ripping out the Header now just means
 184  rebuilding it later.
 185  
 186  *** Decision 2: Where do you import Leaflet's CSS?
 187  
 188  Leaflet ships its own stylesheet at =leaflet/dist/leaflet.css=. If you
 189  forget to import it, tiles will still render — but zoom controls will look
 190  broken and marker icons (later, in Step 7) will be invisible or misaligned.
 191  This is the single most common Leaflet beginner trap.
 192  
 193  Two reasonable places to put the import:
 194  
 195  - Globally in =src/styles.css= or =__root.tsx=, loaded once for the whole
 196    app.
 197  - Co-located at the top of =Map.tsx=, loaded only when the map component
 198    is used.
 199  
 200  We will co-locate it in =Map.tsx=. It deletes cleanly with the component
 201  and it does not leak Leaflet's styles into future routes that have nothing
 202  to do with mapping.
 203  
 204  *** Decision 3: Leaflet, TanStack Start, and SSR
 205  
 206  TanStack Start renders routes on the server by default. Leaflet, on the
 207  other hand, touches =window= at the top of its module — it assumes a
 208  browser. Putting the two together has three possible outcomes:
 209  
 210  1. Clean render on both server and client (lucky).
 211  2. A hydration warning in the browser console.
 212  3. A server-side =ReferenceError: window is not defined= in your terminal
 213     before the page ever reaches the browser.
 214  
 215  Our approach for this tutorial is *try the plain import first*. If the
 216  error fires, that is the lesson — you have just learned, by direct
 217  experience, that SSR and browser-only libraries need a boundary. The fix
 218  (a client-only wrapper around the Leaflet import) will be written up as
 219  a how-to; for now, if you see the error, note it and continue.
 220  
 221  *** Write the component
 222  
 223  Create =src/components/Map.tsx=:
 224  
 225  #+begin_src js
 226    // src/components/Map.tsx
 227    import "leaflet/dist/leaflet.css";
 228    import { MapContainer, TileLayer } from "react-leaflet";
 229  
 230    const MAP_CENTER = [46.8, 8.2]; // Switzerland — dense OpenSky coverage
 231    const MAP_ZOOM = 7;
 232  
 233    export function Map() {
 234      return (
 235        <MapContainer
 236          center={MAP_CENTER}
 237          zoom={MAP_ZOOM}
 238          style={{ height: "calc(100vh - 8rem)", width: "100%" }}
 239        >
 240          <TileLayer
 241            attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
 242            url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
 243          />
 244        </MapContainer>
 245      );
 246    }
 247  #+end_src
 248  
 249  The =8rem= in the height calc is an educated guess for the combined
 250  Header + Footer height. If the map overlaps the footer or leaves a gap,
 251  open devtools, measure the actual chrome height, and adjust.
 252  
 253  *** Wire it into the index route
 254  
 255  =src/routes/index.tsx= still holds the TanStack starter content (a hero
 256  section, feature cards, a quick-start list). Replace the whole file body
 257  with a minimal route that just renders the map. The Header and Footer
 258  come from =__root.tsx=, so you do not repeat them here.
 259  
 260  #+begin_src js
 261    // src/routes/index.tsx
 262    import { createFileRoute } from "@tanstack/react-router";
 263    import { Map } from "../components/Map";
 264  
 265    export const Route = createFileRoute("/")({
 266      component: Home,
 267    });
 268  
 269    function Home() {
 270      return <Map />;
 271    }
 272  #+end_src
 273  
 274  *** Run it
 275  
 276  #+begin_src bash
 277  npm run dev
 278  #+end_src
 279  
 280  You should see your Header at the top, an interactive OpenStreetMap view
 281  of Switzerland in the middle, and your Footer at the bottom. Click and
 282  drag to pan. Scroll to zoom. Use the =+= / =-= controls.
 283  
 284  If your terminal shows =ReferenceError: window is not defined=, the SSR
 285  boundary we talked about in Decision 3 just fired. Read the stack trace,
 286  notice that it points at Leaflet, and move on — we will solve it in a
 287  dedicated how-to rather than derailing the tutorial here.
 288  
 289  *** Why these three decisions matter later
 290  
 291  - *Chrome stays* — Phase 2 will put a WebSocket status badge in the
 292    Header. You are leaving room for it now.
 293  - *CSS co-located* — in Step 7 (aircraft markers) and later in Phase 2
 294    (mission markers) and Phase 3 (transit markers), each marker component
 295    will import only what it needs. The map's styles travel with the map.
 296  - *SSR awareness* — Phase 2's WebSocket client is also browser-only. When
 297    you hit the same kind of boundary there, you will recognize the shape
 298    of the problem instead of being surprised by it.
 299  
 300  ** Step 4: Define the Aircraft Type
 301  
 302  Create =src/types/aircraft.ts=:
 303  
 304  #+begin_src js
 305    // src/types/aircraft.ts
 306    export interface Aircraft {
 307      icao24: string;
 308      callsign: string | null;
 309      originCountry: string;
 310      latitude: number | null;
 311      longitude: number | null;
 312      baroAltitude: number | null;
 313      onGround: boolean;
 314      velocity: number | null;
 315      trueTrack: number | null;
 316      verticalRate: number | null;
 317      geoAltitude: number | null;
 318      squawk: string | null;
 319    }
 320  #+end_src
 321  
 322  You will notice that many fields are nullable. The OpenSky API returns =null=
 323  when a sensor reading is unavailable. Your code must handle this.
 324  
 325  ** Step 5: Create a Server Function for OpenSky
 326  
 327  This is a key advantage of TanStack Start: you can write server-side code in the
 328  same project. The server function runs on the server, so your API credentials
 329  never reach the browser.
 330  
 331  Create =src/server/opensky.ts=:
 332  
 333  #+begin_src js
 334    // src/server/opensky.ts
 335    import { createServerFn } from "@tanstack/react-start";
 336    import type { Aircraft } from "../types/aircraft";
 337  
 338    const OPENSKY_URL = "https://opensky-network.org/api/states/all";
 339  
 340    // Bounding box: Switzerland
 341    const BOUNDS = {
 342      lamin: 45.8,
 343      lamax: 47.8,
 344      lomin: 5.9,
 345      lomax: 10.5,
 346    };
 347  
 348    function parseStateVector(state: unknown[]): Aircraft {
 349      return {
 350        icao24: state[0] as string,
 351        callsign: (state[1] as string)?.trim() || null,
 352        originCountry: state[2] as string,
 353        latitude: state[6] as number | null,
 354        longitude: state[5] as number | null,
 355        baroAltitude: state[7] as number | null,
 356        onGround: state[8] as boolean,
 357        velocity: state[9] as number | null,
 358        trueTrack: state[10] as number | null,
 359        verticalRate: state[11] as number | null,
 360        geoAltitude: state[13] as number | null,
 361        squawk: state[14] as string | null,
 362      };
 363    }
 364  
 365    export const fetchAircraft = createServerFn({ method: "GET" }).handler(
 366      async () => {
 367        const params = new URLSearchParams({
 368          lamin: String(BOUNDS.lamin),
 369          lamax: String(BOUNDS.lamax),
 370          lomin: String(BOUNDS.lomin),
 371          lomax: String(BOUNDS.lomax),
 372        });
 373  
 374        const response = await fetch(`${OPENSKY_URL}?${params}`);
 375  
 376        if (!response.ok) {
 377          throw new Error(`OpenSky HTTP ${response.status}`);
 378        }
 379  
 380        const data = await response.json();
 381        return (data.states ?? [])
 382          .map(parseStateVector)
 383          .filter((a: Aircraft) => a.latitude !== null && a.longitude !== null);
 384      }
 385    );
 386  #+end_src
 387  
 388  =createServerFn= defines a function that executes on the server but can be
 389  called from client code as if it were a normal async function. TanStack Start
 390  handles the network boundary transparently. This means the =fetch= to OpenSky
 391  happens server-side — no CORS issues, no API keys in the browser.
 392  
 393  ** Step 6: Build the Polling State Machine
 394  
 395  This is where XState replaces the =useState= + =useEffect= + =setInterval=
 396  pattern. Create =src/machines/aircraftPollingMachine.ts=:
 397  
 398  #+begin_src js
 399    // src/machines/aircraftPollingMachine.ts
 400    import { setup, fromPromise, assign } from "xstate";
 401    import type { Aircraft } from "../types/aircraft";
 402    import { fetchAircraft } from "../server/opensky";
 403  
 404    const POLL_INTERVAL = 10_000; // 10 seconds
 405  
 406    export const aircraftPollingMachine = setup({
 407      types: {
 408        context: {} as {
 409          aircraft: Aircraft[];
 410          error: string | null;
 411          lastUpdated: Date | null;
 412        },
 413      },
 414      actors: {
 415        fetchAircraft: fromPromise(async () => {
 416          return await fetchAircraft();
 417        }),
 418      },
 419    }).createMachine({
 420      id: "aircraftPolling",
 421      initial: "fetching",
 422      context: {
 423        aircraft: [],
 424        error: null,
 425        lastUpdated: null,
 426      },
 427      states: {
 428        fetching: {
 429          invoke: {
 430            src: "fetchAircraft",
 431            onDone: {
 432              target: "waiting",
 433              actions: assign({
 434                aircraft: ({ event }) => event.output,
 435                lastUpdated: () => new Date(),
 436                error: () => null,
 437              }),
 438            },
 439            onError: {
 440              target: "waiting",
 441              actions: assign({
 442                error: ({ event }) =>
 443                  event.error instanceof Error
 444                    ? event.error.message
 445                    : "Unknown error",
 446              }),
 447            },
 448          },
 449        },
 450        waiting: {
 451          after: {
 452            [POLL_INTERVAL]: "fetching",
 453          },
 454        },
 455      },
 456    });
 457  #+end_src
 458  
 459  Compare this to the =useEffect= + =setInterval= version. The state machine
 460  makes the polling lifecycle explicit:
 461  
 462  1. =fetching= — an API call is in flight. Only one fetch at a time. No
 463     overlapping requests.
 464  2. =waiting= — the response arrived (success or error). Wait 10 seconds, then
 465     go back to =fetching=.
 466  
 467  There is no cleanup function to forget. There is no =useRef= to hold the
 468  interval ID. The machine handles it.
 469  
 470  Notice the =after= property in the =waiting= state. XState's delayed
 471  transitions replace =setTimeout= / =setInterval=. When the machine enters
 472  =waiting=, it starts a 10-second timer. When the timer fires, it transitions
 473  back to =fetching=. If the component unmounts, the machine stops, and the timer
 474  is cancelled automatically.
 475  
 476  ** Step 7: Render Aircraft Markers on the Map
 477  
 478  Create =src/components/AircraftMarker.tsx=:
 479  
 480  #+begin_src js
 481    // src/components/AircraftMarker.tsx
 482    import { Marker, Popup } from "react-leaflet";
 483    import L from "leaflet";
 484    import type { Aircraft } from "../types/aircraft";
 485  
 486    const aircraftIcon = L.divIcon({
 487      html: "✈",
 488      className: "aircraft-icon",
 489      iconSize: [20, 20],
 490      iconAnchor: [10, 10],
 491    });
 492  
 493    interface AircraftMarkerProps {
 494      aircraft: Aircraft;
 495      onSelect: (aircraft: Aircraft) => void;
 496    }
 497  
 498    export function AircraftMarker({ aircraft, onSelect }: AircraftMarkerProps) {
 499      if (aircraft.latitude === null || aircraft.longitude === null) {
 500        return null;
 501      }
 502  
 503      return (
 504        <Marker
 505          position={[aircraft.latitude, aircraft.longitude]}
 506          icon={aircraftIcon}
 507          eventHandlers={{
 508            click: () => onSelect(aircraft),
 509          }}
 510        >
 511          <Popup>
 512            <strong>{aircraft.callsign ?? aircraft.icao24}</strong>
 513            <br />
 514            Altitude: {aircraft.baroAltitude?.toFixed(0) ?? "N/A"} m
 515            <br />
 516            Speed: {aircraft.velocity?.toFixed(0) ?? "N/A"} m/s
 517          </Popup>
 518        </Marker>
 519      );
 520    }
 521  #+end_src
 522  
 523  Update =src/components/Map.tsx= to include the markers and the state machine:
 524  
 525  #+begin_src js
 526    // src/components/Map.tsx
 527    import { useState } from "react";
 528    import { MapContainer, TileLayer } from "react-leaflet";
 529    import { useMachine } from "@xstate/react";
 530    import { aircraftPollingMachine } from "../machines/aircraftPollingMachine";
 531    import { AircraftMarker } from "./AircraftMarker";
 532    import type { Aircraft } from "../types/aircraft";
 533  
 534    const MAP_CENTER: [number, number] = [46.8, 8.2];
 535    const MAP_ZOOM = 7;
 536  
 537    export function Map() {
 538      const [snapshot] = useMachine(aircraftPollingMachine);
 539      const { aircraft, error, lastUpdated } = snapshot.context;
 540      const [selected, setSelected] = useState<Aircraft | null>(null);
 541  
 542      return (
 543        <div style={{ display: "flex", height: "100vh" }}>
 544          <MapContainer center={MAP_CENTER} zoom={MAP_ZOOM} style={{ flex: 1 }}>
 545            <TileLayer
 546              attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
 547              url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
 548            />
 549            {aircraft.map((a) => (
 550              <AircraftMarker
 551                key={a.icao24}
 552                aircraft={a}
 553                onSelect={setSelected}
 554              />
 555            ))}
 556          </MapContainer>
 557  
 558          <aside style={{ width: 300, padding: 16, overflowY: "auto" }}>
 559            <h2>Mission Control</h2>
 560            <p>{aircraft.length} aircraft tracked</p>
 561            <p>
 562              State: <code>{snapshot.value}</code>
 563            </p>
 564            {lastUpdated && (
 565              <p>Last update: {lastUpdated.toLocaleTimeString()}</p>
 566            )}
 567            {error && <p style={{ color: "red" }}>Error: {error}</p>}
 568  
 569            {selected && (
 570              <div>
 571                <h3>{selected.callsign ?? selected.icao24}</h3>
 572                <table>
 573                  <tbody>
 574                    <tr><td>Country</td><td>{selected.originCountry}</td></tr>
 575                    <tr><td>Altitude</td><td>{selected.baroAltitude?.toFixed(0) ?? "N/A"} m</td></tr>
 576                    <tr><td>Speed</td><td>{selected.velocity?.toFixed(0) ?? "N/A"} m/s</td></tr>
 577                    <tr><td>Heading</td><td>{selected.trueTrack?.toFixed(0) ?? "N/A"}°</td></tr>
 578                    <tr><td>Vertical rate</td><td>{selected.verticalRate?.toFixed(1) ?? "N/A"} m/s</td></tr>
 579                    <tr><td>On ground</td><td>{selected.onGround ? "Yes" : "No"}</td></tr>
 580                    <tr><td>Squawk</td><td>{selected.squawk ?? "N/A"}</td></tr>
 581                  </tbody>
 582                </table>
 583              </div>
 584            )}
 585          </aside>
 586        </div>
 587      );
 588    }
 589  #+end_src
 590  
 591  Add a small CSS rule for the aircraft icon. Create =src/aircraft.css=:
 592  
 593  #+begin_src css
 594  /* src/aircraft.css */
 595  .aircraft-icon {
 596    font-size: 20px;
 597    line-height: 20px;
 598    text-align: center;
 599    color: #1a73e8;
 600    background: none;
 601    border: none;
 602  }
 603  #+end_src
 604  
 605  Import it at the top of =src/components/Map.tsx=:
 606  
 607  #+begin_src js
 608    import "../aircraft.css";
 609  #+end_src
 610  
 611  Run =npm run dev= and open the browser. You will see aircraft icons appearing
 612  over Switzerland, updating every 10 seconds. Clicking an aircraft shows its
 613  telemetry in the sidebar.
 614  
 615  Notice =State: fetching= flashes briefly during each poll, then returns to
 616  =waiting=. This is the state machine at work — you can see exactly what the
 617  system is doing at any moment.
 618  
 619  ** Phase 1 Checkpoint
 620  
 621  You now have a working dashboard with:
 622  - A live map showing 50-200 aircraft
 623  - A sidebar with asset count, machine state, last update time, and error state
 624  - A telemetry panel for the selected aircraft
 625  - Data refreshing every 10 seconds via an XState polling machine
 626  - API calls proxied through a TanStack Start server function
 627  
 628  The polling pattern works but has limitations: you are always 10 seconds behind,
 629  you get the full state every time (not just changes), and you are using API
 630  credits on every request. Phase 2 introduces WebSocket streaming which solves
 631  all three problems.
 632  
 633  * Phase 2: Simulated Mission Assets over WebSocket
 634  
 635  In this phase you will build a WebSocket server on your VPS that simulates
 636  mission assets (drones, vehicles, sensors) and stream their positions to the
 637  dashboard in real time. You will model the WebSocket connection lifecycle as an
 638  XState machine.
 639  
 640  ** Step 8: Build the WebSocket Server
 641  
 642  On your VPS, create a new project:
 643  
 644  #+begin_src bash
 645  mkdir mission-ws-server && cd mission-ws-server
 646  npm init -y
 647  npm install ws
 648  #+end_src
 649  
 650  Create =server.js=:
 651  
 652  #+begin_src javascript
 653  // server.js
 654  const { WebSocketServer } = require("ws");
 655  
 656  const PORT = 8080;
 657  const NUM_ASSETS = 50;
 658  
 659  // Generate initial positions around a base location
 660  function createAssets() {
 661    const baseLat = 46.8;
 662    const baseLng = 8.2;
 663    const types = ["drone", "vehicle", "sensor"];
 664    const statuses = ["active", "idle", "alert"];
 665  
 666    return Array.from({ length: NUM_ASSETS }, (_, i) => ({
 667      id: `ASSET-${String(i + 1).padStart(3, "0")}`,
 668      type: types[i % types.length],
 669      status: statuses[0],
 670      latitude: baseLat + (Math.random() - 0.5) * 2,
 671      longitude: baseLng + (Math.random() - 0.5) * 4,
 672      altitude: Math.random() * 5000,
 673      speed: Math.random() * 100,
 674      heading: Math.random() * 360,
 675      battery: 80 + Math.random() * 20,
 676      lastUpdate: Date.now(),
 677    }));
 678  }
 679  
 680  function moveAsset(asset) {
 681    const headingRad = (asset.heading * Math.PI) / 180;
 682    const distance = (asset.speed / 3600) * 0.001; // rough degrees per tick
 683    asset.latitude += Math.cos(headingRad) * distance;
 684    asset.longitude += Math.sin(headingRad) * distance;
 685    asset.heading += (Math.random() - 0.5) * 10;
 686    asset.speed = Math.max(0, asset.speed + (Math.random() - 0.5) * 5);
 687    asset.altitude = Math.max(0, asset.altitude + (Math.random() - 0.5) * 50);
 688    asset.battery = Math.max(0, asset.battery - Math.random() * 0.1);
 689    asset.lastUpdate = Date.now();
 690  
 691    // Randomly trigger alerts
 692    if (Math.random() < 0.005) {
 693      asset.status = "alert";
 694    } else if (asset.status === "alert" && Math.random() < 0.1) {
 695      asset.status = "active";
 696    }
 697  
 698    return asset;
 699  }
 700  
 701  const assets = createAssets();
 702  const alerts = [];
 703  
 704  const wss = new WebSocketServer({ port: PORT });
 705  
 706  wss.on("connection", (ws) => {
 707    console.log("Client connected");
 708  
 709    // Send full state on connection
 710    ws.send(JSON.stringify({ type: "snapshot", assets, alerts }));
 711  
 712    ws.on("close", () => console.log("Client disconnected"));
 713  });
 714  
 715  // Broadcast updates every second
 716  setInterval(() => {
 717    assets.forEach(moveAsset);
 718  
 719    // Generate alert if any asset is in alert status
 720    const alertingAssets = assets.filter((a) => a.status === "alert");
 721    alertingAssets.forEach((a) => {
 722      if (!alerts.find((al) => al.assetId === a.id && al.active)) {
 723        const alert = {
 724          id: `ALERT-${Date.now()}`,
 725          assetId: a.id,
 726          message: `${a.type} ${a.id} triggered alert — battery: ${a.battery.toFixed(0)}%`,
 727          severity: a.battery < 20 ? "critical" : "warning",
 728          timestamp: Date.now(),
 729          active: true,
 730        };
 731        alerts.push(alert);
 732      }
 733    });
 734  
 735    // Keep only last 50 alerts
 736    while (alerts.length > 50) alerts.shift();
 737  
 738    const message = JSON.stringify({
 739      type: "update",
 740      assets,
 741      alerts: alerts.filter((a) => a.active),
 742    });
 743  
 744    wss.clients.forEach((client) => {
 745      if (client.readyState === 1) {
 746        client.send(message);
 747      }
 748    });
 749  }, 1000);
 750  
 751  console.log(`WebSocket server running on ws://localhost:${PORT}`);
 752  #+end_src
 753  
 754  Start the server:
 755  
 756  #+begin_src bash
 757  node server.js
 758  #+end_src
 759  
 760  #+begin_example
 761  WebSocket server running on ws://localhost:8080
 762  #+end_example
 763  
 764  ** Step 9: Define Mission Types
 765  
 766  Back in your TanStack Start project, create =src/types/mission.ts=:
 767  
 768  #+begin_src js
 769    // src/types/mission.ts
 770    export interface MissionAsset {
 771      id: string;
 772      type: "drone" | "vehicle" | "sensor";
 773      status: "active" | "idle" | "alert";
 774      latitude: number;
 775      longitude: number;
 776      altitude: number;
 777      speed: number;
 778      heading: number;
 779      battery: number;
 780      lastUpdate: number;
 781    }
 782  
 783    export interface MissionAlert {
 784      id: string;
 785      assetId: string;
 786      message: string;
 787      severity: "warning" | "critical";
 788      timestamp: number;
 789      active: boolean;
 790    }
 791  
 792    export interface MissionMessage {
 793      type: "snapshot" | "update";
 794      assets: MissionAsset[];
 795      alerts: MissionAlert[];
 796    }
 797  #+end_src
 798  
 799  ** Step 10: Build the WebSocket State Machine
 800  
 801  This is where XState really shines. A WebSocket connection has a clear
 802  lifecycle — connecting, connected, disconnected, reconnecting — that maps
 803  perfectly to a state machine.
 804  
 805  Create =src/machines/missionWebSocketMachine.ts=:
 806  
 807  #+begin_src js
 808    // src/machines/missionWebSocketMachine.ts
 809    import { setup, fromCallback, assign } from "xstate";
 810    import type { MissionAsset, MissionAlert, MissionMessage } from "../types/mission";
 811  
 812    const WS_URL = "ws://YOUR_VPS_IP:8080"; // Replace with your VPS address
 813    const RECONNECT_DELAY = 3_000;
 814  
 815    type WebSocketEvent =
 816      | { type: "CONNECTED" }
 817      | { type: "MESSAGE"; data: MissionMessage }
 818      | { type: "DISCONNECTED" };
 819  
 820    export const missionWebSocketMachine = setup({
 821      types: {
 822        context: {} as {
 823          assets: MissionAsset[];
 824          alerts: MissionAlert[];
 825          connected: boolean;
 826        },
 827        events: {} as WebSocketEvent,
 828      },
 829      actors: {
 830        webSocket: fromCallback<WebSocketEvent>(({ sendBack }) => {
 831          const ws = new WebSocket(WS_URL);
 832  
 833          ws.onopen = () => sendBack({ type: "CONNECTED" });
 834  
 835          ws.onmessage = (event) => {
 836            const message: MissionMessage = JSON.parse(event.data);
 837            sendBack({ type: "MESSAGE", data: message });
 838          };
 839  
 840          ws.onclose = () => sendBack({ type: "DISCONNECTED" });
 841          ws.onerror = () => ws.close();
 842  
 843          return () => ws.close();
 844        }),
 845      },
 846    }).createMachine({
 847      id: "missionWebSocket",
 848      initial: "connecting",
 849      context: {
 850        assets: [],
 851        alerts: [],
 852        connected: false,
 853      },
 854      states: {
 855        connecting: {
 856          invoke: {
 857            src: "webSocket",
 858          },
 859          on: {
 860            CONNECTED: {
 861              actions: assign({ connected: () => true }),
 862            },
 863            MESSAGE: {
 864              actions: assign({
 865                assets: ({ event }) => event.data.assets,
 866                alerts: ({ event }) => event.data.alerts,
 867              }),
 868            },
 869            DISCONNECTED: "reconnecting",
 870          },
 871        },
 872        reconnecting: {
 873          entry: assign({ connected: () => false }),
 874          after: {
 875            [RECONNECT_DELAY]: "connecting",
 876          },
 877        },
 878      },
 879    });
 880  #+end_src
 881  
 882  Compare this to the =useEffect= + =useRef= + =useCallback= version. The custom
 883  hook had reconnection logic scattered across =onclose=, =setTimeout=, and a
 884  =useCallback=. The machine makes every state transition visible:
 885  
 886  - =connecting= → invokes the WebSocket actor. Events flow in via =sendBack=.
 887  - If a =DISCONNECTED= event arrives → transition to =reconnecting=.
 888  - =reconnecting= → wait 3 seconds, then go back to =connecting=.
 889  
 890  The =fromCallback= actor is perfect for WebSocket: it is a long-running process
 891  that sends events back to the parent machine. The cleanup function (=return () =>
 892  ws.close()=) runs when the machine leaves the =connecting= state or stops
 893  entirely.
 894  
 895  ** Step 11: Add Mission Assets to the Map
 896  
 897  Create =src/components/MissionMarker.tsx=:
 898  
 899  #+begin_src js
 900    // src/components/MissionMarker.tsx
 901    import { Marker, Popup } from "react-leaflet";
 902    import L from "leaflet";
 903    import type { MissionAsset } from "../types/mission";
 904  
 905    const icons: Record<string, L.DivIcon> = {
 906      drone: L.divIcon({ html: "🛸", className: "mission-icon", iconSize: [20, 20], iconAnchor: [10, 10] }),
 907      vehicle: L.divIcon({ html: "🚙", className: "mission-icon", iconSize: [20, 20], iconAnchor: [10, 10] }),
 908      sensor: L.divIcon({ html: "📡", className: "mission-icon", iconSize: [20, 20], iconAnchor: [10, 10] }),
 909    };
 910  
 911    interface MissionMarkerProps {
 912      asset: MissionAsset;
 913      onSelect: (asset: MissionAsset) => void;
 914    }
 915  
 916    export function MissionMarker({ asset, onSelect }: MissionMarkerProps) {
 917      return (
 918        <Marker
 919          position={[asset.latitude, asset.longitude]}
 920          icon={icons[asset.type]}
 921          eventHandlers={{ click: () => onSelect(asset) }}
 922        >
 923          <Popup>
 924            <strong>{asset.id}</strong> ({asset.type})
 925            <br />
 926            Status: {asset.status}
 927            <br />
 928            Battery: {asset.battery.toFixed(0)}%
 929          </Popup>
 930        </Marker>
 931      );
 932    }
 933  #+end_src
 934  
 935  ** Step 12: Add the Alerts Sidebar
 936  
 937  Create =src/components/AlertsSidebar.tsx=:
 938  
 939  #+begin_src js
 940  // src/components/AlertsSidebar.tsx
 941  import type { MissionAlert } from "../types/mission";
 942  
 943  interface AlertsSidebarProps {
 944    alerts: MissionAlert[];
 945  }
 946  
 947  export function AlertsSidebar({ alerts }: AlertsSidebarProps) {
 948    return (
 949      <div>
 950        <h3>Alerts ({alerts.length})</h3>
 951        {alerts.length === 0 && <p>No active alerts</p>}
 952        <ul style={{ listStyle: "none", padding: 0 }}>
 953          {alerts.map((alert) => (
 954            <li
 955              key={alert.id}
 956              style={{
 957                padding: 8,
 958                marginBottom: 4,
 959                background: alert.severity === "critical" ? "#fde2e2" : "#fff3cd",
 960                borderLeft: `4px solid ${alert.severity === "critical" ? "#dc3545" : "#ffc107"}`,
 961              }}
 962            >
 963              <strong>{alert.severity.toUpperCase()}</strong>
 964              <br />
 965              {alert.message}
 966              <br />
 967              <small>{new Date(alert.timestamp).toLocaleTimeString()}</small>
 968            </li>
 969          ))}
 970        </ul>
 971      </div>
 972    );
 973  }
 974  #+end_src
 975  
 976  Update =src/components/Map.tsx= to include mission assets alongside aircraft.
 977  Add the import and =useMachine= call for =missionWebSocketMachine=, then render
 978  =MissionMarker= components the same way you rendered =AircraftMarker=.
 979  
 980  ** Phase 2 Checkpoint
 981  
 982  You now have two data sources feeding the same dashboard:
 983  - Aircraft from OpenSky (polling every 10s via XState =after= delay)
 984  - Mission assets from your WebSocket server (streaming every 1s via XState
 985    =fromCallback=)
 986  
 987  The difference is visible: mission assets move smoothly, aircraft jump. This is
 988  the fundamental trade-off between polling and streaming.
 989  
 990  Both machines manage their own lifecycle independently. If the WebSocket
 991  disconnects, it reconnects without affecting aircraft polling. If OpenSky returns
 992  an error, the machine captures it and keeps polling. Each machine is a
 993  self-contained unit.
 994  
 995  ** Optional upgrade: self-hosted map tiles
 996  
 997  Your VPS is now running a WebSocket server behind Caddy with TLS. This is a
 998  good moment to consider self-hosting your own OSM tile server on the same VPS —
 999  eliminating the dependency on OpenStreetMap's public tile servers and unlocking
1000  dark map themes via CSS filters.
1001  
1002  This is an optional upgrade. The dashboard works fine with the default OSM
1003  tiles. But if you want full control over your map layer, see the
1004  [[file:../decisions/adr_002_map_tile_provider.org::*Self-hosted tile server (future upgrade)][ADR-002 upgrade path]] for the rationale and requirements, and the how-to guide
1005  (coming soon) for step-by-step setup.
1006  
1007  * Phase 3: Transit Vehicles via Server-Sent Events
1008  
1009  In this phase you will add Boston transit vehicles from the MBTA API using
1010  Server-Sent Events — a third real-time data transport pattern. You will model
1011  it with yet another XState machine, this time using =fromCallback= for the
1012  =EventSource= subscription.
1013  
1014  ** Step 13: Define Transit Types
1015  
1016  Create =src/types/transit.ts=:
1017  
1018  #+begin_src js
1019    // src/types/transit.ts
1020    export interface TransitVehicle {
1021      id: string;
1022      label: string;
1023      latitude: number;
1024      longitude: number;
1025      bearing: number | null;
1026      speed: number | null;
1027      currentStatus: string;
1028      routeId: string;
1029      updatedAt: string;
1030    }
1031  #+end_src
1032  
1033  ** Step 14: Build the SSE State Machine
1034  
1035  Create =src/machines/transitSSEMachine.ts=:
1036  
1037  #+begin_src js
1038    // src/machines/transitSSEMachine.ts
1039    import { setup, fromCallback, assign } from "xstate";
1040    import type { TransitVehicle } from "../types/transit";
1041  
1042    const MBTA_API_KEY = "YOUR_MBTA_API_KEY"; // Replace with your key
1043    const SSE_URL = `https://api-v3.mbta.com/vehicles?filter[route_type]=3&api_key=${MBTA_API_KEY}`;
1044  
1045    function parseVehicle(resource: any): TransitVehicle {
1046      return {
1047        id: resource.id,
1048        label: resource.attributes.label,
1049        latitude: resource.attributes.latitude,
1050        longitude: resource.attributes.longitude,
1051        bearing: resource.attributes.bearing,
1052        speed: resource.attributes.speed,
1053        currentStatus: resource.attributes.current_status,
1054        routeId: resource.relationships?.route?.data?.id ?? "unknown",
1055        updatedAt: resource.attributes.updated_at,
1056      };
1057    }
1058  
1059    type SSEEvent =
1060      | { type: "CONNECTED" }
1061      | { type: "RESET"; data: any[] }
1062      | { type: "UPDATE"; data: any }
1063      | { type: "ADD"; data: any }
1064      | { type: "REMOVE"; data: any }
1065      | { type: "ERROR" };
1066  
1067    export const transitSSEMachine = setup({
1068      types: {
1069        context: {} as {
1070          vehicles: TransitVehicle[];
1071          connected: boolean;
1072        },
1073        events: {} as SSEEvent,
1074      },
1075      actors: {
1076        eventSource: fromCallback<SSEEvent>(({ sendBack }) => {
1077          const source = new EventSource(SSE_URL);
1078  
1079          source.onopen = () => sendBack({ type: "CONNECTED" });
1080  
1081          source.addEventListener("reset", (event: MessageEvent) => {
1082            sendBack({ type: "RESET", data: JSON.parse(event.data) });
1083          });
1084  
1085          source.addEventListener("update", (event: MessageEvent) => {
1086            sendBack({ type: "UPDATE", data: JSON.parse(event.data) });
1087          });
1088  
1089          source.addEventListener("add", (event: MessageEvent) => {
1090            sendBack({ type: "ADD", data: JSON.parse(event.data) });
1091          });
1092  
1093          source.addEventListener("remove", (event: MessageEvent) => {
1094            sendBack({ type: "REMOVE", data: JSON.parse(event.data) });
1095          });
1096  
1097          source.onerror = () => sendBack({ type: "ERROR" });
1098  
1099          return () => source.close();
1100        }),
1101      },
1102    }).createMachine({
1103      id: "transitSSE",
1104      initial: "streaming",
1105      context: {
1106        vehicles: [],
1107        connected: false,
1108      },
1109      states: {
1110        streaming: {
1111          invoke: {
1112            src: "eventSource",
1113          },
1114          on: {
1115            CONNECTED: {
1116              actions: assign({ connected: () => true }),
1117            },
1118            RESET: {
1119              actions: assign({
1120                vehicles: ({ event }) => event.data.map(parseVehicle),
1121              }),
1122            },
1123            UPDATE: {
1124              actions: assign({
1125                vehicles: ({ context, event }) => {
1126                  const updated = parseVehicle(event.data);
1127                  return context.vehicles.map((v) =>
1128                    v.id === updated.id ? updated : v
1129                  );
1130                },
1131              }),
1132            },
1133            ADD: {
1134              actions: assign({
1135                vehicles: ({ context, event }) => [
1136                  ...context.vehicles,
1137                  parseVehicle(event.data),
1138                ],
1139              }),
1140            },
1141            REMOVE: {
1142              actions: assign({
1143                vehicles: ({ context, event }) =>
1144                  context.vehicles.filter((v) => v.id !== event.data.id),
1145              }),
1146            },
1147            ERROR: {
1148              actions: assign({ connected: () => false }),
1149              // EventSource reconnects automatically — stay in streaming state
1150            },
1151          },
1152        },
1153      },
1154    });
1155  #+end_src
1156  
1157  Notice the difference from the WebSocket machine: there is no =reconnecting=
1158  state. =EventSource= reconnects automatically — it is built into the SSE
1159  specification. The machine stays in the =streaming= state even on error, because
1160  the browser will re-establish the connection without your help.
1161  
1162  Also notice how SSE handles different event types (=reset=, =update=, =add=,
1163  =remove=). Unlike the WebSocket server which sends you the full state each time,
1164  MBTA's SSE stream sends granular events. This is more bandwidth-efficient but
1165  requires you to manage state incrementally — XState's =assign= actions make
1166  each update explicit.
1167  
1168  ** Step 15: Add Transit Markers
1169  
1170  Create =src/components/TransitMarker.tsx= following the same pattern as
1171  =AircraftMarker= and =MissionMarker=. Use a bus icon (🚌) and display route,
1172  status, and speed in the popup.
1173  
1174  ** Step 16: Create the Dashboard Store
1175  
1176  Right now, each machine holds its own data in its context, and components read
1177  from three separate snapshots. This works, but it creates a problem: the sidebar
1178  needs data from all three machines, the telemetry panel needs a "selected asset"
1179  that could be any type, and the alert queue spans sources.
1180  
1181  This is where =@xstate/store= comes in. Instead of each component reaching into
1182  three machine snapshots, we create a single shared store that all machines push
1183  data into.
1184  
1185  Create =src/stores/dashboard.store.ts=:
1186  
1187  #+begin_src javascript
1188    // src/stores/dashboard.store.ts
1189    import { createStore } from "@xstate/store";
1190    import type { Aircraft } from "../types/aircraft";
1191    import type { MissionAsset, MissionAlert } from "../types/mission";
1192    import type { TransitVehicle } from "../types/transit";
1193  
1194    export type Asset =
1195      | (Aircraft & { source: "opensky" })
1196      | (MissionAsset & { source: "mission" })
1197      | (TransitVehicle & { source: "transit" });
1198  
1199    export const dashboardStore = createStore({
1200      context: {
1201        aircraft: [] as Aircraft[],
1202        missionAssets: [] as MissionAsset[],
1203        transitVehicles: [] as TransitVehicle[],
1204        alerts: [] as MissionAlert[],
1205        selectedAssetId: null as string | null,
1206      },
1207      on: {
1208        "aircraft.updated": (ctx, event: { aircraft: Aircraft[] }) => ({
1209          ...ctx,
1210          aircraft: event.aircraft,
1211        }),
1212        "mission.updated": (
1213          ctx,
1214          event: { assets: MissionAsset[]; alerts: MissionAlert[] },
1215        ) => ({
1216          ...ctx,
1217          missionAssets: event.assets,
1218          alerts: event.alerts,
1219        }),
1220        "transit.reset": (ctx, event: { vehicles: TransitVehicle[] }) => ({
1221          ...ctx,
1222          transitVehicles: event.vehicles,
1223        }),
1224        "transit.updated": (ctx, event: { vehicle: TransitVehicle }) => ({
1225          ...ctx,
1226          transitVehicles: ctx.transitVehicles.map((v) =>
1227            v.id === event.vehicle.id ? event.vehicle : v,
1228          ),
1229        }),
1230        "asset.selected": (ctx, event: { id: string | null }) => ({
1231          ...ctx,
1232          selectedAssetId: event.id,
1233        }),
1234      },
1235    });
1236  #+end_src
1237  
1238  Notice the pattern: the store's event names use =source.verb= naming
1239  (=aircraft.updated=, =transit.reset=). Each transition is a pure function — same
1240  inputs, same outputs, easy to test.
1241  
1242  Now the machines push data into the store instead of only holding it in their own
1243  context. In each machine's =onDone= or event handler, add a call to the store:
1244  
1245  #+begin_src javascript
1246    import { dashboardStore } from "../stores/dashboard.store";
1247  
1248    // In the polling machine's onDone action:
1249    actions: [
1250      assign({ aircraft: ({ event }) => event.output }),
1251      ({ event }) => dashboardStore.send({
1252        type: "aircraft.updated",
1253        aircraft: event.output,
1254      }),
1255    ],
1256  #+end_src
1257  
1258  The same pattern applies to the WebSocket and SSE machines — each pushes its
1259  data into the shared store when it receives updates.
1260  
1261  ** Step 17: Bring It All Together
1262  
1263  Update your =Map.tsx= to read from the dashboard store using =useSelector=:
1264  
1265  #+begin_src javascript
1266    import { useSelector } from "@xstate/store/react";
1267    import { dashboardStore } from "../stores/dashboard.store";
1268  
1269    // In the component:
1270    const aircraft = useSelector(dashboardStore, (s) => s.context.aircraft);
1271    const missionAssets = useSelector(dashboardStore, (s) => s.context.missionAssets);
1272    const transitVehicles = useSelector(dashboardStore, (s) => s.context.transitVehicles);
1273    const alerts = useSelector(dashboardStore, (s) => s.context.alerts);
1274    const selectedAssetId = useSelector(dashboardStore, (s) => s.context.selectedAssetId);
1275  #+end_src
1276  
1277  The machines still run independently via =useMachine= (they manage connection
1278  lifecycle), but the UI reads from the store. Each =useSelector= call only
1279  re-renders the component when that specific slice of state changes.
1280  
1281  The sidebar should show:
1282  
1283  - Total assets tracked (aircraft + mission + transit)
1284  - Connection status for WebSocket and SSE (from machine context)
1285  - Machine states (=fetching= / =waiting= for polling, =connecting= /
1286    =reconnecting= for WebSocket)
1287  - Last poll time for OpenSky
1288  - Alerts from the mission server
1289  - Telemetry panel for whichever asset is selected
1290  
1291  ** Phase 3 Checkpoint
1292  
1293  You now have a dashboard consuming three real-time data sources using three
1294  different transport patterns. The map shows aircraft, mission assets, and
1295  transit vehicles simultaneously.
1296  
1297  Each data source is managed by its own XState machine for connection lifecycle.
1298  The machines push data into a shared =@xstate/store= which provides a single
1299  source of truth for all UI components. React components read from the store via
1300  =useSelector= — they do not need to know which machine or transport pattern
1301  produced the data.
1302  
1303  * What You Built
1304  
1305  #+begin_example
1306  ┌──────────────────────────────────────────────────────┐
1307  │ Mission Control Dashboard                            │
1308  ├────────────────────────────────────┬─────────────────┤
1309  │                                    │ Status          │
1310  │     Interactive Map                │ ● WS Connected  │
1311  │                                    │ ● SSE Connected │
1312  │   ✈ Aircraft (polling, 10s)       │ ↻ Poll: 12:34  │
1313  │   🛸 Drones (WebSocket, 1s)       │                 │
1314  │   🚌 Buses (SSE, streaming)       │ Alerts (3)      │
1315  │                                    │ ⚠ ASSET-012... │
1316  │                                    │ 🔴 ASSET-007... │
1317  │                                    │                 │
1318  │                                    │ Telemetry       │
1319  │                                    │ Speed: 42 m/s  │
1320  │                                    │ Alt: 1200 m    │
1321  │                                    │ Hdg: 270°      │
1322  └────────────────────────────────────┴─────────────────┘
1323  #+end_example
1324  
1325  You learned:
1326  
1327  1. *TanStack Start* — file-based routing, server functions that proxy external
1328     APIs (keeping credentials off the client), SSR-ready React
1329  2. *XState machines for connection lifecycle* — =fromPromise= + =after= for
1330     polling, =fromCallback= for WebSocket and SSE long-lived connections
1331  3. *@xstate/store for shared state* — event-driven store that unifies data from
1332     all three machines into a single source of truth
1333  4. *Two-layer state architecture* — machines own connection logic (reconnection,
1334     error recovery), the store owns application data (combined assets, selection,
1335     alerts)
1336  5. *Polling vs WebSocket vs SSE* — simple/delayed vs bidirectional/fast vs
1337     server-push/auto-reconnect
1338  
1339  * Next Steps
1340  
1341  - [[file:../explanation/explanation_realtime_data_patterns.org][Explanation: Real-Time Data Patterns]] — understand the trade-offs deeply
1342  - [[file:../howto/howto_connect_opensky.org][How-to: Connect to OpenSky Network]] — authentication, rate limits, bounding boxes
1343  - [[file:../howto/howto_websocket_server.org][How-to: Build a WebSocket Server]] — production hardening, scaling
1344  - [[file:../howto/howto_consume_sse_react.org][How-to: Consume SSE in React with XState]] — edge cases, error handling
1345  - [[file:../reference/ref_api_contracts.org][Reference: API Contracts]] — exact field shapes for all three APIs
1346  - [[file:../decisions/adr_002_map_tile_provider.org][ADR-002: Map Tile Provider]] — self-host OSM tiles on your VPS for full
1347    control and dark map themes