CodingNic

Feature Build-Out

Directing Squawk Alerts & the Alert Feed

Feature Build-Out 30 min read

Directing Squawk Alerts & the Alert Feed

Objectives

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

  • Direct an AI assistant to finally consume the WebSocket’s squawk_alert and overflight message types, and verify the switch it writes stays exhaustive as the union grows
  • Prompt a scrolling alert feed with a flash-on-new-row treatment, and catch an AI assistant that conflates “never seen” with “not yet acknowledged”
  • Direct the audible + browser-notification layer, including a persisted mute preference, and verify it survives the same hydrate/persist race you already fixed once in Module 5’s first lesson

💡 Why this matters: The WebSocket has been carrying squawk_alert and overflight messages since Module 2 – the server’s been pushing them the whole time, to a frontend that’s simply been throwing them away. This is the lesson where that changes. It’s also a good test of whether an AI assistant actually reads the existing message union before writing a handler for it, or just writes a handler for the one case it assumes matters.

Two Message Types the Frontend Has Ignored Since Module 2

ServerToClientMessage has had four branches since the WebSocket layer went in: connected, positions, squawk_alert, overflight. useLiveFeed’s handler has only ever acted on one of them – there’s a comment sitting in that file right now saying, essentially, “a later module handles the other two.” This lesson is that later module, and the reason it’s worth calling out before you prompt anything: an AI assistant reading useLiveFeed.ts cold, without being told the union already has four members, might reasonably assume it only needs to add whatever cases you mention and leave the rest alone. Point it at the existing type instead of describing the message shapes from scratch, and it has no excuse to miss one.

The Prompt

code
useLiveFeed.ts currently only handles the "positions" case of ServerToClientMessage (a union with connected/positions/squawk_alert/ overflight branches, defined in packages/shared). The other two message types have been arriving over the socket since Module 2 but nothing does anything with them. I want to finally wire them up: 1. Replace the current single `if (msg.type === "positions")` with a switch over msg.type that handles every branch of the union explicitly, including an explicit no-op case for "connected" -- don't fall through to a default, I want this to stay exhaustive so TypeScript can flag it if the union grows a fifth branch later. squawk_alert should call a new pushAlert store action, overflight should call a new pushOverflight action. 2. Add recentAlerts (SquawkAlertEvent[]) and recentOverflights (OverflightEvent[]) to the store, plus pushAlert/pushOverflight actions. Both should prepend newest-first and cap at 100 entries -- this is browser-tab-lifetime state, not a durable log, the real history lives in the squawk_alerts/overflight_log tables. 3. Add a GET /api/alerts endpoint (repo + route, same shape as our existing /api/trails and /api/history endpoints) backed by the squawk_alerts table, for anything that wants alert history older than what this tab has seen live. Cap the limit at 500 both in the Zod schema and again in the repo function itself. 4. A header dropdown, AlertFeed, showing recentAlerts and recentOverflights merged and sorted newest-first, capped at 40 visible rows. Rows that were already in the feed at mount should never flash. Rows that arrive after mount should flash once, and that flash state shouldn't reset just because the user opened and closed the panel. Separately, track an unseen count for the header badge that DOES clear when the panel opens -- these are two different kinds of "seen" and need to be tracked independently. 5. useAlertSounds: a hook, mounted once near the root, that watches recentAlerts/recentOverflights for newly-arrived entries (not whatever's already there on mount -- a reconnect replaying history shouldn't retroactively alert) and plays a sound plus fires a browser Notification per new entry. Synthesize the sounds with the Web Audio API, don't ship an audio file. Emergency squawks and overflights should sound distinct. Persist a mute flag to localStorage using the same hydrate-on-mount/write-on-change pattern as the airports toggle -- including that same guard against the first-mount hydrate/persist race, where the persist effect's first run would otherwise stomp a previously-stored value with the pre-hydration default before hydration has a chance to land. 6. A mute toggle button. Unmuting should request Notification permission if it hasn't been asked yet, since browsers require that prompt to originate from a real user gesture. The click should also play a short confirmation beep every time, mute or unmute, since browsers only let an AudioContext actually produce sound after it's been created/resumed from within a genuine click -- this beep is also the audio unlock for every later async alert sound. Show me every file, and the switch statement in useLiveFeed in full.

