CodingNic

Watch Regions & History

Directing Watch Regions and the Default View

Watch Regions & History 35 min read

Directing Watch Regions and the Default View

Objectives

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

  • Direct an AI assistant to grow the store from a single flat aircraft list into per-region positions, keyed by the watch region that produced them, plus an operator-configurable default view
  • Prompt a watch-regions control panel, list, add, enable/disable, delete, and set-default, and verify it’s built entirely on top of the repo and routes Module 2 already shipped, not a second copy of them
  • Catch an AI assistant that wires the map’s initial view once at mount and never again, and steer it toward the two-writer pattern that keeps the default correct without a page reload

💡 Why this matters: The backend has supported more than one watch region since Module 2, the schema’s partial unique index, the transactional repo functions, the POST /:id/default route are all already there. An AI assistant working on the frontend now has no memory of writing that backend unless you remind it, and the fastest path to “it works” is often a shortcut around it: a new endpoint, a second place the “one default” rule gets enforced, a local boolean instead of a store slice. This chapter is where you catch that before it ships.

What the AI Is Actually Being Asked to Reuse

Before you write the prompt, know what already exists so you can tell whether the AI is extending it or quietly rebuilding it. positions WebSocket messages have carried a regionId since Module 2’s realtime layer went in, the store just never read it. The store’s own doc comment flagged this as deferred: only one watch region was configured at that point, so the message’s regionId was ignored, and a later module would introduce multiple simultaneously-watched regions with per-region storage. That module is this one.

On the backend side, watch_regions already has its partial unique index on is_default, and Module 2 already shipped a POST /:id/default route whose repo function clears the flag on every other row inside the same transaction it sets the new one. An AI assistant asked to “let the operator pick a default region” with no further context has no way to know that route exists. It’ll model the feature from scratch, which usually means a second place “at most one default” gets enforced, this time in application code with no transaction backing it.

The Prompt

