aprs.me/docs/plans/2026-03-22-weather-nearby-api.md
Graham McIntire 6ed10d11f4
docs: add weather nearby API implementation plan
Detailed TDD implementation plan for GET /api/v1/weather/nearby endpoint.
Includes database queries, JSON views, controller logic, and comprehensive
testing strategy.
2026-03-22 11:27:45 -05:00

29 KiB

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:

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:

  @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

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:

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:

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

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:

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:

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):

  # 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

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

git add <files>
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

# 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).