CodingNic

Frontend Core & Theming

Prompting the Core Dashboard UI

Frontend Core & Theming 35 min read

Prompting the Core Dashboard UI

Objectives

By the end of this chapter, you should be able to:

  • Prompt an AI assistant to consume the Module 2 WebSocket feed from the browser with real reconnect-with-backoff, not a retry loop that hammers the server
  • Get a live Leaflet map rendered without crashing server-side rendering, and catch a marker layer that isn’t actually optimized for repeated updates
  • Direct the AI through the rest of the dashboard shell, header, search, detail panel, flight list, and confirm every piece the prompt asked for actually landed

💡 Why this matters: A map with nothing to click and nowhere to search is a demo, not a dashboard. This lesson turns last lesson’s placeholder page into the three-panel layout, map, detail, list, that every later feature module assumes already exists.

One Big Prompt, or Several Small Ones

This lesson covers more ground than the last one: the live feed, the map, and three real components. You could split it into four separate prompts, and there’s a real argument for that, smaller diffs are easier to review. But the map and the store it reads from are tightly coupled, and the header, detail panel, and flight list all read the exact same store fields (selectedIcao, filterText, aircraft), so asking for them together means the AI can see the whole shape of what’s sharing state before it writes any of it. Splitting this into four prompts risks four components that each reinvent how selection state should work.

The tradeoff is that a bigger prompt means a bigger review. Every checklist item below exists because a five-part prompt makes it easy for one part to get half-done while the other four look complete.

The Prompt

code
Continue building the SKYWATCH frontend in apps/web. The token system, useThemeStore, and useAppStore already exist from last lesson. Now wire up the live feed and build the real dashboard. Install leaflet, react-leaflet, and @types/leaflet as a dev dependency. Requirements: 1. Live feed hook (useLiveFeed): opens a WebSocket to the server's /ws endpoint and feeds every "positions" message into useAppStore. Reconnect automatically if the connection drops, with exponential backoff, not a fixed retry delay, capped at a reasonable max so a long outage doesn't mean waiting minutes between attempts. Update connectionStatus on open/close. The server URL should come from NEXT_PUBLIC_SERVER_URL with a localhost fallback. 2. The map (MapPanel): react-leaflet's MapContainer, dynamically imported with ssr: false since Leaflet touches window/document at module load. One glowing triangle marker per aircraft, rotated to its heading, colored via the phosphor token so it automatically matches the current theme. Swap tile layer (light/dark basemap) based on the current theme. Show a connecting/reconnecting banner when connectionStatus isn't "open". 3. Header: brand mark, a search input that filters by callsign (writes to the store's filterText), live aircraft count, last update timestamp, and the theme toggle. 4. Detail panel: shows full telemetry, altitude, ground speed, vertical rate, status, squawk, category, lat/lon, heading, for whichever aircraft is selected in the store. A real empty state when nothing's selected, not a blank div. 5. Flight list: scrollable, filtered by the same filterText the header writes to, sharing selection with the map and detail panel through the store. Cap it at a reasonable number of rendered rows, a busy region can have way more traffic than is useful to render in a sidebar list a human is scanning. 6. Wire it all into a three-panel Dashboard layout: map on the left, detail panel and flight list stacked in a sidebar on the right. Clicking a marker on the map and clicking a row in the flight list should both select the same aircraft. Show me every file.

What It Built

ts
// apps/web/src/lib/serverUrl.ts
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:4000";

export function apiUrl(path: string): string {
  return `${SERVER_URL}${path.startsWith("/") ? path : `/${path}`}`;
}

export function wsUrl(): string {
  const u = new URL(SERVER_URL);
  u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
  u.pathname = "/ws";
  return u.toString();
}
ts
// apps/web/src/hooks/useLiveFeed.ts
"use client";

import { useEffect } from "react";
import type { ServerToClientMessage } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { wsUrl } from "@/lib/serverUrl";

const MAX_RECONNECT_DELAY_MS = 15_000;

/**
 * Opens the live WebSocket connection to apps/server and feeds every
 * "positions" message into the store. Reconnects with exponential backoff
 * if the connection drops instead of giving up after one failed attempt.
 */
export function useLiveFeed() {
  const setConnectionStatus = useAppStore((s) => s.setConnectionStatus);
  const setPositions = useAppStore((s) => s.setPositions);

  useEffect(() => {
    let ws: WebSocket | null = null;
    let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
    let unmounted = false;
    let attempt = 0;

    function connect() {
      setConnectionStatus("connecting");
      ws = new WebSocket(wsUrl());

      ws.onopen = () => {
        attempt = 0;
        setConnectionStatus("open");
      };

      ws.onmessage = (evt) => {
        let msg: ServerToClientMessage;
        try {
          msg = JSON.parse(evt.data);
        } catch {
          console.error("[live-feed] received malformed WS message");
          return;
        }
        if (msg.type === "positions") {
          setPositions(msg.aircraft, msg.ts);
        }
      };

      ws.onclose = () => {
        setConnectionStatus("closed");
        if (unmounted) return;
        attempt += 1;
        const delay = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY_MS);
        reconnectTimer = setTimeout(connect, delay);
      };

      ws.onerror = () => {
        ws?.close();
      };
    }

    connect();

    return () => {
      unmounted = true;
      if (reconnectTimer) clearTimeout(reconnectTimer);
      ws?.close();
    };
  }, [setConnectionStatus, setPositions]);
}

