CodingNic

Feature Build-Out

Airports & Weather Overlay

Feature Build-Out 30 min read

Airports & Weather Overlay

Objectives

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

  • Populate the airports table (already modeled back in Module 1) from the OurAirports open dataset via a one-time seed script
  • Query airports scoped to the current map viewport through a bounding-box endpoint, and render them as a diamond marker layer that refetches on pan/zoom
  • Add a live METAR weather badge, backed by an in-memory-cached weather proxy, for large airports at closer zoom levels
  • Persist a show/hide airports toggle using the same hydrate-on-mount/write-on-change pattern Module 3 built for the theme preference

๐Ÿ’ก Why this matters: Aircraft markers alone don’t answer “where is this thing headed” or “why is it circling right there” โ€” an operator reads a lot of context off nearby airports and the weather they’re reporting. This chapter adds that reference layer: a real, seeded dataset of airports the map can query by viewport, plus live conditions for the big ones, without touching the aircraft feed at all.

Shared Types, and a One-Time Seed

Airport reference data got its own airports table back in Module 1, indexed on ident (a unique index โ€” the seed script below leans on it to make re-running itself safe) and on (lat, lon) and type for the viewport and marker-styling queries this chapter adds. It just hasn’t had anything in it, or any code reading from it, until now. It needs its own shared types too:

ts
// packages/shared/src/airport.ts
export type AirportType = "large_airport" | "medium_airport" | "small_airport" | "heliport" | "seaplane_base" | "closed";

export interface Airport {
  id: number;
  ident: string;
  type: AirportType;
  name: string;
  lat: number;
  lon: number;
  elevationFt: number | null;
  icaoCode: string | null;
  iataCode: string | null;
  municipality: string | null;
  countryCode: string | null;
}

The viewport query below also needs a plain bounding-box shape โ€” that’s general-purpose enough (nothing airport-specific about it) that it belongs in geo.ts alongside distanceNm and viewRadiusNm, not in this airport-only file:

ts
// packages/shared/src/geo.ts (add to the Module 4 version)

/** A simple lat/lon bounding box, used for airport-by-viewport queries. */
export interface BoundingBox {
  minLat: number;
  maxLat: number;
  minLon: number;
  maxLon: number;
}

Re-export airport.ts from packages/shared/src/index.ts, same as every shared file added since Module 2.

Populating tens of thousands of rows by hand isn’t realistic, so this table gets a dedicated one-time script instead of a form:

ts
// apps/server/src/db/seedAirports.ts
import { parse } from "csv-parse/sync";
import { db, pool } from "./client.js";
import { airports } from "./schema.js";
import type { AirportType } from "@skywatch/shared";

/**
 * Populates the `airports` table from OurAirports' open dataset (public
 * domain). We only keep large/medium/small airports and heliports/seaplane
 * bases with an ICAO or IATA code -- closed airports and the enormous long
 * tail of unpaved private strips would bloat the map overlay for no benefit.
 */
const SOURCE_URL = "https://davidmegginson.github.io/ourairports-data/airports.csv";

const KEEP_TYPES = new Set<AirportType>([
  "large_airport",
  "medium_airport",
  "small_airport",
  "heliport",
  "seaplane_base",
]);

interface OurAirportsRow {
  id: string;
  ident: string;
  type: string;
  name: string;
  latitude_deg: string;
  longitude_deg: string;
  elevation_ft: string;
  municipality: string;
  iso_country: string;
  gps_code: string;
  iata_code: string;
}

