diff --git a/.gitignore b/.gitignore index ef38787..dc81c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ coverage pkg/ .m/ +.worktrees/ diff --git a/Gemfile.lock b/Gemfile.lock index 5e45fa2..6ab0b45 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - go_transit (1.2.0) + go_transit (1.3.0) activesupport GEM diff --git a/lib/go_transit.rb b/lib/go_transit.rb index 60a720a..af6990a 100644 --- a/lib/go_transit.rb +++ b/lib/go_transit.rb @@ -62,9 +62,10 @@ module GoTransit @api_key = "" + @service_day_boundary_hour = 4 class << self - attr_accessor :api_key, :custom_base_url + attr_accessor :api_key, :custom_base_url, :service_day_boundary_hour def configure yield self diff --git a/lib/go_transit/api_resource.rb b/lib/go_transit/api_resource.rb index 11a65a7..9ca4049 100644 --- a/lib/go_transit/api_resource.rb +++ b/lib/go_transit/api_resource.rb @@ -48,5 +48,25 @@ def to_utc(date) def to_local(date, timezone: "America/Toronto") date.in_time_zone(timezone) end + + def to_utc_from_anchor(time, trip_date, timezone: "America/Toronto") + to_local_from_anchor(time, trip_date, timezone: timezone)&.utc + end + + def to_local_from_anchor(time, trip_date, timezone: "America/Toronto") + raise MissingAnchorDateError if trip_date.nil? + return nil if time.blank? + hour = time.split(":").first.to_i + date = determine_date(trip_date, hour) + "#{date} #{time}".in_time_zone(timezone) + end + + def determine_date(date, hour) + if hour < GoTransit.service_day_boundary_hour + date + 1.day + else + date + end + end end end diff --git a/lib/go_transit/errors.rb b/lib/go_transit/errors.rb index c8ec8e6..7c11a12 100644 --- a/lib/go_transit/errors.rb +++ b/lib/go_transit/errors.rb @@ -11,4 +11,5 @@ def initialize(metadata) ForbiddenError = Class.new(ApiError) NotFoundError = Class.new(ApiError) TooManyRequestsError = Class.new(ApiError) + MissingAnchorDateError = Class.new(StandardError) end diff --git a/lib/go_transit/resources/schedule.rb b/lib/go_transit/resources/schedule.rb index 44b2610..66cf765 100644 --- a/lib/go_transit/resources/schedule.rb +++ b/lib/go_transit/resources/schedule.rb @@ -26,7 +26,9 @@ def self.trip(date:, trip_number:) formatted_date = date.strftime("%Y%m%d") client = Client.new response = client.get("Schedule/Trip/#{formatted_date}/#{trip_number}") - new(response.data).trips + trips = new(response.data).trips + trips.each { |trip| trip.anchor_date = date } + trips end end end diff --git a/lib/go_transit/resources/schedule/journey/sch_journey.rb b/lib/go_transit/resources/schedule/journey/sch_journey.rb index 02164cb..18f22f1 100644 --- a/lib/go_transit/resources/schedule/journey/sch_journey.rb +++ b/lib/go_transit/resources/schedule/journey/sch_journey.rb @@ -1,5 +1,21 @@ module GoTransit class Schedule::Journey::SchJourney < ApiResource attr_accessor :date, :time, :to, :from, :services + + def initialize(params) + super + propagate_anchor_date + end + + private + + def propagate_anchor_date + parsed_date = Date.parse(date) + services.each do |service| + service.trips.each do |trip| + trip.stops.each { |stop| stop.anchor_date = parsed_date } + end + end + end end end diff --git a/lib/go_transit/resources/schedule/journey/sch_journey/service/trip/stop.rb b/lib/go_transit/resources/schedule/journey/sch_journey/service/trip/stop.rb index c6eb135..8db5c75 100644 --- a/lib/go_transit/resources/schedule/journey/sch_journey/service/trip/stop.rb +++ b/lib/go_transit/resources/schedule/journey/sch_journey/service/trip/stop.rb @@ -1,9 +1,17 @@ module GoTransit class Schedule::Journey::SchJourney::Service::Trip::Stop < ApiResource - attr_accessor :code, :order, :time, :sorting_time, :is_major + attr_accessor :code, :order, :time, :sorting_time, :is_major, :anchor_date def major? is_major.to_i.positive? end + + def time_utc + to_utc_from_anchor(time, anchor_date) + end + + def time_local + to_local_from_anchor(time, anchor_date) + end end end diff --git a/lib/go_transit/resources/schedule/trip.rb b/lib/go_transit/resources/schedule/trip.rb index b1d08de..74daab5 100644 --- a/lib/go_transit/resources/schedule/trip.rb +++ b/lib/go_transit/resources/schedule/trip.rb @@ -10,5 +10,9 @@ def time_stamp_utc def time_stamp_local to_local(time_stamp) end + + def anchor_date=(date) + stops.each { |stop| stop.anchor_date = date } + end end end diff --git a/lib/go_transit/resources/schedule/trip/stop.rb b/lib/go_transit/resources/schedule/trip/stop.rb index 4136f5b..968aea3 100644 --- a/lib/go_transit/resources/schedule/trip/stop.rb +++ b/lib/go_transit/resources/schedule/trip/stop.rb @@ -2,5 +2,10 @@ module GoTransit class Schedule::Trip::Stop < ApiResource attr_accessor :arrival_time, :departure_time, :track, :code, :status, :remark + + def anchor_date=(date) + arrival_time.anchor_date = date + departure_time.anchor_date = date + end end end diff --git a/lib/go_transit/resources/schedule/trip/stop/arrival_time.rb b/lib/go_transit/resources/schedule/trip/stop/arrival_time.rb index ec12d9b..c87f1c6 100644 --- a/lib/go_transit/resources/schedule/trip/stop/arrival_time.rb +++ b/lib/go_transit/resources/schedule/trip/stop/arrival_time.rb @@ -1,5 +1,21 @@ module GoTransit class Schedule::Trip::Stop::ArrivalTime < ApiResource - attr_accessor :scheduled, :computed, :status + attr_accessor :scheduled, :computed, :status, :anchor_date + + def scheduled_utc + to_utc_from_anchor(scheduled, anchor_date) + end + + def scheduled_local + to_local_from_anchor(scheduled, anchor_date) + end + + def computed_utc + to_utc_from_anchor(computed, anchor_date) + end + + def computed_local + to_local_from_anchor(computed, anchor_date) + end end end diff --git a/lib/go_transit/resources/schedule/trip/stop/departure_time.rb b/lib/go_transit/resources/schedule/trip/stop/departure_time.rb index 0cb4647..e3adced 100644 --- a/lib/go_transit/resources/schedule/trip/stop/departure_time.rb +++ b/lib/go_transit/resources/schedule/trip/stop/departure_time.rb @@ -1,5 +1,21 @@ module GoTransit class Schedule::Trip::Stop::DepartureTime < ApiResource - attr_accessor :scheduled, :computed, :status + attr_accessor :scheduled, :computed, :status, :anchor_date + + def scheduled_utc + to_utc_from_anchor(scheduled, anchor_date) + end + + def scheduled_local + to_local_from_anchor(scheduled, anchor_date) + end + + def computed_utc + to_utc_from_anchor(computed, anchor_date) + end + + def computed_local + to_local_from_anchor(computed, anchor_date) + end end end diff --git a/lib/go_transit/response.rb b/lib/go_transit/response.rb index a5b6f93..c10f6bb 100644 --- a/lib/go_transit/response.rb +++ b/lib/go_transit/response.rb @@ -25,10 +25,6 @@ def payload_data(payload) end def throw_error - # A genuine transport-level 429 always wins, even if the JSON body - # (when there is one) claims success - GO Transit's own error - # convention embeds the real code in the body, but an upstream - # rate limiter returning 429 isn't guaranteed to follow that. raise TooManyRequestsError.new(metadata) if http_status == 429 return if code == 200 raise NoContentError.new(metadata) if code == 204 diff --git a/lib/go_transit/version.rb b/lib/go_transit/version.rb index 5814574..aea879d 100644 --- a/lib/go_transit/version.rb +++ b/lib/go_transit/version.rb @@ -1,3 +1,3 @@ module GoTransit - VERSION = "1.2.0".freeze + VERSION = "1.3.0".freeze end diff --git a/readme.md b/readme.md index ba28479..03f2b44 100644 --- a/readme.md +++ b/readme.md @@ -83,6 +83,45 @@ value itself. For example, `departure_time_utc` on a stop returns the regular `departure_time` converted to UTC, and `departure_time_local` returns the same time zoned to Toronto instead. +### Time-only fields (no date) + +A few fields are returned as a bare time with no date at all (for +example `Schedule::Trip::Stop::ArrivalTime`/`DepartureTime#scheduled` and +`#computed`, and `Schedule::Journey::SchJourney::Service::Trip::Stop#time`). +Converting one of these to a real `Time` requires knowing which calendar +date it belongs to, and GO Transit's own "service date" doesn't line up +with calendar midnight: a service date that starts at 6am can run past +2am the next calendar day, and every stop in that overnight tail is still +part of the *original* service date, not the next one. + +These fields still expose `_utc`/`_local`, resolved against a known +anchor date (the date you requested, for `Schedule.trip`, or the date +embedded in the response itself, for `Schedule::Journey.journey`, so you +never need to pass one in yourself). We use a boundry hours defined as +`GoTransit.service_day_boundary_hour` (default `4`). If the time is after +the boundry hour then it belongs to the day *after* the anchor date; otherwise +it's the anchor date itself. This correctly handles a trip whose very first stop +is already past midnight, with nothing else to compare it against. Optionally +you can change when this hour is. Local to Toronto time: + +```ruby +GoTransit.configure do |config| + config.service_day_boundary_hour = 3 +end +``` + +Calling `_utc`/`_local` on one of these fields without ever going through +`Schedule.trip`/`Schedule.journey` (e.g. constructing the resource class +directly) raises `GoTransit::MissingAnchorDateError` rather than silently +assuming "today". + +One endpoint's time-only fields don't get this treatment yet: +`ServiceUpdate::Exceptions::Trip::Stop#sch_arrival`/`#sch_departure`/ +`#actual_time` are always `null` in every real response we've captured, so +their actual non-null format is unconfirmed, and the endpoint gives no +date to anchor them to either way. If you can confirm a real non-null +value for these fields, a PR is welcome. + ## Missing Test Data At the time of development I was unable to get test data for the following endpoints. Some of these seem like they are restricted access endpoints and my diff --git a/spec/go_transit_spec.rb b/spec/go_transit_spec.rb new file mode 100644 index 0000000..7cbcc83 --- /dev/null +++ b/spec/go_transit_spec.rb @@ -0,0 +1,15 @@ +RSpec.describe GoTransit do + describe ".service_day_boundary_hour" do + it "defaults to 4" do + expect(GoTransit.service_day_boundary_hour).to eq(4) + end + + it "can be overridden via configure" do + GoTransit.configure { |config| config.service_day_boundary_hour = 3 } + + expect(GoTransit.service_day_boundary_hour).to eq(3) + + GoTransit.configure { |config| config.service_day_boundary_hour = 4 } + end + end +end diff --git a/spec/resources/schedule/journey/sch_journey/service/trip/stop_spec.rb b/spec/resources/schedule/journey/sch_journey/service/trip/stop_spec.rb index f94dded..5f3ca73 100644 --- a/spec/resources/schedule/journey/sch_journey/service/trip/stop_spec.rb +++ b/spec/resources/schedule/journey/sch_journey/service/trip/stop_spec.rb @@ -20,4 +20,27 @@ end end end + + describe "#time_local and #time_utc" do + it "resolve using the assigned anchor_date" do + stop = GoTransit::Schedule::Journey::SchJourney::Service::Trip::Stop.new(time: "07:00") + stop.anchor_date = Date.new(2026, 1, 1) + + expect(stop.time_local.to_date).to eq(Date.new(2026, 1, 1)) + expect(stop.time_utc).to eq(stop.time_local.utc) + end + + it "rolls forward past the boundary hour, same rule as Schedule::Trip's stops" do + stop = GoTransit::Schedule::Journey::SchJourney::Service::Trip::Stop.new(time: "00:15") + stop.anchor_date = Date.new(2026, 1, 1) + + expect(stop.time_local.to_date).to eq(Date.new(2026, 1, 2)) + end + + it "raises MissingAnchorDateError when anchor_date was never assigned" do + stop = GoTransit::Schedule::Journey::SchJourney::Service::Trip::Stop.new(time: "07:00") + + expect { stop.time_local }.to raise_error(GoTransit::MissingAnchorDateError) + end + end end diff --git a/spec/resources/schedule/trip/stop/arrival_time_spec.rb b/spec/resources/schedule/trip/stop/arrival_time_spec.rb new file mode 100644 index 0000000..187df6c --- /dev/null +++ b/spec/resources/schedule/trip/stop/arrival_time_spec.rb @@ -0,0 +1,82 @@ +RSpec.describe GoTransit::Schedule::Trip::Stop::ArrivalTime do + describe "#scheduled_local" do + it "stays on the anchor date when the hour is at or after the boundary" do + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "23:58") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + result = arrival_time.scheduled_local + + expect(result).to eq(Date.new(2026, 1, 1).in_time_zone("America/Toronto") + 23.hours + 58.minutes) + end + + it "rolls forward to the next day when the hour is before the boundary" do + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "00:04") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + result = arrival_time.scheduled_local + + expect(result).to eq(Date.new(2026, 1, 2).in_time_zone("America/Toronto") + 4.minutes) + end + + it "rolls forward even when it is the trip's very first stop, with no prior stop to compare against" do + # This is the whole point of a fixed boundary hour rather than + # sequence-based rollover detection: a trip whose first stop is + # already in the "overnight tail" of the previous service date + # rolls forward correctly with no other stop involved at all. + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "01:00") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + result = arrival_time.scheduled_local + + expect(result.to_date).to eq(Date.new(2026, 1, 2)) + end + + it "honors a custom service_day_boundary_hour" do + original_boundary_hour = GoTransit.service_day_boundary_hour + GoTransit.service_day_boundary_hour = 3 + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "03:30") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + result = arrival_time.scheduled_local + + expect(result.to_date).to eq(Date.new(2026, 1, 1)) + ensure + GoTransit.service_day_boundary_hour = original_boundary_hour + end + + it "returns nil when the underlying field is blank" do + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + expect(arrival_time.scheduled_local).to be_nil + end + + it "raises MissingAnchorDateError when anchor_date was never assigned" do + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "14:54") + + expect { arrival_time.scheduled_local }. + to raise_error(GoTransit::MissingAnchorDateError) + end + end + + describe "#scheduled_utc" do + it "converts the resolved local time to UTC" do + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "14:54") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + result = arrival_time.scheduled_utc + + expect(result).to eq(Date.new(2026, 1, 1).in_time_zone("America/Toronto").utc + 14.hours + 54.minutes) + end + end + + describe "#computed_local and #computed_utc" do + it "resolve the same way as scheduled, independently" do + arrival_time = GoTransit::Schedule::Trip::Stop::ArrivalTime.new(scheduled: "14:54", computed: "15:01") + arrival_time.anchor_date = Date.new(2026, 1, 1) + + expect(arrival_time.computed_local.strftime("%H:%M")).to eq("15:01") + expect(arrival_time.computed_utc).to eq(arrival_time.computed_local.utc) + end + end +end diff --git a/spec/resources/schedule/trip/stop/departure_time_spec.rb b/spec/resources/schedule/trip/stop/departure_time_spec.rb new file mode 100644 index 0000000..c23881c --- /dev/null +++ b/spec/resources/schedule/trip/stop/departure_time_spec.rb @@ -0,0 +1,29 @@ +RSpec.describe GoTransit::Schedule::Trip::Stop::DepartureTime do + describe "#scheduled_local and #scheduled_utc" do + it "resolve using the assigned anchor_date, same as ArrivalTime" do + departure_time = GoTransit::Schedule::Trip::Stop::DepartureTime.new(scheduled: "00:04") + departure_time.anchor_date = Date.new(2026, 1, 1) + + expect(departure_time.scheduled_local.to_date).to eq(Date.new(2026, 1, 2)) + expect(departure_time.scheduled_utc).to eq(departure_time.scheduled_local.utc) + end + end + + describe "#computed_local and #computed_utc" do + it "resolve independently of scheduled" do + departure_time = GoTransit::Schedule::Trip::Stop::DepartureTime.new(computed: "14:54") + departure_time.anchor_date = Date.new(2026, 1, 1) + + expect(departure_time.computed_local.strftime("%H:%M")).to eq("14:54") + end + end + + describe "without an anchor_date" do + it "raises MissingAnchorDateError" do + departure_time = GoTransit::Schedule::Trip::Stop::DepartureTime.new(scheduled: "14:54") + + expect { departure_time.scheduled_local }. + to raise_error(GoTransit::MissingAnchorDateError) + end + end +end diff --git a/spec/resources/schedule/trip/stop_spec.rb b/spec/resources/schedule/trip/stop_spec.rb new file mode 100644 index 0000000..bb7d3fd --- /dev/null +++ b/spec/resources/schedule/trip/stop_spec.rb @@ -0,0 +1,15 @@ +RSpec.describe GoTransit::Schedule::Trip::Stop do + describe "#anchor_date=" do + it "cascades the anchor date to both arrival_time and departure_time" do + stop = GoTransit::Schedule::Trip::Stop.new( + "ArrivalTime" => { "Scheduled" => "14:50", "Computed" => "", "Status" => "" }, + "DepartureTime" => { "Scheduled" => "14:54", "Computed" => "", "Status" => "" }, + ) + + stop.anchor_date = Date.new(2026, 1, 1) + + expect(stop.arrival_time.anchor_date).to eq(Date.new(2026, 1, 1)) + expect(stop.departure_time.anchor_date).to eq(Date.new(2026, 1, 1)) + end + end +end diff --git a/spec/resources/schedule_spec.rb b/spec/resources/schedule_spec.rb index 3ca6381..75f62f6 100644 --- a/spec/resources/schedule_spec.rb +++ b/spec/resources/schedule_spec.rb @@ -9,6 +9,20 @@ expect(journeys.first). to be_kind_of(GoTransit::Schedule::Journey::SchJourney) end + + it "propagates its own Date to every stop, across every service and trip" do + date = Date.parse("2023-03-13") + journeys = GoTransit::Schedule.journey(date: date, from_stop_code: "UN", + to_stop_code: "CL", + start_time: "0900", max_journey: 5) + + journey = journeys.first + first_stop = journey.services.first.trips.first.stops.first + deeper_stop = journey.services.last.trips.last.stops.last + + expect(first_stop.time_local.to_date).to eq(date) + expect(deeper_stop.time_local.to_date).to eq(date) + end end describe ".line" do @@ -28,5 +42,15 @@ expect(trips.first).to be_kind_of(GoTransit::Schedule::Trip) end + + it "gives every stop's departure time a working scheduled_local, with no extra work from the caller" do + date = Date.parse("2023-03-13") + trips = GoTransit::Schedule.trip(date: date, trip_number: "1024") + + stops = trips.first.stops + + expect(stops.first.departure_time.scheduled_local.to_date).to eq(date) + expect(stops.last.departure_time.scheduled_local.to_date).to eq(date) + end end end \ No newline at end of file