The backoff, capped at 15 seconds, matters in practice: without it, a server restart during development causes the browser to hammer /ws with a reconnect attempt every few milliseconds. onerror just closing the socket is deliberate, too, close always fires after error for a WebSocket, so routing both through the same onclose reconnect logic avoids writing the backoff dance twice.

ts
// apps/web/src/lib/aircraftIcon.ts
// Client-only: touches `document` via Leaflet's divIcon. Only ever imported
// from MapPanel, which is reached through a dynamic import with ssr:false.
import L from "leaflet";

function planeIconHtml(track: number): string {
  return `
    <svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${track}deg)">
      <path d="M12 2 L15 11 L22 14 L15 15.5 L14 21 L12 18.5 L10 21 L9 15.5 L2 14 L9 11 Z" fill="var(--phosphor)" stroke="var(--bg-deep)" stroke-width="0.8"/>
    </svg>
  `;
}

export function makePlaneIcon(track: number): L.DivIcon {
  return L.divIcon({
    className: "plane-icon",
    html: planeIconHtml(track),
    iconSize: [24, 24],
    iconAnchor: [12, 12],
  });
}
ts
// apps/web/src/lib/mapView.ts
/** Fallback view centered on the North Atlantic -- wide enough to be a reasonable "nothing configured yet" default. */
export const DEFAULT_VIEW: { center: [number, number]; zoom: number } = {
  center: [40.0, -30.0],
  zoom: 3,
};
tsx
// apps/web/src/components/MapPanel.tsx
"use client";

import { memo, useMemo } from "react";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import type { AircraftState } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { useThemeStore } from "@/store/useThemeStore";
import { makePlaneIcon } from "@/lib/aircraftIcon";
import { DEFAULT_VIEW } from "@/lib/mapView";

const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const LIGHT_TILES = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const TILE_ATTRIBUTION = "&copy; OpenStreetMap &copy; CARTO";

interface AircraftMarkerProps {
  aircraft: AircraftState & { lat: number; lon: number };
}

/**
 * Memoized so a store update caused by one aircraft moving doesn't recreate
 * every other aircraft's icon too. Leaflet's setIcon() tears down and
 * rebuilds a marker's DOM node on every icon change, so a fresh L.divIcon()
 * on every render, even with identical content, makes every marker flicker
 * on each poll cycle instead of just the ones that moved.
 */
