Trip and Calendar Patterns #20
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#20
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
This feature adds holiday pattern recognition to the Service Days editor. No new GTFS state is stored — the UI detects when a service's
calendar_datesentries collectively form a known holiday pattern (e.g., all US federal holidays within the calendar's date range with the sameexception_type). Matched patterns are displayed as named groups; unrecognized dates are shown individually below. Users can add or remove entire patterns at once, which inserts or deletes individualcalendar_datesrows, keeping the underlying GTFS fully standard and round-trip safe. Each holiday set lives in its own file undersrc/calendar-patterns/, making it trivial to add new regions.Key constraints:
exception_type.exception_type = 1(Add Service) andexception_type = 2(Remove Service) are supported for adding/recognizing patterns.Relevant Context
src/modules/service-days-controller.ts— primary change target.renderExceptions(line 470) builds the exceptions UI;refreshExceptionsDisplay(line 560) only refreshes the inner.exceptions-list— we'll widen this to refresh a new outer container#service-exceptions-${service_id}.addExceptionFromForm/addException/removeExceptionare the mutation entry points.src/types/gtfs-entities.ts—CalendarandCalendarDatestypes.Calendarhasstart_date/end_date(YYYYMMDD strings).CalendarDateshasservice_id,date(YYYYMMDD),exception_type(1 or 2).src/calendar-patterns/— new directory (does not yet exist). Will hold pure, framework-free holiday logic.ServiceDaysControllerusing existingpatchManagercalls.Phase 1: Holiday Pattern Infrastructure
Goal: Define a pure, framework-independent layer for holiday sets. No imports from the rest of the app. Testable standalone.
src/calendar-patterns/types.tsdefining:src/calendar-patterns/us-federal.tsimplementingHolidayPattern. Compute all 11 US federal observed holidays per year:US_FEDERAL_HOLIDAYS: HolidayPatternconstant.src/calendar-patterns/index.tsas the registry: Adding a new region in the future = create a file + add one line here.Gotchas:
Date.UTC(year, month, day)throughout.getDatesreturns only dates for that single year; the caller is responsible for iterating years.Phase 2: Pattern Matching + Updated
renderExceptionsGoal: Update
ServiceDaysControllerto detect patterns and render a new 2-section exceptions UI (groups + individuals) with a raw-view toggle.New private state
private rawModeServices: Set<string> = new Set()toServiceDaysController.New private helper:
matchPatternsprivate matchPatterns(exceptions: CalendarDates[], calendar: Calendar | null): { matched: MatchedPattern[], individual: CalendarDates[] }where:startYear = parseInt(start_date.substring(0,4)),endYear = parseInt(end_date.substring(0,4)). If nocalendar, derive frommin/maxof exception dates.HolidayPatterninHOLIDAY_PATTERNS, for eachexception_typein[1, 2]:startYear..endYearthat fall within[start_date, end_date](string comparison is fine for YYYYMMDD).exceptionswith thatexception_type→ this is a match; record{ pattern, exception_type }.Set<string>keyed bydate.individual= exceptions whosedateis not in that claimed set.private getYearsRange(calendar: Calendar | null, exceptions: CalendarDates[]): { startYear: number, endYear: number }as a helper used by bothmatchPatternsandaddPatternGroup/removePatternGroup.Update
renderExceptionscalendar: Calendar | null(already available inrenderServiceEditorHTML's call path — pass it through).renderServiceEditorHTMLto passcalendartorenderExceptions.renderExceptions:<div id="service-exceptions-${service_id}">(used as the refresh target).matchPatternsto getmatchedandindividual.pattern.name, a badge for exception_type, and a remove button (onclick="window.gtfsEditor.serviceDaysController.removePatternGroup('${service_id}', '${pattern.id}', ${exception_type})")<select>ofHOLIDAY_PATTERNS(by id/name)<select>for exception_type (Add Service / Remove Service)onclick="...addPatternGroupFromForm('${service_id}')")onclick="window.gtfsEditor.serviceDaysController.toggleRawMode('${service_id}')". Show "Show raw dates" or "Hide raw dates" depending onrawModeServices.has(service_id).exceptionsflat.individual(unmatched dates).Discoveries:
toggleRawModeis also implemented in this phase (it's trivial and needed for the UI to function).refreshExceptionsDisplaywas madeasync(was already) and its visibility changed fromprivateto package-level (no modifier) so phase 3 methods can call it. It now usesouterHTMLreplacement to swap the full#service-exceptions-${service_id}container. TherenderExceptionsListhelper was removed (no longer needed). TheMatchedPatterninterface was defined at module level (above the class) so it's accessible inside the class methods.Gotchas:
exceptions-listselector used inrefreshExceptionsDisplayis a bare class selector and will break if the DOM has more than one service open at once. Use the new#service-exceptions-${service_id}id for all refreshes going forward.Phase 3: Add/Remove Pattern Methods + Refresh
Goal: Wire up the add/remove pattern mutations to the DB/patch system, and fix the refresh to target the full exceptions container.
addPatternGroupasync addPatternGroup(service_id: string, patternId: string, exception_type: 1 | 2): Promise<void>:pattern = HOLIDAY_PATTERNS.find(p => p.id === patternId). Throw if not found.getYearsRangeto get the year span.matchPatterns).exception_type: callinsertRows+patchManager.recordInsert.refreshExceptionsDisplay(service_id).removePatternGroupasync removePatternGroup(service_id: string, patternId: string, exception_type: 1 | 2): Promise<void>:exception_type: calldeleteRow+patchManager.recordDelete.refreshExceptionsDisplay(service_id).addPatternGroupFromFormasync addPatternGroupFromForm(service_id: string): Promise<void>(DOM-facing, likeaddExceptionFromForm):#pattern-select-${service_id}and#pattern-type-${service_id}from DOM.addPatternGroup.toggleRawModetoggleRawMode(service_id: string): void: togglerawModeServices, then callrefreshExceptionsDisplay(service_id).Fix
refreshExceptionsDisplayrefreshExceptionsDisplayto re-render the full#service-exceptions-${service_id}container (not just.exceptions-list):renderExceptions(service_id, calendar, exceptions).innerHTMLofdocument.getElementById(service-exceptions-${service_id}).Gotchas:
addPatternGroupis idempotent: if the user clicks "Add" when the pattern is already fully present, it's a no-op (all dates skipped). No error needed.removePatternGroupshould only delete entries with the matchingexception_type. If a holiday date appears as both type 1 and type 2 (pathological but possible), only delete the one matching the requested type.recordInsert/recordDeleteper date) so undo works date-by-date via the existing patch system.Phase 4: Batch Patch for Add/Remove Pattern Group
Goal: Make
addPatternGroupandremovePatternGroupeach produce a single undo step (one batch patch) instead of one patch per date. A user who adds "US Federal Holidays" for a 2-year calendar should be able to undo the entire operation in one Ctrl+Z.Add
recordBatchInserttoPatchManagerasync recordBatchInsert(ops: Array<{ table: string; id: string; record: Record<string, unknown> }>, label?: string): Promise<void>tosrc/modules/patch-manager.ts, mirroring the existingrecordBatchDelete. Note: unlikerecordBatchDelete, inserts do not needapplyPatchForward— the DB writes are done by the caller beforehand (same contract asrecordInsert).PatchManagerDepsinterface(s) inbrowse-navigation.ts,page-content-renderer.ts, andservice-days-controller.ts(schedule-controller.ts does not declare recordBatchDelete; the relevant files were browse-navigation.ts, page-content-renderer.ts, and service-days-controller.ts's PatchManagerInterface).Refactor
addPatternGroupthis.gtfsParser.gtfsDatabase.insertRows('calendar_dates', rows)once for all new rows.this.patchManager?.recordBatchInsert(ops, label)once with a label likeAdd ${pattern.name} (${exception_type === 1 ? 'Add Service' : 'Remove Service'}).insertRows+recordInsertcalls inside the loop.Refactor
removePatternGroup{ key, existing }pairs to delete into an array first.this.gtfsParser.gtfsDatabase.deleteRow(...)for each in a loop (no batch DB delete exists — that's fine, just no longer interleaved with patches).this.patchManager?.recordBatchDelete(ops, label)once with a label likeRemove ${pattern.name} (${exception_type === 1 ? 'Add Service' : 'Remove Service'}).recordDeletecalls inside the loop.Discoveries:
schedule-controller.tsdoes not declarerecordBatchDeletein its interface, so no update was needed there — onlybrowse-navigation.ts,page-content-renderer.ts, andservice-days-controller.ts's localPatchManagerInterfacerequired the new method.Phase 5: Force-update exception_type on duplicate date
Goal: When a user adds an individual exception for a date that already has an exception, instead of silently doing nothing (the insert is a no-op because
service_id:dateis the IDB key), detect the conflict and update the existing record'sexception_typeto the new value. Undo/redo must work correctly.Context
The
calendar_datesprimary key is(service_id, date)— matching the GTFS spec. This means there can only be one exception per date per service. Currently,addExceptioncallsinsertRows, which silently overwrites or errors if the record already exists, with no feedback to the user. The fix is to detect when the date already has an exception with a differentexception_typeand treat it as an update rather than an insert.Changes to
addExceptionexceptionData, query existing exceptions for{ service_id, date }.insertRows+recordInsertas today.exception_type: no-op (already correct), return early.exception_type:gtfsDatabase.updateRow('calendar_dates', key, { exception_type })to update in place.patchManager.recordUpdate('calendar_dates', key, { exception_type }, { exception_type: existing.exception_type })so undo restores the original type.Changes to
addPatternGrouprowsToInsert, currently dates already present with the sameexception_typeare skipped. Dates present with a differentexception_typeare also skipped (becauseexistingSetfilters byexception_type). After this phase, collect those "wrong-type" dates separately asrowsToUpdate.updateRow+ collectrecordUpdateops for each "wrong-type" date.recordBatchMixedtoPatchManager(takes mixed insert+update ops, noapplyPatchForwardsince caller handles all DB writes).Gotchas:
recordUpdateneeds both the new delta and the old values so undo can reverse. Old value is just{ exception_type: existing.exception_type }.Add ${pattern.name} (Add Service)is still accurate enough; no UI change needed.addExceptionFromFormdoesn't need changes — it delegates toaddException.Original Issue
It would be really cool if the ui could automatically identify trips that are just shifted versions of each other and show this in some compacted form. It could default to the compact mode and then you could expand it to edit one, for instance. Then that trip would jump out of the pattern view. This would just be a ui change, not changing the way we store the data.
For calendars, we should identify the set of US holidays and other common patterns for exclusions.
It would be nice to abstract this so it's easy to identify a pattern. It should allow for adding a trip to a trip set in an intuitive way.
This needs to be fleshed out.