async function main() {
  console.log(`Fetching airport data from ${SOURCE_URL} ...`);
  const resp = await fetch(SOURCE_URL);
  if (!resp.ok) {
    throw new Error(`Failed to fetch airport data: HTTP ${resp.status}`);
  }
  const csvText = await resp.text();
  const rows: OurAirportsRow[] = parse(csvText, { columns: true, skip_empty_lines: true });

  const filtered = rows.filter((r) => {
    if (!KEEP_TYPES.has(r.type as AirportType)) return false;
    // Skip small_airport entries with no useful code -- these are almost
    // always unpaved private strips that would dominate the marker count.
    if (r.type === "small_airport" && !r.gps_code && !r.iata_code) return false;
    const lat = Number(r.latitude_deg);
    const lon = Number(r.longitude_deg);
    return Number.isFinite(lat) && Number.isFinite(lon);
  });

  const truncate = (s: string | null, max: number) => (s ? s.slice(0, max) : null);
  const values = filtered.map((r) => ({
    ident: truncate(r.ident, 10)!,
    type: r.type,
    name: truncate(r.name, 160)!,
    lat: Number(r.latitude_deg),
    lon: Number(r.longitude_deg),
    elevationFt: r.elevation_ft ? Number(r.elevation_ft) : null,
    icaoCode: truncate(r.gps_code || null, 4),
    iataCode: truncate(r.iata_code || null, 3),
    municipality: truncate(r.municipality || null, 120),
    countryCode: truncate(r.iso_country || null, 2),
  }));

  // ident has a unique index; the source data shouldn't have duplicates but
  // dedupe defensively (last row wins) rather than let one bad row abort the batch.
  const byIdent = new Map(values.map((v) => [v.ident, v]));
  const deduped = [...byIdent.values()];

  console.log("Clearing existing airports table...");
  await db.delete(airports);

  const BATCH_SIZE = 1000;
  for (let i = 0; i < deduped.length; i += BATCH_SIZE) {
    const batch = deduped.slice(i, i + BATCH_SIZE);
    await db.insert(airports).values(batch).onConflictDoNothing();
    console.log(`Inserted ${Math.min(i + BATCH_SIZE, deduped.length)} / ${deduped.length}`);
  }

  console.log("Airport seed complete.");
  await pool.end();
}

main().catch((err) => {
  console.error("Airport seed failed:", err);
  process.exit(1);
});

Add "db:seed:airports": "tsx src/db/seedAirports.ts" to apps/server/package.json and run it once with npm run db:seed:airports. Notice what this isn’t: it’s not wired into startPoller() next to ensureDefaultWatchRegion() from Module 2. That function’s job is a cheap existence check on every boot โ€” insert one row if the table’s empty, otherwise do nothing, in milliseconds. This script’s job is fetching and parsing a several-megabyte CSV and re-inserting tens of thousands of rows every time it runs; doing that automatically on every server restart would be slow and wasteful for no benefit, so it stays a deliberate, manually-triggered (but safely re-runnable, thanks to the delete-then-reinsert plus onConflictDoNothing()) command instead.

Querying Airports by Viewport

The repo layer takes a bounding box and an optional list of types to filter to:

ts
// apps/server/src/db/repos/airportsRepo.ts
import { and, gte, lte, inArray } from "drizzle-orm";
import type { Airport, AirportType, BoundingBox } from "@skywatch/shared";
import { db } from "../client.js";
import { airports } from "../schema.js";

function toApi(row: typeof airports.$inferSelect): Airport {
  return {
    id: row.id,
    ident: row.ident,
    type: row.type as AirportType,
    name: row.name,
    lat: row.lat,
    lon: row.lon,
    elevationFt: row.elevationFt,
    icaoCode: row.icaoCode,
    iataCode: row.iataCode,
    municipality: row.municipality,
    countryCode: row.countryCode,
  };
}

export async function queryAirportsInBounds(
  bbox: BoundingBox,
  types: AirportType[]
): Promise<Airport[]> {
  const rows = await db
    .select()
    .from(airports)
    .where(
      and(
        gte(airports.lat, bbox.minLat),
        lte(airports.lat, bbox.maxLat),
        gte(airports.lon, bbox.minLon),
        lte(airports.lon, bbox.maxLon),
        types.length > 0 ? inArray(airports.type, types) : undefined
      )
    )
    .limit(2000);
  return rows.map(toApi);
}

types.length > 0 ? inArray(...) : undefined is the same “conditionally omit a clause” idiom Drizzle’s and() accepts elsewhere in this codebase โ€” an empty types array means “no type filter,” not “match nothing,” so the clause is simply left out rather than built into an always-false inArray([]).