const AircraftMarker = memo(function AircraftMarker({ aircraft }: AircraftMarkerProps) {
  const track = aircraft.track ?? aircraft.true_heading ?? aircraft.mag_heading ?? 0;
  const icon = useMemo(() => makePlaneIcon(track), [track]);
  return <Marker position={[aircraft.lat, aircraft.lon]} icon={icon} />;
});

function AircraftMarkers() {
  const aircraft = useAppStore((s) => s.aircraft);
  return (
    <>
      {aircraft
        .filter((a): a is typeof a & { lat: number; lon: number } => a.lat != null && a.lon != null)
        .map((a) => (
          <AircraftMarker key={a.hex} aircraft={a} />
        ))}
    </>
  );
}

export function MapPanel() {
  const isDark = useThemeStore((s) => s.isDark);
  const connectionStatus = useAppStore((s) => s.connectionStatus);

  return (
    <div className="relative h-full w-full">
      <MapContainer
        center={DEFAULT_VIEW.center}
        zoom={DEFAULT_VIEW.zoom}
        minZoom={2}
        maxZoom={12}
        worldCopyJump
        zoomControl
        attributionControl
        className="h-full w-full"
      >
        <TileLayer
          key={isDark ? "dark" : "light"}
          url={isDark ? DARK_TILES : LIGHT_TILES}
          attribution={TILE_ATTRIBUTION}
          subdomains="abcd"
          maxZoom={19}
        />
        <AircraftMarkers />
      </MapContainer>

      {connectionStatus !== "open" && (
        <div className="absolute left-3.5 top-3.5 z-[500] rounded-sm border border-line bg-surface/90 px-3 py-2 text-[11px] tracking-[0.08em] text-phosphor">
          {connectionStatus === "connecting" ? "CONNECTING TO SKYWATCH SERVER…" : "SIGNAL LOST, reconnecting…"}
        </div>
      )}
    </div>
  );
}

key={isDark ? "dark" : "light"} on TileLayer forces React to unmount and remount the layer when the theme flips, Leaflet’s TileLayer doesn’t cleanly support swapping its tile URL template on an existing instance, so a key change is the reliable way to get the new tiles to actually load.

tsx
// apps/web/src/components/ThemeToggle.tsx
"use client";

import { useThemeStore } from "@/store/useThemeStore";

/**
 * Two states for now, extracted from the placeholder button in last lesson's
 * page.tsx into its own component so Header can use it. The multi-theme
 * lesson rewrites this file's internals entirely once there are four states
 * to cycle through instead of two to flip between.
 */
export function ThemeToggle() {
  const isDark = useThemeStore((s) => s.isDark);
  const toggleTheme = useThemeStore((s) => s.toggleTheme);

  return (
    <button
      type="button"
      onClick={toggleTheme}
      aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
      className="flex h-7 items-center gap-1.5 rounded-sm border border-line px-2 text-[11px] tracking-[0.08em] text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
    >
      {isDark ? "☾ DARK" : "☀ LIGHT"}
    </button>
  );
}
tsx
// apps/web/src/components/Header.tsx
"use client";

import { ThemeToggle } from "./ThemeToggle";

export interface HeaderProps {
  searchValue: string;
  onSearchChange: (value: string) => void;
  aircraftCount: number | null;
  lastUpdateLabel: string;
}

