Skip to content

Report the service date a trip instance belongs to - #1316

Merged
burma-shave merged 16 commits into
OneBusAway:mainfrom
ARCoder181105:fix/trip-service-date
Aug 21, 2026
Merged

Report the service date a trip instance belongs to#1316
burma-shave merged 16 commits into
OneBusAway:mainfrom
ARCoder181105:fix/trip-service-date

Conversation

@ARCoder181105

@ARCoder181105 ARCoder181105 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #1311.

Depends on #1315 (and transitively #1314, #1313) and includes their commits; merge those first.

trips-for-location and trips-for-route both stamped the query day's midnight onto every entry's serviceDate. GTFS stop times may run past 24:00:00, so a trip still running just after midnight belongs to the previous day's service — a client computing serviceDate + arrivalTime landed 24 hours off.

Changes

  • Add serviceDateResolver: checks the query day first, then the day before, matching each trip's service calendar against its cached min_arrival_time/max_departure_time window. Trips matching neither keep the query day, so nothing changes for the ordinary daytime case.
  • Pass the resolved date to BuildTripStatus too, so status.serviceDate agrees with the entry's.
  • trips-for-route's DUPLICATED branch keeps the trip row from its first GetTrip instead of fetching it twice.

No schema or query changes — trips.min_arrival_time / max_departure_time and GetActiveServiceIDsForDate already exist.

Both handlers in one PR because it is one shared helper and one semantic; splitting would leave the two endpoints disagreeing in the interim.

Verification

Live against the King County feed: querying at 01:00 local returns 160 trips across two distinct service dates, 34 of them with schedules crossing 24:00. Previously every entry carried the same date.

TestServiceDateResolver_Resolve covers trip running now, past-midnight trip resolving to yesterday, trip not running, service inactive on both days, missing time window, and zero trip.

go vet (both tag sets) and make test pass.

Summary by CodeRabbit

  • New Features

    • Trip responses now provide more accurate service dates for trips crossing midnight, recently completed trips, and previous-day services.
    • Improved handling of duplicated and interlined trips, including fallback matching for numeric-suffixed trip IDs.
    • Situation and alert references are resolved more consistently across trip and route results.
  • Bug Fixes

    • Improved fallback behavior when schedule timing, service information, or time zones are incomplete.
    • Prevented unresolved trip references from appearing in responses.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ed4f3401-39d5-4787-8848-f6f530418a97

📥 Commits

Reviewing files that changed from the base of the PR and between 9780dfe and a372a8f.

📒 Files selected for processing (1)
  • internal/restapi/trips_helper.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The PR adds shared trip service-date resolution using current- and previous-day services. It applies resolved dates to location and route responses, improves duplicated-trip lookup, and adds tests for past-midnight, fallback, situation-reference, and service-date consistency cases.

Changes

Trip service-date resolution

Layer / File(s) Summary
Service-date resolver
internal/restapi/trips_helper.go, internal/restapi/service_date_resolver_test.go
The resolver loads active services for the query and previous day, checks scheduled-span overlap, and falls back to the query day. Tests cover current-day, past-midnight, inactive, missing-data, zero-value, and preloaded-service cases.
Location service-date integration
internal/restapi/trips_for_location_handler.go
The location handler groups trips by agency, precomputes service data, reuses query-day service IDs for block queries, and uses each trip’s resolved service date in statuses and entries.
Route trip and reference resolution
internal/restapi/trips_for_route_handler.go, internal/restapi/trips_for_route_handler_test.go
The route handler applies resolved dates to regular and duplicated trips, resolves duplicated IDs with numeric-suffix fallback, indexes resolved trips for reference handling, and tests service-date consistency and unresolved IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🟡 Moderate · up to a372a

The PR corrects trip service dates and aligns trip status dates, with reported tests and checks passing, but an invalid configured agency timezone can still make the location endpoint fail for all agencies; merge requires explicit owner acceptance or follow-up.

Suggested reviewers: aaronbrethorst, ahmedhossamdev

Sequence Diagram(s)