code
I'm building the watch-regions control panel for SKYWATCH. The backend has been done since Module 2: watch_regions has a partial unique index on is_default, and there's already a POST /api/watch-regions/:id/default route whose repo function clears the flag on every other row inside one transaction. Do not add a new endpoint for setting the default, and do not re-implement "clear the others, set this one" in the frontend or in a new route. Use the existing route. Frontend work only, in apps/web: 1. Grow the Zustand store (src/store/useAppStore.ts) from its current flat `aircraft` array into `positionsByRegion: Record<number, AircraftState[]>`, keyed by the regionId the WS `positions` message already carries (it's been ignored until now). Add a `defaultRegionView: { id, center, zoom } | null` field. This field needs two separate writers: MapPanel seeds it once from a load-time fetch of GET /api/watch-regions, and the control panel we're about to build overwrites it immediately, live, the moment the operator picks a new default. Both need to work, not just the initial load one, since I want the map to reflect a new default without a reload. Add a `selectPrimaryAircraft` selector that prefers the default region's data once it's arrived, falling back to whichever region we heard from first. 2. Add a useWatchRegions hook: fetch GET /api/watch-regions once on mount, no polling, no refetch. MapPanel uses this only to compute its initial center/zoom. 3. Build WatchRegionsControl.tsx, a floating panel on the map: - Lists all watch regions with name, radius, an enabled ON/OFF toggle, a star button to set default, and a delete button. - "Add at view": name + radius inputs, creates a region centered on the map's current view via POST /api/watch-regions. - Every write (toggle, set-default, delete) should update local state optimistically, and roll back to the pre-mutation state if the request fails, not just log the error. - Setting a new default needs to update defaultRegionView in the store immediately and pan the map there, not wait for a reload. - Deleting the current default region promotes another one server-side, refetch the list afterward rather than guessing which one. 4. Wire MapPanel to use useWatchRegions for its initial view (falling back to the existing DEFAULT_VIEW constant if no regions exist yet), and seed defaultRegionView once that fetch resolves. Show me the full store, the hook, WatchRegionsControl.tsx, and the MapPanel changes.

What It Built

ts
// apps/web/src/store/useAppStore.ts
import { create } from "zustand";
import type { AircraftState } from "@skywatch/shared";

export type ConnectionStatus = "connecting" | "open" | "closed";

interface AppState {
  connectionStatus: ConnectionStatus;
  /** Live aircraft, keyed by the watch region that produced them (a region can be rendered by more than one view, e.g. a later multi-region compare). */
  positionsByRegion: Record<number, AircraftState[]>;
  /** The first region we've heard from, fallback for selectPrimaryAircraft until defaultRegionView is known or its data has arrived. */
  primaryRegionId: number | null;
  /**
   * The operator-configured default watch region (WatchRegion.isDefault),
   * id plus the map view it corresponds to. Two writers keep this current:
   * MapPanel syncs it from /api/watch-regions on load, and
   * WatchRegionsControl overwrites it immediately when the user picks a new
   * default via the star button, so both selectPrimaryAircraft (which
   * region's feed is "primary") and the "return to watch region" control
   * stay correct without a page reload.
   */
  defaultRegionView: { id: number; center: [number, number]; zoom: number } | null;
  lastUpdate: Date | null;

  selectedIcao: string | null;
  filterText: string;

  setConnectionStatus: (status: ConnectionStatus) => void;
  setPositions: (regionId: number, aircraft: AircraftState[], ts: number) => void;
  setDefaultRegionView: (view: { id: number; center: [number, number]; zoom: number } | null) => void;
  setSelectedIcao: (icao: string | null) => void;
  setFilterText: (text: string) => void;
}

export const useAppStore = create<AppState>((set) => ({
  connectionStatus: "connecting",
  positionsByRegion: {},
  primaryRegionId: null,
  defaultRegionView: null,
  lastUpdate: null,

  selectedIcao: null,
  filterText: "",

  setConnectionStatus: (status) => set({ connectionStatus: status }),

  setPositions: (regionId, aircraft, ts) =>
    set((state) => ({
      positionsByRegion: { ...state.positionsByRegion, [regionId]: aircraft },
      primaryRegionId: state.primaryRegionId ?? regionId,
      lastUpdate: new Date(ts),
    })),

  setDefaultRegionView: (view) => set({ defaultRegionView: view }),

  setSelectedIcao: (icao) => set({ selectedIcao: icao }),
  setFilterText: (text) => set({ filterText: text }),
}));

// A fresh `[]` literal on every selector call would change reference
// identity each render even when nothing changed, which, combined with
// Zustand's useSyncExternalStore-based subscription, causes React to
// re-render in a loop ("Maximum update depth exceeded"). Reusing one
// constant keeps the "no data yet" case referentially stable.
const EMPTY_AIRCRAFT: AircraftState[] = [];

/**
 * The primary region's live aircraft list, or empty until data has arrived.
 * Prefers the operator-configured default region the moment its WS feed has
 * produced at least one message; before that, or if no default is
 * configured yet, falls back to whichever enabled region we heard from
 * first, so the map never shows a blank feed just because the default's
 * data hasn't arrived.
 */
export function selectPrimaryAircraft(state: AppState): AircraftState[] {
  const defaultId = state.defaultRegionView?.id;
  if (defaultId != null && state.positionsByRegion[defaultId]) {
    return state.positionsByRegion[defaultId];
  }
  if (state.primaryRegionId == null) return EMPTY_AIRCRAFT;
  return state.positionsByRegion[state.primaryRegionId] ?? EMPTY_AIRCRAFT;
}
ts
// apps/web/src/hooks/useWatchRegions.ts
"use client";

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

export function useWatchRegions() {
  const [regions, setRegions] = useState<WatchRegion[]>([]);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    let cancelled = false;
    fetch(apiUrl("/api/watch-regions"))
      .then((r) => r.json())
      .then((data: { regions: WatchRegion[] }) => {
        if (!cancelled) setRegions(data.regions ?? []);
      })
      .catch((err) => console.error("[watch-regions] fetch failed:", err))
      .finally(() => {
        if (!cancelled) setLoaded(true);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  return { regions, loaded };
}
ts
// apps/web/src/lib/mapView.ts
import type L from "leaflet";

/**
 * Leaflet fires `moveend` for both user interaction (drag/scroll-zoom) and
 * our own programmatic `setView`/`panTo` calls. The free-pan feed built
 * later in this module only wants the former to trigger, so every call site
 * that moves the map programmatically tags the map instance right before
 * moving it, and that feed consumes (and clears) the tag on the next
 * `moveend` instead of treating it as a real user pan.
 */
export function markProgrammaticMove(map: L.Map): void {
  (map as unknown as { __skywatchProgrammatic?: boolean }).__skywatchProgrammatic = true;
}

/** Rough radius(nm) -> Leaflet zoom heuristic, tuned by eye, for framing a watch region's initial view. */
export function radiusNmToZoom(radiusNm: number): number {
  if (radiusNm > 200) return 5;
  if (radiusNm > 120) return 6;
  if (radiusNm > 60) return 7;
  if (radiusNm > 30) return 8;
  return 9;
}

/** Fallback view when no watch region is configured yet, matches the original's default. */
export const DEFAULT_VIEW: { center: [number, number]; zoom: number } = {
  center: [40.0, -30.0],
  zoom: 3,
};
tsx
// apps/web/src/components/WatchRegionsControl.tsx
"use client";

import { useEffect, useState } from "react";
import { useMap } from "react-leaflet";
import type { WatchRegion } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";
import { useAppStore } from "@/store/useAppStore";
import { markProgrammaticMove, radiusNmToZoom } from "@/lib/mapView";

const DEFAULT_RADIUS_NM = 200;

/**
 * Manage the region(s) the *backend poller* continuously watches, distinct
 * from a bookmarks-style control that would only save map views for the
 * frontend. Every enabled region here gets polled every ~15s, so trails and
 * history both become available inside it.
 *
 * The list here is deliberately independent of MapPanel's own
 * useWatchRegions() call (which only picks an initial view once on load),
 * changes made here take effect on the poller's very next cycle regardless,
 * since it re-reads enabled regions from the DB fresh every tick.
 */
export function WatchRegionsControl() {
  const map = useMap();
  const setDefaultRegionView = useAppStore((s) => s.setDefaultRegionView);
  const [open, setOpen] = useState(false);
  const [regions, setRegions] = useState<WatchRegion[]>([]);
  const [loaded, setLoaded] = useState(false);
  const [newName, setNewName] = useState("");
  const [newRadius, setNewRadius] = useState(String(DEFAULT_RADIUS_NM));
  const [saving, setSaving] = useState(false);
  const [settingDefaultId, setSettingDefaultId] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);

  const refresh = () => {
    fetch(apiUrl("/api/watch-regions"))
      .then((r) => r.json())
      .then((data: { regions: WatchRegion[] }) => setRegions(data.regions ?? []))
      .catch((err) => console.error("[watch-regions] fetch failed:", err))
      .finally(() => setLoaded(true));
  };

  useEffect(() => {
    if (open && !loaded) refresh();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  const jumpTo = (region: WatchRegion) => {
    markProgrammaticMove(map);
    map.setView([region.lat, region.lon], map.getZoom());
    setOpen(false);
  };

  const addAtCurrentView = async () => {
    const name = newName.trim();
    const radiusNm = Number(newRadius);
    if (!name) return;
    if (!Number.isFinite(radiusNm) || radiusNm < 1 || radiusNm > 250) {
      setError("Radius must be between 1 and 250nm.");
      return;
    }
    setError(null);
    setSaving(true);
    try {
      const center = map.getCenter();
      const res = await fetch(apiUrl("/api/watch-regions"), {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name, lat: center.lat, lon: center.lng, radiusNm }),
      });
      if (!res.ok) throw new Error(`create failed (${res.status})`);
      const data: { region: WatchRegion } = await res.json();
      setRegions((prev) => [...prev, data.region]);
      setNewName("");
      setNewRadius(String(DEFAULT_RADIUS_NM));
    } catch (err) {
      console.error("[watch-regions] create failed:", err);
      setError("Couldn't create that region, try again.");
    } finally {
      setSaving(false);
    }
  };

  const toggleEnabled = async (region: WatchRegion, e: React.MouseEvent) => {
    e.stopPropagation();
    const nextEnabled = !region.enabled;
    setRegions((prev) => prev.map((r) => (r.id === region.id ? { ...r, enabled: nextEnabled } : r)));
    try {
      await fetch(apiUrl(`/api/watch-regions/${region.id}`), {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ enabled: nextEnabled }),
      });
    } catch (err) {
      console.error("[watch-regions] toggle failed:", err);
      refresh();
    }
  };

  /**
   * Sets `region` as THE default: the backend force-enables it and clears
   * the flag on every other row (see setWatchRegionDefault from Module 2),
   * so we mirror both of those locally rather than just flipping one
   * boolean. Also updates the store immediately and pans the map there now,
   * so setting a default feels like an immediate action instead of
   * something that only takes effect on next reload.
   */
  const setDefault = async (region: WatchRegion, e: React.MouseEvent) => {
    e.stopPropagation();
    if (region.isDefault) return;
    setSettingDefaultId(region.id);
    const prevRegions = regions;
    setRegions((prev) => prev.map((r) => ({ ...r, isDefault: r.id === region.id, enabled: r.id === region.id ? true : r.enabled })));
    try {
      const res = await fetch(apiUrl(`/api/watch-regions/${region.id}/default`), { method: "POST" });
      if (!res.ok) throw new Error(`set default failed (${res.status})`);
      const zoom = radiusNmToZoom(region.radiusNm);
      setDefaultRegionView({ id: region.id, center: [region.lat, region.lon], zoom });
      markProgrammaticMove(map);
      map.setView([region.lat, region.lon], zoom);
    } catch (err) {
      console.error("[watch-regions] set default failed:", err);
      setRegions(prevRegions);
      setError("Couldn't set that as the default, try again.");
    } finally {
      setSettingDefaultId(null);
    }
  };

  const remove = async (id: number, e: React.MouseEvent) => {
    e.stopPropagation();
    const wasDefault = regions.find((r) => r.id === id)?.isDefault ?? false;
    setRegions((prev) => prev.filter((r) => r.id !== id));
    try {
      await fetch(apiUrl(`/api/watch-regions/${id}`), { method: "DELETE" });
      // Deleting the default region promotes another one server-side (see
      // deleteWatchRegion from Module 2), refetch to find out which,
      // rather than guessing at the promotion order locally.
      if (wasDefault) {
        const res = await fetch(apiUrl("/api/watch-regions"));
        const data: { regions: WatchRegion[] } = await res.json();
        setRegions(data.regions ?? []);
        const newDefault = data.regions?.find((r) => r.isDefault) ?? null;
        setDefaultRegionView(
          newDefault
            ? { id: newDefault.id, center: [newDefault.lat, newDefault.lon], zoom: radiusNmToZoom(newDefault.radiusNm) }
            : null
        );
      }
    } catch (err) {
      console.error("[watch-regions] delete failed:", err);
      refresh();
    }
  };

  return (
    <div className="absolute left-3.5 top-24 z-[500]">
      {open ? (
        <div className="w-[250px] rounded-sm border border-line bg-surface/95 p-3 text-[11px] text-fg">
          <div className="mb-2 flex items-center justify-between">
            <span className="text-[10px] uppercase tracking-[0.08em] text-fg-dim">Watch regions</span>
            <button type="button" onClick={() => setOpen(false)} className="text-fg-dim hover:text-fg">
              ✕
            </button>
          </div>

          <p className="mb-1.5 text-[9px] text-fg-dim">
            ★ sets the default, where the map opens to and which region's traffic it shows.
          </p>

          <div className="mb-2 max-h-[200px] overflow-y-auto">
            {regions.length === 0 && <div className="py-1.5 text-fg-dim">No watch regions yet.</div>}
            {regions.map((region) => (
              <div
                key={region.id}
                role="button"
                tabIndex={0}
                onClick={() => jumpTo(region)}
                onKeyDown={(e) => e.key === "Enter" && jumpTo(region)}
                className="flex cursor-pointer items-center justify-between gap-1.5 rounded-sm px-1 py-1.5 hover:bg-phosphor/10"
              >
                <span className="flex min-w-0 flex-col">
                  <span className="flex items-center gap-1 truncate text-fg">
                    {region.name}
                    {region.isDefault && (
                      <span className="shrink-0 rounded-sm border border-phosphor-dim px-1 py-px text-[8px] tracking-[0.05em] text-phosphor">
                        DEFAULT
                      </span>
                    )}
                  </span>
                  <span className="text-[9px] text-fg-dim">{region.radiusNm}nm radius</span>
                </span>
                <span className="flex shrink-0 items-center gap-1.5">
                  <button
                    type="button"
                    onClick={(e) => setDefault(region, e)}
                    disabled={region.isDefault || settingDefaultId === region.id}
                    aria-label={region.isDefault ? `${region.name} is the default` : `Set ${region.name} as default`}
                    title={region.isDefault ? "Default watch region" : "Set as default, MapPanel opens here"}
                    className={`text-[11px] leading-none ${
                      region.isDefault
                        ? "cursor-default text-phosphor"
                        : "text-fg-dim hover:text-phosphor disabled:opacity-50"
                    }`}
                  >
                    {region.isDefault ? "★" : "☆"}
                  </button>
                  <button
                    type="button"
                    onClick={(e) => toggleEnabled(region, e)}
                    aria-label={region.enabled ? `Disable ${region.name}` : `Enable ${region.name}`}
                    className={`rounded-sm border px-1.5 py-0.5 text-[9px] tracking-[0.05em] ${
                      region.enabled
                        ? "border-phosphor-dim text-phosphor"
                        : "border-line text-fg-dim hover:border-phosphor-dim hover:text-phosphor"
                    }`}
                  >
                    {region.enabled ? "ON" : "OFF"}
                  </button>
                  <button
                    type="button"
                    onClick={(e) => remove(region.id, e)}
                    className="text-fg-dim hover:text-danger"
                    aria-label={`Delete ${region.name}`}
                  >
                    ✕
                  </button>
                </span>
              </div>
            ))}
          </div>

          <div className="border-t border-line pt-2">
            <input
              value={newName}
              onChange={(e) => setNewName(e.target.value)}
              placeholder="Name this region…"
              className="mb-1.5 w-full rounded-sm border border-line bg-surface-raised px-1.5 py-1 font-mono text-[11px] text-fg placeholder:text-fg-dim focus:outline-none"
            />
            <div className="flex gap-1.5">
              <input
                value={newRadius}
                onChange={(e) => setNewRadius(e.target.value)}
                onKeyDown={(e) => e.key === "Enter" && addAtCurrentView()}
                inputMode="numeric"
                placeholder="Radius (nm)"
                className="min-w-0 flex-1 rounded-sm border border-line bg-surface-raised px-1.5 py-1 font-mono text-[11px] text-fg placeholder:text-fg-dim focus:outline-none"
              />
              <button
                type="button"
                onClick={addAtCurrentView}
                disabled={saving || !newName.trim()}
                className="shrink-0 rounded-sm border border-phosphor-dim px-2 py-1 text-[10px] tracking-[0.05em] text-phosphor hover:bg-phosphor/10 disabled:opacity-50"
              >
                ADD AT VIEW
              </button>
            </div>
            <p className="mt-1 text-[9px] text-fg-dim">
              Adds a region centered on the current map view. The poller starts watching it within ~15s.
            </p>
            {error && <p className="mt-1 text-[9px] text-danger">{error}</p>}
          </div>
        </div>
      ) : (
        <button
          type="button"
          onClick={() => setOpen(true)}
          className="rounded-sm border border-line bg-surface/90 px-3 py-2 text-[11px] tracking-[0.08em] text-phosphor transition-colors hover:border-phosphor-dim"
        >
          ◎ WATCH REGIONS
        </button>
      )}
    </div>
  );
}
tsx
// apps/web/src/components/MapPanel.tsx (additions to the Module 3 version)
import { useWatchRegions } from "@/hooks/useWatchRegions";
import { DEFAULT_VIEW, markProgrammaticMove, radiusNmToZoom } from "@/lib/mapView";
import { WatchRegionsControl } from "./WatchRegionsControl";
import { selectPrimaryAircraft } from "@/store/useAppStore";