What It Built

The finished useLiveFeed, now exhaustive over all four branches:

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

import { useEffect } from "react";
import type { ServerToClientMessage } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { wsUrl } from "@/lib/serverUrl";

const MAX_RECONNECT_DELAY_MS = 15_000;

export function useLiveFeed() {
  const setConnectionStatus = useAppStore((s) => s.setConnectionStatus);
  const setPositions = useAppStore((s) => s.setPositions);
  const pushAlert = useAppStore((s) => s.pushAlert);
  const pushOverflight = useAppStore((s) => s.pushOverflight);

  useEffect(() => {
    let ws: WebSocket | null = null;
    let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
    let unmounted = false;
    let attempt = 0;

    function connect() {
      setConnectionStatus("connecting");
      ws = new WebSocket(wsUrl());

      ws.onopen = () => {
        attempt = 0;
        setConnectionStatus("open");
      };

      ws.onmessage = (evt) => {
        let msg: ServerToClientMessage;
        try {
          msg = JSON.parse(evt.data);
        } catch {
          console.error("[live-feed] received malformed WS message");
          return;
        }

        switch (msg.type) {
          case "positions":
            setPositions(msg.regionId, msg.aircraft, msg.ts);
            break;
          case "squawk_alert":
            pushAlert(msg.event);
            break;
          case "overflight":
            pushOverflight(msg.event);
            break;
          case "connected":
            break;
        }
      };

      ws.onclose = () => {
        setConnectionStatus("closed");
        if (unmounted) return;
        attempt += 1;
        const delay = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY_MS);
        reconnectTimer = setTimeout(connect, delay);
      };

      ws.onerror = () => {
        ws?.close();
      };
    }

    connect();

    return () => {
      unmounted = true;
      if (reconnectTimer) clearTimeout(reconnectTimer);
      ws?.close();
    };
  }, [setConnectionStatus, setPositions, pushAlert, pushOverflight]);
}

case "connected": break; is a genuine no-op, written out explicitly rather than left to a default – add a fifth message type to ServerToClientMessage later and TypeScript can be configured to flag the missing case instead of silently ignoring it.

The store additions:

ts
// apps/web/src/store/useAppStore.ts (additions)
import type { OverflightEvent, SquawkAlertEvent } from "@skywatch/shared";

const MAX_EVENT_HISTORY = 100;

interface AppState {
  // ...existing fields...
  recentAlerts: SquawkAlertEvent[];
  recentOverflights: OverflightEvent[];
  alertsMuted: boolean;
  pushAlert: (event: SquawkAlertEvent) => void;
  pushOverflight: (event: OverflightEvent) => void;
  setAlertsMuted: (muted: boolean) => void;
  toggleAlertsMuted: () => void;
}

export const useAppStore = create<AppState>((set) => ({
  // ...existing fields...
  recentAlerts: [],
  recentOverflights: [],
  alertsMuted: false,
  pushAlert: (event) =>
    set((state) => ({ recentAlerts: [event, ...state.recentAlerts].slice(0, MAX_EVENT_HISTORY) })),
  pushOverflight: (event) =>
    set((state) => ({ recentOverflights: [event, ...state.recentOverflights].slice(0, MAX_EVENT_HISTORY) })),
  setAlertsMuted: (muted) => set({ alertsMuted: muted }),
  toggleAlertsMuted: () => set((state) => ({ alertsMuted: !state.alertsMuted })),
}));

The alert-history endpoint:

ts
// apps/server/src/db/repos/alertsRepo.ts
import { desc } from "drizzle-orm";
import type { SquawkAlertEvent } from "@skywatch/shared";
import { db } from "../client.js";
import { squawkAlerts } from "../schema.js";