export function Header({ searchValue, onSearchChange, aircraftCount, lastUpdateLabel }: HeaderProps) {
  return (
    <header className="z-20 flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
      <div className="flex items-center gap-2.5">
        <span className="brand-dot h-[9px] w-[9px] rounded-full bg-phosphor" />
        <div>
          <h1 className="font-display text-[17px] font-bold tracking-[0.14em] text-fg">SKYWATCH</h1>
          <div className="-mt-0.5 text-[10px] tracking-[0.12em] text-fg-dim">LIVE AIRCRAFT POSITIONS · ADSB.LOL</div>
        </div>
      </div>

      <div className="hidden items-center gap-6 sm:flex">
        <div className="flex items-center gap-1.5 rounded-sm border border-line bg-surface-raised px-2.5 py-1.5">
          <span className="text-[11px] text-fg-dim">⌕</span>
          <input
            value={searchValue}
            onChange={(e) => onSearchChange(e.target.value)}
            type="text"
            placeholder="FILTER CALLSIGN..."
            className="w-[140px] bg-transparent font-mono text-xs tracking-[0.05em] text-fg placeholder:text-fg-dim focus:outline-none"
          />
        </div>

        <div className="text-right">
          <div className="text-[9px] uppercase tracking-[0.12em] text-fg-dim">Aircraft in view</div>
          <div className="font-mono text-[15px] font-semibold text-phosphor">{aircraftCount ?? "--"}</div>
        </div>

        <div className="text-right">
          <div className="text-[9px] uppercase tracking-[0.12em] text-fg-dim">Last update</div>
          <div className="font-mono text-xs text-fg">{lastUpdateLabel}</div>
        </div>

        <ThemeToggle />
      </div>

      <div className="flex items-center gap-1.5 sm:hidden">
        <ThemeToggle />
      </div>
    </header>
  );
}
tsx
// apps/web/src/components/DetailPanel.tsx
"use client";

import { useMemo } from "react";
import { altitudeFt, fmtNum, isOnGround } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";

function EmptyState() {
  return (
    <div className="flex flex-1 flex-col items-center justify-center gap-2.5 border-b border-line p-8 text-center text-fg-dim">
      <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" opacity="0.5">
        <path d="M2 16l4.5-1.5L12 3l1.5 1L10 13l6 2 2-2 1.5 1.5L17 17l-5-1-3 3-1.5-1L9 15l-7 1z" />
      </svg>
      <p className="text-[11px] leading-relaxed tracking-[0.03em]">
        SELECT AN AIRCRAFT ON THE MAP
        <br />
        OR FROM THE LIST BELOW TO
        <br />
        VIEW ITS FLIGHT DATA
      </p>
    </div>
  );
}

function DataCell({ label, value, tone }: { label: string; value: string; tone?: "accent" | "warn" }) {
  return (
    <div>
      <div className="text-[9px] uppercase tracking-[0.1em] text-fg-dim">{label}</div>
      <div className={`text-[15px] font-semibold ${tone === "accent" ? "text-phosphor" : tone === "warn" ? "text-amber" : "text-fg"}`}>
        {value}
      </div>
    </div>
  );
}

