If there is only one agency show all routes #100
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#100
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The GTFS spec makes
agency_idoptional inagency.txt(when there's only one agency) and inroutes.txt(required only when multiple agencies exist). The codebase currently breaks for feeds like LIRR where neither the single agency nor its routes carry anagency_idfield — routes never appear in the agency view, and agency display can render empty/broken. The fix is threefold: (1) indexagency_idin the virtual-table fieldMaps so missing IDs are bucketed as"", (2) create a small utility that encapsulates the "single-agency fallback" logic so it lives in one place, and (3) update every consumer that compares againstagency_idto go through that abstraction. Using""as the sentinel for a missing agency_id is the natural choice because the CSV parser already produces an empty string for blank/absent fields.Relevant context
src/modules/gtfs-parser.ts:buildAndRegisterVirtual, ~L190–270): in-memory store used for all CSV-backed tables. Itsquery()method uses a pre-builtfieldMapsindex if available (keyed withString(val ?? ''), so undefined →""), otherwise falls back to a linear scan with strict===equality. The linear scan does NOT coerceundefinedto"", so{ agency_id: '' }never matches a route whoseagency_idfield is absent. The fix: addagency_idtofieldMapsfor theagencyandroutestables insetupVirtual(~L375–388), which forces the indexed path and gets the normalization for free.setupVirtual(src/modules/gtfs-parser.ts:375): only builds fieldMaps forstop_times(trip_id, stop_id) andtrips(route_id, service_id). Routes and agency have no fieldMaps — all their queries fall through to the linear scan.getRoutesForAgencyAsync(src/modules/gtfs-relationships.ts:442): queriesroutesby{ agency_id }. In a single-agency feed where routes omitagency_id, this returns nothing.getRoutesForAgency(sync,src/modules/gtfs-relationships.ts:51): same issue —filter(route => route.agency_id === agency_id)never matchesundefined.agency-view-controller.ts:176: callsqueryRows('routes', { agency_id })directly — same problem.map-controller.ts(~L720):route.agency_id === agency_idin highlight logic.service-view-controller.ts(~L192): groups routes byroute.agency_id || 'default'— partially defensive but inconsistent with the rest.getAgencyDisplay(src/utils/entity-display.ts:9): falls back to{ primary: id ?? '' }— renders a blank card for an agency with no id and no name.src/modules/page-content-renderer.ts:287): setsdata-agency-id="${agencyData.agency_id}"— if id is"", navigation still works but display needs to show "Not specified".Phase 1 — Core abstraction (
src/utils/agency-helpers.ts)Create a new file with pure utilities that all consumers import. This is the single place that documents and encodes the GTFS optionality rule.
Create
src/utils/agency-helpers.ts:export const UNSPECIFIED_AGENCY_ID = '' as constexport function normalizeAgencyId(id: string | undefined | null): string— returnsidif truthy, otherwiseUNSPECIFIED_AGENCY_IDexport function agencyRouteFilter(agencyId: string, agencyCount: number): string[]— returns[agencyId]in the multi-agency case, or[agencyId, UNSPECIFIED_AGENCY_ID]in the single-agency case (deduplicated if agencyId is already""). This is the canonical logic for "which agency_id values count as belonging to this agency."Update
getAgencyDisplayinsrc/utils/entity-display.ts:agency_nameandagency_idare falsy/empty, return{ primary: 'Not specified' }instead of an empty string.Phase 2 — Virtual table indexing (
src/modules/gtfs-parser.ts)Fix
setupVirtualso agency and route lookups byagency_iduse the indexed path (which normalises undefined →""), not the strict-equality linear scan.setupVirtual(~L378), add branches foragencyandroutes:Gotcha: The fieldMap key is built with
String(val ?? ''), so a row whereagency_idisundefinedends up in the""bucket. AqueryRows('routes', { agency_id: '' })call will then correctly find those rows via the map path.Phase 3 — Fix relationship query methods
All methods that fetch routes for an agency must apply the single-agency fallback.
src/modules/gtfs-relationships.ts:getRoutesForAgency(agency_id)(sync, ~L51): count agencies viathis.gtfsParser.getFileDataSync('agency.txt').length. UseagencyRouteFilterto get the set of accepted agency_id values; filter routes against that set.getRoutesForAgencyAsync(agency_id)(~L442):await this.gtfsDatabase.getAllRows('agency')to get agency count, callagencyRouteFilter, then union twoqueryRowscalls (one per accepted id).getAgenciesAsync()(~L415): normalizeagency_idvianormalizeAgencyIdwhen constructing the returned objects'idandagency_idfields.getAgencyByIdAsync(agency_id)(~L984): normalize the incomingagency_idwithnormalizeAgencyIdbefore passing toqueryRows.src/modules/agency-view-controller.ts:getAgencyData(agency_id)(~L148): normalize withnormalizeAgencyIdbeforequeryRows.getRoutesForAgency(agency_id)(~L176): fetch agency count viaqueryRows('agency'), applyagencyRouteFilter, union results with Promise.all.Notes:
normalizeAgencyIdsignature widened to acceptnumber | boolean | undefined | nullto matchGTFSDatabaseRecordvalue types.QueryOnlyDatabasehas onlyqueryRows(nogetAllRows), so agency count is obtained viaqueryRows('agency')with no filter.Phase 4 — Fix remaining consumers
Any other location that compares
route.agency_idoragency.agency_iddirectly.src/modules/map-controller.ts(highlightAgencyRoutes, ~L720): replace.filter(route => route.agency_id === agency_id)with a filter usingnormalizeAgencyIdon both sides, respecting the single-agency case viaagencyRouteFilter(pass agency count or fetch it).src/modules/service-view-controller.ts(~L192): replaceroute.agency_id || 'default'withnormalizeAgencyId(route.agency_id)for consistency. Also fixed the agency lookup key inroutesByAgency.getto usenormalizeAgencyId(agency.agency_id).src/modules/stop-view-controller.ts(~L125): same —route.agency_id || 'default'→normalizeAgencyId(route.agency_id). Also fixed the agency lookup key.src/modules/page-content-renderer.ts(~L287): wrapagencyData.agency_idinnormalizeAgencyId(agencyData.agency_id)for thedata-agency-idattribute so clicking navigates to""rather than"undefined".Phase 5 — Fix service discovery on home page
The
getServices()method insrc/modules/page-content-renderer.ts(~L405) only reads from thecalendartable. GTFS allows services to be defined exclusively incalendar_dates.txt(nocalendar.txtrow required). For such feeds the home page shows "No services found in GTFS data." even though trips reference valid service IDs.src/modules/page-content-renderer.ts:getServices()(~L405): after fetchingcalendarrows, also fetch all rows fromcalendar_datesviathis.dependencies.gtfsDatabase.getAllRows('calendar_dates'). Build aSet<string>ofservice_ids already covered bycalendarrows. For eachcalendar_datesrow whoseservice_idis not in the set, synthesize a minimal{ service_id }record and add it to the result array. Return the merged array.getServiceDisplayalready falls back toservice_idas the label, so synthesized records render correctly.Gotcha:
calendar_dateshas multiple rows perservice_id(one per date exception). Deduplication is essential — use theSetapproach above rather than aMapto avoid returning duplicate cards.Testing notes
calendar_dates.txtwith no correspondingcalendar.txtrow.agency_idbut with a name — card shows name as primary, no secondary.Original Issue
https://gtfs.org/documentation/schedule/reference/#routestxt
agency_id Foreign ID referencing agency.agency_id Conditionally Required Agency for the specified route.
Conditionally Required:
I think that every foreign key to agency is optional. It at least applies to services as well. Even in agency.txt, the id is not required, so lets make sure that we safely handle that.
In the routes list, we should show it properly. Lets make sure to abstract out this edge case because it's a tricky one to test. Use LIRR for manual test.