@tmlmobilidade/dates 20260612.214.31 → 20260612.1446.12

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.
Files changed (26) hide show
  1. package/dist/calendar/rules/calculation/filters.d.ts +8 -1
  2. package/dist/calendar/rules/calculation/filters.js +27 -18
  3. package/dist/calendar/rules/calculation/index.d.ts +40 -35
  4. package/dist/calendar/rules/calculation/index.js +139 -53
  5. package/dist/calendar/rules/export-attribution/canonical-registry.d.ts +16 -0
  6. package/dist/calendar/rules/export-attribution/canonical-registry.js +61 -0
  7. package/dist/calendar/rules/export-attribution/collectors.d.ts +16 -0
  8. package/dist/calendar/rules/export-attribution/collectors.js +37 -0
  9. package/dist/calendar/rules/export-attribution/index.d.ts +38 -0
  10. package/dist/calendar/rules/export-attribution/index.js +313 -0
  11. package/dist/calendar/rules/export-attribution/replacement.d.ts +10 -0
  12. package/dist/calendar/rules/export-attribution/replacement.js +24 -0
  13. package/dist/calendar/rules/export-attribution/trip-row-dates.d.ts +38 -0
  14. package/dist/calendar/rules/export-attribution/trip-row-dates.js +71 -0
  15. package/dist/calendar/rules/export-attribution/types.d.ts +17 -0
  16. package/dist/calendar/rules/export-attribution/types.js +1 -0
  17. package/dist/calendar/rules/index.d.ts +2 -0
  18. package/dist/calendar/rules/index.js +2 -0
  19. package/dist/calendar/rules/merging/index.js +1 -0
  20. package/dist/calendar/rules/preview/computeRuleTimePoints.d.ts +1 -1
  21. package/dist/calendar/rules/preview/computeRuleTimePoints.js +11 -12
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/vkm.d.ts +11 -0
  25. package/dist/vkm.js +189 -0
  26. package/package.json +6 -4
@@ -1,5 +1,5 @@
1
1
  import type { DayContext } from './types.js';
2
- import { type EventReplacementRule, HHMM, type ManualRule, type OperationalDate, type ScheduleRule } from '@tmlmobilidade/types';
2
+ import { type EventReplacementRule, type EventRestrictionRule, HHMM, type ManualRule, type OperationalDate, type ScheduleRule } from '@tmlmobilidade/types';
3
3
  /**
4
4
  * Removes time points based on manual EXCLUDE rules that match a given day context.
5
5
  *
@@ -38,6 +38,13 @@ export declare function applyManualExcludes(timepoints: Set<string>, appliedRule
38
38
  * @param manualRules - Array of all manual rules to check for intersection
39
39
  */
40
40
  export declare function applyReplacementManualExcludes(timepoints: Set<string>, appliedRuleIds: string[], replacement: EventReplacementRule, manualRules: ManualRule[]): void;
