calendar-dates only service handling #108

Closed
opened 2026-05-10 23:41:37 +00:00 by maxtkc · 0 comments
Owner

Summary

GTFS allows a service_id to be defined exclusively in calendar_dates.txt with no corresponding row in calendar.txt. The codebase has two gaps when this occurs:

  1. The "Add timetable for service" dropdown on the route page only reads from calendar, so calendar_dates-only services never appear in it.
  2. The service-days editor shows a blank date range for these services and uses wrong fallback dates (today / today+1year) when the user makes their first edit.

The fix for both gaps is straightforward: union data from both tables where only calendar is currently read, and derive the date range from the existing exception dates rather than defaulting to today.

Relevant context

  • src/modules/page-content-renderer.ts:renderRoute (~L458): builds allServices from getAllRows('calendar') only (~L509). Uses it to populate the "Add timetable for service" <select>. The rendering code calls getServiceDisplay(service) which already falls back to service_id as the label, so synthesized records with only a service_id field render fine.
  • src/modules/service-days-controller.ts:renderServiceEditorHTML (~L415): calls renderWeeklyPattern, renderDateRange, and renderExceptions with calendar (which is null for calendar_dates-only services).
  • renderWeeklyPattern (~L456): already renders all buttons unselected when calendar is null — no change needed.
  • renderDateRange (~L492): derives start/end from calendar?.start_date / calendar?.end_date — both are empty strings when calendar is null, producing blank inputs.
  • matchPatterns (~L706): already falls back to min/max of exception dates when calendar is null (lines ~L712-719) — exceptions section renders correctly as-is.
  • toggleDay (~L168): when calendar is null, creates a new calendar.txt entry with start_date=today, end_date=today+1year, then records it via patchManager.recordInsert.
  • updateDateRange (~L244): same null-calendar fallback with today/today+1year.

Phase 1 — Fix "Add timetable" dropdown

File: src/modules/page-content-renderer.ts

The dropdown only shows services from calendar. Services defined only in calendar_dates.txt are invisible even though trips can (and should) reference them.

  • In renderRoute() (~L509): after fetching allServices from calendar, also fetch all rows from calendar_dates via this.dependencies.gtfsDatabase.getAllRows('calendar_dates').
  • Build a Set<string> of service_ids already present in allServices.
  • Deduplicate calendar_dates rows by service_id (one row per date, so many rows per service) and, for each unique id not in the set, push a minimal { service_id } record into allServices.
  • No change to dropdown rendering needed — getServiceDisplay already falls back to the raw service_id as label.

Gotcha: calendar_dates has one row per (service_id, date) pair — deduplication before merging is essential.

Phase 2 — Fix service-days editor display and calendar creation

File: src/modules/service-days-controller.ts

Display (blank date range)

  • In renderServiceEditorHTML (~L415): when calendar is null and exceptions.length > 0, sort the exception date fields (YYYYMMDD, so lexicographic sort is correct) and derive derivedStart = sorted[0], derivedEnd = sorted[last].
  • Add an optional third parameter to renderDateRange: derivedDates?: { start: string; end: string }. When calendar is null, use derivedDates.start / derivedDates.end to populate the input values instead of empty strings.
  • Pass the derived dates from renderServiceEditorHTML down into renderDateRange.

Calendar creation on first edit

Both toggleDay and updateDateRange already create a calendar.txt entry (via insertRows + patchManager.recordInsert) when calendar is null — the patch handling is correct. The only problem is the fallback date range (today / today+1year).

  • In the !calendar branch of toggleDay (~L179): before constructing the new calendar object, query calendar_dates for this service_id. If rows are returned, compute start_date = min(row.date), end_date = max(row.date). Fall back to today/today+1year only when calendar_dates is also empty.
  • Apply the same fix to the !calendar branch of updateDateRange (~L262).

Note: do not create a calendar.txt entry on page load — only on the first user-initiated edit. The existing code structure already ensures this; the fix is only to the date values used when that entry is created.

Implementation notes: updateDateRange constructs the full calendar object before the calendar[dateType] = gtfsDate line, so for the null-calendar path we initialize start_date and end_date in the object literal directly using the derived otherDate — the subsequent calendar[dateType] = gtfsDate line then correctly overwrites whichever field the user just edited.