export async function listRecentSquawkAlerts(limit: number): Promise<SquawkAlertEvent[]> {
  const rows = await db
    .select()
    .from(squawkAlerts)
    .orderBy(desc(squawkAlerts.detectedAt))
    .limit(Math.min(limit, 500));

  return rows.map((row) => ({
    id: row.id,
    hex: row.hex,
    flight: row.flight,
    squawk: row.squawk,
    lat: row.lat,
    lon: row.lon,
    altBaroFt: row.altBaroFt,
    detectedAt: row.detectedAt.toISOString(),
  }));
}
ts
// apps/server/src/routes/alerts.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { listRecentSquawkAlerts } from "../db/repos/alertsRepo.js";

const querySchema = z.object({ limit: z.coerce.number().min(1).max(500).default(50) });

export function registerAlertsRoutes(app: FastifyInstance): void {
  app.get("/alerts", async (req, reply) => {
    const parsed = querySchema.safeParse(req.query);
    if (!parsed.success) return reply.status(400).send({ error: "invalid limit" });
    const alerts = await listRecentSquawkAlerts(parsed.data.limit);
    return { alerts };
  });
}

The feed itself, with the two independent seen-state refs:

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

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

type FeedItem =
  | {
      kind: "squawk";
      key: string;
      hex: string;
      flight: string | null;
      squawk: string;
      altBaroFt: number | null;
      detectedAt: string;
    }
  | {
      kind: "overflight";
      key: string;
      hex: string;
      flight: string | null;
      distanceNm: number;
      altBaroFt: number | null;
      detectedAt: string;
    };

function fmtTime(iso: string): string {
  return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}

