Directing Multi-Region Compare & Saved Locations
Objectives
By the end of this chapter, you should be able to:
- Direct a side-by-side compare view that reuses
positionsByRegioninstead of standing up any new backend, and verify the AI actually noticed that data was already there - Prompt a region-scoped map pane that reuses
MapPanel’s pieces instead of duplicating them, and catch the specific stale-map bug that shows up when it doesn’t - Direct a saved-locations bookmark feature and verify, concretely, that saving a bookmark never touches what the poller watches
💡 Why this matters: This is the lesson where “does the AI actually understand the architecture, or is it just generating plausible code” gets tested hardest. Two watch regions on screen at once and a list of saved map points look, from a UI screenshot, almost identical. They are not the same feature, they don’t share a backend, and an AI assistant that doesn’t already understand the difference between “what the poller tracks” and “where the user likes to look” will happily blur the line between them if you let it.
Say the Distinction Before You Prompt Either Feature
The poller has re-polled every enabled watch region independently since Module 2. The store has kept positionsByRegion, one entry per region, since Module 4. Put those two facts together and a compare view needs zero new backend – it’s a second consumer of data that’s already flowing. That’s worth stating explicitly in the prompt, because an AI assistant that doesn’t already know positionsByRegion exists might reasonably invent a new endpoint or a new poll cycle to solve a problem the codebase already solved.
Saved locations is the opposite kind of feature, and the prompt needs to say that just as plainly: a watch region is something the poller continuously tracks. A saved location is a bookmark for the frontend map view only – a name, a lat/lon, a zoom level – with zero effect on what’s being polled. The two features end up looking almost identical in a screenshot (a floating panel, a list, a name field, a jump-to action) which is exactly the trap. State the distinction up front, or you’ll be reviewing code that quietly wires “save this bookmark” into the same table the poller reads from.
The Prompt
What It Built
CompareView, defaulting to two enabled regions where possible:
// apps/web/src/components/CompareView.tsx
"use client";
import { useEffect, useState } from "react";
import type { AircraftState } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { useWatchRegions } from "@/hooks/useWatchRegions";
import { RegionMapPane } from "./RegionMapPane";
const EMPTY_AIRCRAFT: AircraftState[] = [];
function RegionSelect({
value,
onChange,
options,
}: {
value: number | null;
onChange: (id: number) => void;
options: { id: number; name: string; enabled: boolean }[];
}) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(Number(e.target.value))}
className="min-w-0 flex-1 truncate rounded-sm border border-line bg-surface-raised px-2 py-1.5 font-mono text-[11px] text-fg focus:outline-none"
>
{options.length === 0 && <option value="">No watch regions yet</option>}
{options.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
{r.enabled ? "" : " (disabled)"}
</option>
))}
</select>
);
}
export function CompareView() {
const { regions, loaded } = useWatchRegions();
const [leftId, setLeftId] = useState<number | null>(null);
const [rightId, setRightId] = useState<number | null>(null);
useEffect(() => {
if (!loaded || regions.length === 0 || leftId != null) return;
const enabled = regions.filter((r) => r.enabled);
const pool = enabled.length > 0 ? enabled : regions;
setLeftId(pool[0].id);
setRightId(pool.length > 1 ? pool[1].id : pool[0].id);
}, [loaded, regions, leftId]);
const leftAircraft = useAppStore((s) => (leftId != null ? (s.positionsByRegion[leftId] ?? EMPTY_AIRCRAFT) : EMPTY_AIRCRAFT));
const rightAircraft = useAppStore((s) =>
rightId != null ? (s.positionsByRegion[rightId] ?? EMPTY_AIRCRAFT) : EMPTY_AIRCRAFT
);
const leftRegion = regions.find((r) => r.id === leftId) ?? null;
const rightRegion = regions.find((r) => r.id === rightId) ?? null;
if (loaded && regions.length === 0) {
return (
<div className="flex h-full items-center justify-center px-8 text-center text-[12px] tracking-[0.05em] text-fg-dim">
No watch regions configured yet -- add at least two from Watch Regions on the map to compare them side by
side.
</div>
);
}
return (
<div className="flex h-full min-w-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col border-r border-line">
<div className="flex items-center gap-2 border-b border-line bg-surface px-2.5 py-2">
<RegionSelect value={leftId} onChange={setLeftId} options={regions} />
<span className="shrink-0 font-mono text-[10px] text-fg-dim">{leftAircraft.length} ac</span>
</div>
<div className="relative min-h-0 flex-1">
{leftRegion && <RegionMapPane region={leftRegion} aircraft={leftAircraft} />}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-line bg-surface px-2.5 py-2">
<RegionSelect value={rightId} onChange={setRightId} options={regions} />
<span className="shrink-0 font-mono text-[10px] text-fg-dim">{rightAircraft.length} ac</span>
</div>
<div className="relative min-h-0 flex-1">
{rightRegion && <RegionMapPane region={rightRegion} aircraft={rightAircraft} />}
</div>
</div>
</div>
);
}
RegionMapPane, with the key={region.id} remount fix in place:
// apps/web/src/components/RegionMapPane.tsx
"use client";
import { memo, useMemo } from "react";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import type { AircraftState, WatchRegion } from "@skywatch/shared";
import { altitudeFt, isOnGround } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { useThemeStore } from "@/store/useThemeStore";
import { makePlaneIcon } from "@/lib/aircraftIcon";
import { radiusNmToZoom } 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 = "© OpenStreetMap © CARTO";
interface PaneMarkerProps {
aircraft: AircraftState & { lat: number; lon: number };
isSelected: boolean;
onSelect: (hex: string) => void;
}
const PaneMarker = memo(function PaneMarker({ aircraft, isSelected, onSelect }: PaneMarkerProps) {
const track = aircraft.track ?? aircraft.true_heading ?? aircraft.mag_heading ?? 0;
const altFt = altitudeFt(aircraft);
const onGround = isOnGround(aircraft);
const icon = useMemo(
() => makePlaneIcon(track, isSelected, altFt, onGround),
[track, isSelected, altFt, onGround]
);
return (
<Marker
position={[aircraft.lat, aircraft.lon]}
icon={icon}
zIndexOffset={isSelected ? 1000 : 0}
eventHandlers={{ click: () => onSelect(aircraft.hex) }}
/>
);
});
export const RegionMapPane = memo(function RegionMapPane({
region,
aircraft,
}: {
region: WatchRegion;
aircraft: AircraftState[];
}) {
const isDark = useThemeStore((s) => s.isDark);
const selectedIcao = useAppStore((s) => s.selectedIcao);
const setSelectedIcao = useAppStore((s) => s.setSelectedIcao);
const center: [number, number] = [region.lat, region.lon];
const zoom = radiusNmToZoom(region.radiusNm);
const withPosition = aircraft.filter(
(a): a is typeof a & { lat: number; lon: number } => a.lat != null && a.lon != null
);
return (
<div className="relative h-full w-full">
<MapContainer
key={region.id}
center={center}
zoom={zoom}
minZoom={2}
maxZoom={12}
worldCopyJump
zoomControl
attributionControl={false}
className="h-full w-full"
>
<TileLayer
key={isDark ? "dark" : "light"}
url={isDark ? DARK_TILES : LIGHT_TILES}
attribution={TILE_ATTRIBUTION}
subdomains="abcd"
maxZoom={19}
/>
{withPosition.map((a) => (
<PaneMarker key={a.hex} aircraft={a} isSelected={a.hex === selectedIcao} onSelect={setSelectedIcao} />
))}
</MapContainer>
{!region.enabled && (
<div className="absolute inset-x-3 top-3 z-[500] rounded-sm border border-line bg-surface/95 px-2.5 py-1.5 text-center text-[10px] tracking-[0.05em] text-fg-dim">
This region is disabled -- enable it in Watch Regions to see live traffic here.
</div>
)}
</div>
);
});
Wiring compare mode into the header and dashboard:
// apps/web/src/components/Header.tsx (additions)
export interface HeaderProps {
// ...existing props...
compareMode: boolean;
onToggleCompareMode: () => void;
}
// in the icon row:
<button
type="button"
onClick={onToggleCompareMode}
aria-pressed={compareMode}
title={compareMode ? "Exit compare mode" : "Compare two watch regions side by side"}
className={`flex h-7 w-7 items-center justify-center rounded-sm border transition-colors ${
compareMode
? "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">
<rect x="3" y="4" width="18" height="16" rx="1.5" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
</button>
// apps/web/src/components/Dashboard.tsx (additions)
import dynamic from "next/dynamic";
const CompareView = dynamic(() => import("./CompareView").then((m) => m.CompareView), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center text-xs tracking-[0.1em] text-fg-dim">
LOADING COMPARE VIEW…
</div>
),
});
// inside Dashboard():
const [compareMode, setCompareMode] = useState(false);
// ...
<Header
// ...existing props...
compareMode={compareMode}
onToggleCompareMode={() => setCompareMode((v) => !v)}
/>
{compareMode ? (
<div className="flex min-h-0 flex-1">
<CompareView />
</div>
) : (
<div className="flex min-h-0 flex-1">
{/* MapPanel + detail panel + flight list, unchanged */}
</div>
)}
The saved-locations type, tacked onto the same file HomeLocationSettings already lives in:
// packages/shared/src/location.ts (add to the Module 2 version)
/** A user-saved map location for the "jump to a favorite spot" dropdown. */
export interface SavedLocation {
id: number;
name: string;
lat: number;
lon: number;
zoom: number;
createdAt: string;
}
The saved-locations repo, deliberately with no invariants to protect:
// apps/server/src/db/repos/savedLocationsRepo.ts
import { eq } from "drizzle-orm";
import type { SavedLocation } from "@skywatch/shared";
import { db } from "../client.js";
import { savedLocations } from "../schema.js";
function toApi(row: typeof savedLocations.$inferSelect): SavedLocation {
return {
id: row.id,
name: row.name,
lat: row.lat,
lon: row.lon,
zoom: row.zoom,
createdAt: row.createdAt.toISOString(),
};
}
export async function listSavedLocations(): Promise<SavedLocation[]> {
const rows = await db.select().from(savedLocations).orderBy(savedLocations.createdAt);
return rows.map(toApi);
}
export async function createSavedLocation(input: {
name: string;
lat: number;
lon: number;
zoom: number;
}): Promise<SavedLocation> {
const [row] = await db.insert(savedLocations).values(input).returning();
return toApi(row);
}
export async function deleteSavedLocation(id: number): Promise<boolean> {
const deleted = await db.delete(savedLocations).where(eq(savedLocations.id, id)).returning();
return deleted.length > 0;
}
// apps/server/src/routes/savedLocations.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import {
createSavedLocation,
deleteSavedLocation,
listSavedLocations,
} from "../db/repos/savedLocationsRepo.js";
const postBodySchema = z.object({
name: z.string().trim().min(1).max(80),
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
zoom: z.number().min(1).max(19).default(9),
});
const deleteParamsSchema = z.object({ id: z.coerce.number().int().positive() });
export function registerSavedLocationsRoutes(app: FastifyInstance): void {
app.get("/saved-locations", async () => {
const locations = await listSavedLocations();
return { locations };
});
app.post("/saved-locations", async (req, reply) => {
const parsed = postBodySchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: "invalid saved location", issues: parsed.error.issues });
}
const location = await createSavedLocation(parsed.data);
return reply.status(201).send({ location });
});
app.delete("/saved-locations/:id", async (req, reply) => {
const parsed = deleteParamsSchema.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ error: "invalid id" });
const deleted = await deleteSavedLocation(parsed.data.id);
if (!deleted) return reply.status(404).send({ error: "not found" });
return reply.status(204).send();
});
}
SavedLocationsControl, whose jumpTo deliberately does not mark the move as programmatic:
// apps/web/src/components/SavedLocationsControl.tsx
"use client";
import { useEffect, useState } from "react";
import { useMap } from "react-leaflet";
import type { SavedLocation } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";
export function SavedLocationsControl() {
const map = useMap();
const [open, setOpen] = useState(false);
const [locations, setLocations] = useState<SavedLocation[]>([]);
const [loaded, setLoaded] = useState(false);
const [newName, setNewName] = useState("");
const [saving, setSaving] = useState(false);
const refresh = () => {
fetch(apiUrl("/api/saved-locations"))
.then((r) => r.json())
.then((data: { locations: SavedLocation[] }) => setLocations(data.locations ?? []))
.catch((err) => console.error("[saved-locations] fetch failed:", err))
.finally(() => setLoaded(true));
};
useEffect(() => {
if (open && !loaded) refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const jumpTo = (loc: SavedLocation) => {
map.setView([loc.lat, loc.lon], loc.zoom);
setOpen(false);
};
const saveCurrentView = async () => {
const name = newName.trim();
if (!name) return;
setSaving(true);
try {
const center = map.getCenter();
const res = await fetch(apiUrl("/api/saved-locations"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, lat: center.lat, lon: center.lng, zoom: map.getZoom() }),
});
if (!res.ok) throw new Error(`save failed (${res.status})`);
const data: { location: SavedLocation } = await res.json();
setLocations((prev) => [...prev, data.location]);
setNewName("");
} catch (err) {
console.error("[saved-locations] save failed:", err);
} finally {
setSaving(false);
}
};
const remove = async (id: number, e: React.MouseEvent) => {
e.stopPropagation();
setLocations((prev) => prev.filter((l) => l.id !== id));
try {
await fetch(apiUrl(`/api/saved-locations/${id}`), { method: "DELETE" });
} catch (err) {
console.error("[saved-locations] delete failed:", err);
refresh();
}
};
return (
<div className="absolute bottom-3.5 right-3.5 z-[500]">
{open ? (
<div className="w-[230px] 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">Saved locations</span>
<button type="button" onClick={() => setOpen(false)} className="text-fg-dim hover:text-fg">
✕
</button>
</div>
<div className="mb-2 max-h-[180px] overflow-y-auto">
{locations.length === 0 && (
<div className="py-1.5 text-fg-dim">No saved locations yet.</div>
)}
{locations.map((loc) => (
<div
key={loc.id}
role="button"
tabIndex={0}
onClick={() => jumpTo(loc)}
onKeyDown={(e) => e.key === "Enter" && jumpTo(loc)}
className="flex cursor-pointer items-center justify-between rounded-sm px-1 py-1.5 hover:bg-phosphor/10"
>
<span className="truncate text-fg">{loc.name}</span>
<button
type="button"
onClick={(e) => remove(loc.id, e)}
className="ml-2 shrink-0 text-fg-dim hover:text-danger"
aria-label={`Delete ${loc.name}`}
>
✕
</button>
</div>
))}
</div>
<div className="flex gap-1.5 border-t border-line pt-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveCurrentView()}
placeholder="Name this view…"
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={saveCurrentView}
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"
>
SAVE
</button>
</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"
>
★ SAVED LOCATIONS
</button>
)}
</div>
);
}
Review This
Did RegionMapPane actually get key={region.id} on its MapContainer, or does switching a dropdown leave a stale map behind? react-leaflet doesn’t re-point an already-mounted map at a new center/zoom prop after the fact – this is a real limitation, not a bug you can code around inside the component’s render logic, the only fix is forcing React to tear down and remount. An AI assistant that isn’t specifically warned about this will often write RegionMapPane exactly the way you’d naively expect a React component to work: pass region as a prop, let center/zoom update reactively. It compiles, and it even looks right the very first time you pick a region, because that’s the initial mount. It breaks the second time you change either dropdown, when the map pane keeps its old framing with new markers plotted on top of the wrong background. Check for key={region.id} on the MapContainer specifically. If it’s missing: “RegionMapPane’s MapContainer needs key={region.id} – react-leaflet won’t re-center an already-mounted map when center/zoom props change, so switching regions needs to force a remount, not rely on prop updates.”
Does SavedLocationsControl.jumpTo accidentally call whatever marks a move as programmatic? This is the mistake most worth catching in this entire lesson, because it’s the one place an AI assistant might reach for code reuse in exactly the wrong spot. WatchRegionsControl’s own jump-to-region function deliberately hides its move from free-pan detection, and if the AI pattern-matches “this looks like the same kind of jump” and copies that call over, saved locations silently stop behaving like a manual pan – the “RETURN TO WATCH REGION” free-pan control simply never appears, and nothing on screen tells you why. It still visually jumps to the right spot, so a five-second test looks completely fine. Check jumpTo in SavedLocationsControl for any call to a “mark as programmatic move” helper. If it’s there: “SavedLocationsControl.jumpTo must not mark the move as programmatic – a saved-location jump should trigger the same free-pan/ViewportFeed behavior as a manual drag, not the watch-region recenter behavior. Remove that call.”
Did the saved-locations repo pick up an isDefault-style flag or unique-index logic it doesn’t need? Because SavedLocationsControl looks structurally so similar to WatchRegionsControl, an AI assistant working from that similarity as a template can sometimes carry over more than the shape – an enabled column, a “set as default” route, a partial unique index that has no invariant behind it here. It’s harmless in the sense that nothing breaks, but it’s dead weight that misrepresents what this feature actually guarantees, and it’s exactly the kind of thing that looks like careful engineering in a schema review until you ask what it’s protecting. Check the saved_locations table and its repo for anything beyond plain CRUD. If something extra snuck in: “saved_locations doesn’t need an enabled flag, a default flag, or any unique index beyond the primary key – there’s no invariant to protect here beyond ‘rows have a name and a place.’ Strip out anything copied over from the watch-regions pattern that isn’t doing real work.”
Try It
- Restart the server so
registerSavedLocationsRoutesis live, then reload the frontend. - Make sure at least two watch regions exist. Click the compare-mode icon in the header and confirm two map panes appear, each with its own region dropdown and live aircraft count.
- Change the left pane’s dropdown to a different region and confirm only that pane’s map re-centers and re-populates – watch closely for a frame where the old tiles are still showing under new markers, which is exactly what a missing remount key looks like.
- Disable one of the two compared regions from Watch Regions, return to compare mode, and confirm that pane shows the “region is disabled” banner instead of stale traffic.
- Pan somewhere interesting, click ★ SAVED LOCATIONS, save that view under a name. Pan elsewhere, click the saved entry, and confirm the map jumps back – and confirm the free-pan “RETURN TO WATCH REGION” control actually appears. If it doesn’t appear, the jump was wrongly marked as programmatic.
Recap
CompareViewandRegionMapPaneneeded zero new backend work –positionsByRegion, keyed by region since Module 4, already held everything two simultaneous panes needed. Worth confirming the AI actually noticed that instead of inventing a redundant endpoint.- The
key={region.id}remount is a one-line fix for a real react-leaflet limitation, and it’s invisible until the second time someone switches a dropdown – exactly the kind of thing a first pass through the UI won’t catch. - Saved locations and watch regions solve different problems that happen to share a UI shape. The one line of code that actually enforces that difference is the absence of a “mark as programmatic” call in
jumpTo– worth checking by name, not just by clicking around. - When two features share a UI shape, an AI assistant copying one as a template for the other can carry over more than the shape. Check what invariants actually got copied, not just what the buttons look like.
Next lesson: snapshot sharing and the mobile layout.