41
+ /**
42
+ * Returns candidate timepoints removed by a single event restriction rule.
43
+ *
44
+ * Mirrors the three restriction modes used by {@link applyEventRestrictions}:
45
+ * all day, explicit timepoint list, or start/end window (midnight crossing supported).
46
+ */
47
+ export declare function getTimepointsRemovedByEventRestriction(restriction: EventRestrictionRule, candidateTimepoints: Iterable<HHMM>): HHMM[];
41
48
  /**
42
49
  * Applies event restriction rules to remove time points for a specific date.
43
50
  *
@@ -96,6 +96,31 @@ function removeTimePointsByWindow(timepoints, startHHMM, endHHMM) {
96
96
  timepoints.delete(tp);
97
97
  }
98
98
  }
99
+ /**
100
+ * Returns candidate timepoints removed by a single event restriction rule.
101
+ *
102
+ * Mirrors the three restriction modes used by {@link applyEventRestrictions}:
103
+ * all day, explicit timepoint list, or start/end window (midnight crossing supported).
104
+ */
105
+ export function getTimepointsRemovedByEventRestriction(restriction, candidateTimepoints) {
106
+ const candidates = [...candidateTimepoints];
107
+ if (!candidates.length)
108
+ return [];
109
+ if (restriction.all_day)
110
+ return candidates;
111
+ if (restriction.timepoints?.length) {
112
+ const explicit = new Set(restriction.timepoints);
113
+ return candidates.filter(tp => explicit.has(tp));
114
+ }
115
+ const start = restriction.start_time;
116
+ const end = restriction.end_time;
117
+ if (start && end) {
118
+ const remaining = new Set(candidates);
119
+ removeTimePointsByWindow(remaining, start, end);
120
+ return candidates.filter(tp => !remaining.has(tp));
121
+ }
122
+ return [];
123
+ }
99
124
  /**
100
125
  * Applies event restriction rules to remove time points for a specific date.
101
126
  *
@@ -129,24 +154,8 @@ export function applyEventRestrictions(date, timepoints, appliedRuleIds, rules)
129
154
  continue;
130
155
  if (r._id)
131
156
  appliedRuleIds.push(r._id);
132
- // 1) all day kills everything
133
- if (r?.all_day) {
134
- timepoints.clear();
135
- continue;
136
- }
137
- // 2) explicit timepoints removal (UI generated)
138
- if (r.timepoints?.length) {
139
- for (const tp of r.timepoints)
140
- timepoints.delete(tp);
141
- continue;
142
- }
143
- // 3) fallback: compute from window
144
- // If start/end exist, remove anything within that time window.
145
- // If somehow missing, do nothing.
146
- const start = r?.start_time;
147
- const end = r?.end_time;
148
- if (start && end) {
149
- removeTimePointsByWindow(timepoints, start, end);
157
+ for (const tp of getTimepointsRemovedByEventRestriction(r, timepoints)) {
158
+ timepoints.delete(tp);
150
159
  }
151
160
  }
152
161
  }
@@ -1,41 +1,46 @@
1
- import type { RuleApplication } from './types.js';
2
- import type { Event, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
1
+ import type { DayContext, RuleApplication } from './types.js';
2
+ import type { Event, EventReplacementRule, EventRestrictionRule, Holiday, ManualRule, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
3
+ export interface ComputeActiveRulesOptions {
4
+ dateContext?: ActiveRulesDateContext;
5
+ eventById?: Map<string, Event>;
6
+ events?: Event[];
7
+ }
8
+ export interface ActiveRulesDateContext extends DayContext {
9
+ key: string;
10
+ }
11
+ export interface SplitScheduleRules {
12
+ manualRules: ManualRule[];
13
+ replacementRules: EventReplacementRule[];
14
+ restrictionRules: EventRestrictionRule[];
15
+ }
16
+ export declare function buildActiveRulesEventIndex(events?: Event[]): Map<string, Event>;
17
+ export declare function buildActiveRulesDateContext(date: OperationalDate, periods: YearPeriod[], holidays: Holiday[]): ActiveRulesDateContext;
18
+ export declare function splitScheduleRules(rules: ScheduleRule[]): SplitScheduleRules;
3
19
  /**
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
20
+ * Operating schedule for one operational date: merged timepoints and applied rule IDs.
10
21
  *
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
22
+ * This is the core scheduling algorithm for “what runs” (preview, VKM, rules grid).
23
+ * It processes three rule kinds:
24
+ * 1. Manual rules (include/exclude) base schedules for weekday/period combinations
25
+ * 2. Event replacement rules override base schedules on specific dates
26
+ * 3. Event restriction rules — remove timepoints or windows from the result
19
27
  *
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
28
+ * Algorithm flow:
29
+ * - If a replacement applies: intersection-based manual matching + replacement excludes
30
+ * - Otherwise: weekday + period context matching + manual excludes
31
+ * - Always apply event restrictions by date
24
32
  *
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
33
+ * For GTFS `service_id` token binding (`operating` / `base_off` rows), use
34
+ * `collectGtfsIncludeContributionsForDate` in `export-attribution/index.ts`.
35
+ * That layer delegates here on replacement days; the `operating` timepoint set
36
+ * should match this function’s result (see SCENARIOS P5).
30
37
  *
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
+ * @param date - Operational date to evaluate
39
+ * @param rules - All scheduling rules (manual, replacement, restriction)
40
+ * @param periods - YearPeriod definitions for context resolution
41
+ * @param holidays - Holiday calendar for weekday resolution
42
+ * @returns Active timepoints and IDs of rules that contributed
38
43
  */