/** Applies the watch region's center/zoom once it's loaded (async, arrives after initial mount). */
function InitialViewSetter({ center, zoom }: { center: [number, number]; zoom: number }) {
  const map = useMap();
  const appliedKey = useRef<string | null>(null);

  useEffect(() => {
    const key = `${center[0]},${center[1]},${zoom}`;
    if (appliedKey.current === key) return;
    appliedKey.current = key;
    markProgrammaticMove(map);
    map.setView(center, zoom);
  }, [center, zoom, map]);

  return null;
}

function AircraftMarkers() {
  const aircraft = useAppStore(selectPrimaryAircraft);
  const selectedIcao = useAppStore((s) => s.selectedIcao);
  const setSelectedIcao = useAppStore((s) => s.setSelectedIcao);

  return (
    <>
      {aircraft
        .filter((a): a is typeof a & { lat: number; lon: number } => a.lat != null && a.lon != null)
        .map((a) => (
          <Marker
            key={a.hex}
            position={[a.lat, a.lon]}
            icon={makePlaneIcon(a.track ?? a.true_heading ?? a.mag_heading ?? 0)}
            eventHandlers={{ click: () => setSelectedIcao(a.hex) }}
          />
        ))}
    </>
  );
}

export function MapPanel() {
  const isDark = useThemeStore((s) => s.isDark);
  const connectionStatus = useAppStore((s) => s.connectionStatus);
  const setDefaultRegionView = useAppStore((s) => s.setDefaultRegionView);
  const { regions, loaded } = useWatchRegions();

  // Seeds the store's defaultRegionView from this component's own load-time
  // fetch, so selectPrimaryAircraft can follow the default region
  // deterministically instead of racing whichever region's WS message
  // happens to land first. This only runs once useWatchRegions() resolves,
  // it does NOT react to later default changes, since useWatchRegions()
  // itself never refetches after mount. WatchRegionsControl is the other,
  // later writer: it overwrites defaultRegionView directly the moment the
  // user picks a new default, which is what keeps things live after this.
  useEffect(() => {
    if (!loaded) return;
    const primary = regions.find((r) => r.isDefault);
    setDefaultRegionView(
      primary ? { id: primary.id, center: [primary.lat, primary.lon], zoom: radiusNmToZoom(primary.radiusNm) } : null
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded]);

  const initialView = useMemo(() => {
    const primary = regions.find((r) => r.isDefault) ?? regions.find((r) => r.enabled) ?? regions[0];
    if (!primary) return DEFAULT_VIEW;
    return { center: [primary.lat, primary.lon] as [number, number], zoom: radiusNmToZoom(primary.radiusNm) };
  }, [regions]);

  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 unchanged from Module 3 */}
        <AircraftMarkers />
        <WatchRegionsControl />
        {loaded && <InitialViewSetter center={initialView.center} zoom={initialView.zoom} />}
      </MapContainer>
      {connectionStatus !== "open" && (/* unchanged connection banner */ <></>)}
    </div>
  );
}

