Directing Historical Playback
Objectives
By the end of this chapter, you should be able to:
- Prompt a bounded time-range query over
position_snapshotsthat returns every aircraft’s positions across a whole window, not just one hex like the trail query from the previous chapter - Direct an AI assistant to reconstruct “what did the map look like at time X” on the client and expose it as a scrubber the operator can play, pause, and drag
- Catch an AI assistant that gives historical playback the wrong priority against the live feed, or that doesn’t cleanly hand control back to live data when the operator exits
💡 Why this matters: Trails answer “where has this one aircraft been.” History playback answers a different question, “what did the whole picture look like ten minutes ago,” and that difference matters for how you review it too. This isn’t a new query bolted onto the trail feature, it’s a third, higher-priority source competing for the same screen real estate as live data and free-pan, and the most common way an AI assistant gets this wrong isn’t in the query, it’s in the handoff, what happens the instant the operator exits playback and expects live data back.
Why This Needs to Outrank Everything Else
By this point in the module, selectViewAircraft already has two branches: free-pan if active, otherwise the primary watch-region feed. History playback needs to become a third branch, and it needs to sit above both, not because it’s more important in some abstract sense, but because it’s the one mode the operator explicitly opened. Free-pan can quietly activate as a side effect of an ordinary drag. Live data arrives whether you’re looking or not. Playback is different, the operator clicked a button that says HISTORY, and while that’s active, nothing else should be allowed to silently blend into what’s on screen. An AI assistant that doesn’t internalize that distinction will happily write correct-looking code where a stray moveend during a scrub knocks the map back into free-pan mid-playback, showing live data from one moment stitched next to historical data from another as if they were the same instant.
The Prompt
What It Built
// apps/server/src/db/repos/positionSnapshotsRepo.ts (add to the previous chapter's version)
import { and, asc, eq, gte, lte } from "drizzle-orm";
// ...eq import now also used here...
export interface HistorySnapshot {
hex: string;
flight: string | null;
lat: number;
lon: number;
altBaroFt: number | null;
onGround: boolean;
trackDeg: number | null;
recordedAt: string;
}
/** All aircraft positions in a time range, powers the historical playback slider. */
export async function getHistoryRange(from: Date, to: Date): Promise<HistorySnapshot[]> {
const rows = await db
.select({
hex: positionSnapshots.hex,
flight: positionSnapshots.flight,
lat: positionSnapshots.lat,
lon: positionSnapshots.lon,
altBaroFt: positionSnapshots.altBaroFt,
onGround: positionSnapshots.onGround,
trackDeg: positionSnapshots.trackDeg,
recordedAt: positionSnapshots.recordedAt,
})
.from(positionSnapshots)
.where(and(gte(positionSnapshots.recordedAt, from), lte(positionSnapshots.recordedAt, to)))
.orderBy(asc(positionSnapshots.recordedAt))
.limit(20_000);
return rows.map((r) => ({ ...r, recordedAt: r.recordedAt.toISOString() }));
}
// apps/server/src/routes/history.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { getHistoryRange } from "../db/repos/positionSnapshotsRepo.js";
const querySchema = z
.object({
from: z.coerce.date(),
to: z.coerce.date(),
})
.refine((v) => v.to > v.from, { message: "'to' must be after 'from'" })
.refine((v) => v.to.getTime() - v.from.getTime() <= 6 * 60 * 60 * 1000, {
message: "range too large, max 6 hours per request",
});
/** Powers the historical playback slider: all recorded positions in a bounded time window. */
export function registerHistoryRoutes(app: FastifyInstance): void {
app.get("/history", async (req, reply) => {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) {
return reply.status(400).send({ error: "invalid range", issues: parsed.error.issues });
}
const snapshots = await getHistoryRange(parsed.data.from, parsed.data.to);
return { from: parsed.data.from.toISOString(), to: parsed.data.to.toISOString(), snapshots };
});
}
The 6-hour cap on the request and the 20,000-row cap on the query are two independent limits catching two independent problems, a caller could ask for a small time range that still happens to be enormous in a very busy region, or a huge time range that would be enormous even quietly.
// apps/web/src/hooks/useHistoryPlayback.ts
"use client";
import { useCallback, useState } from "react";
import type { AircraftState } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";
export interface HistorySnapshot {
hex: string;
flight: string | null;
lat: number;
lon: number;
altBaroFt: number | null;
onGround: boolean;
trackDeg: number | null;
recordedAt: string;
}
export type HistoryLoadStatus = "idle" | "loading" | "ready" | "error";
/** How far back playback looks, kept modest since /api/history returns up
* to 20k rows total across every aircraft in the window, and busy regions
* can produce hundreds of rows per 15s poll cycle. */
const WINDOW_MINUTES = 20;
function toAircraftState(row: HistorySnapshot): AircraftState {
return {
hex: row.hex,
flight: row.flight,
lat: row.lat,
lon: row.lon,
alt_baro: row.onGround ? "ground" : row.altBaroFt,
track: row.trackDeg,
};
}
/**
* Reconstructs "what did the map look like at time X" from stored
* position_snapshots, for the historical playback scrubber. Only ever has
* data for aircraft the poller actually recorded (i.e. inside a watch
* region), same caveat as trails.
*/
export function useHistoryPlayback() {
const [status, setStatus] = useState<HistoryLoadStatus>("idle");
const [byHex, setByHex] = useState<Map<string, HistorySnapshot[]>>(new Map());
const [range, setRange] = useState<{ from: number; to: number } | null>(null);
const load = useCallback(async () => {
setStatus("loading");
const to = new Date();
const from = new Date(to.getTime() - WINDOW_MINUTES * 60_000);
try {
const res = await fetch(apiUrl(`/api/history?from=${from.toISOString()}&to=${to.toISOString()}`));
if (!res.ok) throw new Error(`history fetch failed (${res.status})`);
const data: { snapshots: HistorySnapshot[] } = await res.json();
const grouped = new Map<string, HistorySnapshot[]>();
for (const row of data.snapshots) {
const list = grouped.get(row.hex);
if (list) list.push(row);
else grouped.set(row.hex, [row]);
}
setByHex(grouped);
setRange({ from: from.getTime(), to: to.getTime() });
setStatus("ready");
} catch (err) {
console.error("[history] load failed:", err);
setStatus("error");
}
}, []);
const reset = useCallback(() => {
setStatus("idle");
setByHex(new Map());
setRange(null);
}, []);
/** Aircraft as they were at `atMs`, each aircraft's most recent snapshot at-or-before that time. */
const frameAt = useCallback(
(atMs: number): AircraftState[] => {
const out: AircraftState[] = [];
for (const rows of byHex.values()) {
// Server returns rows ordered ascending by recordedAt within each hex's group.
let best: HistorySnapshot | null = null;
for (const row of rows) {
if (new Date(row.recordedAt).getTime() > atMs) break;
best = row;
}
if (best) out.push(toAircraftState(best));
}
return out;
},
[byHex]
);
return { status, range, hasData: byHex.size > 0, load, reset, frameAt };
}
// apps/web/src/store/useAppStore.ts (additions to the previous chapter's version)
/**
* Playback of DB-backed history: reconstructs "what did the map look like at
* time X" from stored position_snapshots, for the historical playback
* scrubber. Takes priority over both the live WS feed and free-pan when
* active, see selectViewAircraft.
*/
interface HistoryPlaybackState {
active: boolean;
aircraft: AircraftState[];
asOf: Date | null;
}
interface AppState {
// ...existing fields...
historyPlayback: HistoryPlaybackState;
setHistoryPlaybackFrame: (aircraft: AircraftState[], asOf: Date) => void;
exitHistoryPlayback: () => void;
}
export const useAppStore = create<AppState>((set) => ({
// ...existing fields...
historyPlayback: { active: false, aircraft: EMPTY_AIRCRAFT, asOf: null },
setHistoryPlaybackFrame: (aircraft, asOf) => set({ historyPlayback: { active: true, aircraft, asOf } }),
exitHistoryPlayback: () => set({ historyPlayback: { active: false, aircraft: EMPTY_AIRCRAFT, asOf: null } }),
}));
/**
* Aircraft for whatever the map is currently showing: a reconstructed
* historical frame when playback is active (highest priority, it's an
* explicit, deliberate mode the user opted into), the on-demand /api/live
* result once the user has panned/zoomed away from the primary region, or
* the primary watch region's WS-pushed feed otherwise. This is what the map
* markers, flight list, and detail panel should all read from, so they stay
* in sync with what's actually visible.
*/
export function selectViewAircraft(state: AppState): AircraftState[] {
if (state.historyPlayback.active) return state.historyPlayback.aircraft;
if (state.freePan.active) return state.freePan.aircraft;
return selectPrimaryAircraft(state);
}
/** Timestamp of the data currently backing selectViewAircraft. */
export function selectViewLastUpdate(state: AppState): Date | null {
if (state.historyPlayback.active) return state.historyPlayback.asOf;
return state.freePan.active ? state.freePan.lastUpdate : state.lastUpdate;
}
This replaces the two-branch selectViewAircraft from the previous chapter, history now checked first, free-pan second, the live primary feed last. The live WS feed keeps running underneath the whole time playback is active (useLiveFeed doesn’t unmount, doesn’t pause), it’s just not what’s rendered, so live mode resumes instantly the moment playback exits, with no reconnect delay.
// apps/web/src/components/HistoryPlaybackControl.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useAppStore } from "@/store/useAppStore";
import { useHistoryPlayback } from "@/hooks/useHistoryPlayback";
/** One playback step per stored poll cycle, matches the poller's interval. */
const STEP_MS = 15_000;
const PLAY_TICK_MS = 500;
function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
/**
* Scrub through DB-backed history: "what did the map look like N minutes
* ago." While active, this overrides the live view entirely (see
* selectViewAircraft), the WS feed keeps running underneath so live mode
* resumes instantly on close, it's just not what's rendered.
*/
export function HistoryPlaybackControl() {
const [open, setOpen] = useState(false);
const [playing, setPlaying] = useState(false);
const [atMs, setAtMs] = useState<number | null>(null);
const { status, range, hasData, load, reset, frameAt } = useHistoryPlayback();
const setFrame = useAppStore((s) => s.setHistoryPlaybackFrame);
const exitPlayback = useAppStore((s) => s.exitHistoryPlayback);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const openPanel = () => {
setOpen(true);
setAtMs(null);
load();
};
const closePanel = () => {
setOpen(false);
setPlaying(false);
setAtMs(null);
reset();
exitPlayback();
};
// Once data loads, start the scrubber at the most recent frame.
useEffect(() => {
if (status === "ready" && range && atMs == null) {
setAtMs(range.to);
}
}, [status, range, atMs]);
// Push whichever frame is selected into the shared store so the map,
// list, and detail panel all render it.
useEffect(() => {
if (!open || atMs == null) return;
setFrame(frameAt(atMs), new Date(atMs));
}, [open, atMs, frameAt, setFrame]);
// Auto-advance while playing.
useEffect(() => {
if (!playing || !range) return;
timerRef.current = setInterval(() => {
setAtMs((prev) => {
const next = (prev ?? range.from) + STEP_MS;
if (next >= range.to) {
setPlaying(false);
return range.to;
}
return next;
});
}, PLAY_TICK_MS);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [playing, range]);
if (!open) {
return (
<button
type="button"
onClick={openPanel}
className="absolute right-3.5 top-24 z-[500] 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"
>
⏱ HISTORY
</button>
);
}
return (
<div className="absolute inset-x-3.5 bottom-16 z-[500] rounded-sm border border-line bg-surface/95 px-4 py-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">
History playback {range && `, last ${Math.round((range.to - range.from) / 60_000)} min`}
</span>
<button type="button" onClick={closePanel} className="text-fg-dim hover:text-fg">
✕ EXIT PLAYBACK
</button>
</div>
{status === "loading" && <div className="py-2 text-fg-dim">Loading history…</div>}
{status === "error" && <div className="py-2 text-danger">Couldn't load history, try again.</div>}
{status === "ready" && !hasData && (
<div className="py-2 text-fg-dim">No positions recorded in this window yet.</div>
)}
{status === "ready" && hasData && range && atMs != null && (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
className="shrink-0 rounded-sm border border-phosphor-dim px-2.5 py-1 text-[10px] tracking-[0.05em] text-phosphor hover:bg-phosphor/10"
>
{playing ? "⏸ PAUSE" : "▶ PLAY"}
</button>
<input
type="range"
min={range.from}
max={range.to}
step={STEP_MS}
value={atMs}
onChange={(e) => {
setPlaying(false);
setAtMs(Number(e.target.value));
}}
className="min-w-0 flex-1 accent-phosphor"
/>
<span className="w-[80px] shrink-0 text-right font-mono text-fg-dim">{fmtTime(atMs)}</span>
</div>
)}
</div>
);
}
STEP_MS matches the poller’s own 15-second cycle deliberately, stepping any finer than the data’s actual resolution would just repeat the same frame, and the range <input>’s step attribute uses the same constant so dragging the handle can’t land between two real snapshots. Add <HistoryPlaybackControl /> inside MapPanel’s outer wrapper (not inside MapContainer, it’s an overlay, not a map layer, same placement as WatchRegionsControl).
Review This
Does closePanel actually clear historyPlayback.active in the store, or just reset local component state? This is the single most likely place for an AI assistant to produce something that looks complete, because closePanel has four other lines that all obviously matter, setOpen(false), setPlaying(false), setAtMs(null), reset(), and it’s easy for exitPlayback() to feel redundant next to them, especially if the assistant reasons “the panel data was just reset, so of course the map will go back to live.” It won’t. selectViewAircraft checks state.historyPlayback.active, not whether the hook’s local byHex map is empty, those are two separate pieces of state that both need to go back to their resting values. If exitPlayback() is missing or gets called before reset() in a way that doesn’t matter functionally but hints the AI didn’t distinguish the two, check both paths anyway: the panel visually closes, but the map keeps rendering the last historical frame it was showing, frozen, indefinitely, until something else happens to touch the store. Follow-up: “closePanel needs to call the store’s exitHistoryPlayback action, not just reset the hook’s local state, otherwise historyPlayback.active stays true and the map keeps showing the frozen last frame after the panel closes.”
Is history actually checked first in selectViewAircraft, or did it get appended after free-pan? Building this feature by editing the two-branch selector from the previous chapter, an AI assistant might reasonably add the new check wherever the diff is smallest, at the end, after the existing free-pan branch, rather than reasoning about priority order. That still type-checks and still shows historical data most of the time. It breaks specifically when both are true at once: playback is open and the operator (out of habit, or just testing) drags the map. If free-pan is checked first, that drag now takes over the screen with live current-moment data from wherever the map ended up, while the scrubber UI still shows itself mid-scrub through the past, two different moments in time rendered as if they were one. Check the order of the if statements: historyPlayback.active must be checked before freePan.active. Follow-up: “selectViewAircraft needs to check historyPlayback.active before freePan.active, history playback should never be interrupted by a drag on the map while it’s open.”
Did both the 6-hour route cap and the 20,000-row query limit actually make it in, or just one? These read as redundant to an AI assistant asked to “add reasonable limits,” a sufficiently strict range cap feels like it should make a row cap unnecessary, or vice versa, so it’s common to see only one show up even when the prompt asked for both by name. The gap only bites in the case the other limit can’t cover: a from/to window under 6 hours, comfortably inside the cap, but pointed at a stretch where several regions were being watched simultaneously at a busy hour, easily tens of thousands of rows without the .limit(20_000) backstop. Check getHistoryRange’s query for .limit(20_000) independently of whatever range validation the route does. Follow-up: “getHistoryRange needs its own .limit(20000) in the query, independent of the route’s 6-hour range cap, a short but busy window can still return an unbounded number of rows without it.”
Try It
- Restart the frontend, let a watch region collect two or three poll cycles of data (roughly 30-45s), then click ⏱ HISTORY.
- Confirm the panel loads, the slider starts at the rightmost (most recent) position, and the map is now showing that reconstructed frame instead of live data.
- Drag the slider left and confirm aircraft positions on the map jump backward to match. Click ▶ PLAY and confirm it auto-advances in 15-second steps until it reaches the end and stops itself.
- While playback is open and scrubbed to some point in the past, click and drag the live map itself. Confirm the map keeps showing the historical frame, not a free-pan fetch for wherever you dragged to, that’s the priority-order check.
- While playback is open, confirm the live connection indicator still shows “open” (the WS feed is still running underneath), then click ✕ EXIT PLAYBACK and confirm the map immediately shows current live positions again, with no reconnect delay and no lingering historical markers even briefly.
- Open the history panel again right after a fresh page load, before any watch region has recorded two cycles yet, and confirm the “No positions recorded in this window yet” state renders instead of an empty or broken slider.
Recap
getHistoryRangeandgetTrailboth readposition_snapshots, but answer different questions, one aircraft’s history over time vs. every aircraft’s state at one moment, and that’s why they’re two queries, not one parameterized by an optional hex.- Two independent limits, the route’s 6-hour range cap and the query’s 20,000-row limit, catch two different failure shapes, and an AI assistant that treats them as redundant will usually only implement one.
selectViewAircraft’s three-way priority, history first, then free-pan, then live, exists because exactly one of these should ever be authoritative at a time. Get the order wrong and a stray drag mid-scrub blends two different moments together on screen.- “Exit playback” isn’t done when the panel visually closes, it’s done when the store’s
historyPlayback.activeflag actually flips back to false. Those are two different pieces of state, and an AI assistant can reset one without the other.
Next module: directing an AI assistant through the airport and weather overlay, the first feature in the build-out module.