export function DetailPanel() {
  const selectedIcao = useAppStore((s) => s.selectedIcao);
  const setSelectedIcao = useAppStore((s) => s.setSelectedIcao);
  const aircraft = useAppStore((s) => s.aircraft);

  const selected = useMemo(() => aircraft.find((a) => a.hex === selectedIcao) ?? null, [aircraft, selectedIcao]);

  if (!selected) return <EmptyState />;

  const cs = (selected.flight ?? "").trim() || selected.r || selected.hex.toUpperCase();
  const onGround = isOnGround(selected);
  const altFt = altitudeFt(selected);
  const spdKt = selected.gs ?? null;
  const vrate = selected.baro_rate ?? selected.geom_rate ?? null;
  const track = selected.track ?? selected.true_heading ?? selected.mag_heading ?? null;

  return (
    <div className="border-b border-line p-4">
      <button
        type="button"
        onClick={() => setSelectedIcao(null)}
        className="float-right rounded-sm border border-line px-2 py-1 text-[10px] tracking-[0.08em] text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
      >
        CLOSE ✕
      </button>

      <div className="font-display text-[26px] font-bold leading-tight text-phosphor">{cs}</div>
      <div className="mb-4 text-[10px] tracking-[0.1em] text-fg-dim">{selected.hex.toUpperCase()}</div>

      <div className="grid grid-cols-2 gap-x-3.5 gap-y-3">
        <DataCell label="Altitude" value={altFt != null ? `${fmtNum(altFt)} ft` : "--"} tone="accent" />
        <DataCell label="Ground speed" value={spdKt != null ? `${fmtNum(spdKt)} kt` : "--"} tone="accent" />
        <DataCell
          label="Vertical rate"
          value={vrate != null ? `${vrate > 0 ? "+" : ""}${fmtNum(vrate)} fpm` : "--"}
          tone={vrate != null ? (vrate > 100 ? "accent" : vrate < -100 ? "warn" : undefined) : undefined}
        />
        <DataCell label="Status" value={onGround ? "ON GROUND" : "AIRBORNE"} tone={onGround ? "warn" : "accent"} />
        <DataCell label="Squawk" value={selected.squawk || "--"} />
        <DataCell label="Category" value={selected.category || "--"} />
        <DataCell label="Latitude" value={selected.lat != null ? `${fmtNum(selected.lat, 3)}°` : "--"} />
        <DataCell label="Longitude" value={selected.lon != null ? `${fmtNum(selected.lon, 3)}°` : "--"} />
      </div>

      <div className="mt-4 flex items-center gap-2.5 border-t border-line pt-4">
        <div className="relative h-[46px] w-[46px] shrink-0 rounded-full border border-line">
          <div
            className="absolute left-1/2 top-1/2 h-[18px] w-0.5 bg-phosphor shadow-[0_0_4px_var(--phosphor)]"
            style={{ transform: `translate(-50%, -100%) rotate(${track ?? 0}deg)`, transformOrigin: "bottom center" }}
          />
        </div>
        <DataCell label="Heading" value={track != null ? `${fmtNum(track)}°` : "--"} />
      </div>
    </div>
  );
}

Everything here comes straight from the store and the aircraft’s own ADS-B fields, no network calls beyond the WebSocket feed already running. A later module upgrades this panel with a reference photo, resolved aircraft type and operator, a route lookup, and distance/bearing from a configured home location, each backed by a small external API. Building the plain-telemetry version first means that upgrade is a clean addition to a panel that already works, not a prerequisite to seeing anything at all.

tsx
// apps/web/src/components/FlightList.tsx
"use client";

import { useMemo } from "react";
import { altitudeFt, fmtNum, isOnGround } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";

const MAX_ROWS = 60;

export function FlightList() {
  const aircraft = useAppStore((s) => s.aircraft);
  const filterText = useAppStore((s) => s.filterText);
  const selectedIcao = useAppStore((s) => s.selectedIcao);
  const setSelectedIcao = useAppStore((s) => s.setSelectedIcao);

  const items = useMemo(() => {
    const q = filterText.trim().toUpperCase();
    let list = aircraft.filter((a) => a.lat != null && a.lon != null);
    if (q) {
      list = list.filter((a) => (a.flight ?? "").toUpperCase().includes(q) || a.hex.toUpperCase().includes(q));
    }
    return list.slice(0, MAX_ROWS);
  }, [aircraft, filterText]);

  return (
    <div className="flex flex-1 flex-col overflow-hidden px-4 pb-4">
      <div className="flex justify-between border-b border-line pb-2 text-[9px] uppercase tracking-[0.08em] text-fg-dim">
        <span className="w-[70px]">Callsign</span>
        <span className="w-[60px] text-right">Alt ft</span>
        <span className="w-[60px] text-right">Spd kt</span>
      </div>
      <div className="flex-1 overflow-y-auto">
        {items.length === 0 ? (
          <div className="py-3.5 text-[11px] text-fg-dim">NO AIRCRAFT MATCH</div>
        ) : (
          items.map((a) => {
            const cs = (a.flight ?? "").trim() || a.r || a.hex.toUpperCase();
            const onGround = isOnGround(a);
            const altFt = altitudeFt(a);
            const active = a.hex === selectedIcao;
            return (
              <div
                key={a.hex}
                role="button"
                tabIndex={0}
                onClick={() => setSelectedIcao(a.hex)}
                onKeyDown={(e) => e.key === "Enter" && setSelectedIcao(a.hex)}
                className={`flex cursor-pointer items-center justify-between border-b border-line/50 py-2 text-[11px] transition-colors ${
                  active ? "bg-amber/10" : "hover:bg-phosphor/5"
                }`}
              >
                <span className="w-[70px] font-semibold tracking-[0.05em] text-fg">{cs}</span>
                <span className="w-[60px] text-right text-fg-dim">
                  {altFt != null ? fmtNum(altFt) : onGround ? "GND" : "--"}
                </span>
                <span className="w-[60px] text-right text-fg-dim">{a.gs != null ? fmtNum(a.gs) : "--"}</span>
              </div>
            );
          })
        )}
      </div>
    </div>
  );
}

