@tmlmobilidade/dates 20260302.1453.8 → 20260305.1602.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar/index.d.ts +4 -0
- package/dist/calendar/index.js +4 -0
- package/dist/calendar/parameters/index.d.ts +1 -0
- package/dist/calendar/parameters/index.js +1 -0
- package/dist/calendar/parameters/stops-parameters.d.ts +28 -0
- package/dist/calendar/parameters/stops-parameters.js +80 -0
- package/dist/{period-utils.d.ts → calendar/periods/index.d.ts} +1 -1
- package/dist/{period-utils.js → calendar/periods/index.js} +1 -1
- package/dist/calendar/rules/calculation/collectors.d.ts +47 -0
- package/dist/calendar/rules/calculation/collectors.js +70 -0
- package/dist/calendar/rules/calculation/filters.d.ts +66 -0
- package/dist/calendar/rules/calculation/filters.js +162 -0
- package/dist/calendar/rules/calculation/index.d.ts +41 -0
- package/dist/calendar/rules/calculation/index.js +87 -0
- package/dist/calendar/rules/calculation/matchers.d.ts +39 -0
- package/dist/calendar/rules/calculation/matchers.js +60 -0
- package/dist/calendar/rules/calculation/types.d.ts +22 -0
- package/dist/calendar/rules/calculation/types.js +1 -0
- package/dist/calendar/rules/formatting/common.d.ts +35 -0
- package/dist/calendar/rules/formatting/common.js +94 -0
- package/dist/calendar/rules/formatting/parameter-summary.d.ts +17 -0
- package/dist/calendar/rules/formatting/parameter-summary.js +50 -0
- package/dist/calendar/rules/formatting/rule-summary.d.ts +40 -0
- package/dist/calendar/rules/formatting/rule-summary.js +149 -0
- package/dist/calendar/rules/index.d.ts +8 -0
- package/dist/calendar/rules/index.js +8 -0
- package/dist/calendar/rules/preview/affectedDates.d.ts +30 -0
- package/dist/calendar/rules/preview/affectedDates.js +71 -0
- package/dist/calendar/rules/preview/buildDayDetails.d.ts +11 -0
- package/dist/calendar/rules/preview/buildDayDetails.js +109 -0
- package/dist/calendar/rules/preview/computeRuleTimePoints.d.ts +13 -0
- package/dist/calendar/rules/preview/computeRuleTimePoints.js +92 -0
- package/dist/calendar/rules/preview/types.d.ts +27 -0
- package/dist/calendar/rules/preview/types.js +1 -0
- package/dist/calendar/rules/utils/date.d.ts +35 -0
- package/dist/calendar/rules/utils/date.js +58 -0
- package/dist/{calendar.d.ts → calendar/utils/index.d.ts} +2 -2
- package/dist/{calendar.js → calendar/utils/index.js} +1 -1
- package/dist/day-periods.d.ts +6 -0
- package/dist/day-periods.js +33 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -2
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './stops-parameters.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './stops-parameters.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Path, type StopsParameter } from '@tmlmobilidade/types';
|
|
2
|
+
export type MergedPathItem = Path & {
|
|
3
|
+
avg_speed?: null | number;
|
|
4
|
+
dwell_time?: null | number;
|
|
5
|
+
};
|
|
6
|
+
export interface SegmentTravelTimes {
|
|
7
|
+
segmentTravelSeconds: {
|
|
8
|
+
formatted: Array<string>;
|
|
9
|
+
raw: Array<null | number>;
|
|
10
|
+
};
|
|
11
|
+
stopDwellSeconds: {
|
|
12
|
+
formatted: Array<string>;
|
|
13
|
+
raw: Array<null | number>;
|
|
14
|
+
};
|
|
15
|
+
totalTripSecondsWithoutStops: {
|
|
16
|
+
formatted: string;
|
|
17
|
+
raw: number;
|
|
18
|
+
};
|
|
19
|
+
totalTripSecondsWithStops: {
|
|
20
|
+
formatted: string;
|
|
21
|
+
raw: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export declare function toNumberOrNull(value: unknown): null | number;
|
|
25
|
+
export declare function computeSegmentSeconds(distanceMeters: null | number, speedKmh: null | number): number;
|
|
26
|
+
export declare function formatSecondsToTime(timeInSeconds: null | number): string;
|
|
27
|
+
export declare function computeSegmentTravelTimes(mergedPath: MergedPathItem[]): SegmentTravelTimes;
|
|
28
|
+
export declare function getMergedPath(basePath: Path[], newPath: StopsParameter['path']): MergedPathItem[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
/* * */
|
|
3
|
+
export function toNumberOrNull(value) {
|
|
4
|
+
if (value === null || value === undefined)
|
|
5
|
+
return null;
|
|
6
|
+
if (typeof value === 'number')
|
|
7
|
+
return Number.isFinite(value) ? value : null;
|
|
8
|
+
if (typeof value === 'string') {
|
|
9
|
+
const v = Number(value.replace(',', '.'));
|
|
10
|
+
return Number.isFinite(v) ? v : null;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
export function computeSegmentSeconds(distanceMeters, speedKmh) {
|
|
15
|
+
if (!distanceMeters || !speedKmh)
|
|
16
|
+
return 0;
|
|
17
|
+
// km/h -> m/s
|
|
18
|
+
const speedMs = speedKmh * 1000 / 3600;
|
|
19
|
+
return Math.round(distanceMeters / speedMs) || 0;
|
|
20
|
+
}
|
|
21
|
+
export function formatSecondsToTime(timeInSeconds) {
|
|
22
|
+
if (timeInSeconds === null || timeInSeconds === undefined)
|
|
23
|
+
return '•••';
|
|
24
|
+
if (timeInSeconds < 60) {
|
|
25
|
+
return timeInSeconds + ' seg';
|
|
26
|
+
}
|
|
27
|
+
if (timeInSeconds < 3600) {
|
|
28
|
+
const minutes = Math.floor(timeInSeconds / 60);
|
|
29
|
+
const seconds = timeInSeconds % 60;
|
|
30
|
+
return `${minutes} min ${seconds} seg`;
|
|
31
|
+
}
|
|
32
|
+
const hours = Math.floor(timeInSeconds / 3600);
|
|
33
|
+
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
|
34
|
+
const seconds = timeInSeconds % 60;
|
|
35
|
+
return `${hours} h ${minutes} min ${seconds} seg`;
|
|
36
|
+
}
|
|
37
|
+
export function computeSegmentTravelTimes(mergedPath) {
|
|
38
|
+
const segmentTravelSeconds = [];
|
|
39
|
+
const stopDwellSeconds = [];
|
|
40
|
+
for (let i = 0; i < mergedPath.length; i++) {
|
|
41
|
+
const row = mergedPath[i];
|
|
42
|
+
// Dwell applies at the stop itself
|
|
43
|
+
const dwell = toNumberOrNull(row.dwell_time);
|
|
44
|
+
stopDwellSeconds.push(dwell && dwell > 0 ? Math.round(dwell) : null);
|
|
45
|
+
// Segment travel time applies from previous stop -> current stop
|
|
46
|
+
if (i === 0) {
|
|
47
|
+
segmentTravelSeconds.push(null);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const distanceMeters = toNumberOrNull(row.distance_delta);
|
|
51
|
+
const speedKmh = toNumberOrNull(row.avg_speed);
|
|
52
|
+
segmentTravelSeconds.push(computeSegmentSeconds(distanceMeters, speedKmh));
|
|
53
|
+
}
|
|
54
|
+
const totalTripSecondsWithoutStops = segmentTravelSeconds
|
|
55
|
+
.filter((v) => typeof v === 'number')
|
|
56
|
+
.reduce((a, b) => a + b, 0);
|
|
57
|
+
const totalStopSeconds = stopDwellSeconds
|
|
58
|
+
.filter((v) => typeof v === 'number')
|
|
59
|
+
.reduce((a, b) => a + b, 0);
|
|
60
|
+
const totalTripSecondsWithStops = totalTripSecondsWithoutStops + totalStopSeconds;
|
|
61
|
+
return {
|
|
62
|
+
segmentTravelSeconds: { formatted: segmentTravelSeconds.map(s => formatSecondsToTime(s)), raw: segmentTravelSeconds },
|
|
63
|
+
stopDwellSeconds: { formatted: stopDwellSeconds.map(s => formatSecondsToTime(s)), raw: stopDwellSeconds },
|
|
64
|
+
totalTripSecondsWithoutStops: { formatted: formatSecondsToTime(totalTripSecondsWithoutStops), raw: totalTripSecondsWithoutStops },
|
|
65
|
+
totalTripSecondsWithStops: { formatted: formatSecondsToTime(totalTripSecondsWithStops), raw: totalTripSecondsWithStops },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function getMergedPath(basePath, newPath) {
|
|
69
|
+
const editedByStopId = new Map((newPath || [])
|
|
70
|
+
.filter(p => p?.stop_id)
|
|
71
|
+
.map(p => [p.stop_id, p]));
|
|
72
|
+
return basePath.map((baseItem) => {
|
|
73
|
+
const edit = editedByStopId.get(baseItem.stop_id);
|
|
74
|
+
return {
|
|
75
|
+
...baseItem,
|
|
76
|
+
avg_speed: edit?.avg_speed,
|
|
77
|
+
dwell_time: edit?.dwell_time,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { CalendarKey } from '../utils/index.js';
|
|
1
2
|
import { OperationalDate } from '@tmlmobilidade/types';
|
|
2
|
-
import { CalendarKey } from './calendar.js';
|
|
3
3
|
/**
|
|
4
4
|
* Converts a date range into an array of CalendarKey strings (YYYY-MM-DD).
|
|
5
5
|
* Returned in sorted order (ascending).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* * */
|
|
2
|
-
import { calendarKey, datesFromCalendarKey, keyToYYYYMMDD } from '
|
|
2
|
+
import { calendarKey, datesFromCalendarKey, keyToYYYYMMDD } from '../utils/index.js';
|
|
3
3
|
/* * */
|
|
4
4
|
/**
|
|
5
5
|
* Converts a date range into an array of CalendarKey strings (YYYY-MM-DD).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { DayContext } from './types.js';
|
|
2
|
+
import { EventReplacementRule, ManualRule } from '@tmlmobilidade/types';
|
|
3
|
+
/**
|
|
4
|
+
* Collects time points from manual INCLUDE rules that match a given day context.
|
|
5
|
+
*
|
|
6
|
+
* This is the first phase of rule application for normal (non-replacement) days.
|
|
7
|
+
* Finds all manual rules with operating_mode='include' that match both the weekday
|
|
8
|
+
* and period ID, then aggregates their time points.
|
|
9
|
+
*
|
|
10
|
+
* @param manualRules - Array of all manual rules to check
|
|
11
|
+
* @param ctx - The day context (weekday + period) to match against
|
|
12
|
+
* @returns Object containing the applied rule IDs and the collected time points
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const result = collectManualIncludes(rules, { weekday: 1, yearPeriodId: 'school' });
|
|
17
|
+
* // result = { appliedRuleIds: ['rule1', 'rule2'], timepoints: Set(['08:00', '09:00']) }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function collectManualIncludes(manualRules: ManualRule[], ctx: DayContext): {
|
|
21
|
+
appliedRuleIds: string[];
|
|
22
|
+
timepoints: Set<string>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Collects time points from manual INCLUDE rules that intersect with a replacement rule.
|
|
26
|
+
*
|
|
27
|
+
* This is the first phase of rule application for days controlled by an event replacement.
|
|
28
|
+
* Uses intersection-based matching: if a manual rule matches ANY of the weekdays/periods
|
|
29
|
+
* targeted by the replacement, its time points are included.
|
|
30
|
+
*
|
|
31
|
+
* The replacement rule itself is always marked as applied (if it has an ID).
|
|
32
|
+
*
|
|
33
|
+
* @param replacement - The event replacement rule controlling this date
|
|
34
|
+
* @param manualRules - Array of all manual rules to check for intersection
|
|
35
|
+
* @returns Object containing the applied rule IDs (including replacement) and collected time points
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const replacement = { weekdays: [1], year_period_ids: ['school'], _id: 'event1' };
|
|
40
|
+
* const result = collectReplacementManualIncludes(replacement, manualRules);
|
|
41
|
+
* // result.appliedRuleIds will always include 'event1'
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function collectReplacementManualIncludes(replacement: EventReplacementRule, manualRules: ManualRule[]): {
|
|
45
|
+
appliedRuleIds: string[];
|
|
46
|
+
timepoints: Set<string>;
|
|
47
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { manualRuleMatchesContext, manualRuleMatchesReplacement } from './matchers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Collects time points from manual INCLUDE rules that match a given day context.
|
|
4
|
+
*
|
|
5
|
+
* This is the first phase of rule application for normal (non-replacement) days.
|
|
6
|
+
* Finds all manual rules with operating_mode='include' that match both the weekday
|
|
7
|
+
* and period ID, then aggregates their time points.
|
|
8
|
+
*
|
|
9
|
+
* @param manualRules - Array of all manual rules to check
|
|
10
|
+
* @param ctx - The day context (weekday + period) to match against
|
|
11
|
+
* @returns Object containing the applied rule IDs and the collected time points
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const result = collectManualIncludes(rules, { weekday: 1, yearPeriodId: 'school' });
|
|
16
|
+
* // result = { appliedRuleIds: ['rule1', 'rule2'], timepoints: Set(['08:00', '09:00']) }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function collectManualIncludes(manualRules, ctx) {
|
|
20
|
+
const timepoints = new Set();
|
|
21
|
+
const appliedRuleIds = [];
|
|
22
|
+
for (const r of manualRules) {
|
|
23
|
+
if (r.operating_mode !== 'include')
|
|
24
|
+
continue;
|
|
25
|
+
if (!manualRuleMatchesContext(r, ctx))
|
|
26
|
+
continue;
|
|
27
|
+
appliedRuleIds.push(r._id);
|
|
28
|
+
for (const tp of r.timepoints ?? [])
|
|
29
|
+
timepoints.add(tp);
|
|
30
|
+
}
|
|
31
|
+
return { appliedRuleIds, timepoints };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Collects time points from manual INCLUDE rules that intersect with a replacement rule.
|
|
35
|
+
*
|
|
36
|
+
* This is the first phase of rule application for days controlled by an event replacement.
|
|
37
|
+
* Uses intersection-based matching: if a manual rule matches ANY of the weekdays/periods
|
|
38
|
+
* targeted by the replacement, its time points are included.
|
|
39
|
+
*
|
|
40
|
+
* The replacement rule itself is always marked as applied (if it has an ID).
|
|
41
|
+
*
|
|
42
|
+
* @param replacement - The event replacement rule controlling this date
|
|
43
|
+
* @param manualRules - Array of all manual rules to check for intersection
|
|
44
|
+
* @returns Object containing the applied rule IDs (including replacement) and collected time points
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* const replacement = { weekdays: [1], year_period_ids: ['school'], _id: 'event1' };
|
|
49
|
+
* const result = collectReplacementManualIncludes(replacement, manualRules);
|
|
50
|
+
* // result.appliedRuleIds will always include 'event1'
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function collectReplacementManualIncludes(replacement, manualRules) {
|
|
54
|
+
const timepoints = new Set();
|
|
55
|
+
const appliedRuleIds = [];
|
|
56
|
+
// Always mark replacement as applied if it has an id (optional in schema)
|
|
57
|
+
if (replacement._id)
|
|
58
|
+
appliedRuleIds.push(replacement._id);
|
|
59
|
+
for (const r of manualRules) {
|
|
60
|
+
if (r.operating_mode !== 'include')
|
|
61
|
+
continue;
|
|
62
|
+
if (!manualRuleMatchesReplacement(r, replacement))
|
|
63
|
+
continue;
|
|
64
|
+
if (r._id)
|
|
65
|
+
appliedRuleIds.push(r._id);
|
|
66
|
+
for (const tp of r.timepoints)
|
|
67
|
+
timepoints.add(tp);
|
|
68
|
+
}
|
|
69
|
+
return { appliedRuleIds, timepoints };
|
|
70
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DayContext } from './types.js';
|
|
2
|
+
import { type EventReplacementRule, type ManualRule, type OperationalDate, type ScheduleRule } from '@tmlmobilidade/types';
|
|
3
|
+
/**
|
|
4
|
+
* Removes time points based on manual EXCLUDE rules that match a given day context.
|
|
5
|
+
*
|
|
6
|
+
* This is the second phase of rule application for normal days. Finds all manual rules
|
|
7
|
+
* with operating_mode='exclude' that match the weekday and period, then removes their
|
|
8
|
+
* time points from the collected set.
|
|
9
|
+
*
|
|
10
|
+
* Mutates the timepoints Set and appliedRuleIds array in place.
|
|
11
|
+
*
|
|
12
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
13
|
+
* @param appliedRuleIds - Array to track which rules were applied (mutated in place)
|
|
14
|
+
* @param manualRules - Array of all manual rules to check
|
|
15
|
+
* @param ctx - The day context (weekday + period) to match against
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const timepoints = new Set(['08:00', '09:00', '10:00']);
|
|
20
|
+
* const appliedRuleIds = ['rule1'];
|
|
21
|
+
* applyManualExcludes(timepoints, appliedRuleIds, rules, { weekday: 1, yearPeriodId: 'school' });
|
|
22
|
+
* // timepoints might now be Set(['08:00', '10:00']) if a rule excluded '09:00'
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyManualExcludes(timepoints: Set<string>, appliedRuleIds: string[], manualRules: ManualRule[], ctx: DayContext): void;
|
|
26
|
+
/**
|
|
27
|
+
* Removes time points based on manual EXCLUDE rules that intersect with a replacement rule.
|
|
28
|
+
*
|
|
29
|
+
* This is the second phase of rule application for replacement days. Uses intersection
|
|
30
|
+
* matching: if an exclude rule matches ANY of the weekdays/periods targeted by the
|
|
31
|
+
* replacement, its time points are removed.
|
|
32
|
+
*
|
|
33
|
+
* Mutates the timepoints Set and appliedRuleIds array in place.
|
|
34
|
+
*
|
|
35
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
36
|
+
* @param appliedRuleIds - Array to track which rules were applied (mutated in place)
|
|
37
|
+
* @param replacement - The event replacement rule controlling this date
|
|
38
|
+
* @param manualRules - Array of all manual rules to check for intersection
|
|
39
|
+
*/
|
|
40
|
+
export declare function applyReplacementManualExcludes(timepoints: Set<string>, appliedRuleIds: string[], replacement: EventReplacementRule, manualRules: ManualRule[]): void;
|
|
41
|
+
/**
|
|
42
|
+
* Applies event restriction rules to remove time points for a specific date.
|
|
43
|
+
*
|
|
44
|
+
* Event restrictions can work in three modes:
|
|
45
|
+
* 1. all_day: Removes all time points (complete service suspension)
|
|
46
|
+
* 2. explicit timepoints: Removes only the specified times (UI-generated)
|
|
47
|
+
* 3. time window: Removes times within start_time to end_time range (supports midnight crossing)
|
|
48
|
+
*
|
|
49
|
+
* This is the final filtering phase after manual includes/excludes have been processed.
|
|
50
|
+
*
|
|
51
|
+
* Mutates the timepoints Set and appliedRuleIds array in place.
|
|
52
|
+
*
|
|
53
|
+
* @param date - The operational date to check restrictions for
|
|
54
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
55
|
+
* @param appliedRuleIds - Array to track which rules were applied (mutated in place)
|
|
56
|
+
* @param rules - Array of all schedule rules (filters for event_restriction type)
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const timepoints = new Set(['08:00', '09:00', '10:00']);
|
|
61
|
+
* const appliedRuleIds = [];
|
|
62
|
+
* applyEventRestrictions('2026-12-25', timepoints, appliedRuleIds, rules);
|
|
63
|
+
* // If there's an all_day restriction, timepoints is now Set()
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare function applyEventRestrictions(date: OperationalDate, timepoints: Set<string>, appliedRuleIds: string[], rules: ScheduleRule[]): void;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { manualRuleMatchesContext, manualRuleMatchesReplacement } from './matchers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Removes time points based on manual EXCLUDE rules that match a given day context.
|
|
4
|
+
*
|
|
5
|
+
* This is the second phase of rule application for normal days. Finds all manual rules
|
|
6
|
+
* with operating_mode='exclude' that match the weekday and period, then removes their
|
|
7
|
+
* time points from the collected set.
|
|
8
|
+
*
|
|
9
|
+
* Mutates the timepoints Set and appliedRuleIds array in place.
|
|
10
|
+
*
|
|
11
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
12
|
+
* @param appliedRuleIds - Array to track which rules were applied (mutated in place)
|
|
13
|
+
* @param manualRules - Array of all manual rules to check
|
|
14
|
+
* @param ctx - The day context (weekday + period) to match against
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const timepoints = new Set(['08:00', '09:00', '10:00']);
|
|
19
|
+
* const appliedRuleIds = ['rule1'];
|
|
20
|
+
* applyManualExcludes(timepoints, appliedRuleIds, rules, { weekday: 1, yearPeriodId: 'school' });
|
|
21
|
+
* // timepoints might now be Set(['08:00', '10:00']) if a rule excluded '09:00'
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function applyManualExcludes(timepoints, appliedRuleIds, manualRules, ctx) {
|
|
25
|
+
for (const r of manualRules) {
|
|
26
|
+
if (r.operating_mode !== 'exclude')
|
|
27
|
+
continue;
|
|
28
|
+
if (!manualRuleMatchesContext(r, ctx))
|
|
29
|
+
continue;
|
|
30
|
+
appliedRuleIds.push(r._id);
|
|
31
|
+
// If exclude rule has no timepoints, treat as "exclude nothing"
|
|
32
|
+
// (If you want "exclude whole day", add an explicit flag later.)
|
|
33
|
+
for (const tp of r.timepoints ?? [])
|
|
34
|
+
timepoints.delete(tp);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Removes time points based on manual EXCLUDE rules that intersect with a replacement rule.
|
|
39
|
+
*
|
|
40
|
+
* This is the second phase of rule application for replacement days. Uses intersection
|
|
41
|
+
* matching: if an exclude rule matches ANY of the weekdays/periods targeted by the
|
|
42
|
+
* replacement, its time points are removed.
|
|
43
|
+
*
|
|
44
|
+
* Mutates the timepoints Set and appliedRuleIds array in place.
|
|
45
|
+
*
|
|
46
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
47
|
+
* @param appliedRuleIds - Array to track which rules were applied (mutated in place)
|
|
48
|
+
* @param replacement - The event replacement rule controlling this date
|
|
49
|
+
* @param manualRules - Array of all manual rules to check for intersection
|
|
50
|
+
*/
|
|
51
|
+
export function applyReplacementManualExcludes(timepoints, appliedRuleIds, replacement, manualRules) {
|
|
52
|
+
for (const r of manualRules) {
|
|
53
|
+
if (r.operating_mode !== 'exclude')
|
|
54
|
+
continue;
|
|
55
|
+
if (!manualRuleMatchesReplacement(r, replacement))
|
|
56
|
+
continue;
|
|
57
|
+
if (r._id)
|
|
58
|
+
appliedRuleIds.push(r._id);
|
|
59
|
+
for (const tp of r.timepoints)
|
|
60
|
+
timepoints.delete(tp);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Converts HH:MM time string to total minutes since midnight.
|
|
65
|
+
* Helper function for time window calculations.
|
|
66
|
+
*
|
|
67
|
+
* @param hhmm - Time string in HH:MM format (validated by Zod)
|
|
68
|
+
* @returns Total minutes (e.g., "09:30" → 570)
|
|
69
|
+
*/
|
|
70
|
+
function hhmmToMinutes(hhmm) {
|
|
71
|
+
const [h, m] = hhmm.split(':');
|
|
72
|
+
return Number(h) * 60 + Number(m);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Removes all time points that fall within a specified time window.
|
|
76
|
+
*
|
|
77
|
+
* Handles midnight crossing: if end < start, the window wraps around midnight
|
|
78
|
+
* and removes times in [start, 24:00) ∪ [00:00, end).
|
|
79
|
+
*
|
|
80
|
+
* Window is inclusive of start, exclusive of end: [start, end)
|
|
81
|
+
*
|
|
82
|
+
* Mutates the timepoints Set in place.
|
|
83
|
+
*
|
|
84
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
85
|
+
* @param startHHMM - Window start time in HH:MM format
|
|
86
|
+
* @param endHHMM - Window end time in HH:MM format
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const times = new Set(['08:00', '12:00', '22:00', '01:00']);
|
|
91
|
+
* // Remove times between 22:00 and 02:00 (crosses midnight)
|
|
92
|
+
* removeTimePointsByWindow(times, '22:00', '02:00');
|
|
93
|
+
* // times is now Set(['08:00', '12:00'])
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
function removeTimePointsByWindow(timepoints, startHHMM, endHHMM) {
|
|
97
|
+
const start = hhmmToMinutes(startHHMM);
|
|
98
|
+
const end = hhmmToMinutes(endHHMM);
|
|
99
|
+
const crossesMidnight = end < start;
|
|
100
|
+
for (const tp of Array.from(timepoints)) {
|
|
101
|
+
const t = hhmmToMinutes(tp);
|
|
102
|
+
const inWindow = crossesMidnight
|
|
103
|
+
? (t >= start || t < end)
|
|
104
|
+
: (t >= start && t < end);
|
|
105
|
+
if (inWindow)
|
|
106
|
+
timepoints.delete(tp);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Applies event restriction rules to remove time points for a specific date.
|
|
111
|
+
*
|
|
112
|
+
* Event restrictions can work in three modes:
|
|
113
|
+
* 1. all_day: Removes all time points (complete service suspension)
|
|
114
|
+
* 2. explicit timepoints: Removes only the specified times (UI-generated)
|
|
115
|
+
* 3. time window: Removes times within start_time to end_time range (supports midnight crossing)
|
|
116
|
+
*
|
|
117
|
+
* This is the final filtering phase after manual includes/excludes have been processed.
|
|
118
|
+
*
|
|
119
|
+
* Mutates the timepoints Set and appliedRuleIds array in place.
|
|
120
|
+
*
|
|
121
|
+
* @param date - The operational date to check restrictions for
|
|
122
|
+
* @param timepoints - Set of time points to filter (mutated in place)
|
|
123
|
+
* @param appliedRuleIds - Array to track which rules were applied (mutated in place)
|
|
124
|
+
* @param rules - Array of all schedule rules (filters for event_restriction type)
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const timepoints = new Set(['08:00', '09:00', '10:00']);
|
|
129
|
+
* const appliedRuleIds = [];
|
|
130
|
+
* applyEventRestrictions('2026-12-25', timepoints, appliedRuleIds, rules);
|
|
131
|
+
* // If there's an all_day restriction, timepoints is now Set()
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function applyEventRestrictions(date, timepoints, appliedRuleIds, rules) {
|
|
135
|
+
for (const r of rules) {
|
|
136
|
+
if (r.kind !== 'event_restriction')
|
|
137
|
+
continue;
|
|
138
|
+
if (!r.dates?.includes(date))
|
|
139
|
+
continue;
|
|
140
|
+
if (r._id)
|
|
141
|
+
appliedRuleIds.push(r._id);
|
|
142
|
+
// 1) all day kills everything
|
|
143
|
+
if (r?.all_day) {
|
|
144
|
+
timepoints.clear();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// 2) explicit timepoints removal (UI generated)
|
|
148
|
+
if (r.timepoints?.length) {
|
|
149
|
+
for (const tp of r.timepoints)
|
|
150
|
+
timepoints.delete(tp);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// 3) fallback: compute from window
|
|
154
|
+
// If start/end exist, remove anything within that time window.
|
|
155
|
+
// If somehow missing, do nothing.
|
|
156
|
+
const start = r?.start_time;
|
|
157
|
+
const end = r?.end_time;
|
|
158
|
+
if (start && end) {
|
|
159
|
+
removeTimePointsByWindow(timepoints, start, end);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RuleApplication } from './types.js';
|
|
2
|
+
import type { Event, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
|
|
3
|
+
/**
|
|
4
|
+
* Calculates which time points are active for each date in a range, based on scheduling rules.
|
|
5
|
+
*
|
|
6
|
+
* This is the core scheduling algorithm that processes three types of rules:
|
|
7
|
+
* 1. Manual rules (include/exclude) - Define base schedules for weekday/period combinations
|
|
8
|
+
* 2. Event replacement rules - Override base schedules for specific dates
|
|
9
|
+
* 3. Event restriction rules - Remove specific time points or time windows from schedules
|
|
10
|
+
*
|
|
11
|
+
* Algorithm flow for each date:
|
|
12
|
+
* - Check for event replacement rule
|
|
13
|
+
* - If found: Use intersection-based matching with manual rules
|
|
14
|
+
* - Otherwise: Use context-based matching (weekday + period)
|
|
15
|
+
* - Collect INCLUDE time points
|
|
16
|
+
* - Apply EXCLUDE time points
|
|
17
|
+
* - Apply event restrictions (by date)
|
|
18
|
+
* - Return final time points and applied rule IDs
|
|
19
|
+
*
|
|
20
|
+
* @param rules - All scheduling rules to process (manual, replacement, restriction)
|
|
21
|
+
* @param dateRange - Array of operational dates to calculate schedules for
|
|
22
|
+
* @param periods - YearPeriod definitions (school term, summer, etc.) for context resolution
|
|
23
|
+
* @returns Map from CalendarKey to RuleApplication with active time points and metadata
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* const rules = [...]; // Manual, replacement, and restriction rules
|
|
28
|
+
* const dates = ['2026-02-01', '2026-02-02', ...];
|
|
29
|
+
* const periods = [...]; // YearPeriod definitions
|
|
30
|
+
*
|
|
31
|
+
* const result = computeActiveTimePoints(rules, dates, periods);
|
|
32
|
+
* const feb1 = result.get(calendarKey(Dates.fromOperationalDate('2026-02-01')));
|
|
33
|
+
* // feb1 = {
|
|
34
|
+
* // timepoints: ['08:00', '09:00', '10:00'],
|
|
35
|
+
* // appliedRuleIds: ['rule1', 'rule2']
|
|
36
|
+
* // }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function computeActiveRules(date: OperationalDate, rules: ScheduleRule[], periods: YearPeriod[], options?: {
|
|
40
|
+
events?: Event[];
|
|
41
|
+
}): RuleApplication;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { calendarKey, calendarWeekday } from '../../utils/index.js';
|
|
2
|
+
import { Dates } from '../../../dates.js';
|
|
3
|
+
import { findReplacementForDate, getActivePeriodId } from '../utils/date.js';
|
|
4
|
+
import { collectManualIncludes, collectReplacementManualIncludes } from './collectors.js';
|
|
5
|
+
import { applyEventRestrictions, applyManualExcludes, applyReplacementManualExcludes } from './filters.js';
|
|
6
|
+
/* * */
|
|
7
|
+
/**
|
|
8
|
+
* Calculates which time points are active for each date in a range, based on scheduling rules.
|
|
9
|
+
*
|
|
10
|
+
* This is the core scheduling algorithm that processes three types of rules:
|
|
11
|
+
* 1. Manual rules (include/exclude) - Define base schedules for weekday/period combinations
|
|
12
|
+
* 2. Event replacement rules - Override base schedules for specific dates
|
|
13
|
+
* 3. Event restriction rules - Remove specific time points or time windows from schedules
|
|
14
|
+
*
|
|
15
|
+
* Algorithm flow for each date:
|
|
16
|
+
* - Check for event replacement rule
|
|
17
|
+
* - If found: Use intersection-based matching with manual rules
|
|
18
|
+
* - Otherwise: Use context-based matching (weekday + period)
|
|
19
|
+
* - Collect INCLUDE time points
|
|
20
|
+
* - Apply EXCLUDE time points
|
|
21
|
+
* - Apply event restrictions (by date)
|
|
22
|
+
* - Return final time points and applied rule IDs
|
|
23
|
+
*
|
|
24
|
+
* @param rules - All scheduling rules to process (manual, replacement, restriction)
|
|
25
|
+
* @param dateRange - Array of operational dates to calculate schedules for
|
|
26
|
+
* @param periods - YearPeriod definitions (school term, summer, etc.) for context resolution
|
|
27
|
+
* @returns Map from CalendarKey to RuleApplication with active time points and metadata
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const rules = [...]; // Manual, replacement, and restriction rules
|
|
32
|
+
* const dates = ['2026-02-01', '2026-02-02', ...];
|
|
33
|
+
* const periods = [...]; // YearPeriod definitions
|
|
34
|
+
*
|
|
35
|
+
* const result = computeActiveTimePoints(rules, dates, periods);
|
|
36
|
+
* const feb1 = result.get(calendarKey(Dates.fromOperationalDate('2026-02-01')));
|
|
37
|
+
* // feb1 = {
|
|
38
|
+
* // timepoints: ['08:00', '09:00', '10:00'],
|
|
39
|
+
* // appliedRuleIds: ['rule1', 'rule2']
|
|
40
|
+
* // }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function computeActiveRules(date, rules, periods, options) {
|
|
44
|
+
const manualRules = rules.filter((r) => r.kind === 'manual');
|
|
45
|
+
const restrictionRules = rules.filter(r => r.kind === 'event_restriction');
|
|
46
|
+
const replacementRules = rules.filter((r) => r.kind === 'event_replacement');
|
|
47
|
+
const filteredManualRules = manualRules.filter((rule) => {
|
|
48
|
+
if (!rule.event_id)
|
|
49
|
+
return true;
|
|
50
|
+
const event = options?.events?.find(e => e._id === rule.event_id);
|
|
51
|
+
if (!event?.dates?.length)
|
|
52
|
+
return false;
|
|
53
|
+
return event.dates.includes(date);
|
|
54
|
+
});
|
|
55
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
56
|
+
const replacement = findReplacementForDate(date, replacementRules);
|
|
57
|
+
// 1) Base timepoints
|
|
58
|
+
let timepoints;
|
|
59
|
+
let appliedRuleIds;
|
|
60
|
+
if (replacement) {
|
|
61
|
+
// Replacement mode: match manuals by intersection (Option A)
|
|
62
|
+
const base = collectReplacementManualIncludes(replacement, filteredManualRules);
|
|
63
|
+
timepoints = base.timepoints;
|
|
64
|
+
appliedRuleIds = base.appliedRuleIds;
|
|
65
|
+
// Apply manual excludes *also* by intersection with replacement targets
|
|
66
|
+
applyReplacementManualExcludes(timepoints, appliedRuleIds, replacement, filteredManualRules);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Normal mode: day resolves to a single weekday + single yearPeriodId
|
|
70
|
+
const ctx = {
|
|
71
|
+
weekday: calendarWeekday(key),
|
|
72
|
+
yearPeriodId: getActivePeriodId(date, periods),
|
|
73
|
+
};
|
|
74
|
+
const base = collectManualIncludes(filteredManualRules, ctx);
|
|
75
|
+
timepoints = base.timepoints;
|
|
76
|
+
appliedRuleIds = base.appliedRuleIds;
|
|
77
|
+
applyManualExcludes(timepoints, appliedRuleIds, filteredManualRules, ctx);
|
|
78
|
+
}
|
|
79
|
+
// 2) Event restrictions always apply by date
|
|
80
|
+
applyEventRestrictions(date, timepoints, appliedRuleIds, restrictionRules);
|
|
81
|
+
const result = {
|
|
82
|
+
appliedRuleIds,
|
|
83
|
+
timepoints: Array.from(timepoints).sort() || [],
|
|
84
|
+
};
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
/* * */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DayContext } from './types.js';
|
|
2
|
+
import { EventReplacementRule, ManualRule } from '@tmlmobilidade/types';
|
|
3
|
+
/**
|
|
4
|
+
* Determines if a manual rule matches the given day context.
|
|
5
|
+
*
|
|
6
|
+
* Uses strict AND logic: the rule must include BOTH the specified weekday
|
|
7
|
+
* AND the period ID to match. This is used for normal (non-replacement) days.
|
|
8
|
+
*
|
|
9
|
+
* @param rule - The manual rule to check
|
|
10
|
+
* @param ctx - The day context containing the weekday and period ID
|
|
11
|
+
* @returns True if the rule applies to this day context
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const rule = { weekdays: [1, 2, 3], year_period_ids: ['school'], ... };
|
|
16
|
+
* const ctx = { weekday: 1, yearPeriodId: 'school' };
|
|
17
|
+
* manualRuleMatchesContext(rule, ctx); // true
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function manualRuleMatchesContext(rule: ManualRule, ctx: DayContext): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Determines if a manual rule intersects with an event replacement rule's targets.
|
|
23
|
+
*
|
|
24
|
+
* Uses OR logic via set intersection: if the rule matches ANY weekday or period
|
|
25
|
+
* that the replacement targets, it's considered a match. This is used when a
|
|
26
|
+
* replacement rule overrides the normal schedule for certain weekdays/periods.
|
|
27
|
+
*
|
|
28
|
+
* @param rule - The manual rule to check
|
|
29
|
+
* @param replacement - The event replacement rule defining override targets
|
|
30
|
+
* @returns True if the rule intersects with the replacement's targets
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const rule = { weekdays: [1, 2], year_period_ids: ['school'], ... };
|
|
35
|
+
* const replacement = { weekdays: [1], year_period_ids: ['school', 'summer'], ... };
|
|
36
|
+
* manualRuleMatchesReplacement(rule, replacement); // true (weekday 1 matches)
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function manualRuleMatchesReplacement(rule: ManualRule, replacement: EventReplacementRule): boolean;
|