39
- export declare function computeActiveRules(date: OperationalDate, rules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
40
- events?: Event[];
41
- }): RuleApplication;
44
+ export declare function computeActiveRules(date: OperationalDate, rules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: ComputeActiveRulesOptions): RuleApplication;
45
+ export declare function computeActiveRulesFromSplit(date: OperationalDate, rules: SplitScheduleRules, periods: YearPeriod[], holidays: Holiday[], options?: ComputeActiveRulesOptions): RuleApplication;
46
+ export declare function computeActiveRuleCountFromSplit(date: OperationalDate, rules: SplitScheduleRules, periods: YearPeriod[], holidays: Holiday[], options?: ComputeActiveRulesOptions): number;
@@ -1,71 +1,140 @@
1
1
  import { calendarKey, calendarWeekday } from '../../utils/index.js';
2
2
  import { Dates } from '../../../dates.js';
3
+ import { resolveEffectiveReplacement } from '../export-attribution/replacement.js';
3
4
  import { findReplacementForDate, getActivePeriodId } from '../utils/date.js';
4
5
  import { collectManualIncludes, collectReplacementManualIncludes } from './collectors.js';
5
6
  import { applyEventRestrictions, applyManualExcludes, applyReplacementManualExcludes } from './filters.js';
6
- /* * */
7
+ import { getTimepointsRemovedByEventRestriction } from './filters.js';
8
+ import { manualRuleMatchesContext, manualRuleMatchesReplacement } from './matchers.js';
9
+ export function buildActiveRulesEventIndex(events = []) {
10
+ return new Map(events.map(event => [event._id, event]));
11
+ }
12
+ export function buildActiveRulesDateContext(date, periods, holidays) {
13
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
14
+ return {
15
+ key,
16
+ weekday: calendarWeekday(key, holidays),
17
+ yearPeriodId: getActivePeriodId(date, periods),
18
+ };
19
+ }
20
+ export function splitScheduleRules(rules) {
21
+ return {
22
+ manualRules: rules.filter((r) => r.kind === 'manual'),
23
+ replacementRules: rules.filter((r) => r.kind === 'event_replacement'),
24
+ restrictionRules: rules.filter((r) => r.kind === 'event_restriction'),
25
+ };
26
+ }
27
+ function resolveEventById(id, options) {
28
+ return options?.eventById?.get(id) ?? options?.events?.find(event => event._id === id);
29
+ }
30
+ function filterManualRulesByEventDate(manualRules, date, options) {
31
+ return manualRules.filter((rule) => {
32
+ if (!rule.event_id)
33
+ return true;
34
+ const event = resolveEventById(rule.event_id, options);
35
+ if (!event?.dates?.length)
36
+ return false;
37
+ return event.dates.includes(date);
38
+ });
39
+ }
40
+ function filterManualRulesByMonth(manualRules, month) {
41
+ return manualRules.filter(rule => !rule.months?.length || rule.months.includes(month));
42
+ }
43
+ function collectManualIncludeTimepoints(manualRules, ctx) {
44
+ const timepoints = new Set();
45
+ for (const rule of manualRules) {
46
+ if (rule.operating_mode !== 'include')
47
+ continue;
48
+ if (!manualRuleMatchesContext(rule, ctx))
49
+ continue;
50
+ for (const timepoint of rule.timepoints ?? [])
51
+ timepoints.add(timepoint);
52
+ }
53
+ return timepoints;
54
+ }
55
+ function collectReplacementManualIncludeTimepoints(replacement, manualRules) {
56
+ const timepoints = new Set();
57
+ for (const rule of manualRules) {
58
+ if (rule.operating_mode !== 'include')
59
+ continue;
60
+ if (!manualRuleMatchesReplacement(rule, replacement))
61
+ continue;
62
+ for (const timepoint of rule.timepoints)
63
+ timepoints.add(timepoint);
64
+ }
65
+ return timepoints;
66
+ }
67
+ function applyManualExcludeTimepoints(timepoints, manualRules, ctx) {
68
+ for (const rule of manualRules) {
69
+ if (rule.operating_mode !== 'exclude')
70
+ continue;
71
+ if (!manualRuleMatchesContext(rule, ctx))
72
+ continue;
73
+ for (const timepoint of rule.timepoints ?? [])
74
+ timepoints.delete(timepoint);
75
+ }
76
+ }
77
+ function applyReplacementManualExcludeTimepoints(timepoints, replacement, manualRules) {
78
+ for (const rule of manualRules) {
79
+ if (rule.operating_mode !== 'exclude')
80
+ continue;
81
+ if (!manualRuleMatchesReplacement(rule, replacement))
82
+ continue;
83
+ for (const timepoint of rule.timepoints)
84
+ timepoints.delete(timepoint);
85
+ }
86
+ }
87
+ function applyEventRestrictionTimepoints(date, timepoints, rules) {
88
+ for (const rule of rules) {
89
+ if (!rule.dates?.includes(date))
90
+ continue;
91
+ for (const timepoint of getTimepointsRemovedByEventRestriction(rule, timepoints)) {
92
+ timepoints.delete(timepoint);
93
+ }
94
+ }
95
+ }
7
96
  /**
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
97
+ * Operating schedule for one operational date: merged timepoints and applied rule IDs.
14
98
  *
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
99
+ * This is the core scheduling algorithm for “what runs” (preview, VKM, rules grid).
100
+ * It processes three rule kinds:
101
+ * 1. Manual rules (include/exclude) base schedules for weekday/period combinations
102
+ * 2. Event replacement rules override base schedules on specific dates
103
+ * 3. Event restriction rules — remove timepoints or windows from the result
23
104
  *
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
105
+ * Algorithm flow:
106
+ * - If a replacement applies: intersection-based manual matching + replacement excludes
107
+ * - Otherwise: weekday + period context matching + manual excludes
108
+ * - Always apply event restrictions by date
28
109
  *
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
110
+ * For GTFS `service_id` token binding (`operating` / `base_off` rows), use
111
+ * `collectGtfsIncludeContributionsForDate` in `export-attribution/index.ts`.
112
+ * That layer delegates here on replacement days; the `operating` timepoint set
113
+ * should match this function’s result (see SCENARIOS P5).
34
114
  *
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
- * ```
115
+ * @param date - Operational date to evaluate
116
+ * @param rules - All scheduling rules (manual, replacement, restriction)
117
+ * @param periods - YearPeriod definitions for context resolution
118
+ * @param holidays - Holiday calendar for weekday resolution
119
+ * @returns Active timepoints and IDs of rules that contributed
42
120
  */