export function AlertFeed() {
  const [open, setOpen] = useState(false);
  const recentAlerts = useAppStore((s) => s.recentAlerts);
  const recentOverflights = useAppStore((s) => s.recentOverflights);

  const events = useMemo<FeedItem[]>(() => {
    const squawks: FeedItem[] = recentAlerts.map((a) => ({
      kind: "squawk",
      key: `squawk-${a.id}`,
      hex: a.hex,
      flight: a.flight,
      squawk: a.squawk,
      altBaroFt: a.altBaroFt,
      detectedAt: a.detectedAt,
    }));
    const overflights: FeedItem[] = recentOverflights.map((o) => ({
      kind: "overflight",
      key: `overflight-${o.id}`,
      hex: o.hex,
      flight: o.flight,
      distanceNm: o.distanceNm,
      altBaroFt: o.altBaroFt,
      detectedAt: o.detectedAt,
    }));
    return [...squawks, ...overflights]
      .sort((a, b) => new Date(b.detectedAt).getTime() - new Date(a.detectedAt).getTime())
      .slice(0, 40);
  }, [recentAlerts, recentOverflights]);

  const baselineKeysRef = useRef<Set<string> | null>(null);
  if (baselineKeysRef.current === null) {
    baselineKeysRef.current = new Set(events.map((e) => e.key));
  }

  const ackedKeysRef = useRef<Set<string> | null>(null);
  if (ackedKeysRef.current === null) {
    ackedKeysRef.current = new Set(baselineKeysRef.current);
  }
  useEffect(() => {
    if (open) ackedKeysRef.current = new Set(events.map((e) => e.key));
  }, [open, events]);

  const unseenCount = events.filter((e) => !ackedKeysRef.current!.has(e.key)).length;

  return (
    <div className="relative">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-label="Alert feed"
        title="Emergency squawk & overflight feed"
        className="relative flex h-7 w-7 items-center justify-center rounded-sm border border-line text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
      >
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
          <path
            d="M12 3a5 5 0 0 0-5 5v3.2c0 .9-.35 1.77-.98 2.42L4.6 15.1a1 1 0 0 0 .72 1.7h13.36a1 1 0 0 0 .72-1.7l-1.42-1.48A3.4 3.4 0 0 1 17 11.2V8a5 5 0 0 0-5-5Z"
            strokeLinejoin="round"
          />
          <path d="M9.5 19a2.5 2.5 0 0 0 5 0" strokeLinecap="round" />
        </svg>
        {unseenCount > 0 && !open && (
          <span
            style={{ animation: "pulse 1.4s ease-in-out infinite" }}
            className="absolute -right-1 -top-1 flex h-3.5 min-w-[14px] items-center justify-center rounded-full bg-danger px-[3px] text-[8px] font-semibold leading-none text-white"
          >
            {unseenCount > 9 ? "9+" : unseenCount}
          </span>
        )}
      </button>

      {open && (
        <div className="absolute right-0 top-[calc(100%+6px)] z-[600] w-[320px] rounded-sm border border-line bg-surface/95 text-fg shadow-lg">
          <div className="flex items-center justify-between border-b border-line px-3 py-2">
            <span className="text-[10px] uppercase tracking-[0.1em] text-fg-dim">Alert feed</span>
            <button type="button" onClick={() => setOpen(false)} className="text-fg-dim hover:text-fg">
              ✕
            </button>
          </div>
          <div className="max-h-[320px] overflow-y-auto">
            {events.length === 0 && (
              <div className="px-3 py-6 text-center text-[11px] text-fg-dim">No alerts yet.</div>
            )}
            {events.map((e) => {
              const isFresh = !baselineKeysRef.current!.has(e.key);
              const cs = e.flight?.trim() || e.hex.toUpperCase();
              return (
                <div
                  key={e.key}
                  className={`flex items-start gap-2.5 border-b border-line/60 px-3 py-2 text-[11px] last:border-b-0 ${
                    isFresh ? (e.kind === "squawk" ? "alert-flash-squawk" : "alert-flash-overflight") : ""
                  }`}
                >
                  <span
                    className={`mt-0.5 shrink-0 rounded-sm px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.05em] ${
                      e.kind === "squawk" ? "bg-danger/15 text-danger" : "bg-amber/15 text-amber"
                    }`}
                  >
                    {e.kind === "squawk" ? `SQUAWK ${e.squawk}` : "OVERFLIGHT"}
                  </span>
                  <div className="min-w-0 flex-1">
                    <div className="truncate font-semibold text-fg">{cs}</div>
                    <div className="text-fg-dim">
                      {e.kind === "overflight" ? `${e.distanceNm.toFixed(1)} nm · ` : ""}
                      {e.altBaroFt != null ? `${e.altBaroFt} ft · ` : ""}
                      {fmtTime(e.detectedAt)}
                    </div>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

The synthesized alert sounds:

ts
// apps/web/src/lib/sound.ts
"use client";

let ctx: AudioContext | null = null;

function getContext(): AudioContext | null {
  if (typeof window === "undefined") return null;
  const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
  if (!Ctor) return null;
  if (!ctx) ctx = new Ctor();
  if (ctx.state === "suspended") void ctx.resume();
  return ctx;
}

function beep(frequencies: number[], durationMs: number, gainValue: number): void {
  const audioCtx = getContext();
  if (!audioCtx) return;

  const now = audioCtx.currentTime;
  frequencies.forEach((freq, i) => {
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.type = "square";
    osc.frequency.value = freq;
    const start = now + i * (durationMs / 1000);
    const end = start + durationMs / 1000;
    gain.gain.setValueAtTime(0, start);
    gain.gain.linearRampToValueAtTime(gainValue, start + 0.01);
    gain.gain.linearRampToValueAtTime(0, end);
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.start(start);
    osc.stop(end + 0.02);
  });
}

export function playEmergencyAlertSound(): void {
  beep([880, 660, 880, 660], 160, 0.18);
}

export function playOverflightSound(): void {
  beep([520, 780], 140, 0.12);
}

export function playTestSound(): void {
  beep([440, 660], 120, 0.14);
}

The hook watching for new alerts, with the persisted mute flag:

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

import { useEffect, useRef } from "react";
import { isEmergencySquawk } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { playEmergencyAlertSound, playOverflightSound } from "@/lib/sound";

const MUTE_STORAGE_KEY = "skywatch:alerts-muted";

export function useAlertSounds(): void {
  const alertsMuted = useAppStore((s) => s.alertsMuted);
  const setAlertsMuted = useAppStore((s) => s.setAlertsMuted);
  const recentAlerts = useAppStore((s) => s.recentAlerts);
  const recentOverflights = useAppStore((s) => s.recentOverflights);

  const seenAlertIds = useRef<Set<number> | null>(null);
  const seenOverflightIds = useRef<Set<number> | null>(null);
  const skippedFirstPersist = useRef(false);

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

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

  useEffect(() => {
    if (seenAlertIds.current === null) {
      seenAlertIds.current = new Set(recentAlerts.map((a) => a.id));
      return;
    }
    const fresh = recentAlerts.filter((a) => !seenAlertIds.current!.has(a.id));
    if (fresh.length === 0) return;
    fresh.forEach((a) => seenAlertIds.current!.add(a.id));

    if (alertsMuted) return;
    const hasEmergency = fresh.some((a) => isEmergencySquawk(a.squawk));
    if (hasEmergency) playEmergencyAlertSound();

    if (typeof Notification !== "undefined" && Notification.permission === "granted") {
      fresh.forEach((a) => {
        const cs = a.flight?.trim() || a.hex.toUpperCase();
        new Notification(`Emergency squawk ${a.squawk}`, {
          body: `${cs} · squawk ${a.squawk}${a.altBaroFt != null ? ` · ${a.altBaroFt} ft` : ""}`,
          tag: `skywatch-squawk-${a.id}`,
        });
      });
    }
  }, [recentAlerts, alertsMuted]);

  useEffect(() => {
    if (seenOverflightIds.current === null) {
      seenOverflightIds.current = new Set(recentOverflights.map((o) => o.id));
      return;
    }
    const fresh = recentOverflights.filter((o) => !seenOverflightIds.current!.has(o.id));
    if (fresh.length === 0) return;
    fresh.forEach((o) => seenOverflightIds.current!.add(o.id));

    if (alertsMuted) return;
    playOverflightSound();

    if (typeof Notification !== "undefined" && Notification.permission === "granted") {
      fresh.forEach((o) => {
        const cs = o.flight?.trim() || o.hex.toUpperCase();
        new Notification("Overflight near home", {
          body: `${cs} · ${o.distanceNm.toFixed(1)} nm away${o.altBaroFt != null ? ` · ${o.altBaroFt} ft` : ""}`,
          tag: `skywatch-overflight-${o.id}`,
        });
      });
    }
  }, [recentOverflights, alertsMuted]);
}

The mute toggle, doubling as the audio unlock gesture:

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

import { useAppStore } from "@/store/useAppStore";
import { playTestSound } from "@/lib/sound";

export function AlertMuteToggle() {
  const muted = useAppStore((s) => s.alertsMuted);
  const toggle = useAppStore((s) => s.toggleAlertsMuted);

  const handleClick = () => {
    const willBeMuted = !muted;
    toggle();
    playTestSound();
    if (!willBeMuted && typeof Notification !== "undefined" && Notification.permission === "default") {
      void Notification.requestPermission();
    }
  };

  return (
    <button
      type="button"
      onClick={handleClick}
      aria-label={muted ? "Unmute alert sounds" : "Mute alert sounds"}
      title={muted ? "Alerts muted -- click to unmute" : "Alerts on -- click to mute"}
      className="flex h-7 w-7 items-center justify-center rounded-sm border border-line text-text-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
    >
      {muted ? (
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
          <path d="M9 9v3a3 3 0 0 0 4.6 2.55M15 9.34V6a3 3 0 0 0-5.94-.6" strokeLinecap="round" />
          <path d="M5 10v2a7 7 0 0 0 10.71 5.93M19 12a7 6.96 0 0 1-.35 2.2" strokeLinecap="round" />
          <line x1="12" y1="19" x2="12" y2="22" strokeLinecap="round" />
          <line x1="2" y1="2" x2="22" y2="22" strokeLinecap="round" />
        </svg>
      ) : (
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
          <path d="M9 9v3a3 3 0 0 0 6 0V6a3 3 0 0 0-6 0v0" strokeLinecap="round" />
          <path d="M5 10v2a7 7 0 0 0 14 0v-2" strokeLinecap="round" />
          <line x1="12" y1="19" x2="12" y2="22" strokeLinecap="round" />
        </svg>
      )}
    </button>
  );
}

Review This

Is the switch in useLiveFeed actually exhaustive, or did the AI write an if/else if chain that quietly drops connected? An if (msg.type === "squawk_alert") { ... } else if (msg.type === "overflight") { ... } chain with no final branch handles the two cases you asked for and looks done, but it isn’t the same guarantee as a switch with an explicit case for every union member. The difference doesn’t show up today – it shows up the day someone adds a fifth message type and nothing anywhere flags that this handler needs updating. Check that every branch of ServerToClientMessage has an explicit case, including a no-op one for connected. If the AI wrote an if chain instead: “rewrite the WS handler as a switch over msg.type with an explicit case for every branch of ServerToClientMessage, including connected as a no-op, so it stays exhaustive as the union grows.”

Did AlertFeed end up with one seen-state ref instead of two? This is the easiest of the three to get “mostly right” while still being wrong. If the AI collapses “should this row flash” and “should this count toward the unseen badge” into a single ref, one of two things breaks: either a freshly-arrived row stops flashing the instant you glance at the panel and close it, or the unseen badge never clears because opening the panel doesn’t mark anything as seen. Both look correct on a quick manual test where you only ever open the panel once. Check for two separate Set<string> refs, one captured exactly once at mount and never updated again, one refreshed every time open flips to true. If there’s only one: “AlertFeed needs two independent seen-state refs – one for the one-time flash, captured once at mount, and one for the unseen badge, refreshed every time the panel opens. Collapsing them into one breaks either the flash or the badge.”

Does the mute preference actually survive a reload, or does the persist effect stomp it on first mount? Same race as the airports toggle from the previous lesson, and just as easy for an AI assistant to reproduce even after fixing it once elsewhere, because nothing about this file visibly points back at that one. Mute the alerts, reload, and if it comes back unmuted, the persist effect ran unconditionally on its first pass and overwrote the stored value with the pre-hydration default before hydration landed. Check for the skippedFirstPersist guard. If it’s missing: “useAlertSounds has the same hydrate/persist race the airports toggle had – add a ref that skips the first run of the persist effect so it doesn’t overwrite the stored mute value with the pre-hydration default.”

Try It

  1. Restart the server and frontend, and open the browser console to watch for [live-feed] errors.
  2. Trigger a manual squawk alert the same way you did back in Module 2’s real-time-layer lesson:
    sql
    SELECT pg_notify('squawk_alert', '{"id":9001,"hex":"test01","flight":"TEST123","squawk":"7700","lat":40.6,"lon":-73.8,"altBaroFt":5000,"detectedAt":"2024-01-01T00:00:00.000Z"}');
    
    Confirm the header badge appears with a count, a sound plays, and (if permission was granted) a browser Notification pops up.
  3. Open the alert feed panel and confirm the new row has the flash treatment, and that the unseen badge clears once you’ve opened it. Close and reopen the panel and confirm that same row does not flash a second time.
  4. Click the mute toggle, trigger another test alert, and confirm it’s silent but still appears in the feed – muting should be audio/Notification-only, never visual.
  5. Reload the page and confirm the mute state you left it in is exactly what it loads back into.

Recap

  • ServerToClientMessage’s squawk_alert and overflight branches existed on the wire since Module 2 – this lesson was entirely about directing the frontend to finally do something with them.
  • An exhaustive switch is a real guarantee an if/else if chain isn’t – worth checking by eye, since both compile and both pass a quick manual test today.
  • AlertFeed’s flash state and unseen-badge state are two different questions and need two different refs, not one shared one.
  • The hydrate/persist race is showing up for the second time in two lessons – once you’ve caught it once, checking for it becomes a five-second habit, which is exactly the point of building that habit early.

Next lesson: home location and the overflight log.