> ## Documentation Index
> Fetch the complete documentation index at: https://docs.canadava.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Active VATSIM Airports

> Live VATSIM ATIS coverage across vACA's eight covered Canadian airports. See which fields have an ATIS controller online right now, view the full broadcast and current METAR, and jump straight to the field briefing.

export const ActiveAtis = ({airports = [], title = "Active VATSIM Airports", loadingLabel = "Tuning broadcasts", errorLabel = "Unable to reach VATSIM data feed", refreshLabel = "Refresh", offlineLabel = "No ATIS online", frequencyLabel = "Frequency", transcriptLabel = "Transcript", viewBriefingLabel = "View briefing", observedLabel = "Updated", agoLabel = "min ago", justNowLabel = "just now", noActiveLabel = "No VATSIM ATIS broadcasts in coverage right now. The feed refreshes every minute.", countSuffixLabel = "live", arrivalsLabel = "Arrivals", departuresLabel = "Departures", weatherLabel = "Weather", windLabel = "Wind", visibilityLabel = "Visibility", cloudsLabel = "Clouds", tempDewLabel = "Temp · Dew", altimeterLabel = "Altimeter", calmLabel = "Calm", variableLabel = "Variable", gustingLabel = "gusting", fromLabel = "from", atLabel = "at", metarObservedLabel = "Observed", metarLoadingLabel = "Fetching latest observation", metarErrorLabel = "Unable to fetch live METAR."}) => {
  const WX_CODES = {
    MI: "shallow",
    PR: "partial",
    BC: "patches",
    DR: "drifting",
    BL: "blowing",
    SH: "showers",
    TS: "thunderstorm",
    FZ: "freezing",
    DZ: "drizzle",
    RA: "rain",
    SN: "snow",
    SG: "snow grains",
    IC: "ice crystals",
    PL: "ice pellets",
    GR: "hail",
    GS: "small hail",
    UP: "unknown precip",
    BR: "mist",
    FG: "fog",
    FU: "smoke",
    VA: "volcanic ash",
    DU: "dust",
    SA: "sand",
    HZ: "haze",
    PY: "spray",
    PO: "dust whirls",
    SQ: "squalls",
    FC: "funnel cloud",
    SS: "sandstorm",
    DS: "duststorm"
  };
  const CLOUD_LABELS = {
    FEW: "Few",
    SCT: "Scattered",
    BKN: "Broken",
    OVC: "Overcast",
    CLR: "Clear",
    SKC: "Sky clear",
    NCD: "No clouds",
    NSC: "No significant clouds",
    VV: "Vertical visibility"
  };
  const parseMetar = input => {
    if (!input || typeof input !== "string") return null;
    const beforeRmk = input.split(/\s+RMK\b/)[0].trim();
    if (beforeRmk.length < 10) return null;
    const tokens = beforeRmk.split(/\s+/);
    let i = 0;
    if (tokens[i] === "METAR" || tokens[i] === "SPECI") i++;
    let station = null;
    if ((/^[A-Z]{4}$/).test(tokens[i] || "")) station = tokens[i++];
    let time = null;
    const tm = (tokens[i] || "").match(/^(\d{2})(\d{2})(\d{2})Z$/);
    if (tm) {
      time = {
        day: +tm[1],
        hour: +tm[2],
        minute: +tm[3]
      };
      i++;
    }
    while (tokens[i] === "AUTO" || tokens[i] === "COR" || tokens[i] === "NIL") i++;
    let wind = null;
    const wt = tokens[i] || "";
    if ((/^00000(KT|MPS|KMH)$/).test(wt)) {
      wind = {
        calm: true
      };
      i++;
    } else {
      const wm = wt.match(/^(VRB|\d{3})(\d{2,3})(?:G(\d{2,3}))?(KT|MPS|KMH)$/);
      if (wm) {
        wind = {
          dir: wm[1] === "VRB" ? "VRB" : +wm[1],
          speed: +wm[2],
          gust: wm[3] ? +wm[3] : null,
          unit: wm[4]
        };
        i++;
        if ((/^\d{3}V\d{3}$/).test(tokens[i] || "")) i++;
      }
    }
    let visibility = null;
    if (tokens[i] === "CAVOK") {
      visibility = {
        cavok: true
      };
      i++;
    } else if ((/^\d{4}$/).test(tokens[i] || "")) {
      visibility = {
        value: +tokens[i],
        unit: "m"
      };
      i++;
    } else if ((/^\d+$/).test(tokens[i] || "") && (/^\d+\/\d+SM$/).test(tokens[i + 1] || "")) {
      const whole = +tokens[i];
      const fm = tokens[i + 1].match(/^(\d+)\/(\d+)SM$/);
      visibility = {
        value: whole + +fm[1] / +fm[2],
        unit: "SM"
      };
      i += 2;
    } else {
      const sm = (tokens[i] || "").match(/^(M)?(\d+(?:\/\d+)?)SM$/);
      if (sm) {
        const v = sm[2].includes("/") ? +sm[2].split("/")[0] / +sm[2].split("/")[1] : +sm[2];
        visibility = {
          value: v,
          unit: "SM",
          lessThan: !!sm[1]
        };
        i++;
      }
    }
    const weather = [];
    const wxRe = /^(VC|\+|-)?((?:MI|PR|BC|DR|BL|SH|TS|FZ){0,2})((?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)+)$/;
    while (i < tokens.length && wxRe.test(tokens[i])) {
      weather.push(tokens[i]);
      i++;
    }
    const clouds = [];
    while (i < tokens.length) {
      const t = tokens[i];
      if (t === "CLR" || t === "SKC" || t === "NCD" || t === "NSC") {
        clouds.push({
          cover: t,
          base: null
        });
        i++;
      } else {
        const cm = t.match(/^(FEW|SCT|BKN|OVC)(\d{3})(CB|TCU)?$/);
        if (cm) {
          clouds.push({
            cover: cm[1],
            base: +cm[2] * 100,
            type: cm[3] || null
          });
          i++;
          continue;
        }
        const vvm = t.match(/^VV(\d{3}|\/\/\/)$/);
        if (vvm) {
          clouds.push({
            cover: "VV",
            base: vvm[1] === "///" ? null : +vvm[1] * 100
          });
          i++;
          continue;
        }
        break;
      }
    }
    let temp = null, dewpoint = null;
    const td = (tokens[i] || "").match(/^(M)?(\d{2})\/(M)?(\d{2})$/);
    if (td) {
      temp = (td[1] ? -1 : 1) * +td[2];
      dewpoint = (td[3] ? -1 : 1) * +td[4];
      i++;
    }
    let altimeter = null;
    const altA = (tokens[i] || "").match(/^A(\d{4})$/);
    const altQ = (tokens[i] || "").match(/^Q(\d{4})$/);
    if (altA) {
      altimeter = {
        value: +altA[1] / 100,
        unit: "inHg"
      };
      i++;
    } else if (altQ) {
      altimeter = {
        value: +altQ[1],
        unit: "hPa"
      };
      i++;
    }
    const ceilingLayer = clouds.find(c => c.cover === "BKN" || c.cover === "OVC");
    const ceiling = ceilingLayer ? ceilingLayer.base : null;
    let visSM = Infinity;
    if (visibility?.cavok) visSM = 10; else if (visibility?.unit === "SM") visSM = visibility.value; else if (visibility?.unit === "m") visSM = visibility.value / 1609;
    let flightCategory = "VFR";
    if (ceiling !== null && ceiling < 500 || visSM < 1) flightCategory = "LIFR"; else if (ceiling !== null && ceiling < 1000 || visSM < 3) flightCategory = "IFR"; else if (ceiling !== null && ceiling < 3000 || visSM <= 5) flightCategory = "MVFR";
    return {
      station,
      time,
      wind,
      visibility,
      weather,
      clouds,
      temp,
      dewpoint,
      altimeter,
      flightCategory,
      raw: input
    };
  };
  const normalized = (airports || []).filter(a => a && a.icao).map(a => ({
    ...a,
    icao: a.icao.toUpperCase()
  }));
  const icaoSet = normalized.map(a => a.icao);
  const [feed, setFeed] = useState(null);
  const [metars, setMetars] = useState({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [refreshing, setRefreshing] = useState(false);
  const [expanded, setExpanded] = useState(() => new Set());
  const [nowTick, setNowTick] = useState(Date.now());
  const fetchFeed = async isUserRefresh => {
    if (icaoSet.length === 0) return;
    try {
      if (isUserRefresh) setRefreshing(true); else setLoading(true);
      setError(null);
      const res = await fetch("https://data.vatsim.net/v3/vatsim-data.json", {
        cache: "no-store"
      });
      if (!res.ok) throw new Error("http");
      const json = await res.json();
      const all = Array.isArray(json.atis) ? json.atis : [];
      const re = new RegExp(`^(${icaoSet.join("|")})(?:_[AD])?_ATIS$`, "i");
      const byIcao = {};
      for (const a of all) {
        const cs = (a.callsign || "").toUpperCase();
        const m = cs.match(re);
        if (!m) continue;
        const base = m[1];
        const split = cs.includes("_A_ATIS") ? "arrival" : cs.includes("_D_ATIS") ? "departure" : null;
        const text = Array.isArray(a.text_atis) ? a.text_atis.join(" ") : "";
        const broadcast = {
          callsign: a.callsign,
          atisCode: a.atis_code || "",
          frequency: a.frequency,
          text,
          lastUpdated: a.last_updated,
          split
        };
        if (!byIcao[base]) byIcao[base] = [];
        byIcao[base].push(broadcast);
      }
      setFeed(byIcao);
    } catch (_) {
      setError("fetch");
    } finally {
      setLoading(false);
      if (isUserRefresh) setTimeout(() => setRefreshing(false), 600);
    }
  };
  const fetchOneMetar = async code => {
    try {
      const res = await fetch(`https://metar.vatsim.net/${code}`, {
        cache: "no-store"
      });
      if (!res.ok) return null;
      const text = (await res.text()).trim();
      if (!text || !(/[A-Z]{4}\s+\d{6}Z/).test(text)) return null;
      return text;
    } catch (_) {
      return null;
    }
  };
  const fetchAllMetars = async () => {
    if (icaoSet.length === 0) return;
    const entries = await Promise.all(icaoSet.map(async code => [code, await fetchOneMetar(code)]));
    const next = {};
    for (const [code, raw] of entries) next[code] = raw;
    setMetars(next);
  };
  useEffect(() => {
    fetchFeed(false);
    const interval = setInterval(() => fetchFeed(false), 60 * 1000);
    return () => clearInterval(interval);
  }, []);
  useEffect(() => {
    fetchAllMetars();
    const interval = setInterval(fetchAllMetars, 5 * 60 * 1000);
    return () => clearInterval(interval);
  }, []);
  useEffect(() => {
    const t = setInterval(() => setNowTick(Date.now()), 30 * 1000);
    return () => clearInterval(t);
  }, []);
  const toggle = icao => {
    setExpanded(prev => {
      const next = new Set(prev);
      if (next.has(icao)) next.delete(icao); else next.add(icao);
      return next;
    });
  };
  const formatFreq = f => {
    if (!f) return "---.---";
    const n = parseFloat(f);
    if (Number.isNaN(n)) return f;
    return n.toFixed(3);
  };
  const ageLabel = iso => {
    if (!iso) return null;
    const t = Date.parse(iso);
    if (Number.isNaN(t)) return null;
    const diffMin = Math.max(0, Math.round((nowTick - t) / 60000));
    if (diffMin === 0) return justNowLabel;
    return `${diffMin} ${agoLabel}`;
  };
  const formatWind = w => {
    if (!w) return "—";
    if (w.calm) return calmLabel;
    const dir = w.dir === "VRB" ? variableLabel : `${String(w.dir).padStart(3, "0")}°`;
    let s = `${fromLabel} ${dir} ${atLabel} ${w.speed} kt`;
    if (w.gust) s += `, ${gustingLabel} ${w.gust} kt`;
    return s;
  };
  const formatVisibility = v => {
    if (v.cavok) return "CAVOK";
    if (v.unit === "SM") {
      const prefix = v.lessThan ? "<" : "";
      const val = Number.isInteger(v.value) ? v.value : v.value.toFixed(1);
      return `${prefix}${val} SM`;
    }
    if (v.unit === "m") {
      if (v.value >= 9999) return "≥10 km";
      if (v.value >= 1000) return `${(v.value / 1000).toFixed(1)} km`;
      return `${v.value} m`;
    }
    return "";
  };
  const formatWeather = wxList => wxList.map(tok => {
    let intensity = "";
    let rest = tok;
    if (rest.startsWith("+")) {
      intensity = "Heavy ";
      rest = rest.slice(1);
    } else if (rest.startsWith("-")) {
      intensity = "Light ";
      rest = rest.slice(1);
    } else if (rest.startsWith("VC")) {
      intensity = "Vicinity ";
      rest = rest.slice(2);
    }
    const parts = [];
    for (let p = 0; p < rest.length; p += 2) {
      const w = WX_CODES[rest.substr(p, 2)];
      if (w) parts.push(w);
    }
    const phrase = (intensity + parts.join(" ")).trim();
    return phrase.charAt(0).toUpperCase() + phrase.slice(1);
  }).join(", ");
  const formatClouds = cs => cs.map(c => {
    const lbl = CLOUD_LABELS[c.cover] || c.cover;
    if (c.base == null) return lbl;
    const ft = c.base.toLocaleString("en-US");
    const type = c.type === "CB" ? " (CB)" : c.type === "TCU" ? " (TCU)" : "";
    return `${lbl} ${ft} ft${type}`;
  }).join(", ");
  const formatTempDew = (t, d) => {
    if (t == null) return null;
    const toF = c => Math.round(c * 9 / 5 + 32);
    let s = `${t}°C / ${toF(t)}°F`;
    if (d != null) s += ` · ${d}°C / ${toF(d)}°F`;
    return s;
  };
  const formatAltimeter = a => {
    if (a.unit === "inHg") return `${a.value.toFixed(2)} inHg · ${Math.round(a.value * 33.8639)} hPa`;
    return `${a.value} hPa · ${(a.value / 33.8639).toFixed(2)} inHg`;
  };
  const formatMetarObserved = metar => {
    if (!metar?.time) return null;
    const now = new Date();
    let year = now.getUTCFullYear();
    let month = now.getUTCMonth();
    if (metar.time.day > now.getUTCDate()) {
      month -= 1;
      if (month < 0) {
        month = 11;
        year -= 1;
      }
    }
    const obs = new Date(Date.UTC(year, month, metar.time.day, metar.time.hour, metar.time.minute));
    if (isNaN(obs)) return null;
    const hh = String(obs.getUTCHours()).padStart(2, "0");
    const mm = String(obs.getUTCMinutes()).padStart(2, "0");
    const diffMin = Math.max(0, Math.round((nowTick - obs.getTime()) / 60000));
    return `${metarObservedLabel} ${hh}${mm}Z · ${diffMin === 0 ? justNowLabel : `${diffMin} ${agoLabel}`}`;
  };
  const rows = normalized.map(a => {
    const broadcasts = feed && feed[a.icao] ? feed[a.icao] : [];
    broadcasts.sort((x, y) => {
      const order = {
        arrival: 0,
        departure: 1
      };
      const a1 = x.split == null ? 2 : order[x.split];
      const b1 = y.split == null ? 2 : order[y.split];
      return a1 - b1;
    });
    const primary = broadcasts[0] || null;
    return {
      meta: a,
      broadcasts,
      isOnline: broadcasts.length > 0,
      primary
    };
  }).sort((x, y) => {
    if (x.isOnline !== y.isOnline) return x.isOnline ? -1 : 1;
    return x.meta.icao.localeCompare(y.meta.icao);
  });
  const onlineCount = rows.filter(r => r.isOnline).length;
  const total = rows.length;
  const anyOnline = onlineCount > 0;
  const mostRecentUpdate = rows.flatMap(r => r.broadcasts.map(b => b.lastUpdated)).filter(Boolean).sort().pop();
  const renderMetarCard = (code, name) => {
    const raw = metars[code];
    if (raw === undefined) {
      return <div className="kb-metar">
          <div className="kb-metar-header">
            <span className="kb-metar-eyebrow">{weatherLabel}</span>
          </div>
          <div className="kb-metar-body">
            <div className="kb-metar-content">
              <div className="kb-metar-skeleton kb-metar-skeleton--long" />
              <div className="kb-metar-skeleton kb-metar-skeleton--short" />
              <div className="kb-metar-meta">
                <span className="kb-metar-meta-status">{metarLoadingLabel}</span>
              </div>
            </div>
          </div>
        </div>;
    }
    if (raw === null) {
      return <div className="kb-metar">
          <div className="kb-metar-header">
            <span className="kb-metar-eyebrow">{weatherLabel}</span>
          </div>
          <div className="kb-metar-body">
            <div className="kb-metar-content">
              <div className="kb-metar-error">{metarErrorLabel}</div>
            </div>
          </div>
        </div>;
    }
    const metar = parseMetar(raw);
    if (!metar) {
      return <div className="kb-metar">
          <div className="kb-metar-header">
            <span className="kb-metar-eyebrow">{weatherLabel}</span>
          </div>
          <div className="kb-metar-body">
            <div className="kb-metar-content">
              <pre className="kb-metar-raw">{raw}</pre>
            </div>
          </div>
        </div>;
    }
    const decoder = `https://metar-taf.com/${code}`;
    const windDeg = typeof metar.wind?.dir === "number" ? metar.wind.dir : null;
    const obsLabel = formatMetarObserved(metar);
    return <div className="kb-metar">
        <div className="kb-metar-header">
          <span className="kb-metar-eyebrow">{weatherLabel}</span>
          <a className="kb-metar-decoder-link" href={decoder} target="_blank" rel="noopener noreferrer">
            Metar & Taf Visual decoder
            <svg className="kb-metar-decoder-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M7 7h10v10" />
              <path d="M7 17 17 7" />
            </svg>
          </a>
        </div>
        <div className="kb-metar-body">
          <div className="kb-metar-icon" aria-hidden="true">
            <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2" />
              <path d="M9.6 4.6A2 2 0 1 1 11 8H2" />
              <path d="M12.6 19.4A2 2 0 1 0 14 16H2" />
            </svg>
          </div>
          <div className="kb-metar-content">
            <div className="kb-metar-title">
              <span className="kb-metar-icao">{code}</span>
              {name ? <span className="kb-metar-name">{name}</span> : null}
              {metar.flightCategory && <span className={`kb-metar-cat kb-metar-cat--${metar.flightCategory.toLowerCase()}`}>
                  {metar.flightCategory}
                </span>}
            </div>
            <pre className="kb-metar-raw">{metar.raw}</pre>
            <div className="kb-metar-decoded">
              {metar.wind && <div className="kb-metar-stat">
                  <span className="kb-metar-stat-label">{windLabel}</span>
                  <span className="kb-metar-stat-value">
                    {windDeg !== null && <svg className="kb-metar-wind-arrow" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{
      transform: `rotate(${windDeg}deg)`
    }}>
                        <polygon points="12 2 19 21 12 17 5 21 12 2" />
                      </svg>}
                    {formatWind(metar.wind)}
                  </span>
                </div>}
              {metar.visibility && <div className="kb-metar-stat">
                  <span className="kb-metar-stat-label">{visibilityLabel}</span>
                  <span className="kb-metar-stat-value">{formatVisibility(metar.visibility)}</span>
                </div>}
              {metar.weather.length > 0 && <div className="kb-metar-stat">
                  <span className="kb-metar-stat-label">{weatherLabel}</span>
                  <span className="kb-metar-stat-value">{formatWeather(metar.weather)}</span>
                </div>}
              {metar.clouds.length > 0 && <div className="kb-metar-stat">
                  <span className="kb-metar-stat-label">{cloudsLabel}</span>
                  <span className="kb-metar-stat-value">{formatClouds(metar.clouds)}</span>
                </div>}
              {metar.temp != null && <div className="kb-metar-stat">
                  <span className="kb-metar-stat-label">{tempDewLabel}</span>
                  <span className="kb-metar-stat-value">{formatTempDew(metar.temp, metar.dewpoint)}</span>
                </div>}
              {metar.altimeter && <div className="kb-metar-stat">
                  <span className="kb-metar-stat-label">{altimeterLabel}</span>
                  <span className="kb-metar-stat-value">{formatAltimeter(metar.altimeter)}</span>
                </div>}
            </div>
            {obsLabel && <div className="kb-metar-meta">
                <span className="kb-metar-meta-time">{obsLabel}</span>
              </div>}
          </div>
        </div>
      </div>;
  };
  const renderRow = row => {
    const {meta, broadcasts, isOnline, primary} = row;
    const isExpanded = expanded.has(meta.icao);
    const code = primary?.atisCode || "—";
    const handleClick = () => {
      if (isOnline) toggle(meta.icao);
    };
    return <div key={meta.icao} className={"kb-active-atis-row" + (isOnline ? " kb-active-atis-row--online" : " kb-active-atis-row--offline") + (isExpanded ? " kb-active-atis-row--expanded" : "")}>
        <button type="button" className="kb-active-atis-row-header" onClick={handleClick} disabled={!isOnline} aria-expanded={isOnline ? isExpanded : undefined} aria-controls={isOnline ? `aa-${meta.icao}-detail` : undefined}>
          <span className={"kb-active-atis-dot" + (isOnline ? " kb-active-atis-dot--on" : "")} aria-hidden="true" />
          <span className="kb-active-atis-icao">{meta.icao}</span>
          <span className="kb-active-atis-name-col">
            <span className="kb-active-atis-name">{meta.name}</span>
            {!isOnline && <span className="kb-active-atis-offline-label">{offlineLabel}</span>}
          </span>
          {isOnline && <span className="kb-active-atis-info-letter" aria-label={`Info ${code}`}>
              {code}
            </span>}
          {isOnline && primary?.frequency && <span className="kb-active-atis-freq">{formatFreq(primary.frequency)}</span>}
          {isOnline && broadcasts.length > 1 && <span className="kb-active-atis-split-count" title={`${broadcasts.length} splits`}>
              {broadcasts.length}×
            </span>}
          {isOnline && <span className="kb-active-atis-chevron" aria-hidden="true">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <polyline points="9 18 15 12 9 6" />
              </svg>
            </span>}
        </button>

        {isOnline && isExpanded && <div id={`aa-${meta.icao}-detail`} className="kb-active-atis-detail" role="region">
            <div className="kb-active-atis-broadcasts">
              {broadcasts.map((b, i) => <div className="kb-active-atis-broadcast" key={`${meta.icao}-${i}`}>
                  <div className="kb-active-atis-broadcast-head">
                    <span className="kb-active-atis-broadcast-callsign">
                      {b.callsign}
                      {b.split && <span className="kb-active-atis-split-tag">
                          {b.split === "arrival" ? arrivalsLabel : departuresLabel}
                        </span>}
                    </span>
                    {b.frequency && <span className="kb-active-atis-broadcast-freq">
                        <span className="kb-active-atis-broadcast-freq-label">{frequencyLabel}</span>
                        <span className="kb-active-atis-broadcast-freq-value">{formatFreq(b.frequency)}</span>
                      </span>}
                    {b.lastUpdated && <span className="kb-active-atis-broadcast-age">
                        {observedLabel} {ageLabel(b.lastUpdated)}
                      </span>}
                  </div>
                  {b.text && <div className="kb-active-atis-transcript-wrap">
                      <span className="kb-active-atis-transcript-label">{transcriptLabel}</span>
                      <pre className="kb-active-atis-transcript">{b.text}</pre>
                    </div>}
                </div>)}
            </div>

            <div className="kb-active-atis-metar-wrap">
              {renderMetarCard(meta.icao, meta.name)}
            </div>

            {meta.href && <a className="kb-active-atis-cta" href={meta.href}>
                <span className="kb-active-atis-cta-text">
                  {viewBriefingLabel}
                  <span className="kb-active-atis-cta-name">{meta.name}</span>
                </span>
                <span className="kb-active-atis-cta-arrow" aria-hidden="true">
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <line x1="5" y1="12" x2="19" y2="12" />
                    <polyline points="13 6 19 12 13 18" />
                  </svg>
                </span>
              </a>}
          </div>}
      </div>;
  };
  return <div className="kb-active-atis">
      <div className="kb-active-atis-header">
        <span className="kb-active-atis-eyebrow">
          <span className={"kb-active-atis-dot" + (anyOnline ? " kb-active-atis-dot--on" : "")} aria-hidden="true" />
          {title}
        </span>
        <div className="kb-active-atis-header-right">
          {!loading && !error && <span className={"kb-active-atis-count" + (anyOnline ? " kb-active-atis-count--on" : "")}>
              <span className="kb-active-atis-count-num">{onlineCount}</span>
              <span className="kb-active-atis-count-sep">/</span>
              <span className="kb-active-atis-count-total">{total}</span>
              <span className="kb-active-atis-count-label">{countSuffixLabel}</span>
            </span>}
          <button type="button" className={"kb-active-atis-refresh" + (refreshing ? " refreshing" : "")} onClick={() => fetchFeed(true)} aria-label={refreshLabel} title={refreshLabel}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
              <path d="M21 3v5h-5" />
              <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
              <path d="M3 21v-5h5" />
            </svg>
          </button>
        </div>
      </div>

      {loading && <div className="kb-active-atis-loading">
          <div className="kb-active-atis-skeleton kb-active-atis-skeleton--long" />
          <div className="kb-active-atis-skeleton kb-active-atis-skeleton--short" />
          <div className="kb-active-atis-skeleton kb-active-atis-skeleton--long" />
          <div className="kb-active-atis-meta-status">{loadingLabel}</div>
        </div>}

      {!loading && error && <div className="kb-active-atis-message">{errorLabel}</div>}

      {!loading && !error && <div className="kb-active-atis-list">{rows.map(renderRow)}</div>}

      {!loading && !error && !anyOnline && <div className="kb-active-atis-empty">{noActiveLabel}</div>}

      <div className="kb-active-atis-meta">
        {mostRecentUpdate && <span className="kb-active-atis-meta-time">
            {observedLabel} {ageLabel(mostRecentUpdate)}
          </span>}
      </div>
    </div>;
};

This page is a live monitor of VATSIM ATIS coverage at the eight Canadian airports covered by vACA. Online fields appear at the top with their current information letter and frequency. Click any live row to reveal the full ATIS transcript, the latest <Tooltip tip="Meteorological Aerodrome Report" cta="See glossary" href="/aops/additional-resources/glossary/metar">METAR</Tooltip>, and a shortcut to the field briefing.

Data comes from the public VATSIM data feed and refreshes once a minute - useful for a quick pre-push glance to see whether the field you're departing or arriving at has an ATIS controller online.

<ActiveAtis
  airports={[
{ icao: "CYYZ", name: "Toronto Pearson", href: "/aops/airports/toronto-pearson" },
{ icao: "CYUL", name: "Montréal-Trudeau", href: "/aops/airports/montreal-trudeau" },
{ icao: "CYVR", name: "Vancouver", href: "/aops/airports/vancouver-international" },
{ icao: "CYYC", name: "Calgary", href: "/aops/airports/calgary-international" },
{ icao: "CYOW", name: "Ottawa", href: "/aops/airports/ottawa" },
{ icao: "CYQB", name: "Québec City", href: "/aops/airports/quebec-city" },
{ icao: "CYEG", name: "Edmonton", href: "/aops/airports/edmonton" },
{ icao: "CYHZ", name: "Halifax", href: "/aops/airports/halifax" }
]}
/>

<Card title="Back to Airports" icon="arrow-left" href="/aops/airports/index">
  Return to the airport briefings overview
</Card>