43
121
  export function computeActiveRules(date, rules, periods, holidays, 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'));
122
+ const splitRules = splitScheduleRules(rules);
123
+ return computeActiveRulesFromSplit(date, splitRules, periods, holidays, options);
124
+ }
125
+ export function computeActiveRulesFromSplit(date, rules, periods, holidays, options) {
126
+ const { manualRules, replacementRules, restrictionRules } = rules;
127
+ const filteredManualRules = filterManualRulesByEventDate(manualRules, date, options);
128
+ const key = options?.dateContext?.key ?? calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
56
129
  // Filter out manual rules whose months list doesn't include the current month
57
130
  const month = Number(key.slice(5, 7));
58
- const monthFilteredManualRules = filteredManualRules.filter(rule => !rule.months?.length || rule.months.includes(month));
131
+ const monthFilteredManualRules = filterManualRulesByMonth(filteredManualRules, month);
59
132
  const replacement = findReplacementForDate(date, replacementRules);
60
133
  // 1) Base timepoints
61
134
  let timepoints;
62
135
  let appliedRuleIds;
63
136
  if (replacement) {
64
- // When same_weekday is true, each event date functions as its own actual weekday
65
- // within the replacement's target periods, rather than all dates acting as one fixed weekday.
66
- const effectiveReplacement = replacement.same_weekday
67
- ? { ...replacement, weekdays: [calendarWeekday(key, holidays)] }
68
- : replacement;
137
+ const effectiveReplacement = resolveEffectiveReplacement(date, replacement, holidays);
69
138
  // Replacement mode: match manuals by intersection (Option A)
70
139
  const base = collectReplacementManualIncludes(effectiveReplacement, monthFilteredManualRules);
71
140
  timepoints = base.timepoints;
@@ -75,10 +144,7 @@ export function computeActiveRules(date, rules, periods, holidays, options) {
75
144
  }
76
145
  else {
77
146
  // Normal mode: day resolves to a single weekday + single yearPeriodId
78
- const ctx = {
79
- weekday: calendarWeekday(key, holidays),
80
- yearPeriodId: getActivePeriodId(date, periods),
81
- };
147
+ const ctx = options?.dateContext ?? buildActiveRulesDateContext(date, periods, holidays);
82
148
  const base = collectManualIncludes(monthFilteredManualRules, ctx);
83
149
  timepoints = base.timepoints;
84
150
  appliedRuleIds = base.appliedRuleIds;
@@ -92,4 +158,24 @@ export function computeActiveRules(date, rules, periods, holidays, options) {
92
158
  };
93
159
  return result;
94
160
  }
161
+ export function computeActiveRuleCountFromSplit(date, rules, periods, holidays, options) {
162
+ const filteredManualRules = filterManualRulesByEventDate(rules.manualRules, date, options);
163
+ const key = options?.dateContext?.key ?? calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
164
+ const month = Number(key.slice(5, 7));
165
+ const monthFilteredManualRules = filterManualRulesByMonth(filteredManualRules, month);
166
+ const replacement = findReplacementForDate(date, rules.replacementRules);
167
+ let timepoints;
168
+ if (replacement) {
169
+ const effectiveReplacement = resolveEffectiveReplacement(date, replacement, holidays);
170
+ timepoints = collectReplacementManualIncludeTimepoints(effectiveReplacement, monthFilteredManualRules);
171
+ applyReplacementManualExcludeTimepoints(timepoints, effectiveReplacement, monthFilteredManualRules);
172
+ }
173
+ else {
174
+ const ctx = options?.dateContext ?? buildActiveRulesDateContext(date, periods, holidays);
175
+ timepoints = collectManualIncludeTimepoints(monthFilteredManualRules, ctx);
176
+ applyManualExcludeTimepoints(timepoints, monthFilteredManualRules, ctx);
177
+ }
178
+ applyEventRestrictionTimepoints(date, timepoints, rules.restrictionRules);
179
+ return timepoints.size;
180
+ }
95
181
  /* * */
@@ -0,0 +1,16 @@
1
+ import type { Event, HHMM, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
2
+ export interface CanonicalRegistryOptions {
3
+ events?: Event[];
4
+ holidays: Holiday[];
5
+ periods: YearPeriod[];
6
+ }
7
+ /**
8
+ * Date set for a rule token from definition (period × weekday × months × events),
9
+ * not from trip-day accumulation.
10
+ */
11
+ export declare function buildCanonicalRuleDates(rule: ScheduleRule, exportDates: readonly OperationalDate[], { events, holidays, periods }: CanonicalRegistryOptions): Set<OperationalDate>;
12
+ /**
13
+ * On forced-retarget replacement days, emit the replacement token for operating
14
+ * timepoints unless one calendar manual rule exactly covers the full operating set.
15
+ */
16
+ export declare function shouldEmitReplacementOnForcedRetarget(operatingTimepoints: Iterable<HHMM>, calendarOperatingByTimepoint: Map<HHMM, ScheduleRule>): boolean;
@@ -0,0 +1,61 @@
1
+ import { calendarKey, calendarWeekday } from '../../utils/index.js';
2
+ import { Dates } from '../../../dates.js';
3
+ import { manualRuleMatchesContext } from '../calculation/matchers.js';
4
+ import { getActivePeriodId } from '../utils/date.js';
5
+ /**
6
+ * Date set for a rule token from definition (period × weekday × months × events),
7
+ * not from trip-day accumulation.
8
+ */
9
+ export function buildCanonicalRuleDates(rule, exportDates, { events, holidays, periods }) {
10
+ if (rule.kind === 'event_replacement') {
11
+ const replacement = rule;
12
+ return new Set(exportDates.filter(date => replacement.dates?.includes(date)));
13
+ }
14
+ if (rule.kind !== 'manual' || rule.operating_mode !== 'include') {
15
+ return new Set();
16
+ }
17
+ const manual = rule;
18
+ const dates = new Set();
19
+ for (const date of exportDates) {
20
+ if (!manualRuleAppliesOnDate(manual, date, periods, holidays, events))
21
+ continue;
22
+ dates.add(date);
23
+ }
24
+ return dates;
25
+ }
26
+ function manualRuleAppliesOnDate(rule, date, periods, holidays, events) {
27
+ if (rule.event_id) {
28
+ const event = events?.find(e => e._id === rule.event_id);
29
+ if (!event?.dates?.length || !event.dates.includes(date))
30
+ return false;
31
+ }
32
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
33
+ const month = Number(key.slice(5, 7));
34
+ if (rule.months?.length && !rule.months.includes(month))
35
+ return false;
36
+ const ctx = {
37
+ weekday: calendarWeekday(key, holidays),
38
+ yearPeriodId: getActivePeriodId(date, periods),
39
+ };
40
+ return manualRuleMatchesContext(rule, ctx);
41
+ }
42
+ function soleCalendarOwnerCoversOperating(operatingTimepoints, calendarOperatingByTimepoint) {
43
+ const sortedOperating = [...operatingTimepoints].sort();
44
+ if (!sortedOperating.length)
45
+ return false;
46
+ const ownerIds = new Set(sortedOperating.map(tp => calendarOperatingByTimepoint.get(tp)?._id));
47
+ if (ownerIds.size !== 1 || ownerIds.has(undefined))
48
+ return false;
49
+ const ownerRule = calendarOperatingByTimepoint.get(sortedOperating[0]);
50
+ if (ownerRule?.kind !== 'manual')
51
+ return false;
52
+ const ownerTimepoints = [...(ownerRule.timepoints ?? [])].sort();
53
+ return ownerTimepoints.join(',') === sortedOperating.join(',');
54
+ }
55
+ /**
56
+ * On forced-retarget replacement days, emit the replacement token for operating
57
+ * timepoints unless one calendar manual rule exactly covers the full operating set.
58
+ */
59
+ export function shouldEmitReplacementOnForcedRetarget(operatingTimepoints, calendarOperatingByTimepoint) {
60
+ return !soleCalendarOwnerCoversOperating(operatingTimepoints, calendarOperatingByTimepoint);
61
+ }
@@ -0,0 +1,16 @@
1
+ import type { DayContext } from '../calculation/types.js';
2
+ import { EventReplacementRule, HHMM, ManualRule } from '@tmlmobilidade/types';
3
+ export interface ManualRuleTimepoints {
4
+ rule: ManualRule;
5
+ timepoints: HHMM[];
6
+ }
7
+ /**
8
+ * Per-rule variant of `collectManualIncludes` (calculation) for GTFS token attribution.
9
+ *
10
+ * Returns one entry per matching manual instead of merging timepoints into a single set.
11
+ */
12
+ export declare function collectManualIncludesByRule(manualRules: ManualRule[], ctx: DayContext): ManualRuleTimepoints[];
13
+ /**
14
+ * Per-rule variant of `collectReplacementManualIncludes` (calculation) for GTFS token attribution.
15
+ */
16
+ export declare function collectReplacementManualIncludesByRule(replacement: EventReplacementRule, manualRules: ManualRule[]): ManualRuleTimepoints[];
@@ -0,0 +1,37 @@
1
+ import { manualRuleMatchesContext, manualRuleMatchesReplacement } from '../calculation/matchers.js';
2
+ /**
3
+ * Per-rule variant of `collectManualIncludes` (calculation) for GTFS token attribution.
4
+ *
5
+ * Returns one entry per matching manual instead of merging timepoints into a single set.
6
+ */
7
+ export function collectManualIncludesByRule(manualRules, ctx) {
8
+ const result = [];
9
+ for (const rule of manualRules) {
10
+ if (rule.operating_mode !== 'include')
11
+ continue;
12
+ if (!manualRuleMatchesContext(rule, ctx))
13
+ continue;
14
+ result.push({
15
+ rule,
16
+ timepoints: (rule.timepoints ?? []),
17
+ });
18
+ }
19
+ return result;
20
+ }
21
+ /**
22
+ * Per-rule variant of `collectReplacementManualIncludes` (calculation) for GTFS token attribution.
23
+ */
24
+ export function collectReplacementManualIncludesByRule(replacement, manualRules) {
25
+ const result = [];
26
+ for (const rule of manualRules) {
27
+ if (rule.operating_mode !== 'include')
28
+ continue;
29
+ if (!manualRuleMatchesReplacement(rule, replacement))
30
+ continue;
31
+ result.push({
32
+ rule,
33
+ timepoints: (rule.timepoints ?? []),
34
+ });
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,38 @@
1
+ import type { GtfsIncludeContribution } from './types.js';
2
+ import type { Event, Holiday, ManualRule, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
3
+ export * from './canonical-registry.js';
4
+ export * from './collectors.js';
5
+ export * from './replacement.js';
6
+ export * from './trip-row-dates.js';
7
+ export * from './types.js';
8
+ /**
9
+ * Ownership order for overlapping general manuals (ND-03).
10
+ *
11
+ * When one rule's applicable dates strictly contain the other's (e.g. an "all days" rule vs a
12
+ * "weekdays" rule sharing a timepoint), the **broader** rule owns the shared timepoint so its
13
+ * date set stays whole and the export emits one clean token instead of fragmenting across two
14
+ * (`ALL` + `ALL_DU`). We only override on true containment — for equal or partially-overlapping
15
+ * scopes the choice is genuinely ambiguous, so the primary schedule (more timepoints, then `_id`)
16
+ * wins, exactly as before.
17
+ *
18
+ * @returns Negative if `a` should sort before `b` (i.e. `a` owns the shared timepoint).
19
+ */
20
+ export declare function compareGeneralManualOwnershipPriority(a: ManualRule, b: ManualRule): number;
21
+ /**
22
+ * GTFS export attribution for one operational date.
23
+ *
24
+ * Unlike {@link computeActiveRules}, which returns the merged operating timepoint set,
25
+ * this returns per-timepoint contributions so the exporter can attach the correct
26
+ * `service_id` rows (`operating` and `base_off`).
27
+ *
28
+ * **Invariant (P5):** the set of `operating` timepoints should match
29
+ * `computeActiveRules(date, allRules, periods, holidays, options).timepoints`.
30
+ *
31
+ * @see SCENARIOS.md for scenario IDs (FR-*, ND-*, ER-*).
32
+ *
33
+ * - Normal days → {@link collectNormalDayContributionsReconciled}
34
+ * - Replacement days → {@link collectReplacementDayContributions}
35
+ */
36
+ export declare function collectGtfsIncludeContributionsForDate(date: OperationalDate, allRules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
37
+ events?: Event[];
38
+ }): GtfsIncludeContribution[];