filterText is read here and in Header’s search input, both from the same store field, type in the header, and the list narrows, with no prop drilling between two components that don’t otherwise know about each other. MAX_ROWS = 60 is a deliberate cap: a busy region can have far more than 60 aircraft in view at once, and rendering every single row would be wasted work for a panel where a human is scanning a list, not processing all of them programmatically.

tsx
// apps/web/src/components/Dashboard.tsx
"use client";

import dynamic from "next/dynamic";
import { Header } from "./Header";
import { DetailPanel } from "./DetailPanel";
import { FlightList } from "./FlightList";
import { useLiveFeed } from "@/hooks/useLiveFeed";
import { useAppStore } from "@/store/useAppStore";

const MapPanel = dynamic(() => import("./MapPanel").then((m) => m.MapPanel), {
  ssr: false,
  loading: () => (
    <div className="flex h-full items-center justify-center text-xs tracking-[0.1em] text-fg-dim">
      LOADING MAP…
    </div>
  ),
});

function formatLastUpdate(date: Date | null): string {
  if (!date) return "--";
  return date.toLocaleTimeString();
}

export function Dashboard() {
  useLiveFeed();

  const searchValue = useAppStore((s) => s.filterText);
  const setFilterText = useAppStore((s) => s.setFilterText);
  const aircraft = useAppStore((s) => s.aircraft);
  const lastUpdate = useAppStore((s) => s.lastUpdate);

  return (
    <div className="flex h-full flex-col bg-base">
      <Header
        searchValue={searchValue}
        onSearchChange={setFilterText}
        aircraftCount={aircraft.length}
        lastUpdateLabel={formatLastUpdate(lastUpdate)}
      />

      <div className="flex min-h-0 flex-1">
        <div className="relative min-w-0 flex-1 bg-base">
          <MapPanel />
        </div>

        <div className="hidden w-[380px] shrink-0 flex-col overflow-y-auto border-l border-line bg-surface md:flex">
          <DetailPanel />
          <div className="flex flex-1 flex-col overflow-hidden">
            <h2 className="px-4 pb-3 pt-4 text-[11px] uppercase tracking-[0.14em] text-fg-dim">Nearby traffic</h2>
            <FlightList />
          </div>
        </div>
      </div>
    </div>
  );
}
tsx
// apps/web/src/app/page.tsx
import { Dashboard } from "@/components/Dashboard";

export default function Home() {
  return <Dashboard />;
}

hidden ... md:flex on the sidebar is the first piece of the responsive layout: below the md breakpoint, the detail panel and flight list disappear entirely rather than squeezing into a phone-sized column, a later module replaces that gap with a purpose-built mobile layout instead of trying to force this desktop layout to shrink.

Review This

Does the reconnect actually back off, or does it retry on a fixed delay, or not at all? Read useLiveFeed.ts’s onclose handler specifically. A fixed setTimeout(connect, 2000) looks completely reasonable, it reconnects, the app recovers, everything works in your local dev loop where the server comes back in a second or two. It only becomes a problem in the exact scenario a backoff exists to handle: the server is down for a while, and now every client is hammering /ws with a new connection attempt every two seconds indefinitely, which is real load on a server that’s already having a bad day. Confirm the delay actually grows with each failed attempt (1000 * 2 ** attempt here) and is capped at a sane maximum, not just present as a fixed number. If it’s flat or missing, the follow-up prompt is: “the WebSocket reconnect in useLiveFeed needs exponential backoff capped at a max delay, not a fixed retry interval, a prolonged outage shouldn’t mean every client retries every two seconds forever.”

