From 4974780d89ab6183ac52c494a0b3c4389e1d8d65 Mon Sep 17 00:00:00 2001 From: scott Date: Mon, 16 Feb 2026 18:43:34 -0800 Subject: [PATCH] Add TFR warning banner for airspace restrictions Fetches active Temporary Flight Restrictions from the FAA website, filters by configured state (LOCATION_STATE env var), and displays a red warning banner at the top of the dashboard when TFRs are present. Data is cached for 30 minutes and degrades gracefully if the FAA is unreachable. Co-Authored-By: Claude Opus 4.6 --- backend/cmd/api/main.go | 69 +++++++++++ backend/internal/client/faa.go | 136 +++++++++++++++++++++ backend/internal/config/config.go | 3 + backend/internal/model/tfr.go | 12 ++ backend/internal/server/routes.go | 6 + frontend/app/page.tsx | 11 +- frontend/components/weather/index.ts | 1 + frontend/components/weather/tfr-banner.tsx | 57 +++++++++ frontend/hooks/use-weather.ts | 16 ++- frontend/lib/api.ts | 10 ++ frontend/lib/types.ts | 18 +++ 11 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 backend/internal/client/faa.go create mode 100644 backend/internal/model/tfr.go create mode 100644 frontend/components/weather/tfr-banner.tsx diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 0110c70..56384ea 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -8,6 +8,8 @@ import ( "net/http" "os" "os/signal" + "strings" + "sync" "syscall" "time" @@ -78,6 +80,9 @@ func main() { Logger: logger, }) + // Create FAA TFR client + faaClient := client.NewFAAClient() + // Create HTTP server srv := server.New(cfg.Addr(), logger) @@ -88,6 +93,7 @@ func main() { config: cfg, weatherSvc: weatherSvc, assessmentSvc: assessmentSvc, + faaClient: faaClient, } srv.SetupRoutes(handler) @@ -194,6 +200,12 @@ type Handler struct { config *config.Config weatherSvc *service.WeatherService assessmentSvc *service.AssessmentService + faaClient *client.FAAClient + + // TFR cache + tfrCache []model.TFR + tfrCacheAt time.Time + tfrCacheMu sync.RWMutex } // Health handles health check requests @@ -371,6 +383,63 @@ func (h *Handler) AssessConditions(w http.ResponseWriter, r *http.Request) { server.RespondJSON(w, 200, response) } +const tfrCacheTTL = 30 * time.Minute + +// GetTFRs handles airspace TFR requests +func (h *Handler) GetTFRs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Check cache + h.tfrCacheMu.RLock() + if h.tfrCache != nil && time.Since(h.tfrCacheAt) < tfrCacheTTL { + tfrs := h.tfrCache + h.tfrCacheMu.RUnlock() + server.RespondJSON(w, 200, map[string]interface{}{ + "tfrs": tfrs, + "count": len(tfrs), + "last_checked": h.tfrCacheAt.UTC(), + }) + return + } + h.tfrCacheMu.RUnlock() + + // Fetch from FAA + allTfrs, err := h.faaClient.FetchTFRs(ctx) + if err != nil { + h.logger.Error("failed to fetch TFRs", "error", err) + // Return empty on error — don't show banner + server.RespondJSON(w, 200, map[string]interface{}{ + "tfrs": []model.TFR{}, + "count": 0, + "last_checked": time.Now().UTC(), + }) + return + } + + // Filter by configured state + var filtered []model.TFR + for _, tfr := range allTfrs { + if strings.EqualFold(tfr.State, h.config.LocationState) { + filtered = append(filtered, tfr) + } + } + if filtered == nil { + filtered = []model.TFR{} + } + + // Update cache + h.tfrCacheMu.Lock() + h.tfrCache = filtered + h.tfrCacheAt = time.Now() + h.tfrCacheMu.Unlock() + + server.RespondJSON(w, 200, map[string]interface{}{ + "tfrs": filtered, + "count": len(filtered), + "last_checked": time.Now().UTC(), + }) +} + // WeatherFetcher runs background weather fetching type WeatherFetcher struct { logger *slog.Logger diff --git a/backend/internal/client/faa.go b/backend/internal/client/faa.go new file mode 100644 index 0000000..3f6a269 --- /dev/null +++ b/backend/internal/client/faa.go @@ -0,0 +1,136 @@ +package client + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/scottyah/paragliding/internal/model" +) + +const ( + faaTFRListURL = "https://tfr.faa.gov/tfr2/list.jsp" + faaBaseURL = "https://tfr.faa.gov" +) + +// FAAClient fetches TFR data from the FAA website +type FAAClient struct { + httpClient *http.Client +} + +// NewFAAClient creates a new FAA TFR client +func NewFAAClient() *FAAClient { + return &FAAClient{ + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +var ( + anchorRe = regexp.MustCompile(`(?is)]*)>(.*?)`) + hrefRe = regexp.MustCompile(`(?i)href="([^"]*)"`) + dateRe = regexp.MustCompile(`^\d{2}/\d{2}/\d{4}$`) + htmlTagRe = regexp.MustCompile(`<[^>]*>`) +) + +type anchor struct { + href string + text string +} + +// FetchTFRs retrieves the list of active TFRs from the FAA website +func (c *FAAClient) FetchTFRs(ctx context.Context) ([]model.TFR, error) { + req, err := http.NewRequestWithContext(ctx, "GET", faaTFRListURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", "ParaglidingWeatherApp/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch TFR list: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TFR list returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + return parseTFRList(string(body)), nil +} + +// extractAnchors finds all tags in HTML and returns their href and text content +func extractAnchors(html string) []anchor { + matches := anchorRe.FindAllStringSubmatch(html, -1) + anchors := make([]anchor, 0, len(matches)) + for _, m := range matches { + text := htmlTagRe.ReplaceAllString(m[2], "") + text = strings.TrimSpace(text) + a := anchor{text: text} + if hrefMatch := hrefRe.FindStringSubmatch(m[1]); hrefMatch != nil { + a.href = hrefMatch[1] + } + anchors = append(anchors, a) + } + return anchors +} + +// parseTFRList extracts TFR entries from the FAA list page HTML. +// The page contains a table where each row has anchor tags for: +// date, notam ID (with detail link), facility, state, type, description, zoom link +func parseTFRList(html string) []model.TFR { + rows := strings.Split(html, " + {/* TFR Warning */} + {tfrData && tfrData.tfrs.length > 0 && ( + + )} + {/* Stale Data Warning */} diff --git a/frontend/components/weather/index.ts b/frontend/components/weather/index.ts index 8a77b91..3cfd1d8 100644 --- a/frontend/components/weather/index.ts +++ b/frontend/components/weather/index.ts @@ -4,3 +4,4 @@ export { WindDirectionChart } from './wind-direction-chart' export { ThresholdControls } from './threshold-controls' export { RefreshCountdown } from './refresh-countdown' export { StaleDataBanner } from './stale-data-banner' +export { TfrBanner } from './tfr-banner' diff --git a/frontend/components/weather/tfr-banner.tsx b/frontend/components/weather/tfr-banner.tsx new file mode 100644 index 0000000..87fa794 --- /dev/null +++ b/frontend/components/weather/tfr-banner.tsx @@ -0,0 +1,57 @@ +'use client' + +import { AlertTriangle, ExternalLink } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TFR } from '@/lib/types' + +interface TfrBannerProps { + tfrs: TFR[] + className?: string +} + +export function TfrBanner({ tfrs, className }: TfrBannerProps) { + if (!tfrs || tfrs.length === 0) return null + + return ( +
+ +
+

