diff --git a/docs/plans/2026-03-22-weather-nearby-api.md b/docs/plans/2026-03-22-weather-nearby-api.md new file mode 100644 index 0000000..6fb9796 --- /dev/null +++ b/docs/plans/2026-03-22-weather-nearby-api.md @@ -0,0 +1,969 @@ +# Weather Nearby API Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add GET /api/v1/weather/nearby endpoint to find weather stations within a radius of a lat/lon point + +**Architecture:** Phoenix controller → PreparedQueries context function → PostGIS ST_DWithin query → JSON view. Uses existing spatial indexes and follows established API patterns. + +**Tech Stack:** Phoenix, Ecto, PostGIS, ExUnit + +--- + +## Task 1: Add Database Query Function + +**Files:** +- Modify: `lib/aprsme/packets/prepared_queries.ex:211` (add new function at end) +- Test: `test/aprsme/packets/prepared_queries_test.exs` (new file) + +**Step 1: Write the failing test** + +Create `test/aprsme/packets/prepared_queries_test.exs`: + +```elixir +defmodule Aprsme.Packets.PreparedQueriesTest do + use Aprsme.DataCase, async: false + + alias Aprsme.Packets.PreparedQueries + alias Aprsme.Repo + + describe "get_nearby_weather_stations/4" do + setup do + # San Francisco center point + center_lat = 37.7749 + center_lon = -122.4194 + + # Station A: ~0.5 miles north, weather 1 hour ago ✓ + {:ok, station_a} = + insert_weather_station("STATION-A", 37.7821, -122.4194, hours_ago: 1) + + # Station B: ~10 miles east, weather 2 hours ago ✓ + {:ok, station_b} = + insert_weather_station("STATION-B", 37.7749, -122.2694, hours_ago: 2) + + # Station C: ~50 miles south, weather 1 hour ago ✗ (outside 25 mile radius) + {:ok, _station_c} = + insert_weather_station("STATION-C", 37.0749, -122.4194, hours_ago: 1) + + # Station D: ~5 miles south, weather 8 hours ago ✗ (outside 6 hour window) + {:ok, _station_d} = + insert_weather_station("STATION-D", 37.7200, -122.4194, hours_ago: 8) + + # Station E: ~5 miles north, no weather data ✗ + {:ok, _station_e} = + insert_packet("STATION-E", 37.8300, -122.4194, has_weather: false, hours_ago: 1) + + %{ + center_lat: center_lat, + center_lon: center_lon, + station_a: station_a, + station_b: station_b + } + end + + test "returns weather stations within radius", %{ + center_lat: lat, + center_lon: lon, + station_a: station_a, + station_b: station_b + } do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + callsigns = Enum.map(results, & &1.callsign) + assert station_a.sender in callsigns + assert station_b.sender in callsigns + assert length(results) == 2 + end + + test "excludes stations outside radius", %{center_lat: lat, center_lon: lon} do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + callsigns = Enum.map(results, & &1.callsign) + refute "STATION-C" in callsigns + end + + test "excludes stations outside time window", %{center_lat: lat, center_lon: lon} do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{hours: 6}) + + callsigns = Enum.map(results, & &1.callsign) + refute "STATION-D" in callsigns + end + + test "excludes stations without weather data", %{center_lat: lat, center_lon: lon} do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + callsigns = Enum.map(results, & &1.callsign) + refute "STATION-E" in callsigns + end + + test "orders results by distance (closest first)", %{ + center_lat: lat, + center_lon: lon, + station_a: station_a, + station_b: station_b + } do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + assert length(results) == 2 + # Station A is ~0.5 miles, Station B is ~10 miles + assert List.first(results).callsign == station_a.sender + assert List.last(results).callsign == station_b.sender + assert List.first(results).distance_miles < List.last(results).distance_miles + end + + test "respects limit option", %{center_lat: lat, center_lon: lon} do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{limit: 1}) + + assert length(results) == 1 + end + + test "uses default hours of 6", %{center_lat: lat, center_lon: lon} do + # Station D is 8 hours old, should be excluded with default + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + callsigns = Enum.map(results, & &1.callsign) + refute "STATION-D" in callsigns + end + + test "returns distance in miles", %{center_lat: lat, center_lon: lon, station_a: station_a} do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + station_a_result = Enum.find(results, &(&1.callsign == station_a.sender)) + assert station_a_result.distance_miles > 0 + assert station_a_result.distance_miles < 1.0 + end + + test "includes weather data in results", %{center_lat: lat, center_lon: lon} do + results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{}) + + assert length(results) > 0 + first = List.first(results) + assert is_float(first.temperature) + assert is_float(first.humidity) + assert is_float(first.wind_speed) + end + + test "returns empty list when no stations in radius", _context do + # Middle of Pacific Ocean + results = PreparedQueries.get_nearby_weather_stations(0.0, -180.0, 10.0, %{}) + + assert results == [] + end + end + + defp insert_weather_station(callsign, lat, lon, opts) do + hours_ago = Keyword.get(opts, :hours_ago, 1) + + attrs = %{ + sender: callsign, + base_callsign: callsign, + destination: "APRS", + path: "WIDE1-1", + received_at: DateTime.add(DateTime.utc_now(), -hours_ago * 3600, :second), + has_position: true, + has_weather: true, + lat: Decimal.from_float(lat), + lon: Decimal.from_float(lon), + location: %Geo.Point{coordinates: {lon, lat}, srid: 4326}, + temperature: 72.5, + humidity: 65.0, + pressure: 1013.2, + wind_speed: 8.5, + wind_direction: 270, + wind_gust: 12.0, + symbol_table_id: "/", + symbol_code: "_", + comment: "Test weather station" + } + + %Aprsme.Packet{} + |> Aprsme.Packet.changeset(attrs) + |> Repo.insert() + end + + defp insert_packet(callsign, lat, lon, opts) do + hours_ago = Keyword.get(opts, :hours_ago, 1) + has_weather = Keyword.get(opts, :has_weather, false) + + attrs = %{ + sender: callsign, + base_callsign: callsign, + destination: "APRS", + path: "WIDE1-1", + received_at: DateTime.add(DateTime.utc_now(), -hours_ago * 3600, :second), + has_position: true, + has_weather: has_weather, + lat: Decimal.from_float(lat), + lon: Decimal.from_float(lon), + location: %Geo.Point{coordinates: {lon, lat}, srid: 4326}, + symbol_table_id: "/", + symbol_code: "/" + } + + %Aprsme.Packet{} + |> Aprsme.Packet.changeset(attrs) + |> Repo.insert() + end +end +``` + +**Step 2: Run test to verify it fails** + +Run: `mix test test/aprsme/packets/prepared_queries_test.exs` + +Expected: FAIL with "function PreparedQueries.get_nearby_weather_stations/4 is undefined" + +**Step 3: Write minimal implementation** + +Add to `lib/aprsme/packets/prepared_queries.ex` at line 211: + +```elixir + @doc """ + Get nearby weather stations within a radius (in miles) from a lat/lon point. + Returns stations with recent weather data, ordered by distance. + + ## Options + * `:hours` - Time window in hours (default: 6) + * `:limit` - Maximum results (default: 50) + + ## Returns + List of maps with station info, weather data, and distance in miles. + """ + @spec get_nearby_weather_stations(float(), float(), float(), map()) :: [map()] + def get_nearby_weather_stations(lat, lon, radius_miles, opts \\ %{}) do + hours = Map.get(opts, :hours, 6) + limit = Map.get(opts, :limit, 50) + + # Convert miles to meters for PostGIS (1 mile = 1609.34 meters) + radius_meters = radius_miles * 1609.34 + + cutoff_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) + + # Build point for distance calculation + point = %Geo.Point{coordinates: {lon, lat}, srid: 4326} + + # Subquery to get most recent weather packet per base_callsign + recent_weather = + from(p in Packet, + where: p.has_weather == true, + where: p.has_position == true, + where: p.received_at >= ^cutoff_time, + where: not is_nil(p.location), + where: + fragment( + "ST_DWithin(?::geography, ?::geography, ?)", + p.location, + ^point, + ^radius_meters + ), + distinct: p.base_callsign, + order_by: [asc: p.base_callsign, desc: p.received_at] + ) + + # Order by distance and limit results + from(p in subquery(recent_weather), + order_by: fragment("? <-> ?", p.location, ^point), + limit: ^limit, + select: %{ + callsign: p.sender, + base_callsign: p.base_callsign, + lat: fragment("ST_Y(?)", p.location), + lon: fragment("ST_X(?)", p.location), + distance_miles: fragment("ST_Distance(?::geography, ?::geography) / 1609.34", p.location, ^point), + temperature: p.temperature, + humidity: p.humidity, + pressure: p.pressure, + wind_speed: p.wind_speed, + wind_direction: p.wind_direction, + wind_gust: p.wind_gust, + rain_1h: p.rain_1h, + rain_24h: p.rain_24h, + rain_since_midnight: p.rain_since_midnight, + symbol_table_id: p.symbol_table_id, + symbol_code: p.symbol_code, + comment: p.comment, + received_at: p.received_at + } + ) + |> Repo.all() + end +``` + +**Step 4: Run test to verify it passes** + +Run: `mix test test/aprsme/packets/prepared_queries_test.exs` + +Expected: All tests PASS + +**Step 5: Run mix format** + +Run: `mix format` + +**Step 6: Commit** + +```bash +git add lib/aprsme/packets/prepared_queries.ex test/aprsme/packets/prepared_queries_test.exs +git commit -m "feat: add get_nearby_weather_stations query function + +Adds PreparedQueries.get_nearby_weather_stations/4 to find weather +stations within a radius using PostGIS ST_DWithin. Supports configurable +time window and result limit. Returns stations ordered by distance with +weather data included." +``` + +--- + +## Task 2: Add JSON View Module + +**Files:** +- Create: `lib/aprsme_web/controllers/api/v1/weather_json.ex` +- Test: `test/aprsme_web/controllers/api/v1/weather_json_test.exs` + +**Step 1: Write the failing test** + +Create `test/aprsme_web/controllers/api/v1/weather_json_test.exs`: + +```elixir +defmodule AprsmeWeb.Api.V1.WeatherJSONTest do + use ExUnit.Case, async: true + + alias AprsmeWeb.Api.V1.WeatherJSON + + describe "nearby/1" do + test "renders list of weather stations with all fields" do + stations = [ + %{ + callsign: "N0CALL-13", + base_callsign: "N0CALL", + lat: 37.7849, + lon: -122.4094, + distance_miles: 0.87, + temperature: 72.5, + humidity: 65.0, + pressure: 1013.2, + wind_speed: 8.5, + wind_direction: 270, + wind_gust: 12.0, + rain_1h: 0.0, + rain_24h: 0.5, + rain_since_midnight: 0.3, + symbol_table_id: "/", + symbol_code: "_", + comment: "Davis Vantage Pro2", + received_at: ~U[2026-03-22 15:30:00Z] + } + ] + + params = %{lat: 37.7749, lon: -122.4194, radius_miles: 25, hours: 6, limit: 50} + + result = WeatherJSON.nearby(%{stations: stations, params: params}) + + assert %{data: [station], meta: meta} = result + assert station.callsign == "N0CALL-13" + assert station.base_callsign == "N0CALL" + assert station.position.lat == 37.7849 + assert station.position.lon == -122.4094 + assert station.distance_miles == 0.87 + assert station.weather.temperature == 72.5 + assert station.weather.humidity == 65.0 + assert station.weather.pressure == 1013.2 + assert station.weather.wind_speed == 8.5 + assert station.weather.wind_direction == 270 + assert station.weather.wind_gust == 12.0 + assert station.weather.rain_1h == 0.0 + assert station.weather.rain_24h == 0.5 + assert station.weather.rain_since_midnight == 0.3 + assert station.symbol.table_id == "/" + assert station.symbol.code == "_" + assert station.comment == "Davis Vantage Pro2" + assert station.last_report == "2026-03-22T15:30:00Z" + + assert meta.count == 1 + assert meta.params.lat == 37.7749 + assert meta.params.lon == -122.4194 + assert meta.params.radius_miles == 25 + assert meta.params.hours == 6 + assert meta.params.limit == 50 + end + + test "handles null weather fields" do + stations = [ + %{ + callsign: "TEST-1", + base_callsign: "TEST", + lat: 37.7849, + lon: -122.4094, + distance_miles: 1.5, + temperature: 70.0, + humidity: nil, + pressure: nil, + wind_speed: nil, + wind_direction: nil, + wind_gust: nil, + rain_1h: nil, + rain_24h: nil, + rain_since_midnight: nil, + symbol_table_id: "/", + symbol_code: "_", + comment: nil, + received_at: ~U[2026-03-22 15:30:00Z] + } + ] + + params = %{lat: 37.7749, lon: -122.4194, radius_miles: 25, hours: 6, limit: 50} + + result = WeatherJSON.nearby(%{stations: stations, params: params}) + + assert %{data: [station]} = result + assert station.weather.temperature == 70.0 + assert station.weather.humidity == nil + assert station.weather.pressure == nil + assert station.comment == nil + end + + test "renders empty list when no stations found" do + params = %{lat: 0.0, lon: -180.0, radius_miles: 10, hours: 6, limit: 50} + + result = WeatherJSON.nearby(%{stations: [], params: params}) + + assert %{data: [], meta: meta} = result + assert meta.count == 0 + end + end +end +``` + +**Step 2: Run test to verify it fails** + +Run: `mix test test/aprsme_web/controllers/api/v1/weather_json_test.exs` + +Expected: FAIL with "module AprsmeWeb.Api.V1.WeatherJSON is not available" + +**Step 3: Write minimal implementation** + +Create `lib/aprsme_web/controllers/api/v1/weather_json.ex`: + +```elixir +defmodule AprsmeWeb.Api.V1.WeatherJSON do + @moduledoc """ + JSON rendering for weather API endpoints. + """ + + @doc """ + Renders a list of nearby weather stations. + """ + def nearby(%{stations: stations, params: params}) do + %{ + data: Enum.map(stations, &station_json/1), + meta: %{ + count: length(stations), + params: params + } + } + end + + defp station_json(station) do + %{ + callsign: station.callsign, + base_callsign: station.base_callsign, + position: %{ + lat: station.lat, + lon: station.lon + }, + distance_miles: station.distance_miles, + weather: %{ + temperature: station.temperature, + humidity: station.humidity, + pressure: station.pressure, + wind_speed: station.wind_speed, + wind_direction: station.wind_direction, + wind_gust: station.wind_gust, + rain_1h: station.rain_1h, + rain_24h: station.rain_24h, + rain_since_midnight: station.rain_since_midnight + }, + symbol: %{ + table_id: station.symbol_table_id, + code: station.symbol_code + }, + comment: station.comment, + last_report: format_datetime(station.received_at) + } + end + + defp format_datetime(%DateTime{} = dt) do + DateTime.to_iso8601(dt) + end + + defp format_datetime(nil), do: nil +end +``` + +**Step 4: Run test to verify it passes** + +Run: `mix test test/aprsme_web/controllers/api/v1/weather_json_test.exs` + +Expected: All tests PASS + +**Step 5: Run mix format** + +Run: `mix format` + +**Step 6: Commit** + +```bash +git add lib/aprsme_web/controllers/api/v1/weather_json.ex test/aprsme_web/controllers/api/v1/weather_json_test.exs +git commit -m "feat: add WeatherJSON view for API responses + +Adds JSON rendering for nearby weather stations endpoint. Formats +station data with position, weather metrics, symbols, and metadata." +``` + +--- + +## Task 3: Add Weather Controller + +**Files:** +- Create: `lib/aprsme_web/controllers/api/v1/weather_controller.ex` +- Test: `test/aprsme_web/controllers/api/v1/weather_controller_test.exs` + +**Step 1: Write the failing test** + +Create `test/aprsme_web/controllers/api/v1/weather_controller_test.exs`: + +```elixir +defmodule AprsmeWeb.Api.V1.WeatherControllerTest do + use AprsmeWeb.ConnCase, async: false + + alias Aprsme.Repo + + describe "GET /api/v1/weather/nearby" do + setup do + # Insert test weather stations + {:ok, station_a} = + insert_weather_station("TESTA-1", 37.7821, -122.4194, hours_ago: 1) + + {:ok, station_b} = + insert_weather_station("TESTB-1", 37.7749, -122.2694, hours_ago: 2) + + %{station_a: station_a, station_b: station_b} + end + + test "returns 200 with weather stations when valid params provided", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25") + + assert %{"data" => data, "meta" => meta} = json_response(conn, 200) + assert is_list(data) + assert length(data) >= 2 + assert meta["count"] >= 2 + assert meta["params"]["lat"] == 37.7749 + assert meta["params"]["lon"] == -122.4194 + assert meta["params"]["radius_miles"] == 25 + end + + test "returns weather station data with correct structure", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25") + + assert %{"data" => [station | _]} = json_response(conn, 200) + assert is_binary(station["callsign"]) + assert is_binary(station["base_callsign"]) + assert is_map(station["position"]) + assert is_float(station["position"]["lat"]) + assert is_float(station["position"]["lon"]) + assert is_float(station["distance_miles"]) + assert is_map(station["weather"]) + assert is_map(station["symbol"]) + assert is_binary(station["last_report"]) + end + + test "returns 400 when lat is missing", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lon=-122.4194&radius=25") + + assert %{"error" => error} = json_response(conn, 400) + assert error =~ "lat" + end + + test "returns 400 when lon is missing", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&radius=25") + + assert %{"error" => error} = json_response(conn, 400) + assert error =~ "lon" + end + + test "returns 400 when radius is missing", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194") + + assert %{"error" => error} = json_response(conn, 400) + assert error =~ "radius" + end + + test "returns 422 when lat is out of range", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=91&lon=-122.4194&radius=25") + + assert %{"error" => error} = json_response(conn, 422) + assert error =~ "latitude" + end + + test "returns 422 when lon is out of range", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=181&radius=25") + + assert %{"error" => error} = json_response(conn, 422) + assert error =~ "longitude" + end + + test "returns 422 when radius is zero or negative", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=0") + + assert %{"error" => error} = json_response(conn, 422) + assert error =~ "radius" + end + + test "returns 422 when radius exceeds maximum", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=1001") + + assert %{"error" => error} = json_response(conn, 422) + assert error =~ "radius" + end + + test "returns 422 when hours is out of range", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=0") + + assert %{"error" => error} = json_response(conn, 422) + assert error =~ "hours" + end + + test "returns 422 when limit is out of range", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&limit=0") + + assert %{"error" => error} = json_response(conn, 422) + assert error =~ "limit" + end + + test "accepts optional hours parameter", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=12") + + assert %{"meta" => meta} = json_response(conn, 200) + assert meta["params"]["hours"] == 12 + end + + test "accepts optional limit parameter", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&limit=10") + + assert %{"meta" => meta} = json_response(conn, 200) + assert meta["params"]["limit"] == 10 + end + + test "uses default hours when not provided", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25") + + assert %{"meta" => meta} = json_response(conn, 200) + assert meta["params"]["hours"] == 6 + end + + test "uses default limit when not provided", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25") + + assert %{"meta" => meta} = json_response(conn, 200) + assert meta["params"]["limit"] == 50 + end + + test "returns empty array when no stations in radius", %{conn: conn} do + # Middle of Pacific Ocean + conn = get(conn, ~p"/api/v1/weather/nearby?lat=0.0&lon=-180.0&radius=10") + + assert %{"data" => data, "meta" => meta} = json_response(conn, 200) + assert data == [] + assert meta["count"] == 0 + end + + test "coerces string params to numbers", %{conn: conn} do + conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25") + + assert json_response(conn, 200) + end + end + + defp insert_weather_station(callsign, lat, lon, opts) do + hours_ago = Keyword.get(opts, :hours_ago, 1) + + attrs = %{ + sender: callsign, + base_callsign: String.split(callsign, "-") |> List.first(), + destination: "APRS", + path: "WIDE1-1", + received_at: DateTime.add(DateTime.utc_now(), -hours_ago * 3600, :second), + has_position: true, + has_weather: true, + lat: Decimal.from_float(lat), + lon: Decimal.from_float(lon), + location: %Geo.Point{coordinates: {lon, lat}, srid: 4326}, + temperature: 72.5, + humidity: 65.0, + pressure: 1013.2, + wind_speed: 8.5, + wind_direction: 270, + wind_gust: 12.0, + rain_1h: 0.0, + rain_24h: 0.5, + rain_since_midnight: 0.3, + symbol_table_id: "/", + symbol_code: "_", + comment: "Test weather station" + } + + %Aprsme.Packet{} + |> Aprsme.Packet.changeset(attrs) + |> Repo.insert() + end +end +``` + +**Step 2: Run test to verify it fails** + +Run: `mix test test/aprsme_web/controllers/api/v1/weather_controller_test.exs` + +Expected: FAIL with route not found or controller not available + +**Step 3: Write minimal implementation** + +Create `lib/aprsme_web/controllers/api/v1/weather_controller.ex`: + +```elixir +defmodule AprsmeWeb.Api.V1.WeatherController do + @moduledoc """ + API controller for weather-related endpoints. + """ + use AprsmeWeb, :controller + + alias Aprsme.Packets.PreparedQueries + alias AprsmeWeb.Api.V1.WeatherJSON + + action_fallback AprsmeWeb.Api.V1.FallbackController + + @doc """ + Get weather stations within a radius of a lat/lon point. + + ## Parameters + * `lat` - Latitude (-90 to 90) + * `lon` - Longitude (-180 to 180) + * `radius` - Search radius in miles (> 0, max 1000) + * `hours` - Time window in hours (optional, default: 6, max: 168) + * `limit` - Maximum results (optional, default: 50, max: 100) + + ## Returns + * 200 - Success with list of weather stations + * 400 - Missing required parameters + * 422 - Invalid parameter values + """ + def nearby(conn, params) do + with {:ok, validated_params} <- validate_params(params) do + stations = + PreparedQueries.get_nearby_weather_stations( + validated_params.lat, + validated_params.lon, + validated_params.radius, + %{ + hours: validated_params.hours, + limit: validated_params.limit + } + ) + + conn + |> put_status(:ok) + |> put_view(json: WeatherJSON) + |> render(:nearby, %{ + stations: stations, + params: validated_params + }) + end + end + + defp validate_params(params) do + with {:ok, lat} <- parse_required_float(params, "lat"), + {:ok, lon} <- parse_required_float(params, "lon"), + {:ok, radius} <- parse_required_float(params, "radius"), + {:ok, hours} <- parse_optional_int(params, "hours", 6), + {:ok, limit} <- parse_optional_int(params, "limit", 50), + :ok <- validate_lat(lat), + :ok <- validate_lon(lon), + :ok <- validate_radius(radius), + :ok <- validate_hours(hours), + :ok <- validate_limit(limit) do + {:ok, + %{ + lat: lat, + lon: lon, + radius: radius, + radius_miles: radius, + hours: hours, + limit: limit + }} + end + end + + defp parse_required_float(params, key) do + case Map.get(params, key) do + nil -> + {:error, "Missing required parameter: #{key}"} + + value when is_binary(value) -> + case Float.parse(value) do + {float, _} -> {:ok, float} + :error -> {:error, "Invalid #{key}: must be a number"} + end + + value when is_number(value) -> + {:ok, value * 1.0} + end + end + + defp parse_optional_int(params, key, default) do + case Map.get(params, key) do + nil -> + {:ok, default} + + value when is_binary(value) -> + case Integer.parse(value) do + {int, _} -> {:ok, int} + :error -> {:error, "Invalid #{key}: must be an integer"} + end + + value when is_integer(value) -> + {:ok, value} + + value when is_float(value) -> + {:ok, trunc(value)} + end + end + + defp validate_lat(lat) when lat >= -90 and lat <= 90, do: :ok + + defp validate_lat(_), + do: {:error, "Invalid latitude: must be a number between -90 and 90"} + + defp validate_lon(lon) when lon >= -180 and lon <= 180, do: :ok + + defp validate_lon(_), + do: {:error, "Invalid longitude: must be a number between -180 and 180"} + + defp validate_radius(radius) when radius > 0 and radius <= 1000, do: :ok + + defp validate_radius(_), + do: {:error, "Radius must be between 0 and 1000 miles"} + + defp validate_hours(hours) when hours >= 1 and hours <= 168, do: :ok + + defp validate_hours(_), + do: {:error, "Hours must be between 1 and 168 (7 days)"} + + defp validate_limit(limit) when limit >= 1 and limit <= 100, do: :ok + + defp validate_limit(_), + do: {:error, "Limit must be between 1 and 100"} +end +``` + +**Step 4: Run test to verify it still fails (no route)** + +Run: `mix test test/aprsme_web/controllers/api/v1/weather_controller_test.exs` + +Expected: FAIL with route not found + +**Step 5: Add route to router** + +Modify `lib/aprsme_web/router.ex` at line 118 (in the `/api/v1` scope): + +```elixir + # API v1 routes + scope "/api/v1", AprsmeWeb.Api.V1, as: :api_v1 do + pipe_through :api + + get "/callsign/:callsign", CallsignController, :show + get "/weather/nearby", WeatherController, :nearby + end +``` + +**Step 6: Run test to verify it passes** + +Run: `mix test test/aprsme_web/controllers/api/v1/weather_controller_test.exs` + +Expected: All tests PASS + +**Step 7: Run mix format** + +Run: `mix format` + +**Step 8: Commit** + +```bash +git add lib/aprsme_web/controllers/api/v1/weather_controller.ex test/aprsme_web/controllers/api/v1/weather_controller_test.exs lib/aprsme_web/router.ex +git commit -m "feat: add weather nearby API endpoint + +Adds GET /api/v1/weather/nearby endpoint to find weather stations +within a radius. Validates parameters (lat, lon, radius, hours, limit) +and returns formatted JSON response with station data and weather +metrics." +``` + +--- + +## Task 4: Run Full Test Suite + +**Step 1: Run all tests** + +Run: `mix test` + +Expected: All tests PASS + +**Step 2: If any tests fail, fix them** + +If tests fail, investigate and fix issues. Commit fixes separately. + +**Step 3: Final commit if any fixes were needed** + +```bash +git add +git commit -m "fix: resolve test failures" +``` + +--- + +## Task 5: Manual Verification + +**Step 1: Start the development server** + +Run: `mix phx.server` + +**Step 2: Test the endpoint with curl** + +```bash +# Valid request +curl "http://localhost:4000/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25" + +# With optional parameters +curl "http://localhost:4000/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=12&limit=10" + +# Invalid parameters +curl "http://localhost:4000/api/v1/weather/nearby?lat=91&lon=-122.4194&radius=25" +``` + +**Step 3: Verify responses match expected format** + +Check that: +- Valid requests return 200 with data and meta +- Invalid parameters return 400/422 with error messages +- Empty results return 200 with empty array + +--- + +## Summary + +This plan implements the weather nearby API endpoint following TDD principles: + +1. **Database query function** - PostGIS spatial query with ST_DWithin +2. **JSON view** - Response formatting with weather data +3. **Controller** - Parameter validation and request handling +4. **Router** - Endpoint registration +5. **Testing** - Comprehensive unit and integration tests + +All code follows existing patterns in the codebase (PreparedQueries, API controllers, JSON views).