Trip and Calendar Patterns #20

Closed
opened 2026-03-24 15:14:35 +00:00 by maxtkc · 0 comments
Owner

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_dates entries collectively form a known holiday pattern (e.g., all US federal holidays within the calendar's date range with the same exception_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 individual calendar_dates rows, keeping the underlying GTFS fully standard and round-trip safe. Each holiday set lives in its own file under src/calendar-patterns/, making it trivial to add new regions.

Key constraints:

  • Strict matching only: a pattern is recognized only when every holiday date in the calendar's year range is present with the same exception_type.
  • Holidays that fall within the calendar date range are matched; dates outside the range are excluded from the pattern check.
  • A "raw view" toggle lets users bypass grouping and see all flat dates.
  • Both exception_type = 1 (Add Service) and exception_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/removeException are the mutation entry points.
  • src/types/gtfs-entities.tsCalendar and CalendarDates types. Calendar has start_date/end_date (YYYYMMDD strings). CalendarDates has service_id, date (YYYYMMDD), exception_type (1 or 2).
  • src/calendar-patterns/ — new directory (does not yet exist). Will hold pure, framework-free holiday logic.
  • No changes needed to DB, types, patch system, or page-content-renderer — all mutations stay inside ServiceDaysController using existing patchManager calls.

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.

  • Create src/calendar-patterns/types.ts defining:
    export interface HolidayPattern {
      id: string;       // e.g. 'us-federal'
      name: string;     // e.g. 'US Federal Holidays'
      region: string;   // e.g. 'US'
      /** Returns YYYYMMDD strings for observed holidays in the given year */
      getDates(year: number): string[];
    }
    
  • Create src/calendar-patterns/us-federal.ts implementing HolidayPattern. Compute all 11 US federal observed holidays per year:
    • Fixed-date holidays (New Year's Day Jan 1, Juneteenth Jun 19, Independence Day Jul 4, Veterans Day Nov 11, Christmas Dec 25): shift to Monday if Sunday, Friday if Saturday.
    • Floating holidays: MLK Day (3rd Mon Jan), Presidents' Day (3rd Mon Feb), Memorial Day (last Mon May), Labor Day (1st Mon Sep), Columbus Day (2nd Mon Oct), Thanksgiving (4th Thu Nov).
    • Export a single US_FEDERAL_HOLIDAYS: HolidayPattern constant.
  • Create src/calendar-patterns/index.ts as the registry:
    import { US_FEDERAL_HOLIDAYS } from './us-federal.js';
    export const HOLIDAY_PATTERNS: HolidayPattern[] = [US_FEDERAL_HOLIDAYS];
    export { HolidayPattern } from './types.js';
    
    Adding a new region in the future = create a file + add one line here.

Gotchas:

  • All date math should use UTC to avoid DST shifts. Use Date.UTC(year, month, day) throughout.
  • getDates returns only dates for that single year; the caller is responsible for iterating years.

Phase 2: Pattern Matching + Updated renderExceptions

Goal: Update ServiceDaysController to detect patterns and render a new 2-section exceptions UI (groups + individuals) with a raw-view toggle.

New private state

  • Add private rawModeServices: Set<string> = new Set() to ServiceDaysController.

New private helper: matchPatterns

  • Add private matchPatterns(exceptions: CalendarDates[], calendar: Calendar | null): { matched: MatchedPattern[], individual: CalendarDates[] } where:
    interface MatchedPattern {
      pattern: HolidayPattern;
      exception_type: 1 | 2;
    }
    
  • Logic:
    1. Derive year range from calendar: startYear = parseInt(start_date.substring(0,4)), endYear = parseInt(end_date.substring(0,4)). If no calendar, derive from min/max of exception dates.
    2. For each HolidayPattern in HOLIDAY_PATTERNS, for each exception_type in [1, 2]:
      • Compute all pattern dates across startYear..endYear that fall within [start_date, end_date] (string comparison is fine for YYYYMMDD).
      • If that set is empty, skip.
      • If every computed date exists in exceptions with that exception_type → this is a match; record { pattern, exception_type }.
    3. Collect all dates claimed by any matched pattern into a Set<string> keyed by date.
    4. individual = exceptions whose date is not in that claimed set.
  • Add private getYearsRange(calendar: Calendar | null, exceptions: CalendarDates[]): { startYear: number, endYear: number } as a helper used by both matchPatterns and addPatternGroup/removePatternGroup.

Update renderExceptions

  • Change signature to accept calendar: Calendar | null (already available in renderServiceEditorHTML's call path — pass it through).
  • Update renderServiceEditorHTML to pass calendar to renderExceptions.
  • In renderExceptions:
    • Wrap everything in <div id="service-exceptions-${service_id}"> (used as the refresh target).
    • Call matchPatterns to get matched and individual.
    • Render Pattern Groups section first (always visible):
      • If no matched patterns: show "No pattern groups recognized" empty state.
      • For each matched pattern: show a row with pattern.name, a badge for exception_type, and a remove button (onclick="window.gtfsEditor.serviceDaysController.removePatternGroup('${service_id}', '${pattern.id}', ${exception_type})")
    • Render Add Pattern form below groups:
      • <select> of HOLIDAY_PATTERNS (by id/name)
      • <select> for exception_type (Add Service / Remove Service)
      • Add button (onclick="...addPatternGroupFromForm('${service_id}')")
    • Render raw toggle button: onclick="window.gtfsEditor.serviceDaysController.toggleRawMode('${service_id}')". Show "Show raw dates" or "Hide raw dates" depending on rawModeServices.has(service_id).
    • Render Individual Exceptions section (always visible):
      • In raw mode: show all exceptions flat.
      • In normal mode: show only individual (unmatched dates).
      • Empty state: "No individual exceptions" if the list is empty.
      • Add individual exception form below the list (unchanged from current).

Discoveries: toggleRawMode is also implemented in this phase (it's trivial and needed for the UI to function). refreshExceptionsDisplay was made async (was already) and its visibility changed from private to package-level (no modifier) so phase 3 methods can call it. It now uses outerHTML replacement to swap the full #service-exceptions-${service_id} container. The renderExceptionsList helper was removed (no longer needed). The MatchedPattern interface was defined at module level (above the class) so it's accessible inside the class methods.

Gotchas:

  • The exceptions-list selector used in refreshExceptionsDisplay is 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.

addPatternGroup

  • Add async addPatternGroup(service_id: string, patternId: string, exception_type: 1 | 2): Promise<void>:
    1. Look up pattern = HOLIDAY_PATTERNS.find(p => p.id === patternId). Throw if not found.
    2. Fetch calendar row + existing exceptions for the service.
    3. Call getYearsRange to get the year span.
    4. Compute all pattern dates in range (same logic as matchPatterns).
    5. For each date not already present in exceptions with that exception_type: call insertRows + patchManager.recordInsert.
    6. Call refreshExceptionsDisplay(service_id).

removePatternGroup

  • Add async removePatternGroup(service_id: string, patternId: string, exception_type: 1 | 2): Promise<void>:
    1. Look up pattern. Throw if not found.
    2. Fetch calendar row + existing exceptions.
    3. Compute all pattern dates in range.
    4. For each date that exists in exceptions with that exception_type: call deleteRow + patchManager.recordDelete.
    5. Call refreshExceptionsDisplay(service_id).

addPatternGroupFromForm

  • Add async addPatternGroupFromForm(service_id: string): Promise<void> (DOM-facing, like addExceptionFromForm):
    • Reads #pattern-select-${service_id} and #pattern-type-${service_id} from DOM.
    • Calls addPatternGroup.

toggleRawMode

  • Add toggleRawMode(service_id: string): void: toggle rawModeServices, then call refreshExceptionsDisplay(service_id).

Fix refreshExceptionsDisplay

  • Change refreshExceptionsDisplay to re-render the full #service-exceptions-${service_id} container (not just .exceptions-list):
    • Re-fetch both calendar and exceptions from DB.
    • Call renderExceptions(service_id, calendar, exceptions).
    • Set innerHTML of document.getElementById(service-exceptions-${service_id}).

Gotchas:

  • addPatternGroup is idempotent: if the user clicks "Add" when the pattern is already fully present, it's a no-op (all dates skipped). No error needed.
  • removePatternGroup should only delete entries with the matching exception_type. If a holiday date appears as both type 1 and type 2 (pathological but possible), only delete the one matching the requested type.
  • Patch records for bulk inserts/deletes should each be recorded individually (one recordInsert/recordDelete per date) so undo works date-by-date via the existing patch system.

Phase 4: Batch Patch for Add/Remove Pattern Group

Goal: Make addPatternGroup and removePatternGroup each 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 recordBatchInsert to PatchManager

  • Add async recordBatchInsert(ops: Array<{ table: string; id: string; record: Record<string, unknown> }>, label?: string): Promise<void> to src/modules/patch-manager.ts, mirroring the existing recordBatchDelete. Note: unlike recordBatchDelete, inserts do not need applyPatchForward — the DB writes are done by the caller beforehand (same contract as recordInsert).
  • Expose the new method signature in the PatchManagerDeps interface(s) in browse-navigation.ts, page-content-renderer.ts, and service-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 addPatternGroup

  • Collect all rows to insert into an array first (instead of inserting one-by-one in the loop).
  • Call this.gtfsParser.gtfsDatabase.insertRows('calendar_dates', rows) once for all new rows.
  • Call this.patchManager?.recordBatchInsert(ops, label) once with a label like Add ${pattern.name} (${exception_type === 1 ? 'Add Service' : 'Remove Service'}).
  • Remove the per-date insertRows + recordInsert calls inside the loop.

Refactor removePatternGroup

  • Collect all { key, existing } pairs to delete into an array first.
  • Call this.gtfsParser.gtfsDatabase.deleteRow(...) for each in a loop (no batch DB delete exists — that's fine, just no longer interleaved with patches).
  • Call this.patchManager?.recordBatchDelete(ops, label) once with a label like Remove ${pattern.name} (${exception_type === 1 ? 'Add Service' : 'Remove Service'}).
  • Remove the per-date recordDelete calls inside the loop.

Discoveries: schedule-controller.ts does not declare recordBatchDelete in its interface, so no update was needed there — only browse-navigation.ts, page-content-renderer.ts, and service-days-controller.ts's local PatchManagerInterface required 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:date is the IDB key), detect the conflict and update the existing record's exception_type to the new value. Undo/redo must work correctly.

Context

The calendar_dates primary key is (service_id, date) — matching the GTFS spec. This means there can only be one exception per date per service. Currently, addException calls insertRows, 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 different exception_type and treat it as an update rather than an insert.

Changes to addException

  • After building exceptionData, query existing exceptions for { service_id, date }.
  • If no existing record: proceed with insertRows + recordInsert as today.
  • If an existing record exists with the same exception_type: no-op (already correct), return early.
  • If an existing record exists with a different exception_type:
    • Call gtfsDatabase.updateRow('calendar_dates', key, { exception_type }) to update in place.
    • Call patchManager.recordUpdate('calendar_dates', key, { exception_type }, { exception_type: existing.exception_type }) so undo restores the original type.
    • Show save success and log as normal.

Changes to addPatternGroup

  • Same logic: when building rowsToInsert, currently dates already present with the same exception_type are skipped. Dates present with a different exception_type are also skipped (because existingSet filters by exception_type). After this phase, collect those "wrong-type" dates separately as rowsToUpdate.
  • After inserting new rows, issue updateRow + collect recordUpdate ops for each "wrong-type" date.
  • Bundle both inserts and updates under the same batch label so they undo together — added recordBatchMixed to PatchManager (takes mixed insert+update ops, no applyPatchForward since caller handles all DB writes).

Gotchas:

  • recordUpdate needs both the new delta and the old values so undo can reverse. Old value is just { exception_type: existing.exception_type }.
  • The pattern group "Add Pattern" button should show something useful to the user if some dates were updated vs inserted — the existing label Add ${pattern.name} (Add Service) is still accurate enough; no UI change needed.
  • addExceptionFromForm doesn't need changes — it delegates to addException.

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.

## 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_dates` entries collectively form a known holiday pattern (e.g., all US federal holidays within the calendar's date range with the same `exception_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 individual `calendar_dates` rows, keeping the underlying GTFS fully standard and round-trip safe. Each holiday set lives in its own file under `src/calendar-patterns/`, making it trivial to add new regions. **Key constraints:** - Strict matching only: a pattern is recognized only when every holiday date in the calendar's year range is present with the same `exception_type`. - Holidays that fall within the calendar date range are matched; dates outside the range are excluded from the pattern check. - A "raw view" toggle lets users bypass grouping and see all flat dates. - Both `exception_type = 1` (Add Service) and `exception_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`/`removeException` are the mutation entry points. - **`src/types/gtfs-entities.ts`** — `Calendar` and `CalendarDates` types. `Calendar` has `start_date`/`end_date` (YYYYMMDD strings). `CalendarDates` has `service_id`, `date` (YYYYMMDD), `exception_type` (1 or 2). - **`src/calendar-patterns/`** — new directory (does not yet exist). Will hold pure, framework-free holiday logic. - **No changes needed** to DB, types, patch system, or page-content-renderer — all mutations stay inside `ServiceDaysController` using existing `patchManager` calls. --- ## 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. - [x] Create `src/calendar-patterns/types.ts` defining: ```ts export interface HolidayPattern { id: string; // e.g. 'us-federal' name: string; // e.g. 'US Federal Holidays' region: string; // e.g. 'US' /** Returns YYYYMMDD strings for observed holidays in the given year */ getDates(year: number): string[]; } ``` - [x] Create `src/calendar-patterns/us-federal.ts` implementing `HolidayPattern`. Compute all 11 US federal observed holidays per year: - Fixed-date holidays (New Year's Day Jan 1, Juneteenth Jun 19, Independence Day Jul 4, Veterans Day Nov 11, Christmas Dec 25): shift to Monday if Sunday, Friday if Saturday. - Floating holidays: MLK Day (3rd Mon Jan), Presidents' Day (3rd Mon Feb), Memorial Day (last Mon May), Labor Day (1st Mon Sep), Columbus Day (2nd Mon Oct), Thanksgiving (4th Thu Nov). - Export a single `US_FEDERAL_HOLIDAYS: HolidayPattern` constant. - [x] Create `src/calendar-patterns/index.ts` as the registry: ```ts import { US_FEDERAL_HOLIDAYS } from './us-federal.js'; export const HOLIDAY_PATTERNS: HolidayPattern[] = [US_FEDERAL_HOLIDAYS]; export { HolidayPattern } from './types.js'; ``` Adding a new region in the future = create a file + add one line here. **Gotchas:** - All date math should use UTC to avoid DST shifts. Use `Date.UTC(year, month, day)` throughout. - `getDates` returns only dates for that single year; the caller is responsible for iterating years. --- ## Phase 2: Pattern Matching + Updated `renderExceptions` **Goal:** Update `ServiceDaysController` to detect patterns and render a new 2-section exceptions UI (groups + individuals) with a raw-view toggle. ### New private state - [x] Add `private rawModeServices: Set<string> = new Set()` to `ServiceDaysController`. ### New private helper: `matchPatterns` - [x] Add `private matchPatterns(exceptions: CalendarDates[], calendar: Calendar | null): { matched: MatchedPattern[], individual: CalendarDates[] }` where: ```ts interface MatchedPattern { pattern: HolidayPattern; exception_type: 1 | 2; } ``` - Logic: 1. Derive year range from calendar: `startYear = parseInt(start_date.substring(0,4))`, `endYear = parseInt(end_date.substring(0,4))`. If no `calendar`, derive from `min`/`max` of exception dates. 2. For each `HolidayPattern` in `HOLIDAY_PATTERNS`, for each `exception_type` in `[1, 2]`: - Compute all pattern dates across `startYear..endYear` that fall within `[start_date, end_date]` (string comparison is fine for YYYYMMDD). - If that set is empty, skip. - If every computed date exists in `exceptions` with that `exception_type` → this is a match; record `{ pattern, exception_type }`. 3. Collect all dates claimed by any matched pattern into a `Set<string>` keyed by `date`. 4. `individual` = exceptions whose `date` is not in that claimed set. - [x] Add `private getYearsRange(calendar: Calendar | null, exceptions: CalendarDates[]): { startYear: number, endYear: number }` as a helper used by both `matchPatterns` and `addPatternGroup`/`removePatternGroup`. ### Update `renderExceptions` - [x] Change signature to accept `calendar: Calendar | null` (already available in `renderServiceEditorHTML`'s call path — pass it through). - [x] Update `renderServiceEditorHTML` to pass `calendar` to `renderExceptions`. - [x] In `renderExceptions`: - Wrap everything in `<div id="service-exceptions-${service_id}">` (used as the refresh target). - Call `matchPatterns` to get `matched` and `individual`. - Render **Pattern Groups section** first (always visible): - If no matched patterns: show "No pattern groups recognized" empty state. - For each matched pattern: show a row with `pattern.name`, a badge for exception_type, and a remove button (`onclick="window.gtfsEditor.serviceDaysController.removePatternGroup('${service_id}', '${pattern.id}', ${exception_type})"`) - Render **Add Pattern form** below groups: - `<select>` of `HOLIDAY_PATTERNS` (by id/name) - `<select>` for exception_type (Add Service / Remove Service) - Add button (`onclick="...addPatternGroupFromForm('${service_id}')"`) - Render **raw toggle** button: `onclick="window.gtfsEditor.serviceDaysController.toggleRawMode('${service_id}')"`. Show "Show raw dates" or "Hide raw dates" depending on `rawModeServices.has(service_id)`. - Render **Individual Exceptions section** (always visible): - In raw mode: show all `exceptions` flat. - In normal mode: show only `individual` (unmatched dates). - Empty state: "No individual exceptions" if the list is empty. - Add individual exception form below the list (unchanged from current). **Discoveries:** `toggleRawMode` is also implemented in this phase (it's trivial and needed for the UI to function). `refreshExceptionsDisplay` was made `async` (was already) and its visibility changed from `private` to package-level (no modifier) so phase 3 methods can call it. It now uses `outerHTML` replacement to swap the full `#service-exceptions-${service_id}` container. The `renderExceptionsList` helper was removed (no longer needed). The `MatchedPattern` interface was defined at module level (above the class) so it's accessible inside the class methods. **Gotchas:** - The `exceptions-list` selector used in `refreshExceptionsDisplay` is 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. ### `addPatternGroup` - [x] Add `async addPatternGroup(service_id: string, patternId: string, exception_type: 1 | 2): Promise<void>`: 1. Look up `pattern = HOLIDAY_PATTERNS.find(p => p.id === patternId)`. Throw if not found. 2. Fetch calendar row + existing exceptions for the service. 3. Call `getYearsRange` to get the year span. 4. Compute all pattern dates in range (same logic as `matchPatterns`). 5. For each date not already present in exceptions with that `exception_type`: call `insertRows` + `patchManager.recordInsert`. 6. Call `refreshExceptionsDisplay(service_id)`. ### `removePatternGroup` - [x] Add `async removePatternGroup(service_id: string, patternId: string, exception_type: 1 | 2): Promise<void>`: 1. Look up pattern. Throw if not found. 2. Fetch calendar row + existing exceptions. 3. Compute all pattern dates in range. 4. For each date that exists in exceptions with that `exception_type`: call `deleteRow` + `patchManager.recordDelete`. 5. Call `refreshExceptionsDisplay(service_id)`. ### `addPatternGroupFromForm` - [x] Add `async addPatternGroupFromForm(service_id: string): Promise<void>` (DOM-facing, like `addExceptionFromForm`): - Reads `#pattern-select-${service_id}` and `#pattern-type-${service_id}` from DOM. - Calls `addPatternGroup`. ### `toggleRawMode` - [x] Add `toggleRawMode(service_id: string): void`: toggle `rawModeServices`, then call `refreshExceptionsDisplay(service_id)`. ### Fix `refreshExceptionsDisplay` - [x] Change `refreshExceptionsDisplay` to re-render the full `#service-exceptions-${service_id}` container (not just `.exceptions-list`): - Re-fetch both calendar and exceptions from DB. - Call `renderExceptions(service_id, calendar, exceptions)`. - Set `innerHTML` of `document.getElementById(`service-exceptions-${service_id}`)`. **Gotchas:** - `addPatternGroup` is idempotent: if the user clicks "Add" when the pattern is already fully present, it's a no-op (all dates skipped). No error needed. - `removePatternGroup` should only delete entries with the matching `exception_type`. If a holiday date appears as both type 1 and type 2 (pathological but possible), only delete the one matching the requested type. - Patch records for bulk inserts/deletes should each be recorded individually (one `recordInsert`/`recordDelete` per date) so undo works date-by-date via the existing patch system. --- ## Phase 4: Batch Patch for Add/Remove Pattern Group **Goal:** Make `addPatternGroup` and `removePatternGroup` each 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 `recordBatchInsert` to `PatchManager` - [x] Add `async recordBatchInsert(ops: Array<{ table: string; id: string; record: Record<string, unknown> }>, label?: string): Promise<void>` to `src/modules/patch-manager.ts`, mirroring the existing `recordBatchDelete`. Note: unlike `recordBatchDelete`, inserts do **not** need `applyPatchForward` — the DB writes are done by the caller beforehand (same contract as `recordInsert`). - [x] Expose the new method signature in the `PatchManagerDeps` interface(s) in `browse-navigation.ts`, `page-content-renderer.ts`, and `service-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 `addPatternGroup` - [x] Collect all rows to insert into an array first (instead of inserting one-by-one in the loop). - [x] Call `this.gtfsParser.gtfsDatabase.insertRows('calendar_dates', rows)` once for all new rows. - [x] Call `this.patchManager?.recordBatchInsert(ops, label)` once with a label like `Add ${pattern.name} (${exception_type === 1 ? 'Add Service' : 'Remove Service'})`. - [x] Remove the per-date `insertRows` + `recordInsert` calls inside the loop. ### Refactor `removePatternGroup` - [x] Collect all `{ key, existing }` pairs to delete into an array first. - [x] Call `this.gtfsParser.gtfsDatabase.deleteRow(...)` for each in a loop (no batch DB delete exists — that's fine, just no longer interleaved with patches). - [x] Call `this.patchManager?.recordBatchDelete(ops, label)` once with a label like `Remove ${pattern.name} (${exception_type === 1 ? 'Add Service' : 'Remove Service'})`. - [x] Remove the per-date `recordDelete` calls inside the loop. **Discoveries:** `schedule-controller.ts` does not declare `recordBatchDelete` in its interface, so no update was needed there — only `browse-navigation.ts`, `page-content-renderer.ts`, and `service-days-controller.ts`'s local `PatchManagerInterface` required 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:date` is the IDB key), detect the conflict and update the existing record's `exception_type` to the new value. Undo/redo must work correctly. ### Context The `calendar_dates` primary key is `(service_id, date)` — matching the GTFS spec. This means there can only be one exception per date per service. Currently, `addException` calls `insertRows`, 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 *different* `exception_type` and treat it as an update rather than an insert. ### Changes to `addException` - [x] After building `exceptionData`, query existing exceptions for `{ service_id, date }`. - [x] If no existing record: proceed with `insertRows` + `recordInsert` as today. - [x] If an existing record exists with the **same** `exception_type`: no-op (already correct), return early. - [x] If an existing record exists with a **different** `exception_type`: - Call `gtfsDatabase.updateRow('calendar_dates', key, { exception_type })` to update in place. - Call `patchManager.recordUpdate('calendar_dates', key, { exception_type }, { exception_type: existing.exception_type })` so undo restores the original type. - Show save success and log as normal. ### Changes to `addPatternGroup` - [x] Same logic: when building `rowsToInsert`, currently dates already present with the same `exception_type` are skipped. Dates present with a *different* `exception_type` are also skipped (because `existingSet` filters by `exception_type`). After this phase, collect those "wrong-type" dates separately as `rowsToUpdate`. - [x] After inserting new rows, issue `updateRow` + collect `recordUpdate` ops for each "wrong-type" date. - [x] Bundle both inserts and updates under the same batch label so they undo together — added `recordBatchMixed` to `PatchManager` (takes mixed insert+update ops, no `applyPatchForward` since caller handles all DB writes). **Gotchas:** - `recordUpdate` needs both the new delta and the old values so undo can reverse. Old value is just `{ exception_type: existing.exception_type }`. - The pattern group "Add Pattern" button should show something useful to the user if some dates were updated vs inserted — the existing label `Add ${pattern.name} (Add Service)` is still accurate enough; no UI change needed. - `addExceptionFromForm` doesn't need changes — it delegates to `addException`. --- ## 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.
maxtkc self-assigned this 2026-03-24 15:14:35 +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#20
No description provided.