Two separate effects intentionally do two separate things: initialView (a useMemo) decides where the MapContainer should point once useWatchRegions() resolves, applied via InitialViewSetter since Leaflet won’t re-read center/zoom props after mount; the defaultRegionView effect seeds the store, which is what selectPrimaryAircraft and the “return to region” control (next chapter) read from afterward.

Review This

Did it reuse POST /:id/default, or quietly build a second “set default” path? This is the failure the prompt warns against directly, and it’s still worth checking, because an AI assistant mid-conversation can lose track of a constraint it agreed to three files ago. Look at setDefault in WatchRegionsControl.tsx: it should call fetch(apiUrl(\/api/watch-regions/${region.id}/default`), { method: “POST” }), nothing more. If instead you see a PATCHto/api/watch-regions/:idwith{ isDefault: true }`, or worse, two separate requests (one to unset the old default, one to set the new one), that’s the AI reimplementing the transactional “clear the others, set this one” logic Module 2 already wrote, and now there are two places that rule can drift out of sync, one backed by a real transaction and one that is not. The follow-up prompt: “setDefault should call the existing POST /api/watch-regions/:id/default route and nothing else, that route already clears the flag on every other row in one transaction. Remove any PATCH-based or two-request version.”

Does the map actually pick up a new default live, or only after a reload? The whole reason defaultRegionView has two writers is so a default change is reflected immediately. An AI assistant that doesn’t fully absorb that requirement will often build the simpler version: MapPanel’s effect seeds defaultRegionView once when useWatchRegions() resolves, and WatchRegionsControl’s setDefault only updates its own local regions state, never calling setDefaultRegionView itself. That version compiles, the star still moves in the panel, and it even looks correct if you don’t reload the page, because positionsByRegion for the old default is probably still populated. The bug only shows up on the very check the original Try It calls out: reload and the map opens on the old default again, because the store’s value was never actually updated live. Check that setDefault calls setDefaultRegionView(...) directly, not just local setRegions. Follow-up: “setDefault needs to call the store’s setDefaultRegionView immediately, not just update local component state, so the new default persists across a reload and is visible right away.”

Does a failed set default request actually roll back the UI, or does the star just stay wrong? setDefault optimistically flips isDefault on the clicked region before the request resolves, which is fine, that’s the pattern the rest of the panel uses too. But it depends on the catch block calling setRegions(prevRegions) to undo that optimistic update if the POST fails. An AI assistant can drop that one line without anything else looking broken, since the success path is what gets exercised in every quick manual test. The failure only surfaces when the backend rejects a request (network blip, a stale region ID after a concurrent delete), and the panel is left showing a star on a region the server never actually made default, while defaultRegionView in the store correctly never changed, an inconsistent state that’s confusing precisely because half of it is right. Check that the catch block in setDefault restores prevRegions. Follow-up: “the catch block in setDefault needs to call setRegions(prevRegions) to roll back the optimistic UI update when the POST fails, right now a failed request leaves the star on the wrong region.”

Try It

  1. Have the AI run the prompt against your Module 2/3 codebase, then read setDefault in WatchRegionsControl.tsx before running anything, confirm it’s a single call to the existing /default route.
  2. Restart the frontend and confirm it loads into DEFAULT_VIEW briefly, then jumps to your first watch region (the seeded default from Module 2’s poller startup) once useWatchRegions() resolves.
  3. Click ◎ WATCH REGIONS, add a second region somewhere else on the map (pan first, then ADD AT VIEW), and confirm it appears in the list, unstarred.
  4. Click its ☆ to make it the default. Confirm the star moves, the DEFAULT badge moves with it, and the map immediately pans there, no reload required. This is the check that catches the second Review This issue if it’s still present.
  5. Reload the page from scratch and confirm it now opens on the new default, not the original one, proof the change is persisted server-side and read correctly on load, not just held in local state.
  6. Kill your backend temporarily, click a different region’s star, and confirm the panel rolls back to showing the previous default instead of leaving the star stuck on the region you just clicked. Restart the backend afterward.
  7. Delete the current default region and confirm another one is promoted (check the DEFAULT badge lands on the oldest remaining region) and the map’s behavior on next reload matches.

Recap

  • The watch-regions backend was already done as of Module 2, this chapter was entirely a frontend problem, and the biggest risk was an AI assistant re-solving a backend problem it had already solved once.
  • positionsByRegion replaces the flat aircraft array from Module 3, finally consuming the regionId the WS message has carried since it was introduced.
  • defaultRegionView needs two writers by design, MapPanel’s load-time fetch seeds it, WatchRegionsControl overwrites it live, and an AI assistant that only builds the first one produces something that looks done until you reload the page.
  • Optimistic UI updates are only correct if the rollback path is real. A missing setRegions(prevRegions) in a catch block doesn’t throw, doesn’t fail a build, it just leaves the screen lying to you the next time a request fails.

Next lesson: what happens when the operator pans away from the watch region, free-pan fallback, the way back, and fading aircraft trails.