Are aircraft markers actually memoized, or is a fresh icon getting built on every store update? This one is easy to miss because it renders correctly either way. Check AircraftMarker in MapPanel.tsx for memo(...) wrapping the component and useMemo(() => makePlaneIcon(track), [track]) around the icon itself. Skip either one and the app still works, aircraft still show up in the right place, still rotate to the right heading, with three or four aircraft on screen you won’t see anything wrong. The cost shows up as a visible flicker across every marker on every poll cycle once a real watch region with real traffic is running, because Leaflet’s setIcon() tears down and rebuilds a marker’s DOM node on every icon change, and an unmemoized component creates a new icon object for every aircraft, including the ones that didn’t move, on every store update. The follow-up prompt, if you catch this: “AircraftMarker needs to be memoized, and the L.divIcon it builds needs to be memoized on the track value, so a position update for one aircraft doesn’t rebuild every other aircraft’s marker icon too.”

Did marker clicks on the map actually get wired to selection, or only the flight list? The prompt asked for both “clicking a marker on the map and clicking a row in the flight list should both select the same aircraft.” Look at the AircraftMarker component in MapPanel.tsx above, it renders a Marker with a position and an icon, and nothing else. There’s no eventHandlers prop calling setSelectedIcao. This is a specific, easy AI failure mode on multi-part prompts: earlier requirements (the marker itself, its icon, its rotation) got built in full, and a requirement mentioned once at the end of a long list got dropped entirely, with nothing about the rest of the app suggesting anything’s missing, FlightList already sets selectedIcao on row click, so the detail panel populates fine from there and the whole dashboard looks finished. The only way to catch this is doing exactly what the prompt asked for: click a marker. The follow-up prompt is: “wire an onClick eventHandler on each AircraftMarker that calls setSelectedIcao with that aircraft’s hex, the flight list already does this, the map should do the same thing.”

Try It

  1. Run the prompt above against your AI coding assistant of choice, with the token system and both stores in place from the previous lesson.
  2. Before running anything, read useLiveFeed.ts and MapPanel.tsx against the three checks above.
  3. With Postgres and the server running from Module 2, run npm run dev from the repo root and open http://localhost:3000.
  4. Confirm the header shows the connecting/live status, a themed map covering the North Atlantic, and, once the poller’s next cycle lands, glowing markers appearing wherever your configured watch region has traffic, each rotated to its actual heading.
  5. Type a partial callsign into the search box and confirm the flight list narrows to matching rows as you type.
  6. Click a row in the flight list and confirm the detail panel populates with that aircraft’s telemetry. Then click a marker on the map directly, if nothing happens, that’s the third review point above, not a bug in your setup.
  7. Click “CLOSE” in the detail panel and confirm it returns to the empty state.
  8. Stop the server process mid-session and watch the browser console: reconnect attempts should visibly space out, not fire every few milliseconds.

Recap

  • A prompt covering several tightly-coupled pieces (map, header, detail panel, list, all reading the same store) is worth writing as one prompt, but it multiplies the surface area you need to review, one dropped requirement out of six doesn’t make the output look broken.
  • Reconnect backoff and marker memoization are both invisible under light load and both real problems under real load, the only way to catch either is reading the specific line that handles it, not watching the happy path run.
  • Header, DetailPanel, and FlightList all read from the same store, no prop drilling required between siblings that need to share selection or filter state, but shared state also means a missing wire-up (map clicks) can hide behind a working alternate path (list clicks).

Next lesson: growing the theme toggle from two states into a full four-theme system.