Skip to content

Commit 9d8cb6a

Browse files
committed
Better exception_type=2 handing of calendar_dates
1 parent a68e51f commit 9d8cb6a

File tree

3 files changed

+140
-15
lines changed

3 files changed

+140
-15
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Updated
11+
- Exclude calendar_dates.txt exception_type=2 if all dates within timetable are excluded
12+
813
## [2.12.2] - 2025-12-04
914

1015
### Fixed

src/lib/time-utils.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import moment from 'moment';
22

3+
type CalendarBit = '0' | '1';
4+
export type CalendarCode =
5+
`${CalendarBit}${CalendarBit}${CalendarBit}${CalendarBit}${CalendarBit}${CalendarBit}${CalendarBit}`;
6+
37
/*
48
* Convert a GTFS formatted time string into a moment less than 24 hours.
59
*/
@@ -23,26 +27,26 @@ export function toGTFSTime(time) {
2327
/*
2428
* Convert a object of weekdays into a a string containing 1s and 0s.
2529
*/
26-
export function calendarToCalendarCode(c: {
30+
export function calendarToCalendarCode(calendar: {
2731
monday?: null | 0 | 1;
2832
tuesday?: null | 0 | 1;
2933
wednesday?: null | 0 | 1;
3034
thursday?: null | 0 | 1;
3135
friday?: null | 0 | 1;
3236
saturday?: null | 0 | 1;
3337
sunday?: null | 0 | 1;
34-
}) {
35-
if (Object.values(c).every((value) => value === null)) {
38+
}): CalendarCode | '' {
39+
if (Object.values(calendar).every((value) => value === null)) {
3640
return '';
3741
}
3842

39-
return `${c.monday}${c.tuesday}${c.wednesday}${c.thursday}${c.friday}${c.saturday}${c.sunday}`;
43+
return `${calendar.monday ?? '0'}${calendar.tuesday ?? '0'}${calendar.wednesday ?? '0'}${calendar.thursday ?? '0'}${calendar.friday ?? '0'}${calendar.saturday ?? '0'}${calendar.sunday ?? '0'}`;
4044
}
4145

4246
/*
4347
* Convert a string of 1s and 0s representing a weekday to an object.
4448
*/
45-
export function calendarCodeToCalendar(code) {
49+
export function calendarCodeToCalendar(code: CalendarCode) {
4650
const days = [
4751
'monday',
4852
'tuesday',
@@ -52,7 +56,15 @@ export function calendarCodeToCalendar(code) {
5256
'saturday',
5357
'sunday',
5458
];
55-
const calendar = {};
59+
const calendar: {
60+
monday?: null | 0 | 1;
61+
tuesday?: null | 0 | 1;
62+
wednesday?: null | 0 | 1;
63+
thursday?: null | 0 | 1;
64+
friday?: null | 0 | 1;
65+
saturday?: null | 0 | 1;
66+
sunday?: null | 0 | 1;
67+
} = {};
5668

5769
for (const [index, day] of days.entries()) {
5870
calendar[day] = code[index];
@@ -61,6 +73,54 @@ export function calendarCodeToCalendar(code) {
6173
return calendar;
6274
}
6375

76+
/* Concert an object of weekdays and a date range into a list of dates. */
77+
export function calendarToDateList(
78+
calendar: {
79+
monday?: null | 0 | 1;
80+
tuesday?: null | 0 | 1;
81+
wednesday?: null | 0 | 1;
82+
thursday?: null | 0 | 1;
83+
friday?: null | 0 | 1;
84+
saturday?: null | 0 | 1;
85+
sunday?: null | 0 | 1;
86+
},
87+
startDate: number,
88+
endDate: number | null,
89+
) {
90+
if (!startDate || !endDate) {
91+
return [];
92+
}
93+
94+
const activeWeekdays = [
95+
calendar.monday === 1 ? 1 : null,
96+
calendar.tuesday === 1 ? 2 : null,
97+
calendar.wednesday === 1 ? 3 : null,
98+
calendar.thursday === 1 ? 4 : null,
99+
calendar.friday === 1 ? 5 : null,
100+
calendar.saturday === 1 ? 6 : null,
101+
calendar.sunday === 1 ? 7 : null,
102+
].filter((weekday): weekday is number => weekday !== null);
103+
104+
if (activeWeekdays.length === 0) {
105+
return [];
106+
}
107+
108+
const activeWeekdaySet = new Set(activeWeekdays);
109+
const dates = new Set<number>();
110+
const date = moment(startDate.toString(), 'YYYYMMDD');
111+
const endDateMoment = moment(endDate.toString(), 'YYYYMMDD');
112+
113+
while (date.isSameOrBefore(endDateMoment)) {
114+
const isoWeekday = date.isoWeekday();
115+
if (activeWeekdaySet.has(isoWeekday)) {
116+
dates.add(parseInt(date.format('YYYYMMDD'), 10));
117+
}
118+
date.add(1, 'day');
119+
}
120+
121+
return Array.from(dates);
122+
}
123+
64124
/*
65125
* Get number of seconds after midnight of a GTFS formatted time string.
66126
*/

src/lib/utils.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
secondsAfterMidnight,
8181
fromGTFSTime,
8282
calendarCodeToCalendar,
83+
calendarToDateList,
8384
} from './time-utils.js';
8485
import { formatTripNameForCSV } from './template-functions.js';
8586

@@ -1022,14 +1023,14 @@ const getCalendarsFromTimetable = (timetable: Timetable) => {
10221023
};
10231024

10241025
/*
1025-
* Get all calendar date service ids for an agency between two dates.
1026+
* Get all calendar dates for an agency between two dates.
10261027
*/
1027-
const getCalendarDatesServiceIds = (
1028+
const getCalendarDatesForDateRange = (
10281029
startDate?: number | null,
10291030
endDate?: number | null,
10301031
) => {
10311032
const db = openDb();
1032-
const whereClauses = ['exception_type = 1'];
1033+
const whereClauses = [];
10331034

10341035
if (endDate) {
10351036
whereClauses.push(`date <= ${sqlString.escape(endDate)}`);
@@ -1041,12 +1042,12 @@ const getCalendarDatesServiceIds = (
10411042

10421043
const calendarDates: CalendarDate[] = db
10431044
.prepare(
1044-
`SELECT DISTINCT service_id FROM calendar_dates WHERE ${whereClauses.join(
1045+
`SELECT service_id, date, exception_type FROM calendar_dates WHERE ${whereClauses.join(
10451046
' AND ',
10461047
)}`,
10471048
)
10481049
.all();
1049-
return calendarDates.map((calendarDate) => calendarDate.service_id);
1050+
return calendarDates;
10501051
};
10511052

10521053
/*
@@ -1398,14 +1399,73 @@ const formatTimetables = (timetables: Timetable[], config: Config) => {
13981399
timetable.warnings = [];
13991400
const dayList = formatDays(timetable, config);
14001401
const calendars = getCalendarsFromTimetable(timetable);
1401-
let serviceIds = calendars.map((calendar: Calendar) => calendar.service_id);
1402+
const serviceIds = new Set();
1403+
1404+
// Add all service IDs from calendars
1405+
for (const calendar of calendars) {
1406+
serviceIds.add(calendar.service_id);
1407+
}
14021408

14031409
if (timetable.include_exceptions === 1) {
1404-
const calendarDatesServiceIds = getCalendarDatesServiceIds(
1410+
const calendarDates = getCalendarDatesForDateRange(
14051411
timetable.start_date,
14061412
timetable.end_date,
14071413
);
1408-
serviceIds = uniq([...serviceIds, ...calendarDatesServiceIds]);
1414+
1415+
const calendarDateGroups = groupBy(calendarDates, 'service_id');
1416+
1417+
for (const [serviceId, calendarDateGroup] of Object.entries(
1418+
calendarDateGroups,
1419+
)) {
1420+
// Check if there is a corresponding calendar.txt entry for this service_id
1421+
const calendar = calendars.find(
1422+
(c: Calendar) => c.service_id === serviceId,
1423+
);
1424+
1425+
// Add service_id if any dates are added
1426+
if (
1427+
calendarDateGroup.some(
1428+
(calendarDate) => calendarDate.exception_type === 1,
1429+
)
1430+
) {
1431+
serviceIds.add(serviceId);
1432+
}
1433+
1434+
const calendarDateGroupExceptionType2 = calendarDateGroup.filter(
1435+
(calendarDate) => calendarDate.exception_type === 2,
1436+
);
1437+
1438+
// Check if ALL dates are excluded
1439+
if (
1440+
timetable.start_date &&
1441+
timetable.end_date &&
1442+
calendar &&
1443+
calendarDateGroupExceptionType2.length > 0
1444+
) {
1445+
const datesDuringDateRange = calendarToDateList(
1446+
calendar,
1447+
timetable.start_date,
1448+
timetable.end_date,
1449+
);
1450+
1451+
// If no dates are are within the date range, remove service_id
1452+
if (datesDuringDateRange.length === 0) {
1453+
serviceIds.delete(serviceId);
1454+
}
1455+
1456+
// Check if every date is excluded and remove service_id if so
1457+
const everyDateIsExcluded = datesDuringDateRange.every(
1458+
(dateDuringDateRange) =>
1459+
calendarDateGroupExceptionType2.some(
1460+
(calendarDate) => calendarDate.date === dateDuringDateRange,
1461+
),
1462+
);
1463+
1464+
if (everyDateIsExcluded) {
1465+
serviceIds.delete(serviceId);
1466+
}
1467+
}
1468+
}
14091469
}
14101470

14111471
Object.assign(timetable, {
@@ -1424,7 +1484,7 @@ const formatTimetables = (timetables: Timetable[], config: Config) => {
14241484
noPickupSymbol: config.noPickupSymbol,
14251485
interpolatedStopSymbol: config.interpolatedStopSymbol,
14261486
orientation: timetable.orientation || config.defaultOrientation,
1427-
service_ids: serviceIds,
1487+
service_ids: Array.from(serviceIds),
14281488
dayList,
14291489
dayListLong: formatDaysLong(dayList, config),
14301490
});

0 commit comments

Comments
 (0)