Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/api_web/lib/api_web/views/stop_event_view.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
defmodule ApiWeb.StopEventView do
use ApiWeb.Web, :api_view

has_one(
:trip,
type: :trip,
serializer: ApiWeb.TripView,
field: :trip_id
)

has_one(
:stop,
type: :stop,
serializer: ApiWeb.StopView,
field: :stop_id
)

has_one(
:route,
type: :route,
serializer: ApiWeb.RouteView,
field: :route_id
)

has_one(
:vehicle,
type: :vehicle,
serializer: ApiWeb.VehicleView,
field: :vehicle_id
)

attributes([
:vehicle_id,
:start_date,
:trip_id,
:direction_id,
:route_id,
:revenue,
:stop_id,
:stop_sequence,
:arrived,
:departed
])

def arrived(%{arrived: nil}, _conn), do: nil
def arrived(%{arrived: %DateTime{} = dt}, _conn), do: DateTime.to_iso8601(dt)

def departed(%{departed: nil}, _conn), do: nil
def departed(%{departed: %DateTime{} = dt}, _conn), do: DateTime.to_iso8601(dt)

@doc """
Preloads schedule relationships for stop events when requested via ?include=schedule to prevent N+1 queries.
"""
def preload(stop_events, conn, _opts) when is_list(stop_events) do
if split_included?("schedule", conn) do
schedules = State.Schedule.schedule_for_many(stop_events)

Enum.map(stop_events, fn stop_event ->
schedule = Map.get(schedules, {stop_event.trip_id, stop_event.stop_sequence})
Map.put(stop_event, :schedule, schedule)
end)
else
stop_events
end
end

def preload(stop_event, conn, _opts) do
if split_included?("schedule", conn) do
schedule = State.Schedule.schedule_for(stop_event)
Map.put(stop_event, :schedule, schedule)
else
stop_event
end
end

def relationships(stop_event, conn) do
# Get the base relationships as a map from has_one macros
base_relationships = super(stop_event, conn)

if split_included?("schedule", conn) do
Map.put(
base_relationships,
:schedule,
%HasOne{
type: :schedule,
name: :schedule,
data: schedule(stop_event, conn),
serializer: ApiWeb.ScheduleView
}
)
else
base_relationships
end
end

defp schedule(%{schedule: schedule}, _conn), do: schedule

defp schedule(stop_event, conn),
do: optional_relationship("schedule", stop_event, &State.Schedule.schedule_for/1, conn)
end
233 changes: 233 additions & 0 deletions apps/api_web/test/api_web/views/stop_event_view_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
defmodule ApiWeb.StopEventViewTest do
use ApiWeb.ConnCase

# Bring render/3 and render_to_string/3 for testing custom views
import Phoenix.View

alias Model.StopEvent

@stop_event %StopEvent{
id: "trip1-route1-v1-1",
vehicle_id: "v1",
start_date: ~D[2026-02-24],
trip_id: "trip1",
direction_id: 0,
route_id: "route1",
revenue: :REVENUE,
stop_id: "stop1",
stop_sequence: 1,
arrived: ~U[2026-02-24 15:28:06Z],
departed: ~U[2026-02-24 15:40:46Z]
}

@trip %Model.Trip{
id: "trip1",
route_id: "route1",
name: "Test Trip",
direction_id: 0,
service_id: "service1",
headsign: "Testination",
wheelchair_accessible: 1,
bikes_allowed: 1,
revenue: :REVENUE
}

@stop %Model.Stop{
id: "stop1",
name: "Test Stop",
latitude: 42.0,
longitude: -71.0,
wheelchair_boarding: 0,
location_type: 0
}

@route %Model.Route{
id: "route1",
agency_id: "agency1",
color: "FF0000",
description: "Test Route",
sort_order: 1,
text_color: "FFFFFF",
line_id: "line1",
listed_route: true,
type: 3
}

@route_pattern %Model.RoutePattern{
id: "route1-_-0"
}

@vehicle %Model.Vehicle{
id: "v1",
current_status: :IN_TRANSIT_TO,
updated_at: ~U[2026-02-24 15:30:00Z],
revenue: :REVENUE
}

@schedule %Model.Schedule{
direction_id: 0,
route_id: "route1",
service_id: "service1",
stop_sequence: 1,
stop_id: "stop1",
timepoint?: false,
trip_id: "trip1"
}

setup %{conn: conn} do
conn = Phoenix.Controller.put_view(conn, ApiWeb.StopEventView)
{:ok, %{conn: conn}}
end

test "renders stop event with all attributes", %{conn: conn} do
rendered = render(ApiWeb.StopEventView, "index.json-api", data: @stop_event, conn: conn)

assert rendered["data"]["type"] == "stop_event"
assert rendered["data"]["id"] == "trip1-route1-v1-1"

assert rendered["data"]["attributes"] == %{
"vehicle_id" => "v1",
"start_date" => ~D[2026-02-24],
"trip_id" => "trip1",
"direction_id" => 0,
"route_id" => "route1",
"revenue" => :REVENUE,
"stop_id" => "stop1",
"stop_sequence" => 1,
"arrived" => "2026-02-24T15:28:06Z",
"departed" => "2026-02-24T15:40:46Z"
}
end

test "renders stop event with nil arrived (first stop)", %{conn: conn} do
stop_event = %StopEvent{@stop_event | arrived: nil}
rendered = render(ApiWeb.StopEventView, "index.json-api", data: stop_event, conn: conn)

assert rendered["data"]["attributes"]["arrived"] == nil
assert rendered["data"]["attributes"]["departed"] == "2026-02-24T15:40:46Z"
end

test "renders stop event with nil departed (last or current stop)", %{conn: conn} do
stop_event = %StopEvent{@stop_event | departed: nil}
rendered = render(ApiWeb.StopEventView, "index.json-api", data: stop_event, conn: conn)

