Prompting Home Location & the Overflight Log
Objectives
By the end of this chapter, you should be able to:
- Prompt a three-state home-location config flow and catch an AI assistant that collapses it into a plain nullable field
- Direct an upsert against the
home_locationsingleton that leans on Module 1’s check constraint instead of re-deriving “does a row exist” in application code - Complete
geo.ts’s third helper,bearingDeg, and verify the argument order didn’t get quietly swapped - Prompt a full-history overflight log page and confirm it’s actually reading from Postgres, not from the same short-lived store state the live alert feed already shows
💡 Why this matters: The poller has been able to detect overflights since Module 2 –
detectOverflightTransitionschecks every aircraft againsthomeLocation.radiusNmandaltitudeCeilingFton every cycle, and the previous lesson wired the WSoverflightmessage all the way to the browser. None of that has had anywhere to point, though, because there’s never been a UI to set a home location. This lesson closes that loop, and it’s a good test of whether an AI assistant will model “not configured yet” correctly or just reach fornulland call it done.
Say the Three States Out Loud Before You Prompt Them
getHomeLocation has been returning null since Module 2, for the entirely mundane reason that nothing has ever called setHomeLocation. A frontend field for this has three genuinely different states, not two: not yet fetched from the server, fetched and confirmed nothing’s set, and fetched with a real value. Collapse the first two into one falsy value and every component that reads it – the overflight log’s empty-state banner, in particular – will flash the wrong message for a frame or two on every page load, before the initial fetch resolves. An AI assistant modeling this cold, without being told to keep those three states distinct, will very often reach for HomeLocationSettings | null and stop there, because that’s the more common shape for “a thing that might not exist yet.” The prompt below names the third state explicitly so there’s no ambiguity to guess at.
The Prompt
What It Built
The repo’s upsert against the singleton row:
// apps/server/src/db/repos/homeLocationRepo.ts (add to the Module 2 version)
export interface SetHomeLocationInput {
lat: number;
lon: number;
radiusNm: number;
altitudeCeilingFt: number;
}
export async function setHomeLocation(input: SetHomeLocationInput): Promise<HomeLocationSettings> {
const [row] = await db
.insert(homeLocation)
.values({ id: 1, ...input })
.onConflictDoUpdate({
target: homeLocation.id,
set: { ...input, updatedAt: new Date() },
})
.returning();
return {
lat: row.lat,
lon: row.lon,
radiusNm: row.radiusNm,
altitudeCeilingFt: row.altitudeCeilingFt,
updatedAt: row.updatedAt.toISOString(),
};
}
// apps/server/src/routes/settings.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { getHomeLocation, setHomeLocation } from "../db/repos/homeLocationRepo.js";
const putBodySchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
radiusNm: z.number().min(0.5).max(100).default(15),
altitudeCeilingFt: z.number().min(0).max(60000).default(5000),
});
export function registerSettingsRoutes(app: FastifyInstance): void {
app.get("/settings/home", async () => {
const home = await getHomeLocation();
return { home };
});
app.put("/settings/home", async (req, reply) => {
const parsed = putBodySchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: "invalid home location", issues: parsed.error.issues });
}
const home = await setHomeLocation(parsed.data);
return { home };
});
}
The store’s three-state field:
// apps/web/src/store/useAppStore.ts (additions)
import type { HomeLocationSettings } from "@skywatch/shared";
interface AppState {
// ...existing fields...
/**
* undefined = not yet fetched, null = fetched and confirmed unset,
* object = configured. Lives here so setting it from HomeLocationLayer
* is immediately visible to DetailPanel's distance/bearing readout.
*/
home: HomeLocationSettings | null | undefined;
setHome: (home: HomeLocationSettings | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
// ...existing fields...
home: undefined,
setHome: (home) => set({ home }),
}));
// apps/web/src/hooks/useHomeLocation.ts
"use client";
import { useCallback, useEffect, useRef } from "react";
import { apiUrl } from "@/lib/serverUrl";
import { useAppStore } from "@/store/useAppStore";
export interface HomeLocationInput {
lat: number;
lon: number;
radiusNm?: number;
altitudeCeilingFt?: number;
}
export function useHomeLocation() {
const home = useAppStore((s) => s.home);
const setHomeState = useAppStore((s) => s.setHome);
const fetchedOnce = useRef(false);
useEffect(() => {
if (fetchedOnce.current) return;
fetchedOnce.current = true;
fetch(apiUrl("/api/settings/home"))
.then((r) => r.json())
.then((data: { home: typeof home }) => setHomeState(data.home ?? null))
.catch((err) => {
console.error("[home-location] fetch failed:", err);
setHomeState(null);
});
}, [setHomeState]);
const setHome = useCallback(
async (input: HomeLocationInput) => {
const res = await fetch(apiUrl("/api/settings/home"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`failed to set home location (${res.status})`);
const data: { home: NonNullable<typeof home> } = await res.json();
setHomeState(data.home);
return data.home;
},
[setHomeState]
);
return { home, setHome };
}
The map control, always saving to the current map center:
// apps/web/src/components/HomeLocationLayer.tsx
"use client";
import { useState } from "react";
import { Circle, Marker, useMap } from "react-leaflet";
import L from "leaflet";
import { useHomeLocation } from "@/hooks/useHomeLocation";
const homeIcon = L.divIcon({
className: "home-icon",
html: `<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--amber)" stroke="var(--bg-deep)" stroke-width="0.8"><path d="M12 2 L22 11 L19 11 L19 21 L14 21 L14 14 L10 14 L10 21 L5 21 L5 11 L2 11 Z"/></svg>`,
iconSize: [20, 20],
iconAnchor: [10, 18],
});
export function HomeLocationLayer() {
const map = useMap();
const { home, setHome } = useHomeLocation();
const [open, setOpen] = useState(false);
const [radiusNm, setRadiusNm] = useState(15);
const [altitudeCeilingFt, setAltitudeCeilingFt] = useState(5000);
const [saving, setSaving] = useState(false);
const startEditing = () => {
setRadiusNm(home?.radiusNm ?? 15);
setAltitudeCeilingFt(home?.altitudeCeilingFt ?? 5000);
setOpen(true);
};
const save = async () => {
const center = map.getCenter();
setSaving(true);
try {
await setHome({ lat: center.lat, lon: center.lng, radiusNm, altitudeCeilingFt });
setOpen(false);
} catch (err) {
console.error("[home-location] save failed:", err);
} finally {
setSaving(false);
}
};
return (
<>
{home && (
<>
<Marker position={[home.lat, home.lon]} icon={homeIcon} />
<Circle
center={[home.lat, home.lon]}
radius={home.radiusNm * 1852}
pathOptions={{ color: "var(--amber)", weight: 1, fillOpacity: 0.04, dashArray: "3 5" }}
/>
</>
)}
<div className="absolute bottom-3.5 left-3.5 z-[500]">
{open ? (
<div className="w-[210px] rounded-sm border border-line bg-surface/95 p-3 text-[11px] text-fg">
<div className="mb-2 text-[10px] uppercase tracking-[0.08em] text-fg-dim">
Home = current map center
</div>
<label className="mb-1.5 block">
<span className="text-[9px] uppercase tracking-[0.08em] text-fg-dim">Overflight radius (nm)</span>
<input
type="number"
min={0.5}
max={100}
step={0.5}
value={radiusNm}
onChange={(e) => setRadiusNm(Number(e.target.value))}
className="mt-0.5 w-full rounded-sm border border-line bg-surface-raised px-1.5 py-1 font-mono text-[11px] text-fg focus:outline-none"
/>
</label>
<label className="mb-2 block">
<span className="text-[9px] uppercase tracking-[0.08em] text-fg-dim">Altitude ceiling (ft)</span>
<input
type="number"
min={0}
max={60000}
step={500}
value={altitudeCeilingFt}
onChange={(e) => setAltitudeCeilingFt(Number(e.target.value))}
className="mt-0.5 w-full rounded-sm border border-line bg-surface-raised px-1.5 py-1 font-mono text-[11px] text-fg focus:outline-none"
/>
</label>
<div className="flex gap-1.5">
<button
type="button"
onClick={save}
disabled={saving}
className="flex-1 rounded-sm border border-phosphor-dim px-2 py-1 text-[10px] tracking-[0.06em] text-phosphor hover:bg-phosphor/10 disabled:opacity-50"
>
{saving ? "SAVING…" : "SAVE HOME HERE"}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-sm border border-line px-2 py-1 text-[10px] tracking-[0.06em] text-fg-dim hover:text-fg"
>
CANCEL
</button>
</div>
</div>
) : (
<button
type="button"
onClick={startEditing}
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"
>
{home ? "EDIT HOME LOCATION" : "SET HOME LOCATION"}
</button>
)}
</div>
</>
);
}
geo.ts’s third and final helper:
// packages/shared/src/geo.ts (add to the existing file)
/** Initial bearing in degrees (0-360, 0 = north) from point 1 to point 2. */
export function bearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const toDeg = (r: number) => (r * 180) / Math.PI;
const y = Math.sin(toRad(lon2 - lon1)) * Math.cos(toRad(lat2));
const x =
Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) -
Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(toRad(lon2 - lon1));
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
The detail panel’s “from home” readout:
// apps/web/src/components/DetailPanel.tsx (additions)
import { bearingDeg, distanceNm } from "@skywatch/shared";
import { useHomeLocation } from "@/hooks/useHomeLocation";
const COMPASS_POINTS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
function compassPoint(deg: number): string {
return COMPASS_POINTS[Math.round(deg / 45) % 8];
}
// inside DetailPanel():
const { home } = useHomeLocation();
const fromHome =
home && selected.lat != null && selected.lon != null
? {
distance: distanceNm(home.lat, home.lon, selected.lat, selected.lon),
bearing: bearingDeg(home.lat, home.lon, selected.lat, selected.lon),
}
: null;
// in the JSX, after the existing altitude/speed/squawk grid:
{fromHome && (
<div className="mt-4 border-t border-line pt-4">
<div className="mb-2 text-[9px] uppercase tracking-[0.1em] text-fg-dim">From home</div>
<div className="grid grid-cols-2 gap-x-3.5 gap-y-3">
<DataCell label="Distance" value={`${fmtNum(fromHome.distance, 1)} nm`} />
<DataCell label="Bearing" value={`${fmtNum(fromHome.bearing)}° ${compassPoint(fromHome.bearing)}`} />
</div>
</div>
)}
The overflight history endpoint and page:
// apps/server/src/db/repos/overflightRepo.ts
import { desc } from "drizzle-orm";
import type { OverflightEvent } from "@skywatch/shared";
import { db } from "../client.js";
import { overflightLog } from "../schema.js";
export async function listRecentOverflights(limit: number): Promise<OverflightEvent[]> {
const rows = await db
.select()
.from(overflightLog)
.orderBy(desc(overflightLog.detectedAt))
.limit(Math.min(limit, 500));
return rows.map((row) => ({
id: row.id,
hex: row.hex,
flight: row.flight,
lat: row.lat,
lon: row.lon,
altBaroFt: row.altBaroFt,
distanceNm: row.distanceNm,
detectedAt: row.detectedAt.toISOString(),
}));
}
// apps/server/src/routes/overflights.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { listRecentOverflights } from "../db/repos/overflightRepo.js";
const querySchema = z.object({ limit: z.coerce.number().min(1).max(500).default(50) });
export function registerOverflightRoutes(app: FastifyInstance): void {
app.get("/overflights", async (req, reply) => {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) return reply.status(400).send({ error: "invalid limit" });
const overflights = await listRecentOverflights(parsed.data.limit);
return { overflights };
});
}
// apps/web/src/components/OverflightLogPage.tsx
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { fmtNum } from "@skywatch/shared";
import type { OverflightEvent } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";
import { useHomeLocation } from "@/hooks/useHomeLocation";
import { ThemeToggle } from "./ThemeToggle";
const LIMIT = 200;
const AUTO_REFRESH_MS = 30_000;
type LoadStatus = "loading" | "ready" | "error";
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function OverflightLogPage() {
const { home } = useHomeLocation();
const [events, setEvents] = useState<OverflightEvent[]>([]);
const [status, setStatus] = useState<LoadStatus>("loading");
const load = useCallback(() => {
fetch(apiUrl(`/api/overflights?limit=${LIMIT}`))
.then((r) => {
if (!r.ok) throw new Error(`overflights fetch failed (${r.status})`);
return r.json() as Promise<{ overflights: OverflightEvent[] }>;
})
.then((data) => {
setEvents(data.overflights ?? []);
setStatus("ready");
})
.catch((err) => {
console.error("[overflight-log] fetch failed:", err);
setStatus("error");
});
}, []);
useEffect(() => {
load();
const timer = setInterval(load, AUTO_REFRESH_MS);
return () => clearInterval(timer);
}, [load]);
return (
<div className="flex h-full min-h-screen flex-col bg-base">
<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="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">OVERFLIGHT LOG</div>
</div>
</div>
<div className="flex items-center gap-3">
<Link
href="/"
className="rounded-sm border border-line px-2.5 py-1.5 text-[10px] uppercase tracking-[0.08em] text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
← Back to map
</Link>
<ThemeToggle />
</div>
</header>
<div className="mx-auto w-full max-w-[900px] flex-1 px-4 py-6">
<div className="mb-4 flex items-baseline justify-between">
<h2 className="text-[13px] uppercase tracking-[0.1em] text-fg">Who’s flying over my house</h2>
<button
type="button"
onClick={load}
className="rounded-sm border border-line px-2.5 py-1 text-[10px] uppercase tracking-[0.06em] text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
↻ Refresh
</button>
</div>
{home === undefined && <div className="mb-4 text-[11px] text-fg-dim">Loading home location…</div>}
{home === null && (
<div className="mb-4 rounded-sm border border-line bg-surface-raised px-3 py-2 text-[11px] text-fg-dim">
No home location set yet -- open the map and use the ⌂ control (bottom-left) to set one. Overflights are
only logged once a home location and radius/altitude ceiling are configured.
</div>
)}
{home && (
<div className="mb-4 rounded-sm border border-line bg-surface-raised px-3 py-2 text-[11px] text-fg-dim">
Logging aircraft within <span className="text-fg">{fmtNum(home.radiusNm, 1)} nm</span> of home at or
below <span className="text-fg">{fmtNum(home.altitudeCeilingFt)} ft</span>. Each row is one flyover
(re-entering the zone after leaving logs again).
</div>
)}
<div className="overflow-hidden rounded-sm border border-line">
<div className="flex bg-surface-raised px-3 py-2 text-[9px] uppercase tracking-[0.08em] text-fg-dim">
<span className="w-[150px]">Time</span>
<span className="flex-1">Callsign</span>
<span className="w-[90px] text-right">Distance</span>
<span className="w-[90px] text-right">Altitude</span>
</div>
{status === "loading" && <div className="px-3 py-8 text-center text-[11px] text-fg-dim">Loading…</div>}
{status === "error" && (
<div className="px-3 py-8 text-center text-[11px] text-danger">Couldn’t load the log, try refresh.</div>
)}
{status === "ready" && events.length === 0 && (
<div className="px-3 py-8 text-center text-[11px] text-fg-dim">
No overflights logged yet. They’ll show up here the next time an aircraft passes through the
zone above.
</div>
)}
{status === "ready" &&
events.map((e) => {
const cs = e.flight?.trim() || e.hex.toUpperCase();
return (
<div
key={e.id}
className="flex items-center border-t border-line/60 px-3 py-2 text-[11px] hover:bg-phosphor/5"
>
<span className="w-[150px] text-fg-dim">{fmtDateTime(e.detectedAt)}</span>
<span className="flex-1 truncate font-semibold text-fg">
{cs}
<span className="ml-1.5 font-normal text-fg-dim">{e.hex.toUpperCase()}</span>
</span>
<span className="w-[90px] text-right text-fg-dim">{fmtNum(e.distanceNm, 1)} nm</span>
<span className="w-[90px] text-right text-fg-dim">
{e.altBaroFt != null ? `${fmtNum(e.altBaroFt)} ft` : "N/A"}
</span>
</div>
);
})}
</div>
{status === "ready" && events.length === LIMIT && (
<div className="mt-2 text-[10px] text-fg-dim">
Showing the {LIMIT} most recent overflights. Older entries aren’t shown here (still kept in the
database).
</div>
)}
</div>
</div>
);
}
// apps/web/src/app/overflights/page.tsx
import { OverflightLogPage } from "@/components/OverflightLogPage";
export default function Overflights() {
return <OverflightLogPage />;
}
Review This
Did home come back as a plain HomeLocationSettings | null instead of the three-state field you asked for? This is the single most likely miss in this whole lesson, because a two-state nullable field type-checks fine, renders fine on a fast local network where the fetch resolves in a few milliseconds, and only shows its bug as a barely-perceptible flash on a slower connection – exactly the kind of thing that’s invisible in a five-second manual test and obvious to a real user on a bad wifi connection. Check the store’s home field type is HomeLocationSettings | null | undefined with an explicit undefined initial value, and check OverflightLogPage branches on home === undefined separately from home === null. If it collapsed to two states: “home needs to be three-state – undefined before the initial fetch resolves, null only after a confirmed empty response. Right now it’s initialized to null, which means every page reading it flashes the wrong empty state before the fetch completes.”
Does bearingDeg get called with home first and the aircraft second, or did the arguments get swapped somewhere? bearingDeg(fromLat, fromLon, toLat, toLon) is directional, not symmetric – swap the two points and you get the bearing to fly home from the aircraft, not the bearing to the aircraft from home, and both numbers look equally plausible on screen since they’re both valid compass headings in the right general range. Nothing type-checks differently either way; two numbers is two numbers. Check the call site in DetailPanel passes home.lat, home.lon as the first pair, not selected.lat, selected.lon. If it’s swapped: “the from-home bearing reads home.lat/home.lon as the first argument to bearingDeg and the aircraft’s position as the second – home first, since bearingDeg’s first two args are the ‘from’ point.”
Is OverflightLogPage actually fetching from Postgres, or did the AI wire it to recentOverflights from the store to save itself a round trip? Both produce a list of overflight rows and both render identically for the first few minutes after a fresh restart, which is exactly when you’re most likely to be testing it. The difference only shows up once the tab has been closed and reopened, or the page loaded fresh without ever having a live WS connection – at that point recentOverflights is empty and the “real” persisted history silently isn’t there. Check that OverflightLogPage calls fetch(apiUrl('/api/overflights?...')) directly, not useAppStore((s) => s.recentOverflights). If it’s reading from the store: “OverflightLogPage needs to fetch GET /api/overflights directly, not read recentOverflights from the store – that store field only holds what’s arrived over the WebSocket since this tab connected, and this page is supposed to show the full persisted history regardless of tab lifetime.”
Try It
- Restart the server so the new
settingsandoverflightsroutes are registered, then reload the frontend. - Open the map and click SET HOME LOCATION. Pan the map somewhere with visible traffic first, then set a radius and altitude ceiling and save. Confirm the amber home marker and dashed radius circle appear where you’d expect.
- Click an aircraft and confirm the detail panel shows a “From home” distance and bearing. Reload the page and confirm it’s still there – proof it’s reading from the server, not local-only state.
- Wait for an aircraft to pass inside your configured radius and ceiling (or temporarily widen both to make one qualify sooner), then click the header’s house icon and confirm it shows up on
/overflightswith the right distance and altitude. - Close the tab entirely, reopen it, and navigate straight to
/overflightswithout visiting the dashboard first. Confirm the full history still loads – if it comes back empty despite Try It step 4 having logged an entry, the page is reading the wrong data source.
Recap
setHomeLocation’sonConflictDoUpdateis a direct consequence of Module 1’shome_location_singletoncheck constraint – becauseidcan only ever be1, there’s no “does a row exist” branch to write.- The store’s
homefield being three-state instead of two is easy to get wrong and easy to miss in a quick test, because the bug is a flash on a slow connection, not a crash. bearingDegis directional – verify the call site passes home first, not just that it type-checks, since a swapped argument order produces an equally plausible-looking wrong number.- The overflight log page and the header’s live alert feed answer genuinely different questions from different data sources – verify the log page is actually hitting Postgres, not quietly reusing the WS-only store state the previous lesson built.
Next lesson: multi-region compare and saved locations.