sequenceDiagram
  participant TripsForRouteHandler
  participant ServiceDateResolver
  participant StaticTripLookup
  participant TripStatus
  TripsForRouteHandler->>ServiceDateResolver: Resolve trip service date
  ServiceDateResolver-->>TripsForRouteHandler: Return query-day or previous-day date
  TripsForRouteHandler->>StaticTripLookup: Resolve duplicated trip ID
  StaticTripLookup-->>TripsForRouteHandler: Return static base trip or unresolved ID
  TripsForRouteHandler->>TripStatus: Build status with resolved service date
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: reporting the service date for each trip instance.
Linked Issues check ✅ Passed The shared resolver updates serviceDate and trip status in both handlers, including trips that cross midnight, as required by [#1311].
Out of Scope Changes check ✅ Passed The changes support service-date resolution, handler consistency, and related tests without introducing unrelated scope.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ARCoder181105
ARCoder181105 force-pushed the fix/trip-service-date branch 5 times, most recently from 5b1836d to 863ce11 Compare August 7, 2026 20:36
@aaronbrethorst

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. runsOn requires strict containment (min_arrival_time <= now <= max_departure_time), but the trips it is asked to classify are not all selected that way. GetActiveTripsWithNullBlockForRoute selects on overlap with [now - 30 min, now + 10 min] (t.min_arrival_time <= time_range_end AND t.max_departure_time >= time_range_start), and tripsForRouteHandler appends its previous-day results straight into activeTrips without any further containment check. So a previous-day null-block trip scheduled 23:00–24:10, queried at 00:30, is selected (24.167h >= 24.0h) but fails the resolver's previous-day test (24.5h > 24.167h) and falls through to return r.queryDayMidnight — the entry still reports today's midnight for a trip that belongs to yesterday's service. Because the same value is now also handed to BuildTripStatus, position, closestStop and scheduleDeviation for that entry stay 24 hours off, which is the failure mode this PR set out to fix. It is not the rare case it looks like: for a feed with no block_id, every trip goes through this path, and at any post-midnight instant the recently-ended/about-to-start fringe is a large share of the returned entries. Widening the resolver's window to the same runningLate/runningEarly slack the selection uses, or resolving the service day at selection time where sd.sinceMidnight is already known, would close it. The same slack gap applies to trips-for-location, where entries come from live vehicles that routinely run outside their scheduled span (bug due to return sinceMidnightNs >= trip.MinArrivalTime.Int64 && sinceMidnightNs <= trip.MaxDepartureTime.Int64)

// runsOn reports whether trip's service is active in services and its scheduled
// span contains sinceMidnightNs, measured from that service day's midnight.
func (r *serviceDateResolver) runsOn(services map[string]struct{}, trip gtfsdb.Trip, sinceMidnightNs int64) bool {
if _, active := services[trip.ServiceID]; !active {
return false
}
if !trip.MinArrivalTime.Valid || !trip.MaxDepartureTime.Valid {
return false
}
return sinceMidnightNs >= trip.MinArrivalTime.Int64 && sinceMidnightNs <= trip.MaxDepartureTime.Int64
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a real improvement over the status quo, and I want to call out one thing you got right that a sibling PR didn't: you thread the resolved date into BuildTripStatus as well as onto entry.serviceDate, so both agree. #1286 fixes only the entry field and leaves status.serviceDate on today's midnight, which produces two contradictory dates in the same response. Your version doesn't have that problem. Mirroring the existing resolveActiveTripID pattern was also the right instinct.

The blocker is a window mismatch.

runsOn requires strict containment, but the handlers select trips by overlap. GetActiveTripsWithNullBlockForRoute (gtfsdb/query.sql:1146-1157) selects trips whose span overlaps [now-30m, now+10m], and trips_for_route_handler.go:178-192 appends previous-day results into activeTrips with no further containment check. runsOn (trips_helper.go:1290-1298) then asks whether the trip's min_arrival_time/max_departure_time window contains the current time-since-midnight.

So a previous-day trip scheduled 23:00–24:10, queried at 00:30, is selected by the query but fails runsOn and falls through to the query day — leaving serviceDate and the BuildTripStatus-derived position and deviation math 24h off. That's precisely the case this PR exists to fix. The same slack applies to trips-for-location, where entries are live vehicles that routinely run outside their scheduled span.

Widening the resolver's window to match the selection window (or resolving from the same overlap test the query uses) should close it.

One other thing worth folding in while you're there: tripsForRouteHandler already has serviceIDs and prevServiceIDs in local scope from identical GetActiveServiceIDsForDate calls, and newServiceDateResolver re-runs both. Two redundant indexed queries per request on a hot endpoint — passing the existing slices in would be cheaper and is what CONTRIBUTING's Code Reuse section is pointing at.

Ordering note: this sits on #1313#1314#1315, so those need to land first.

Re-review whenever you've adjusted the window.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/restapi/trips_for_location_handler.go (1)

395-412: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Load block trips for the resolved previous service day.

This query uses req.ServiceDate before the per-trip resolver runs at Lines 440 and 478. A trip selected after midnight can resolve to the previous service day, but its service ID is absent from activeServiceIDs when it does not also run today. calculateNextPrevFromMemory then returns no schedule links for that trip.

Load active service IDs for both candidate service dates, or resolve dates before grouping the block queries. Add an overnight-trip test that asserts nextTripId and previousTripId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/restapi/trips_for_location_handler.go` around lines 395 - 412, The
block-trip loading flow around GetActiveServiceIDsForDate must include the
resolved previous service day used by per-trip resolution, not only
req.ServiceDate. Resolve or collect both candidate dates before
GetTripsByBlockIDs, merge their active service IDs without duplicates, and
preserve the existing query behavior for current-day trips. Add an
overnight-trip test verifying both nextTripId and previousTripId are populated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@gtfsdb/query.sql`:
- Around line 886-889: Update the exported documentation for GetTripIDsForStops
in gtfsdb/query.sql lines 886-889 to begin with GetTripIDsForStops, then
regenerate gtfsdb/query.sql.go lines 4464-4467 rather than editing generated
output. Update the exported CheckIfOutOfBounds documentation in
internal/gtfs/location_params.go lines 56-60 to begin with CheckIfOutOfBounds.

In `@internal/gtfs/location_params_test.go`:
- Around line 158-172: Refactor TestCheckIfOutOfBoundsClamping into a
table-driven test containing the unclamped and clamped cases, each with its
clamp flag and expected CheckIfOutOfBounds result. Iterate through the cases
with t.Run while preserving the existing Manager, LocationParams, and assertion
messages or equivalent coverage.

In `@internal/restapi/situation_references_test.go`:
- Around line 105-111: Update the test case "arrival-and-departure-for-stop" to
derive serviceDate at the agency’s local midnight rather than using
serviceTime.Truncate(24*time.Hour). Convert serviceTime to the agency timezone,
construct that local calendar date at midnight, and use its Unix milliseconds so
the handler receives June 12 as intended.

In `@internal/restapi/trips_for_location_handler.go`:
- Around line 36-46: The trips-for-location handler currently passes all
uncapped stop IDs to GetTripIDsForStops, which can exceed SQLite’s bind-variable
limit. Keep GetStopsInBounds uncapped, but batch stopIDs into bounded chunks
before calling GetTripIDsForStops, union the returned trip IDs across batches,
and preserve existing error handling and active-trip filtering.
- Around line 269-285: Update stopsReferencedByEntries to preserve every
agency-scoped combined stop ID instead of overwriting or deduplicating by bare
ID; adjust the downstream createStop/reference emission flow around its current
map usage so each stored combined ID produces a corresponding reference,
including when agencies share a bare stop ID. Add a test covering two agencies
referencing the same bare stop ID and verifying both references are emitted.

In `@internal/restapi/trips_for_route_handler_test.go`:
- Around line 1140-1156: Update the handler test around callAPIHandler to
request status data by adding includeStatus=true to the URL, then assert each
entry’s ServiceDate equals entry.Status.ServiceDate using the appropriate
models.ModelTime conversion for its actual type. Preserve the existing
situation-reference assertions.

In `@internal/restapi/trips_for_route_handler.go`:
- Around line 447-460: Update the DUPLICATED-trip fallback in the trip
resolution flow to log non-sql.ErrNoRows errors from the stripped-ID GetTrip
lookup, matching the existing logging for the original lookup. In the GetTrip
fallback block, assign baseTripID only after the stripped lookup succeeds; leave
it unchanged when that lookup fails so downstream buildScheduleForTrip and
BuildTripStatus use a valid resolved trip ID.

In `@internal/restapi/trips_helper.go`:
- Around line 1241-1246: Track the configurability work for runningLate and
runningEarly by creating an issue, then replace the TODO with a reference to
that issue while preserving the current Java OBA default values and behavior.
- Around line 1267-1279: Remove the unused RestAPI.newServiceDateResolver
method, including its obsolete comments, while preserving serviceDateResolver,
newServiceDateResolverFor, and related methods or test usage.

---

Outside diff comments:
In `@internal/restapi/trips_for_location_handler.go`:
- Around line 395-412: The block-trip loading flow around
GetActiveServiceIDsForDate must include the resolved previous service day used
by per-trip resolution, not only req.ServiceDate. Resolve or collect both
candidate dates before GetTripsByBlockIDs, merge their active service IDs
without duplicates, and preserve the existing query behavior for current-day
trips. Add an overnight-trip test verifying both nextTripId and previousTripId
are populated.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 24cc4adc-58ee-4b66-bef3-fdd7f3bf5c8a

📥 Commits

Reviewing files that changed from the base of the PR and between 91226a9 and a81c433.

📒 Files selected for processing (17)
  • gtfsdb/db.go
  • gtfsdb/query.sql
  • gtfsdb/query.sql.go
  • internal/gtfs/location_params.go
  • internal/gtfs/location_params_test.go
  • internal/restapi/arrival_and_departure_for_stop_handler.go
  • internal/restapi/arrivals_and_departures_for_stop_handler.go
  • internal/restapi/reference_utils.go
  • internal/restapi/service_date_resolver_test.go
  • internal/restapi/situation_references_test.go
  • internal/restapi/trip_details_handler.go
  • internal/restapi/trip_for_vehicle_handler.go
  • internal/restapi/trips_for_location_handler.go
  • internal/restapi/trips_for_location_handler_test.go
  • internal/restapi/trips_for_route_handler.go
  • internal/restapi/trips_for_route_handler_test.go
  • internal/restapi/trips_helper.go

Comment thread gtfsdb/query.sql Outdated
Comment thread internal/gtfs/location_params_test.go
Comment thread internal/restapi/situation_references_test.go
Comment thread internal/restapi/trips_for_location_handler.go
Comment thread internal/restapi/trips_for_location_handler.go
Comment thread internal/restapi/trips_for_route_handler_test.go
Comment thread internal/restapi/trips_for_route_handler.go Outdated
Comment thread internal/restapi/trips_helper.go Outdated
Comment thread internal/restapi/trips_helper.go Outdated
trips-for-location and trips-for-route both stamped the query day's
midnight onto every entry's serviceDate. GTFS stop times may run past
24:00:00, so a trip still running just after midnight belongs to the
previous day's service — reporting the query day put serviceDate and
the trip's own stop-time offsets a day apart.

Add serviceDateResolver, which checks the query day first and then the
day before it, matching the trip's service calendar against its cached
min_arrival_time/max_departure_time window. Trips that match neither
keep the query day, so behaviour is unchanged for the ordinary case.

The resolved date is also what is now passed to BuildTripStatus, so
status.serviceDate agrees with the entry's.
The resolver asked whether a trip's scheduled span contained the current
time, but trips are selected on overlap with a window reaching 30
minutes back and 10 minutes forward. A previous-day trip scheduled
23:00-24:10 and queried at 00:30 was therefore returned but classified
as not running, so it reported the query day — leaving its serviceDate,
position and schedule deviation a day out, the case this resolver
exists to correct.

Test against the same window, and move the window constants next to it
so the two cannot drift.
tripsForRouteHandler resolves the active services for the query day and
the day before to select trips, then the service date resolver ran the
same two queries again. Hand it the slices instead.
The resolved service date is handed to BuildTripStatus as well as
stamped on the entry, so a regression that updated only one of them
would otherwise pass.
@ARCoder181105
ARCoder181105 force-pushed the fix/trip-service-date branch from a81c433 to 257aa12 Compare August 10, 2026 12:46
The comment moved into this file with the constants, and a bare TODO
reads as work this change left undone. The configurability it asks for
is already tracked.
Stripping a numeric suffix off a duplicated run's trip ID adopted the
stripped form before checking that it named anything, so a trip whose
suffixed and stripped IDs both miss handed an ID matching no trip to
the schedule and status builders. Adopt it only once it resolves, and
log a stripped lookup that fails for a reason other than absence.

Pull the resolution out of the entry loop so its four outcomes can be
tested.
@aaronbrethorst

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. resolveDuplicatedBaseTrip was inserted between stripNumericSuffix's doc comment and stripNumericSuffix itself, so the two comment blocks are now contiguous with no blank line between them. Godoc reads the whole run as resolveDuplicatedBaseTrip's doc, which opens "stripNumericSuffix removes a trailing .<digits> from a trip ID..." — describing a different function — and stripNumericSuffix is left with no doc comment at all. On main that comment sits directly above func stripNumericSuffix. Moving the four orphaned lines back down to just above stripNumericSuffix restores both (CONTRIBUTING.md says "every exported function, type, and package still needs a doc comment starting with its name")

// stripNumericSuffix removes a trailing ".<digits>" from a trip ID.
// Some GTFS-RT feeds append a numeric suffix to DUPLICATED trip IDs to
// distinguish individual runs (e.g., "LLR_..._1083.00060" -> "LLR_..._1083").
// If the ID has no dot, or the part after the last dot contains non-digits,
// the original string is returned unchanged.
// resolveDuplicatedBaseTrip finds the static trip a DUPLICATED real-time trip
// is a run of, returning the ID to use for schedule and status lookups together
// with the trip row itself.
//
// The full ID is tried first, then the ID with a trailing numeric suffix
// stripped, which is how some feeds distinguish duplicated runs. The stripped
// ID is adopted only once it resolves: handing on an ID that matches no trip is
// worse than keeping the unresolvable one the feed sent. When neither resolves,
// the trip comes back zeroed, which the service date resolver reports as the
// query day.
func (api *RestAPI) resolveDuplicatedBaseTrip(ctx context.Context, dupTripID string) (string, gtfsdb.Trip) {
trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, dupTripID)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need some changes before merging — please see the notes below.

The service-date logic is right, and my earlier objection is fully addressed. I traced the exact case I raised last time — a previous-day 23:00–24:10 trip queried at 00:30 — by hand through the rewritten runsOn, and it now resolves to the previous day. Moving runningLate/runningEarly out of the handler and next to the resolver so the two can't drift apart was the right structural call, and service_date_resolver_test.go covers both that case and its negative. Replacing the bare TODO with a pointer to #800 is appreciated.

One thing to fix:

stripNumericSuffix lost its doc comment. resolveDuplicatedBaseTrip was inserted between the existing comment block and the func stripNumericSuffix line, with no blank line between them. So godoc now attributes "stripNumericSuffix removes a trailing .<digits>..." to resolveDuplicatedBaseTrip, and stripNumericSuffix reads as undocumented. Moving the comment block back down to its function is the whole fix.

Two notes, neither blocking:

  • runsOn's doc comment says it uses "the same slack the handlers select on." That's true for trips-for-route, but trips-for-location selects on live vehicles, not that window.
  • newServiceDateResolver issues two GetActiveServiceIDsForDate queries per trips-for-location request even with zero entries, and one duplicates the call the block logic makes a few lines above. The route handler avoids this via newServiceDateResolverFor.

Sequencing, which matters more than the fix above: I've just merged #1329, which rewrites the same lines in buildTripsForLocationEntries and removes the TodayMidnight/ServiceDate/CurrentLocation fields your resolver reads. The two changes are complementary in intent — #1329 decides which timezone's midnight, this PR decides which day — but they don't compose as written. When you rebase, please anchor the resolver on the per-agency midnight rather than reintroducing TodayMidnight, otherwise day boundaries go back to being resolved in agencies[0]'s zone.

This also still sits on #1315, which sits on #1314 and #1313 — both of those have changes requested and need to clear first. And #1286 is a competing implementation of this PR's route-handler half; worth deciding between them before either lands.

Two resolutions worth noting.

main now stamps each entry with agency-local midnight of the query day,
which is the base this branch's resolver refines. Both halves of a
resolver — the query day's midnight and the wall clock elapsed since it
— are read in the agency's timezone, so trips-for-location now builds
one resolver per agency rather than one per request.

The DUPLICATED trip path collided with OneBusAway#1313's indexing fix, which
landed the same lookup inline. Keep this branch's
resolveDuplicatedBaseTrip extraction and index through it, so the
situation lookup still reuses the record already in hand.
resolveDuplicatedBaseTrip was inserted between stripNumericSuffix's doc
comment and the function it documents, with no blank line separating
the two blocks. godoc now attributes both comments to
resolveDuplicatedBaseTrip, and stripNumericSuffix reads as
undocumented.

Move the comment back down to sit directly above the function it
describes.
The comment attributed the overlap window to "the handlers" plural,
but only trips-for-route selects trips on
[now-runningLate, now+runningEarly]; trips-for-location on this
branch selects from live vehicles, not that window.

Word it as trips-for-route's own selection window, and describe the
overlap-vs-containment reasoning without depending on what any
particular caller happens to select on.
The block lookup queried GetActiveServiceIDsForDate for an agency's
query day, and the entry loop's resolver queried the same date again a
few lines later for the same agency — once per agency per request.

Collect the request's agency set once (trips whose agency has no
resolvable timezone are skipped when entries are built regardless, so
they need neither block nor service-day data), then fetch each agency's
query-day and previous-day service IDs through serviceIDsForDays and
build its resolver up front. The block lookup reads the fetched
query-day IDs instead of querying again, and the entry loop looks its
resolver up instead of building one lazily.

This also closes a latent panic: the block lookup read
request.AgencyLocations[agencyID] without checking the second return
and handed the zero value straight to serviceDateMidnight, which calls
time.Time.In(nil). Reachable when a route names an agency_id absent
from agency.txt, arrived with main's per-agency timezone change
(OneBusAway#1329) and is not otherwise part of this PR. Restricting the collected
agency set to agencies with a known location closes it as a side effect
of removing the duplicate query.

trips-for-route already avoids the equivalent duplicate through
newServiceDateResolverFor; this gives trips-for-location the same
shape. newServiceDateResolver keeps its existing signature, now
implemented in terms of serviceIDsForDays, since OneBusAway#1317's candidate
selection still calls it directly.
@ARCoder181105

Copy link
Copy Markdown
Collaborator Author

All three addressed.

  • stripNumericSuffix's doc comment — restored to sit above the function it documents. resolveDuplicatedBaseTrip had landed between the comment and stripNumericSuffix with no blank line, so godoc was attributing it to the wrong function. (956578c)
  • runsOn's doc comment — now attributes the window to trips-for-route specifically, not "the handlers." (5d4f7a9)
  • Duplicate service-ID queries — collected the request's agency set once (skipping agencies with no resolvable timezone, since those trips are dropped before the block/resolver ever runs), fetch each agency's query-day and previous-day IDs once via serviceIDsForDays, and build its resolver up front. The block lookup reads the fetched IDs instead of querying again. (4825a28)

That third change also closed a latent panic: the block lookup read AgencyLocations[agencyID] without checking the second return and passed the zero value into serviceDateMidnight, which calls time.Time.In(nil). Reachable when a route names an agency_id absent from agency.txt — arrived with main's #1329, not this PR, but the agency-set filter needed for the dedup closes it as a side effect.

newServiceDateResolver keeps its signature, now implemented via serviceIDsForDays — #1317's candidate selection still calls it directly.

On sequencing: confirmed the resolver is anchored on each agency's own midnight after the #1329 merge, not agencies[0]'s zone.

#1286 overlaps only the trips-for-route half of this PR — it adds a tripServiceDay map local to that handler, where this PR adds a resolver both handlers share. Leaving the call on that to you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/restapi/trips_for_location_handler.go (1)

172-180: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Skip invalid agency timezones instead of failing the endpoint.

Lines 174-177 return before buildTripsForLocationEntries can skip agencies missing from AgencyLocations. One invalid agency timezone therefore returns HTTP 500 for valid agencies too.

Log and skip the invalid agency. Select the first valid location for parsing time. Return an error only when no agency location resolves. Add a multi-agency regression test.

Proposed fix
 agencyLocations := make(map[string]*time.Location, len(agencies))
+var currentLocation *time.Location
 for _, agency := range agencies {
   location, locationErr := loadAgencyLocation(agency.ID, agency.Timezone)
   if locationErr != nil {
-    return nil, nil, locationErr
+    api.Logger.Warn("skipping agency with invalid timezone",
+      "agency_id", agency.ID, "error", locationErr)
+    continue
   }
   agencyLocations[agency.ID] = location
+  if currentLocation == nil {
+    currentLocation = location
+  }
 }
-currentLocation := agencyLocations[agencies[0].ID]
+if currentLocation == nil {
+  return nil, nil, errors.New("no agencies have a resolvable timezone")
+}
🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@internal/restapi/trips_for_location_handler.go` around lines 172 - 180,
Update the agency location-loading loop around loadAgencyLocation to log and
skip agencies whose timezone cannot be resolved instead of returning
immediately. Select the first successfully resolved location for parsing time,
preserve valid entries in agencyLocations for buildTripsForLocationEntries, and
return an error only when no agency location resolves; add a regression test
covering multiple agencies with one invalid timezone.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Outside diff comments:
In `@internal/restapi/trips_for_location_handler.go`:
- Around line 172-180: Update the agency location-loading loop around
loadAgencyLocation to log and skip agencies whose timezone cannot be resolved
instead of returning immediately. Select the first successfully resolved
location for parsing time, preserve valid entries in agencyLocations for
buildTripsForLocationEntries, and return an error only when no agency location
resolves; add a regression test covering multiple agencies with one invalid
timezone.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b9e21b44-8736-4ce7-bfa3-bc2ded799565

📥 Commits

Reviewing files that changed from the base of the PR and between a68221b and b5bb96e.

📒 Files selected for processing (4)
  • internal/restapi/trips_for_location_handler.go
  • internal/restapi/trips_for_route_handler.go
  • internal/restapi/trips_for_route_handler_test.go
  • internal/restapi/trips_helper.go

@Ahmedhossamdev Ahmedhossamdev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix. serviceDate now points at the day the trip actually runs on, which
matches Java and the spec. Entry and status serviceDate can't drift apart
since BuildTripStatus stamps the resolved date, and the tests cover the
past-midnight cases I couldn't reproduce live (Unitrans has no >24:00 trips).

The only nit: the unused
newServiceDateResolver helper can be dropped. No blockers, ship it.

Comment thread internal/restapi/trips_helper.go Outdated
Comment thread internal/restapi/trips_for_route_handler.go
@ARCoder181105

Copy link
Copy Markdown
Collaborator Author

Thanks for the review.

newServiceDateResolver — correct that it has no caller in this PR's own diff. Kept deliberately: #1317, stacked on top of this branch, calls it directly for candidate selection (trips_for_location_handler.go:50), since that path needs a resolver in the query's own timezone before any trip's agency is known. Deleting it here would break #1317 on rebase. Noted in the commit message (f897a5b) for exactly this reason — happy to remove it once #1317 lands and either takes over the caller or gets its own copy.

DUPLICATED run precision — agreed, noted as a known Java-parity gap, not blocking.

@aaronbrethorst

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. newServiceDateResolver has no caller anywhere in the PR. newServiceDateResolverFor is what trips_for_location_handler.go:377 and trips_for_route_handler.go:318 construct through, and service_date_resolver_test.go builds resolvers via newServiceDateResolverFor or a struct literal — a grep across the head tree turns up newServiceDateResolver( exactly once, at its own definition. go vet (all make lint runs) does not flag unused methods, so nothing in CI catches this. It also reads oddly on its own terms: the doc comment says a lookup failure "is what callers did before it existed," and there are no callers. The stated reason for keeping it is that Return scheduled trips with no real-time vehicle #1317 calls it, but Return scheduled trips with no real-time vehicle #1317 is unmerged, so as this stands it lands dead code plus an untested code path on main (CONTRIBUTING.md: "untested code paths and leftover dead code are common review findings"). Deleting it here and letting Return scheduled trips with no real-time vehicle #1317 reintroduce it — or move the serviceIDsForDays call inline — keeps main clean either way.

// newServiceDateResolver loads the services active on the query day and the day
// before it. A lookup failure is not fatal — the resolver then reports the
// query day for every trip, which is what callers did before it existed.
func (api *RestAPI) newServiceDateResolver(ctx context.Context, queryDayMidnight, currentTime time.Time) *serviceDateResolver {
return newServiceDateResolverFor(queryDayMidnight, currentTime, api.serviceIDsForDays(ctx, queryDayMidnight))
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is close, and the hard parts are right. Going through my earlier list:

  • The window mismatch (08-09). Fixed properly. runsOn now tests overlap
    with [now-runningLate, now+runningEarly] rather than containment. I re-ran my
    original case by hand: a previous-day trip running 23:00–24:10, queried at
    00:30, gives sinceMidnight+24h = 24.5h, and min 23h ≤ 24.667h with
    max 24.167h ≥ 24.0h resolves to the previous day. That's the behavior I wanted.
  • Redundant service-ID queries. Gone on both endpoints —
    newServiceDateResolverFor takes what the handler already holds, and
    trips-for-location does one serviceIDsForDays per agency up front with the
    block lookup reading services[agencyID].QueryDay.
  • stripNumericSuffix's doc comment. Restored and correctly positioned.
  • The runsOn doc comment overclaiming. Reworded accurately.

And the #1329 rebase is the part I want to call out. This was the thing I
was most worried about — #1329 removed TodayMidnight, ServiceDate and
CurrentLocation out from under your resolver, and the easy wrong move was to
reintroduce them. You didn't: I grepped the head tree and none of those three
appear anywhere in the two handlers or trips_helper.go. Anchoring per agency
via request.AgencyLocations[agencyID] is right, and converting the clock with
.In(agencyLocation) so wallClockSinceMidnightNs is computed in the agency's
own zone is actually more correct than #1329 on its own. I also checked the
+24h arithmetic across spring-forward and fall-back and it matches the stored
GTFS offsets under the wall-clock convention. Nicely done.

The one thing left: please delete newServiceDateResolver
(internal/restapi/trips_helper.go:1272).

It has zero callers. Both handlers use newServiceDateResolverFor; grep finds
newServiceDateResolver( exactly once, at its own definition. go vet doesn't
flag unused methods, so CI can't catch it.

I've read your reply that #1317 will call it, and I understand the reasoning —
but I don't want to merge dead, untested code into main on the strength of a
PR that isn't merged yet. #1317 has open changes on it, so this could sit unused
for a while, and "a future PR will need it" is exactly the argument
CONTRIBUTING's dead-code rule exists to decline. Reintroducing three lines in
#1317 at the moment it actually gains a caller costs you almost nothing and
keeps main honest.

That's the only thing between this and a merge — push that and I'll take it.

Two notes for the record, neither blocking:

  • trips-for-location resolves using trips-for-route's ±30/10 window while
    selecting entries from live vehicles, so a vehicle more than 30 minutes past
    its scheduled end still resolves to the query day. I marked this non-blocking
    on 08-14 and I'm holding that; the doc comment now says so, which is the right
    handling.
  • #1286 is still a competing implementation of this PR's route-handler half.
    That's mine to adjudicate, not yours — I'll sort it out separately and it
    won't hold this up.

@burma-shave

Copy link
Copy Markdown
Collaborator

Spec note (non-blocking, no code change needed): documented the trips-for-location service-date gap in the wiki — candidates there are selected from live vehicle positions with no scheduled-window filter, so the resolver's ±30/+10 window isn't guaranteed to cover every trip this endpoint can select, unlike trips-for-route where selection and resolution now share the same window. This is the write-up the doc comment on runsOn already points at.

See the new Implementation Decisions entry: https://github.com/OneBusAway/maglev/wiki/trips-for-location#implementation-decisions

@burma-shave burma-shave left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just needs aaron's comments addressed

Both handlers build their resolver with newServiceDateResolverFor from
service IDs they already hold, so the constructor that fetched them
itself has no caller and no test. go vet does not flag unused methods,
so it would not have been caught by CI.
@ARCoder181105

Copy link
Copy Markdown
Collaborator Author

Deleted in 0da8388 — you're right, and the "#1317 will need it" argument doesn't justify landing dead code in main. #1317 now inlines the two lines at its own call site instead of reintroducing the helper, so nothing is waiting on it.

Also reworded newServiceDateResolverFor's doc comment, which read as a contrast against the constructor that's now gone.

grep -rn "newServiceDateResolver\b" comes back empty. CI green.

Noted on both non-blocking items.

@sonarqubecloud

Copy link
Copy Markdown

@burma-shave
burma-shave dismissed aaronbrethorst’s stale review August 21, 2026 01:51

comments have been addressed

@burma-shave
burma-shave merged commit 347da6e into OneBusAway:main Aug 21, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serviceDate uses query day instead of the trip's service date

4 participants