ts
// apps/server/src/routes/airports.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { AirportType } from "@skywatch/shared";
import { queryAirportsInBounds } from "../db/repos/airportsRepo.js";

const VALID_TYPES: AirportType[] = [
  "large_airport",
  "medium_airport",
  "small_airport",
  "heliport",
  "seaplane_base",
  "closed",
];

const querySchema = z
  .object({
    minLat: z.coerce.number().min(-90).max(90),
    maxLat: z.coerce.number().min(-90).max(90),
    minLon: z.coerce.number().min(-180).max(180),
    maxLon: z.coerce.number().min(-180).max(180),
    // comma-separated list, e.g. "large_airport,medium_airport"
    types: z
      .string()
      .optional()
      .transform((s) => (s ? s.split(",").filter((t): t is AirportType => VALID_TYPES.includes(t as AirportType)) : [])),
  })
  .refine((v) => v.maxLat >= v.minLat && v.maxLon >= v.minLon, { message: "invalid bounding box" });

export function registerAirportRoutes(app: FastifyInstance): void {
  app.get("/airports", async (req, reply) => {
    const parsed = querySchema.safeParse(req.query);
    if (!parsed.success) {
      return reply.status(400).send({ error: "invalid bounding box", issues: parsed.error.issues });
    }
    const { minLat, maxLat, minLon, maxLon, types } = parsed.data;
    const airports = await queryAirportsInBounds({ minLat, maxLat, minLon, maxLon }, types);
    return { airports };
  });
}

VALID_TYPES includes "closed" even though the seed script above never inserts a closed airport โ€” the type validation here is about rejecting garbage input, not about mirroring exactly what’s in the table today. Register the route the same way as every other resource so far: one import, one call in routes/index.ts.

ts
// apps/server/src/routes/index.ts
import { registerAirportRoutes } from "./airports.js";
// ...
registerAirportRoutes(api);

The Airport Marker Layer

The frontend turns the current map viewport into that same bounding box, debounced the same way ViewportFeed (Module 4) debounces its own moveend-triggered fetch:

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

import { useCallback, useEffect, useRef, useState } from "react";
import { Marker, Tooltip, useMap, useMapEvents } from "react-leaflet";
import type { Airport } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";
import { makeAirportIcon } from "@/lib/airportIcon";
import { useAppStore } from "@/store/useAppStore";
import { AirportWeatherBadge } from "./AirportWeatherBadge";

const DEBOUNCE_MS = 400;
const SMALL_AIRPORT_MIN_ZOOM = 8;
// Below this zoom the bounding box spans most of a continent -- airports
// would just be visual noise (and a large fetch), so skip the overlay.
const MIN_ZOOM_TO_SHOW = 5;
// METAR badges add a second marker + fetch per airport, so they're gated to
// a closer zoom than the airport diamonds themselves -- only large fields
// (which is all we query for below this) get a badge either way.
const METAR_MIN_ZOOM = 7;

/**
 * Airport reference-data overlay (OurAirports dataset, seeded via
 * `npm run db:seed:airports`). Refetches for the current viewport on pan/
 * zoom, same debounce pattern as ViewportFeed. Purely additive -- doesn't
 * touch the aircraft feed or free-pan state at all.
 */