assert rendered["data"]["attributes"]["arrived"] == "2026-02-24T15:28:06Z"
assert rendered["data"]["attributes"]["departed"] == nil
end

test "does not include attributes when empty set is requested", %{conn: conn} do
# JSON:API sparse fieldsets: when client requests empty field list,
# no attributes are returned (only id, type, and relationships)
conn = assign(conn, :opts, %{fields: %{"stop_event" => []}})

rendered =
render(ApiWeb.StopEventView, "index.json-api",
data: @stop_event,
conn: conn,
opts: conn.assigns.opts
)

assert rendered["data"]["attributes"] == %{}
end

describe "relationships" do
setup do
State.Trip.new_state([@trip])
State.Stop.new_state([@stop])
State.Route.new_state([@route])
State.Vehicle.new_state([@vehicle])
:ok
end

test "includes all default relationships", %{conn: conn} do
rendered =
render(ApiWeb.StopEventView, "index.json-api",
data: @stop_event,
conn: conn
)

relationships = rendered["data"]["relationships"]
assert relationships["trip"]["data"]["id"] == "trip1"
assert relationships["stop"]["data"]["id"] == "stop1"
assert relationships["route"]["data"]["id"] == "route1"
assert relationships["vehicle"]["data"]["id"] == "v1"
refute Map.has_key?(relationships, "schedule")
end

test "preloads schedules for multiple stop_events", %{conn: conn} do
State.RoutePattern.new_state([@route_pattern])

schedule2 = %Model.Schedule{
direction_id: 0,
route_id: "route1",
service_id: "service1",
stop_sequence: 2,
stop_id: "stop1",
timepoint?: false,
trip_id: "trip1"
}

State.Schedule.new_state([@schedule, schedule2])
State.RoutesPatternsAtStop.update!()

stop_event2 = %StopEvent{@stop_event | id: "trip1-route1-v1-2", stop_sequence: 2}

conn =
%{conn | params: %{"include" => "schedule"}}
|> ApiWeb.ApiControllerHelpers.split_include([])

rendered =
render(ApiWeb.StopEventView, "index.json-api",
data: [@stop_event, stop_event2],
conn: conn
)

assert length(rendered["data"]) == 2
# Verify schedules were bulk loaded via schedule_for_many
assert get_in(rendered, ["data", Access.at(0), "relationships", "schedule", "data", "id"]) ==
"schedule-trip1-stop1-1"

assert get_in(rendered, ["data", Access.at(1), "relationships", "schedule", "data", "id"]) ==
"schedule-trip1-stop1-2"
end

test "includes schedule relationship when requested", %{conn: conn} do
State.RoutePattern.new_state([@route_pattern])
State.Schedule.new_state([@schedule])
State.RoutesPatternsAtStop.update!()

conn =
%{conn | params: %{"include" => "schedule"}}
|> ApiWeb.ApiControllerHelpers.split_include([])

rendered =
render(ApiWeb.StopEventView, "index.json-api",
data: @stop_event,
conn: conn
)

assert get_in(rendered, ["data", "relationships", "schedule", "data", "id"]) ==
"schedule-trip1-stop1-1"
end

test "returns nil schedule when schedule does not exist", %{conn: conn} do
# Set up required state but no schedule
State.RoutePattern.new_state([@route_pattern])
State.Schedule.new_state([])
State.RoutesPatternsAtStop.update!()

conn =
%{conn | params: %{"include" => "schedule"}}
|> ApiWeb.ApiControllerHelpers.split_include([])

rendered =
render(ApiWeb.StopEventView, "index.json-api",
data: @stop_event,
conn: conn
)

# Schedule relationship should be present but with nil data
assert get_in(rendered, ["data", "relationships", "schedule", "data"]) == nil
end
end
end
33 changes: 17 additions & 16 deletions apps/state/lib/state/schedule.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,35 +97,36 @@ defmodule State.Schedule do

@schedule_relationships_with_schedules [nil, :cancelled, :no_data, :skipped]

@spec schedule_for(Model.Prediction.t()) :: Model.Schedule.t() | nil
def schedule_for(%Model.Prediction{schedule_relationship: relationship} = prediction)
when relationship in @schedule_relationships_with_schedules do
@spec schedule_for(Model.Prediction.t() | Model.StopEvent.t()) :: Model.Schedule.t() | nil

def schedule_for(%Model.Prediction{schedule_relationship: relationship})
when relationship not in @schedule_relationships_with_schedules do
nil
end

def schedule_for(%_{trip_id: trip_id, stop_id: stop_id, stop_sequence: stop_sequence}) do
stop_ids =
case State.Stop.siblings(prediction.stop_id) do
case State.Stop.siblings(stop_id) do
[_ | _] = stops -> Enum.map(stops, & &1.id)
[] -> [prediction.stop_id]
[] -> [stop_id]
end

%{
trips: [prediction.trip_id],
trips: [trip_id],
stops: stop_ids,
stop_sequence: [prediction.stop_sequence]
stop_sequence: [stop_sequence]
}
|> filter_by
|> List.first()
end

def schedule_for(%Model.Prediction{}) do
nil
end

@spec schedule_for_many([Model.Prediction.t()]) :: map
def schedule_for_many(predictions) do
for prediction <- predictions,
schedule = schedule_for(prediction),
@spec schedule_for_many([Model.Prediction.t() | Model.StopEvent.t()]) :: map
def schedule_for_many(records) do
for record <- records,
schedule = schedule_for(record),
schedule != nil,
into: %{} do
{{prediction.trip_id, prediction.stop_sequence}, schedule}
{{record.trip_id, record.stop_sequence}, schedule}
end
end

Expand Down
Loading