From 06781d05a73561d09b4c70afe69a21f70d6c1828 Mon Sep 17 00:00:00 2001 From: Justin Mazur Date: Mon, 14 Sep 2026 15:57:58 -0400 Subject: [PATCH] Add Time Only Local and UTC Helpers The previous change added _utc/_local to every date and time field across the gem except two families that are returned as a bare "HH:MM", no date at all. 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 starting at 6am can run past 2am the next calendar day, and every stop in that overnight tail still belongs to the original service date, not the next one. This change addresses the need by: * Adding a configurable service_day_boundary_hour (default 4) marking where one service date ends and the next begins * Resolving _utc/_local for these fields by anchoring to a known date rather than assuming "today", correctly handling a trip whose very first stop is already past midnight with no other stop to compare it against * Threading the anchor date through Schedule.trip and Schedule.journey automatically, so no caller has to supply one themselves --- .gitignore | 1 + Gemfile.lock | 2 +- lib/go_transit.rb | 3 +- lib/go_transit/api_resource.rb | 20 +++++ lib/go_transit/errors.rb | 1 + lib/go_transit/resources/schedule.rb | 4 +- .../resources/schedule/journey/sch_journey.rb | 16 ++++ .../journey/sch_journey/service/trip/stop.rb | 10 ++- lib/go_transit/resources/schedule/trip.rb | 4 + .../resources/schedule/trip/stop.rb | 5 ++ .../schedule/trip/stop/arrival_time.rb | 18 +++- .../schedule/trip/stop/departure_time.rb | 18 +++- lib/go_transit/response.rb | 4 - lib/go_transit/version.rb | 2 +- readme.md | 39 +++++++++ spec/go_transit_spec.rb | 15 ++++ .../sch_journey/service/trip/stop_spec.rb | 23 ++++++ .../schedule/trip/stop/arrival_time_spec.rb | 82 +++++++++++++++++++ .../schedule/trip/stop/departure_time_spec.rb | 29 +++++++ spec/resources/schedule/trip/stop_spec.rb | 15 ++++ spec/resources/schedule_spec.rb | 24 ++++++ 21 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 spec/go_transit_spec.rb create mode 100644 spec/resources/schedule/trip/stop/arrival_time_spec.rb create mode 100644 spec/resources/schedule/trip/stop/departure_time_spec.rb create mode 100644 spec/resources/schedule/trip/stop_spec.rb 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