export function AirportLayer() {
  const map = useMap();
  const showAirports = useAppStore((s) => s.showAirports);
  const [airports, setAirports] = useState<Airport[]>([]);
  const [zoom, setZoom] = useState(() => map.getZoom());
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const fetchForCurrentView = useCallback(() => {
    // Skip the fetch entirely while hidden -- the toggle is a display
    // preference, not just a render guard, so panning around with airports
    // off shouldn't keep hitting the API for markers nobody will see.
    if (!showAirports) return;
    const zoom = map.getZoom();
    setZoom(zoom);
    if (zoom < MIN_ZOOM_TO_SHOW) {
      setAirports([]);
      return;
    }
    const bounds = map.getBounds();
    const types = zoom >= SMALL_AIRPORT_MIN_ZOOM ? "large_airport,medium_airport,small_airport" : "large_airport,medium_airport";
    const params = new URLSearchParams({
      minLat: String(bounds.getSouth()),
      maxLat: String(bounds.getNorth()),
      minLon: String(bounds.getWest()),
      maxLon: String(bounds.getEast()),
      types,
    });
    fetch(apiUrl(`/api/airports?${params.toString()}`))
      .then((r) => r.json())
      .then((data: { airports: Airport[] }) => setAirports(data.airports ?? []))
      .catch((err) => console.error("[airports] fetch failed:", err));
  }, [map, showAirports]);

  useMapEvents({
    moveend: () => {
      if (debounceTimer.current) clearTimeout(debounceTimer.current);
      debounceTimer.current = setTimeout(fetchForCurrentView, DEBOUNCE_MS);
    },
  });

  useEffect(() => {
    fetchForCurrentView();
    return () => {
      if (debounceTimer.current) clearTimeout(debounceTimer.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Re-fetch the moment the toggle flips back on, for the current viewport
  // -- otherwise airports would stay empty until the next pan/zoom, since
  // the fetch above was skipped for however long the layer was hidden.
  useEffect(() => {
    if (showAirports) fetchForCurrentView();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showAirports]);

  if (!showAirports) return null;

  return (
    <>
      {airports.map((a) => (
        <Marker key={a.id} position={[a.lat, a.lon]} icon={makeAirportIcon(a.type)}>
          <Tooltip direction="top" offset={[0, -4]} opacity={0.95}>
            <span className="font-mono text-[11px]">
              {a.icaoCode ?? a.ident}
              {a.iataCode ? ` / ${a.iataCode}` : ""} ยท {a.name}
            </span>
          </Tooltip>
        </Marker>
      ))}
      {zoom >= METAR_MIN_ZOOM &&
        airports
          .filter((a) => a.type === "large_airport" && a.icaoCode)
          .map((a) => <AirportWeatherBadge key={`metar-${a.id}`} airport={a} />)}
    </>
  );
}

Three separate zoom thresholds are doing three separate jobs, not one: MIN_ZOOM_TO_SHOW decides whether to query at all (zoomed out to a continent, the bounding box is huge and the diamonds would just be noise), SMALL_AIRPORT_MIN_ZOOM decides which airport sizes to ask for once you are querying, and METAR_MIN_ZOOM decides whether to layer live-weather badges on top of whatever diamonds already rendered. makeAirportIcon (in lib/airportIcon.ts) is a small factory that returns a diamond-shaped Leaflet icon colored by airport type โ€” the same shape AirportsToggle’s own icon echoes below.

Add <AirportLayer /> to MapPanel, right after the TileLayer and ahead of TrailLayer/AircraftMarkers โ€” that ordering keeps aircraft markers rendering visually on top of the airport diamonds beneath them:

tsx
// apps/web/src/components/MapPanel.tsx (inside MapContainer)
<TileLayer ... />
<AirportLayer />
<TrailLayer />
<AircraftMarkers />

Live METAR Weather

Weather isn’t stored โ€” it changes on the order of an hour, so a short-lived in-memory cache on the server is the right amount of durability, not a table:

ts
// apps/server/src/lib/weather.ts
import type { MetarInfo } from "@skywatch/shared";
import { fetchJson } from "./http.js";

const BASE = "https://aviationweather.gov/api/data/metar";

interface AwcMetar {
  icaoId?: string;
  obsTime?: number; // unix seconds
  temp?: number;
  wdir?: number | string;
  wspd?: number;
  visib?: number | string;
  fltCat?: string;
  rawOb?: string;
}

/**
 * Fetches the latest METAR for an airport ICAO code from aviationweather.gov
 * (open CORS, no API key). Not cached in Postgres -- weather changes on the
 * order of an hour, so an in-memory cache with a short TTL (see routes/weather.ts)
 * is more appropriate than a durable table.
 */
export async function fetchMetar(icao: string): Promise<MetarInfo | null> {
  const data = await fetchJson<AwcMetar[]>(`${BASE}?ids=${encodeURIComponent(icao)}&format=json`);
  const m = data[0];
  if (!m || !m.rawOb) return null;

  return {
    icao: m.icaoId ?? icao,
    raw: m.rawOb,
    observedAt: m.obsTime ? new Date(m.obsTime * 1000).toISOString() : null,
    tempC: typeof m.temp === "number" ? m.temp : null,
    windDirDeg: typeof m.wdir === "number" ? m.wdir : null,
    windSpeedKt: typeof m.wspd === "number" ? m.wspd : null,
    visibilityMi: typeof m.visib === "number" ? m.visib : typeof m.visib === "string" ? Number(m.visib) || null : null,
    flightCategory: m.fltCat ?? null,
  };
}

fetchJson is the same lib/http.ts helper the poller has used since Module 2 โ€” the same “browser-like User-Agent, typed error on a bad response” wrapper, reused here rather than a second copy of that logic.

ts
// apps/server/src/routes/weather.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { MetarInfo } from "@skywatch/shared";
import { fetchMetar } from "../lib/weather.js";
import { MemoryCache } from "../lib/memoryCache.js";

const paramsSchema = z.object({ icao: z.string().trim().length(4) });

const cache = new MemoryCache<MetarInfo | null>(10 * 60 * 1000); // METAR updates roughly hourly; 10min is plenty fresh

export function registerWeatherRoutes(app: FastifyInstance): void {
  app.get("/weather/:icao", async (req, reply) => {
    const parsed = paramsSchema.safeParse(req.params);
    if (!parsed.success) return reply.status(400).send({ error: "invalid ICAO code" });
    const icao = parsed.data.icao.toUpperCase();

    const cached = cache.get(icao);
    if (cached !== undefined) {
      if (!cached) return reply.status(404).send({ error: "not found" });
      return cached;
    }

    try {
      const metar = await fetchMetar(icao);
      cache.set(icao, metar);
      if (!metar) return reply.status(404).send({ error: "not found" });
      return metar;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return reply.status(502).send({ error: message });
    }
  });
}

MemoryCache is a tiny generic TTL cache (get/set, expires entries after the constructor’s duration) โ€” nothing airport-specific about it, just a small utility this route leans on to avoid hammering aviationweather.gov for the same ICAO code from every connected browser. Register it the same way as airports.ts above: one import, one call in routes/index.ts.

On the client, a hook wraps the fetch in its own module-level cache, so switching zoom levels (which re-renders AirportWeatherBadge for airports already fetched once) doesn’t re-request them:

ts
// apps/web/src/hooks/useAirportMetar.ts
"use client";

import { useEffect, useState } from "react";
import type { MetarInfo } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";

const cache = new Map<string, MetarInfo | null>();

/**
 * Latest METAR for an airport via our server's aviation-weather proxy
 * (Postgres-cached ~10min upstream). Keyed by ICAO code. Falls back to null
 * on any failure/404 -- callers should treat that as "no data," not "clear
 * skies."
 */
export function useAirportMetar(icao: string | null): MetarInfo | null {
  const [info, setInfo] = useState<MetarInfo | null>(icao ? (cache.get(icao) ?? null) : null);

  useEffect(() => {
    if (!icao) {
      setInfo(null);
      return;
    }
    if (cache.has(icao)) {
      setInfo(cache.get(icao) ?? null);
      return;
    }

    let cancelled = false;
    fetch(apiUrl(`/api/weather/${encodeURIComponent(icao)}`))
      .then((r) => (r.ok ? (r.json() as Promise<MetarInfo>) : null))
      .then((data) => {
        cache.set(icao, data);
        if (!cancelled) setInfo(data);
      })
      .catch(() => {
        if (!cancelled) setInfo(null);
      });
    return () => {
      cancelled = true;
    };
  }, [icao]);

  return info;
}
tsx
// apps/web/src/components/AirportWeatherBadge.tsx
"use client";

import { Marker, Tooltip } from "react-leaflet";
import type { Airport } from "@skywatch/shared";
import { useAirportMetar } from "@/hooks/useAirportMetar";
import { makeMetarIcon } from "@/lib/metarIcon";

/**
 * Renders a single airport's live METAR flight-category dot, once loaded.
 * Split out from AirportLayer so each badge's fetch/cache/render cycle is
 * independent -- one airport's slow or failed lookup doesn't block others,
 * and React can key/reconcile them individually as the viewport's airport
 * list changes.
 */
export function AirportWeatherBadge({ airport }: { airport: Airport }) {
  const metar = useAirportMetar(airport.icaoCode);
  if (!metar) return null;

  return (
    <Marker position={[airport.lat, airport.lon]} icon={makeMetarIcon(metar.flightCategory)}>
      <Tooltip direction="top" offset={[0, -4]} opacity={0.95}>
        <div className="font-mono text-[11px] leading-snug">
          <div className="font-semibold">
            {airport.icaoCode} ยท {metar.flightCategory ?? "โ€”"}
          </div>
          <div>{metar.raw}</div>
        </div>
      </Tooltip>
    </Marker>
  );
}

AirportLayer already does the filtering that makes this component’s mount conditional โ€” only large_airport entries with an icaoCode, and only once the map is zoomed past METAR_MIN_ZOOM โ€” so AirportWeatherBadge itself doesn’t need to re-check any of that; its only job is fetch-and-render for the one airport it was handed, returning null until the METAR resolves.

Persisting Show/Hide

The toggle needs a store flag, a button, and a hook that keeps that flag in sync with localStorage across reloads.

ts
// apps/web/src/store/useAppStore.ts (additions to the previous chapter's version)

interface AppState {
  // ...existing fields...

  /** Whether AirportLayer renders airport diamonds + METAR badges, on both the primary map and multi-region compare panes. Persisted to localStorage by useAirportsVisibility; defaults to shown, same hydration pattern as the theme toggle. */
  showAirports: boolean;

  setShowAirports: (show: boolean) => void;
  toggleShowAirports: () => void;
}

export const useAppStore = create<AppState>((set) => ({
  // ...existing fields...
  showAirports: true,

  setShowAirports: (show) => set({ showAirports: show }),
  toggleShowAirports: () => set((state) => ({ showAirports: !state.showAirports })),
}));
tsx
// apps/web/src/components/AirportsToggle.tsx
"use client";

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

/**
 * Shows/hides AirportLayer's diamonds + METAR badges on every map that
 * renders it (the primary MapPanel and each pane of the multi-region
 * compare view) -- a single store flag, so one click affects both. Purely
 * a display preference: hiding airports doesn't stop AirportLayer's
 * viewport fetch or touch the aircraft feed at all.
 */
export function AirportsToggle() {
  const shown = useAppStore((s) => s.showAirports);
  const toggle = useAppStore((s) => s.toggleShowAirports);

  return (
    <button
      type="button"
      onClick={toggle}
      aria-pressed={shown}
      aria-label={shown ? "Hide airports" : "Show airports"}
      title={shown ? "Airports shown -- click to hide" : "Airports hidden -- click to show"}
      className={`flex h-7 w-7 items-center justify-center rounded-sm border transition-colors ${
        shown
          ? "border-phosphor-dim text-phosphor"
          : "border-line text-text-dim hover:border-phosphor-dim hover:text-phosphor"
      }`}
    >
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
        {/* Diamond, matching AirportLayer's own marker shape (lib/airportIcon.ts) */}
        <rect x="7" y="7" width="10" height="10" transform="rotate(45 12 12)" strokeLinejoin="round" />
        {!shown && <line x1="3" y1="21" x2="21" y2="3" strokeLinecap="round" />}
      </svg>
    </button>
  );
}

The store flag alone gets the UI working for one page load, but it doesn’t survive a reload โ€” that’s useAirportsVisibility, which reads/writes localStorage the same way Module 3’s hydrateFromDom kept the theme store in sync with what the no-flash script already wrote to the DOM.

ts
// apps/web/src/hooks/useAirportsVisibility.ts
"use client";

import { useEffect, useRef } from "react";
import { useAppStore } from "@/store/useAppStore";

const STORAGE_KEY = "skywatch:show-airports";

/**
 * Persists the "show airports" preference (AirportsToggle in the header)
 * across sessions -- hydrate-on-mount/write-on-change. Mount once near the
 * root (Dashboard). Defaults to shown, matching the store's SSR default, so
 * there's no flash-of-hidden-airports before this effect runs client-side.
 */
export function useAirportsVisibility(): void {
  const showAirports = useAppStore((s) => s.showAirports);
  const setShowAirports = useAppStore((s) => s.setShowAirports);

  const skippedFirstPersist = useRef(false);

  useEffect(() => {
    try {
      const stored = window.localStorage.getItem(STORAGE_KEY);
      if (stored != null) setShowAirports(stored === "true");
    } catch {
      // localStorage unavailable (private mode, etc.) -- just keep the default.
    }
  }, [setShowAirports]);

  useEffect(() => {
    if (!skippedFirstPersist.current) {
      skippedFirstPersist.current = true;
      return;
    }
    try {
      window.localStorage.setItem(STORAGE_KEY, String(showAirports));
    } catch {
      // ignore
    }
  }, [showAirports]);
}

The skippedFirstPersist ref exists because both effects above fire on the very first mount, in the order they’re declared, inside the same commit. The hydrate effect’s setShowAirports(...) doesn’t force this component to see the new value until React’s next render โ€” so if the persist effect ran unconditionally on that first pass, it would still be closing over the pre-hydration default (true) and would immediately overwrite whatever a previous session had actually stored, before the hydrated value ever got a chance to reach localStorage. Skipping exactly the first persist run sidesteps that: if hydration changed the value, showAirports changing triggers a second, later run of the persist effect (its dependency array is [showAirports]) that writes the correct value; if hydration found nothing stored, there was nothing that needed rewriting in the first place. This is a small piece of reasoning worth holding onto โ€” this same class of race shows up again, on a plain boolean, when the next chapter persists a different preference.

Wire the hook into Dashboard alongside useLiveFeed(), and add the button to Header:

tsx
// apps/web/src/components/Dashboard.tsx (add)
import { useAirportsVisibility } from "@/hooks/useAirportsVisibility";
// ...
useAirportsVisibility();
tsx
// apps/web/src/components/Header.tsx (add, both the desktop and mobile icon rows)
import { AirportsToggle } from "./AirportsToggle";
// ...
<AirportsToggle />
<ThemeToggle />

The airports overlay toggled on, showing nearby airport markers alongside live traffic

Try It

  1. Run npm run db:seed:airports once against your local database, then confirm with psql $DATABASE_URL -c "SELECT count(*) FROM airports;" that it’s populated.
  2. Restart the server and frontend. Zoom the map out past a continent-scale view and confirm no airport diamonds render; zoom back in toward zoom level 5-7 and confirm large/medium airport diamonds appear, with more (including small ones) once you cross zoom 8.
  3. Zoom into a large airport past zoom 7 and confirm a colored METAR badge appears near it, with a tooltip showing the raw METAR string on hover.
  4. Click the airports toggle in the header. Confirm diamonds and badges disappear immediately. Click it again without panning the map and confirm they reappear right away, without needing a pan/zoom to trigger a fresh fetch.
  5. Reload the page after hiding airports and confirm they load back in hidden โ€” the preference survived the reload.

Recap

  • The airports table was modeled all the way back in Module 1; this chapter is the first to put anything in it (seedAirports.ts) or read anything out of it, along with its shared Airport/AirportType/BoundingBox types.
  • seedAirports.ts is a deliberate, manually-triggered (but idempotent-enough via delete-then-reinsert) one-time script, not part of the automatic startup sequence like Module 2’s ensureDefaultWatchRegion โ€” re-fetching and re-parsing a multi-megabyte CSV on every boot would be wasteful for data that essentially never changes between server restarts.
  • AirportLayer’s three independent zoom thresholds each answer a different question: whether to query at all, which airport sizes to ask for, and whether to layer METAR badges on top.
  • useAirportsVisibility’s skippedFirstPersist guard prevents the hydrate and persist effects โ€” which both fire on the same initial mount โ€” from racing each other and stomping a previously-stored preference with the pre-hydration default.

Next lesson: squawk alerts, the alert feed, and the sound/notification layer.