+ {tfrs.length} Active TFR{tfrs.length !== 1 ? 's' : ''} in Area +

+
+ + View all TFRs on FAA website + +
+
+ ) +} diff --git a/frontend/hooks/use-weather.ts b/frontend/hooks/use-weather.ts index 9f34403..aae4f36 100644 --- a/frontend/hooks/use-weather.ts +++ b/frontend/hooks/use-weather.ts @@ -1,8 +1,8 @@ 'use client' import { useQuery } from '@tanstack/react-query' -import { getCurrentWeather, getForecast, getHistorical } from '@/lib/api' -import type { CurrentWeatherResponse, ForecastResponse, HistoricalResponse } from '@/lib/types' +import { getCurrentWeather, getForecast, getHistorical, getTfrs } from '@/lib/api' +import type { CurrentWeatherResponse, ForecastResponse, HistoricalResponse, TFRResponse } from '@/lib/types' const STALE_TIME = 5 * 60 * 1000 // 5 minutes const REFETCH_INTERVAL = 5 * 60 * 1000 // 5 minutes @@ -35,3 +35,15 @@ export function useHistorical(date: string, lat?: number, lon?: number) { enabled: !!date, }) } + +const TFR_STALE_TIME = 30 * 60 * 1000 // 30 minutes +const TFR_REFETCH_INTERVAL = 30 * 60 * 1000 // 30 minutes + +export function useTfrs() { + return useQuery({ + queryKey: ['airspace', 'tfrs'], + queryFn: () => getTfrs(), + staleTime: TFR_STALE_TIME, + refetchInterval: TFR_REFETCH_INTERVAL, + }) +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index bbd8dc1..15a6c00 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -3,6 +3,7 @@ import { ForecastResponse, HistoricalResponse, AssessmentResponse, + TFRResponse, Thresholds, APIError, } from './types' @@ -166,6 +167,13 @@ class APIClient { } } + /** + * Get active TFRs (Temporary Flight Restrictions) near the configured location + */ + async getTfrs(): Promise { + return this.request('/airspace/tfrs') + } + /** * Assess current conditions with custom thresholds */ @@ -200,6 +208,8 @@ export const getForecast = (lat?: number, lon?: number) => export const getHistorical = (date: string, lat?: number, lon?: number) => apiClient.getHistorical(date, lat, lon) +export const getTfrs = () => apiClient.getTfrs() + export const assessWithThresholds = ( thresholds: Thresholds, lat?: number, diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index ac4ff9c..a20e821 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -85,6 +85,24 @@ export interface AssessmentResponse { thresholds_used: Thresholds } +// TFR (Temporary Flight Restriction) types + +export interface TFR { + date: string + notam_id: string + facility: string + state: string + type: string + description: string + detail_url: string +} + +export interface TFRResponse { + tfrs: TFR[] + count: number + last_checked: string +} + // Error response type export interface APIError { error: string