## Summary GTFS allows a `service_id` to be defined exclusively in `calendar_dates.txt` with no corresponding row in `calendar.txt`. The codebase has two gaps when this occurs: 1. The "Add timetable for service" dropdown on the route page only reads from `calendar`, so calendar_dates-only services never appear in it. 2. The service-days editor shows a blank date range for these services and uses wrong fallback dates (today / today+1year) when the user makes their first edit. The fix for both gaps is straightforward: union data from both tables where only `calendar` is currently read, and derive the date range from the existing exception dates rather than defaulting to today. ## Relevant context - **`src/modules/page-content-renderer.ts:renderRoute` (~L458)**: builds `allServices` from `getAllRows('calendar')` only (~L509). Uses it to populate the "Add timetable for service" `<select>`. The rendering code calls `getServiceDisplay(service)` which already falls back to `service_id` as the label, so synthesized records with only a `service_id` field render fine. - **`src/modules/service-days-controller.ts:renderServiceEditorHTML` (~L415)**: calls `renderWeeklyPattern`, `renderDateRange`, and `renderExceptions` with `calendar` (which is `null` for calendar_dates-only services). - **`renderWeeklyPattern` (~L456)**: already renders all buttons unselected when `calendar` is null — no change needed. - **`renderDateRange` (~L492)**: derives start/end from `calendar?.start_date` / `calendar?.end_date` — both are empty strings when `calendar` is null, producing blank inputs. - **`matchPatterns` (~L706)**: already falls back to min/max of exception dates when `calendar` is null (lines ~L712-719) — exceptions section renders correctly as-is. - **`toggleDay` (~L168)**: when `calendar` is null, creates a new `calendar.txt` entry with `start_date=today`, `end_date=today+1year`, then records it via `patchManager.recordInsert`. - **`updateDateRange` (~L244)**: same null-calendar fallback with today/today+1year. ## Phase 1 — Fix "Add timetable" dropdown **File**: `src/modules/page-content-renderer.ts` The dropdown only shows services from `calendar`. Services defined only in `calendar_dates.txt` are invisible even though trips can (and should) reference them. - [x] In `renderRoute()` (~L509): after fetching `allServices` from `calendar`, also fetch all rows from `calendar_dates` via `this.dependencies.gtfsDatabase.getAllRows('calendar_dates')`. - [x] Build a `Set<string>` of service_ids already present in `allServices`. - [x] Deduplicate `calendar_dates` rows by `service_id` (one row per date, so many rows per service) and, for each unique id not in the set, push a minimal `{ service_id }` record into `allServices`. - [x] No change to dropdown rendering needed — `getServiceDisplay` already falls back to the raw `service_id` as label. **Gotcha**: `calendar_dates` has one row per `(service_id, date)` pair — deduplication before merging is essential. ## Phase 2 — Fix service-days editor display and calendar creation **File**: `src/modules/service-days-controller.ts` ### Display (blank date range) - [x] In `renderServiceEditorHTML` (~L415): when `calendar` is null and `exceptions.length > 0`, sort the exception `date` fields (YYYYMMDD, so lexicographic sort is correct) and derive `derivedStart = sorted[0]`, `derivedEnd = sorted[last]`. - [x] Add an optional third parameter to `renderDateRange`: `derivedDates?: { start: string; end: string }`. When `calendar` is null, use `derivedDates.start` / `derivedDates.end` to populate the input values instead of empty strings. - [x] Pass the derived dates from `renderServiceEditorHTML` down into `renderDateRange`. ### Calendar creation on first edit Both `toggleDay` and `updateDateRange` already create a `calendar.txt` entry (via `insertRows` + `patchManager.recordInsert`) when `calendar` is null — the patch handling is correct. The only problem is the fallback date range (today / today+1year). - [x] In the `!calendar` branch of `toggleDay` (~L179): before constructing the new calendar object, query `calendar_dates` for this `service_id`. If rows are returned, compute `start_date = min(row.date)`, `end_date = max(row.date)`. Fall back to today/today+1year only when `calendar_dates` is also empty. - [x] Apply the same fix to the `!calendar` branch of `updateDateRange` (~L262). **Note**: do not create a `calendar.txt` entry on page load — only on the first user-initiated edit. The existing code structure already ensures this; the fix is only to the date values used when that entry is created. **Implementation notes**: `updateDateRange` constructs the full calendar object before the `calendar[dateType] = gtfsDate` line, so for the null-calendar path we initialize `start_date` and `end_date` in the object literal directly using the derived `otherDate` — the subsequent `calendar[dateType] = gtfsDate` line then correctly overwrites whichever field the user just edited.
maxtkc self-assigned this 2026-05-10 23:41:37 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book#108
No description provided.