@tmlmobilidade/dates 20260612.214.31 → 20260612.1126.8

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 +19 -30
  4. package/dist/calendar/rules/calculation/index.js +21 -35
  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 +177 -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,40 +1,29 @@
1
1
  import type { RuleApplication } from './types.js';
2
2
  import type { Event, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
3
3
  /**
4
- * Calculates which time points are active for each date in a range, based on scheduling rules.
4
+ * Operating schedule for one operational date: merged timepoints and applied rule IDs.
5
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
6
+ * This is the core scheduling algorithm for “what runs” (preview, VKM, rules grid).
7
+ * It processes three rule kinds:
8
+ * 1. Manual rules (include/exclude) base schedules for weekday/period combinations
9
+ * 2. Event replacement rules override base schedules on specific dates
10
+ * 3. Event restriction rules — remove timepoints or windows from the result
10
11
  *
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
12
+ * Algorithm flow:
13
+ * - If a replacement applies: intersection-based manual matching + replacement excludes
14
+ * - Otherwise: weekday + period context matching + manual excludes
15
+ * - Always apply event restrictions by date
19
16
  *
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
17
+ * For GTFS `service_id` token binding (`operating` / `base_off` rows), use
18
+ * `collectGtfsIncludeContributionsForDate` in `export-attribution/index.ts`.
19
+ * That layer delegates here on replacement days; the `operating` timepoint set
20
+ * should match this function’s result (see SCENARIOS P5).
24
21
  *
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
- * ```
22
+ * @param date - Operational date to evaluate
23
+ * @param rules - All scheduling rules (manual, replacement, restriction)
24
+ * @param periods - YearPeriod definitions for context resolution
25
+ * @param holidays - Holiday calendar for weekday resolution
26
+ * @returns Active timepoints and IDs of rules that contributed
38
27
  */
39
28
  export declare function computeActiveRules(date: OperationalDate, rules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
40
29
  events?: Event[];
@@ -1,44 +1,34 @@
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
  /* * */
7
8
  /**
8
- * Calculates which time points are active for each date in a range, based on scheduling rules.
9
+ * Operating schedule for one operational date: merged timepoints and applied rule IDs.
9
10
  *
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
11
+ * This is the core scheduling algorithm for “what runs” (preview, VKM, rules grid).
12
+ * It processes three rule kinds:
13
+ * 1. Manual rules (include/exclude) base schedules for weekday/period combinations
14
+ * 2. Event replacement rules override base schedules on specific dates
15
+ * 3. Event restriction rules — remove timepoints or windows from the result
14
16
  *
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
17
+ * Algorithm flow:
18
+ * - If a replacement applies: intersection-based manual matching + replacement excludes
19
+ * - Otherwise: weekday + period context matching + manual excludes
20
+ * - Always apply event restrictions by date
23
21
  *
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
22
+ * For GTFS `service_id` token binding (`operating` / `base_off` rows), use
23
+ * `collectGtfsIncludeContributionsForDate` in `export-attribution/index.ts`.
24
+ * That layer delegates here on replacement days; the `operating` timepoint set
25
+ * should match this function’s result (see SCENARIOS P5).
28
26
  *
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
- * ```
27
+ * @param date - Operational date to evaluate
28
+ * @param rules - All scheduling rules (manual, replacement, restriction)
29
+ * @param periods - YearPeriod definitions for context resolution
30
+ * @param holidays - Holiday calendar for weekday resolution
31
+ * @returns Active timepoints and IDs of rules that contributed
42
32
  */
43
33
  export function computeActiveRules(date, rules, periods, holidays, options) {
44
34
  const manualRules = rules.filter((r) => r.kind === 'manual');
@@ -61,11 +51,7 @@ export function computeActiveRules(date, rules, periods, holidays, options) {
61
51
  let timepoints;
62
52
  let appliedRuleIds;
63
53
  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;
54
+ const effectiveReplacement = resolveEffectiveReplacement(date, replacement, holidays);
69
55
  // Replacement mode: match manuals by intersection (Option A)
70
56
  const base = collectReplacementManualIncludes(effectiveReplacement, monthFilteredManualRules);
71
57
  timepoints = base.timepoints;
@@ -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[];
@@ -0,0 +1,313 @@
1
+ import { calendarKey, calendarWeekday } from '../../utils/index.js';
2
+ import { Dates } from '../../../dates.js';
3
+ import { hhmm } from '@tmlmobilidade/types';
4
+ import { getTimepointsRemovedByEventRestriction } from '../calculation/filters.js';
5
+ import { computeActiveRules } from '../calculation/index.js';
6
+ import { findReplacementForDate, getActivePeriodId } from '../utils/date.js';
7
+ import { shouldEmitReplacementOnForcedRetarget } from './canonical-registry.js';
8
+ import { collectManualIncludesByRule, collectReplacementManualIncludesByRule } from './collectors.js';
9
+ import { isForcedRetargetDay, resolveEffectiveReplacement } from './replacement.js';
10
+ export * from './canonical-registry.js';
11
+ export * from './collectors.js';
12
+ export * from './replacement.js';
13
+ export * from './trip-row-dates.js';
14
+ export * from './types.js';
15
+ /** Keeps event-linked manuals only when the event lists `date` (same filter as {@link computeActiveRules}). */
16
+ function filterManualRulesForDate(manualRules, date, events) {
17
+ return manualRules.filter((rule) => {
18
+ if (!rule.event_id)
19
+ return true;
20
+ const event = events?.find(e => e._id === rule.event_id);
21
+ if (!event?.dates?.length)
22
+ return false;
23
+ return event.dates.includes(date);
24
+ });
25
+ }
26
+ /** Drops manuals whose `months` list does not include the date’s calendar month. */
27
+ function filterManualRulesByMonth(manualRules, date) {
28
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
29
+ const month = Number(key.slice(5, 7));
30
+ return manualRules.filter(rule => !rule.months?.length || rule.months.includes(month));
31
+ }
32
+ const ALL_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
33
+ const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
34
+ function isSubsetOf(inner, outer) {
35
+ const set = new Set(outer);
36
+ return inner.every(x => set.has(x));
37
+ }
38
+ /**
39
+ * True when every date `inner` applies on is also covered by `outer`.
40
+ *
41
+ * A manual rule applies on date `D` iff `weekday(D) ∈ weekdays ∧ period(D) ∈ year_period_ids ∧
42
+ * month(D) ∈ months` (an empty list means "all"). So `inner`'s applicable dates ⊆ `outer`'s
43
+ * exactly when `outer`'s scope contains `inner`'s on every dimension. This is the sound,
44
+ * data-free realization of "every date applied by `inner` is also applied by `outer`".
45
+ */
46
+ function scopeContains(outer, inner) {
47
+ const outerWeekdays = outer.weekdays?.length ? outer.weekdays : ALL_WEEKDAYS;
48
+ const innerWeekdays = inner.weekdays?.length ? inner.weekdays : ALL_WEEKDAYS;
49
+ if (!isSubsetOf(innerWeekdays, outerWeekdays))
50
+ return false;
51
+ const outerMonths = outer.months?.length ? outer.months : ALL_MONTHS;
52
+ const innerMonths = inner.months?.length ? inner.months : ALL_MONTHS;
53
+ if (!isSubsetOf(innerMonths, outerMonths))
54
+ return false;
55
+ // Empty year_period_ids = all periods (open-ended universe). An empty outer covers every
56
+ // period; an empty inner is only contained by an empty outer.
57
+ if (!outer.year_period_ids?.length)
58
+ return true;
59
+ if (!inner.year_period_ids?.length)
60
+ return false;
61
+ return isSubsetOf(inner.year_period_ids, outer.year_period_ids);
62
+ }
63
+ /**
64
+ * Ownership order for overlapping general manuals (ND-03).
65
+ *
66
+ * When one rule's applicable dates strictly contain the other's (e.g. an "all days" rule vs a
67
+ * "weekdays" rule sharing a timepoint), the **broader** rule owns the shared timepoint so its
68
+ * date set stays whole and the export emits one clean token instead of fragmenting across two
69
+ * (`ALL` + `ALL_DU`). We only override on true containment — for equal or partially-overlapping
70
+ * scopes the choice is genuinely ambiguous, so the primary schedule (more timepoints, then `_id`)
71
+ * wins, exactly as before.
72
+ *
73
+ * @returns Negative if `a` should sort before `b` (i.e. `a` owns the shared timepoint).
74
+ */
75
+ export function compareGeneralManualOwnershipPriority(a, b) {
76
+ const aContainsB = scopeContains(a, b);
77
+ const bContainsA = scopeContains(b, a);
78
+ if (aContainsB && !bContainsA)
79
+ return -1; // a strictly broader → a owns
80
+ if (bContainsA && !aContainsB)
81
+ return 1; // b strictly broader → b owns
82
+ const aCount = a.timepoints?.length ?? 0;
83
+ const bCount = b.timepoints?.length ?? 0;
84
+ if (aCount !== bCount)
85
+ return bCount - aCount;
86
+ return (a._id ?? '').localeCompare(b._id ?? '');
87
+ }
88
+ /**
89
+ * Non-replacement days: assign each matching timepoint to one `operating` rule.
90
+ *
91
+ * Event-specific manuals claim first; general manuals follow {@link compareGeneralManualOwnershipPriority}.
92
+ * When two generals share a timepoint, only one `operating` row is emitted (ND-03).
93
+ * When an event manual displaces a general on the same timepoint, emits `base_off` (ND-02).
94
+ */
95
+ function collectNormalDayContributions(monthFilteredManualRules, ctx) {
96
+ const contributions = [];
97
+ const claimedTimepoints = new Set();
98
+ const timepointOwners = new Map();
99
+ const matchingRules = collectManualIncludesByRule(monthFilteredManualRules, ctx);
100
+ const eventSpecificRules = matchingRules.filter(({ rule }) => !!rule.event_id);
101
+ const generalRules = matchingRules
102
+ .filter(({ rule }) => !rule.event_id)
103
+ .sort((a, b) => compareGeneralManualOwnershipPriority(a.rule, b.rule));
104
+ for (const { rule, timepoints } of eventSpecificRules) {
105
+ for (const rawTimepoint of timepoints) {
106
+ const timepoint = hhmm(rawTimepoint);
107
+ if (claimedTimepoints.has(timepoint))
108
+ continue;
109
+ claimedTimepoints.add(timepoint);
110
+ timepointOwners.set(timepoint, rule);
111
+ contributions.push({ kind: 'operating', rule, timepoint });
112
+ }
113
+ }
114
+ for (const { rule, timepoints } of generalRules) {
115
+ for (const rawTimepoint of timepoints) {
116
+ const timepoint = hhmm(rawTimepoint);
117
+ if (claimedTimepoints.has(timepoint)) {
118
+ const ownerRule = timepointOwners.get(timepoint);
119
+ if (!ownerRule)
120
+ continue;
121
+ // ND-03: overlapping general manuals co-apply in the rules engine — one circulation,
122
+ // one operating row. base_off is only for event/replacement displacement (ND-02, FR-*).
123
+ if (!ownerRule.event_id && !rule.event_id)
124
+ continue;
125
+ contributions.push({ excludeRule: ownerRule, kind: 'base_off', rule, timepoint });
126
+ continue;
127
+ }
128
+ claimedTimepoints.add(timepoint);
129
+ timepointOwners.set(timepoint, rule);
130
+ contributions.push({ kind: 'operating', rule, timepoint });
131
+ }
132
+ }
133
+ return contributions;
134
+ }
135
+ /** Rule that removed `timepoint` from the calendar operating set on this date (exclude or restriction). */
136
+ function findDisplacementExcludeRule(date, timepoint, allRules, appliedRuleIds) {
137
+ const rulesById = new Map(allRules.map(rule => [rule._id, rule]));
138
+ for (const id of appliedRuleIds) {
139
+ const rule = rulesById.get(id);
140
+ if (rule?.kind === 'manual' && rule.operating_mode === 'exclude' && rule.timepoints?.includes(timepoint)) {
141
+ return rule;
142
+ }
143
+ }
144
+ for (const id of appliedRuleIds) {
145
+ const rule = rulesById.get(id);
146
+ if (rule?.kind !== 'event_restriction' || !rule.dates?.includes(date))
147
+ continue;
148
+ if (getTimepointsRemovedByEventRestriction(rule, [timepoint]).includes(timepoint)) {
149
+ return rule;
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+ /**
155
+ * Normal days: reconcile calendar manuals with {@link computeActiveRules} (P5, ER-01).
156
+ *
157
+ * Calendar-only timepoints removed by event manual excludes or event restrictions become
158
+ * `base_off` pairs so the exporter emits `BASE-OFF-EVENT` instead of plain `BASE`.
159
+ */
160
+ function collectNormalDayContributionsReconciled(date, allRules, monthFilteredManualRules, ctx, periods, holidays, events) {
161
+ const contributions = [];
162
+ const operating = computeActiveRules(date, allRules, periods, holidays, { events });
163
+ const operatingTimepoints = new Set(operating.timepoints.map(tp => hhmm(tp)));
164
+ const calendarContributions = collectNormalDayContributions(monthFilteredManualRules, ctx);
165
+ const calendarOperatingByTimepoint = new Map();
166
+ for (const contribution of calendarContributions) {
167
+ if (contribution.kind === 'base_off') {
168
+ contributions.push(contribution);
169
+ continue;
170
+ }
171
+ if (contribution.kind === 'operating') {
172
+ calendarOperatingByTimepoint.set(contribution.timepoint, contribution.rule);
173
+ }
174
+ }
175
+ const emittedOperating = new Set();
176
+ for (const [timepoint, ownerRule] of calendarOperatingByTimepoint) {
177
+ if (operatingTimepoints.has(timepoint)) {
178
+ contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
179
+ emittedOperating.add(timepoint);
180
+ continue;
181
+ }
182
+ const excludeRule = findDisplacementExcludeRule(date, timepoint, allRules, operating.appliedRuleIds);
183
+ if (!excludeRule)
184
+ continue;
185
+ contributions.push({ excludeRule, kind: 'base_off', rule: ownerRule, timepoint });
186
+ }
187
+ const rulesById = new Map(allRules.map(rule => [rule._id, rule]));
188
+ for (const timepoint of operatingTimepoints) {
189
+ if (emittedOperating.has(timepoint))
190
+ continue;
191
+ const ownerRule = operating.appliedRuleIds
192
+ .map(id => rulesById.get(id))
193
+ .find((rule) => !!rule
194
+ && rule.kind === 'manual'
195
+ && rule.operating_mode === 'include'
196
+ && (rule.timepoints ?? []).includes(timepoint));
197
+ if (ownerRule) {
198
+ contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
199
+ }
200
+ }
201
+ return contributions;
202
+ }
203
+ /**
204
+ * Replacement days: bind GTFS tokens using {@link computeActiveRules} as operating truth.
205
+ *
206
+ * **Forced retarget** — operating TPs on the replacement rule (+ `base_off` on calendar base owners);
207
+ * calendar-only TPs get `base_off` when absent from the operating set (FR-02, FR-03).
208
+ * Skips the replacement row when calendar manuals already cover the full operating set (FR-04).
209
+ *
210
+ * **Same_weekday** — `operating` rows on calendar manual owners (ND-03), one row per timepoint.
211
+ */
212
+ function collectReplacementDayContributions(date, replacement, allRules, monthFilteredManualRules, periods, holidays, events) {
213
+ const contributions = [];
214
+ const effectiveReplacement = resolveEffectiveReplacement(date, replacement, holidays);
215
+ const operating = computeActiveRules(date, allRules, periods, holidays, { events });
216
+ const operatingTimepoints = new Set(operating.timepoints.map(tp => hhmm(tp)));
217
+ const replacementAsRule = allRules.find(r => r._id === replacement._id) ?? replacement;
218
+ const forcedRetarget = isForcedRetargetDay(date, effectiveReplacement, holidays);
219
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
220
+ const calendarCtx = {
221
+ weekday: calendarWeekday(key, holidays),
222
+ yearPeriodId: getActivePeriodId(date, periods),
223
+ };
224
+ const calendarContributions = collectNormalDayContributions(monthFilteredManualRules, calendarCtx);
225
+ const calendarOperatingByTimepoint = new Map();
226
+ for (const contribution of calendarContributions) {
227
+ if (contribution.kind !== 'operating')
228
+ continue;
229
+ calendarOperatingByTimepoint.set(contribution.timepoint, contribution.rule);
230
+ }
231
+ if (forcedRetarget) {
232
+ const emitReplacement = shouldEmitReplacementOnForcedRetarget(operatingTimepoints, calendarOperatingByTimepoint);
233
+ for (const timepoint of operatingTimepoints) {
234
+ const ownerRule = calendarOperatingByTimepoint.get(timepoint);
235
+ if (emitReplacement) {
236
+ contributions.push({ kind: 'operating', rule: replacementAsRule, timepoint });
237
+ // Shared timepoint (FR-03): replacement runs on the event date; base is OFF, not plain.
238
+ if (ownerRule) {
239
+ contributions.push({
240
+ excludeRule: replacementAsRule,
241
+ kind: 'base_off',
242
+ rule: ownerRule,
243
+ timepoint,
244
+ });
245
+ }
246
+ continue;
247
+ }
248
+ if (ownerRule) {
249
+ contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
250
+ }
251
+ }
252
+ // Weekday-owned timepoints that are not in the replacement operating set (e.g. 06:40
253
+ // when Carnival runs Saturday times from 06:45) → BASE-OFF-replacement on event dates.
254
+ for (const [timepoint, ownerRule] of calendarOperatingByTimepoint) {
255
+ if (operatingTimepoints.has(timepoint))
256
+ continue;
257
+ contributions.push({
258
+ excludeRule: replacementAsRule,
259
+ kind: 'base_off',
260
+ rule: ownerRule,
261
+ timepoint,
262
+ });
263
+ }
264
+ }
265
+ else {
266
+ // same_weekday (SW-01): one operating owner per timepoint (ND-03), not every rule that
267
+ // intersects the replacement — e.g. ALL_DU + ALL_DU-SAB both match but only ALL_DU owns 21:00.
268
+ for (const timepoint of operatingTimepoints) {
269
+ let ownerRule = calendarOperatingByTimepoint.get(timepoint);
270
+ if (!ownerRule) {
271
+ const candidates = collectReplacementManualIncludesByRule(effectiveReplacement, monthFilteredManualRules)
272
+ .filter(({ timepoints }) => timepoints.some(tp => hhmm(tp) === timepoint))
273
+ .sort((a, b) => compareGeneralManualOwnershipPriority(a.rule, b.rule));
274
+ ownerRule = candidates[0]?.rule;
275
+ }
276
+ if (ownerRule) {
277
+ contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
278
+ }
279
+ }
280
+ }
281
+ return contributions;
282
+ }
283
+ /**
284
+ * GTFS export attribution for one operational date.
285
+ *
286
+ * Unlike {@link computeActiveRules}, which returns the merged operating timepoint set,
287
+ * this returns per-timepoint contributions so the exporter can attach the correct
288
+ * `service_id` rows (`operating` and `base_off`).
289
+ *
290
+ * **Invariant (P5):** the set of `operating` timepoints should match
291
+ * `computeActiveRules(date, allRules, periods, holidays, options).timepoints`.
292
+ *
293
+ * @see SCENARIOS.md for scenario IDs (FR-*, ND-*, ER-*).
294
+ *
295
+ * - Normal days → {@link collectNormalDayContributionsReconciled}
296
+ * - Replacement days → {@link collectReplacementDayContributions}
297
+ */
298
+ export function collectGtfsIncludeContributionsForDate(date, allRules, periods, holidays, options) {
299
+ const manualRules = allRules.filter((r) => r.kind === 'manual');
300
+ const replacementRules = allRules.filter((r) => r.kind === 'event_replacement');
301
+ const filteredManualRules = filterManualRulesForDate(manualRules, date, options?.events);
302
+ const monthFilteredManualRules = filterManualRulesByMonth(filteredManualRules, date);
303
+ const replacement = findReplacementForDate(date, replacementRules);
304
+ if (replacement) {
305
+ return collectReplacementDayContributions(date, replacement, allRules, monthFilteredManualRules, periods, holidays, options?.events);
306
+ }
307
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
308
+ const ctx = {
309
+ weekday: calendarWeekday(key, holidays),
310
+ yearPeriodId: getActivePeriodId(date, periods),
311
+ };
312
+ return collectNormalDayContributionsReconciled(date, allRules, monthFilteredManualRules, ctx, periods, holidays, options?.events);
313
+ }
@@ -0,0 +1,10 @@
1
+ import type { EventReplacementRule, Holiday, OperationalDate } from '@tmlmobilidade/types';
2
+ /**
3
+ * Resolves the effective replacement targets for a date, applying same_weekday semantics.
4
+ */
5
+ export declare function resolveEffectiveReplacement(date: OperationalDate, replacement: EventReplacementRule, holidays: Holiday[]): EventReplacementRule;
6
+ /**
7
+ * True when the calendar weekday is not among the replacement's target weekdays —
8
+ * the day is forcibly retargeted (e.g. Tuesday treated as Saturday for Carnival).
9
+ */
10
+ export declare function isForcedRetargetDay(date: OperationalDate, effectiveReplacement: EventReplacementRule, holidays: Holiday[]): boolean;
@@ -0,0 +1,24 @@
1
+ import { calendarKey, calendarWeekday } from '../../utils/index.js';
2
+ import { Dates } from '../../../dates.js';
3
+ /**
4
+ * Resolves the effective replacement targets for a date, applying same_weekday semantics.
5
+ */
6
+ export function resolveEffectiveReplacement(date, replacement, holidays) {
7
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
8
+ if (replacement.same_weekday) {
9
+ return { ...replacement, weekdays: [calendarWeekday(key, holidays)] };
10
+ }
11
+ return replacement;
12
+ }
13
+ /**
14
+ * True when the calendar weekday is not among the replacement's target weekdays —
15
+ * the day is forcibly retargeted (e.g. Tuesday treated as Saturday for Carnival).
16
+ */
17
+ export function isForcedRetargetDay(date, effectiveReplacement, holidays) {
18
+ const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
19
+ const calendarDay = calendarWeekday(key, holidays);
20
+ const targetWeekdays = effectiveReplacement.weekdays ?? [];
21
+ if (!targetWeekdays.length)
22
+ return false;
23
+ return !targetWeekdays.includes(calendarDay);
24
+ }
@@ -0,0 +1,38 @@
1
+ import type { Event, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
2
+ export type CanonicalDateCache = Map<string, Set<OperationalDate>>;
3
+ export interface ExcludeScheduleSlice {
4
+ dates: Set<OperationalDate>;
5
+ rule: ScheduleRule;
6
+ }
7
+ export interface SplitByExcludeOverlapResult {
8
+ offOperationalDates: Set<OperationalDate>;
9
+ overlappingExcludeSchedules: ExcludeScheduleSlice[];
10
+ plainOperationalDates: Set<OperationalDate>;
11
+ }
12
+ /**
13
+ * Splits accumulated include dates into plain vs OFF buckets by **per-date** exclude overlap.
14
+ *
15
+ * Without this split, a single displacement day (e.g. Santo António, Véspera de Natal) marks the
16
+ * entire rule×timepoint schedule as OFF and forces canonical expansion for all dates — including
17
+ * same-weekday replacement days that should stay plain (SW-01, ND-04).
18
+ */
19
+ export declare function splitOperationalDatesByExcludeOverlap(includeDates: Set<OperationalDate>, includeRuleId: string | undefined, excludeSchedules: ExcludeScheduleSlice[]): SplitByExcludeOverlapResult;
20
+ export declare function resolveCanonicalDatesForRule(rule: ScheduleRule, accumulated: Set<OperationalDate>, cache: CanonicalDateCache, exportDates: readonly OperationalDate[], registryOptions: {
21
+ events: Event[];
22
+ holidays: Holiday[];
23
+ periods: YearPeriod[];
24
+ }): Set<OperationalDate>;
25
+ /**
26
+ * Resolves calendar membership for an OFF include row (canonical base minus canonical excludes).
27
+ *
28
+ * `allIncludeAccumulated` — union of all accumulated dates for this include rule at this
29
+ * timepoint (plain + off). Canonical expansion of the base rule can include dates the rule
30
+ * never actually accumulated on — e.g. Easter dates leak into a JUN_U2_SEM OFF row because
31
+ * Easter is within the same year-period, but the event_replacement redirected the rule away
32
+ * from that timepoint on Easter. Capping to accumulated dates prevents those phantoms.
33
+ */
34
+ export declare function computeOffRowDates(includeRule: ScheduleRule, overlappingExcludeSchedules: ExcludeScheduleSlice[], canonicalCache: CanonicalDateCache, exportDates: readonly OperationalDate[], registryOptions: {
35
+ events: Event[];
36
+ holidays: Holiday[];
37
+ periods: YearPeriod[];
38
+ }, offOperationalDates: Set<OperationalDate>, allIncludeAccumulated?: Set<OperationalDate>): Set<OperationalDate>;
@@ -0,0 +1,71 @@
1
+ import { buildCanonicalRuleDates } from './canonical-registry.js';
2
+ /**
3
+ * Splits accumulated include dates into plain vs OFF buckets by **per-date** exclude overlap.
4
+ *
5
+ * Without this split, a single displacement day (e.g. Santo António, Véspera de Natal) marks the
6
+ * entire rule×timepoint schedule as OFF and forces canonical expansion for all dates — including
7
+ * same-weekday replacement days that should stay plain (SW-01, ND-04).
8
+ */
9
+ export function splitOperationalDatesByExcludeOverlap(includeDates, includeRuleId, excludeSchedules) {
10
+ const plainOperationalDates = new Set();
11
+ const offOperationalDates = new Set();
12
+ const overlappingById = new Map();
13
+ for (const date of includeDates) {
14
+ const overlapping = excludeSchedules.filter(ex => ex.rule._id !== includeRuleId && ex.dates.has(date));
15
+ if (overlapping.length === 0) {
16
+ plainOperationalDates.add(date);
17
+ continue;
18
+ }
19
+ offOperationalDates.add(date);
20
+ for (const ex of overlapping) {
21
+ const id = ex.rule._id ?? '';
22
+ if (!overlappingById.has(id))
23
+ overlappingById.set(id, ex);
24
+ }
25
+ }
26
+ return {
27
+ offOperationalDates,
28
+ overlappingExcludeSchedules: [...overlappingById.values()],
29
+ plainOperationalDates,
30
+ };
31
+ }
32
+ export function resolveCanonicalDatesForRule(rule, accumulated, cache, exportDates, registryOptions) {
33
+ if (rule.kind !== 'manual' && rule.kind !== 'event_replacement') {
34
+ return accumulated;
35
+ }
36
+ if (!cache.has(rule._id)) {
37
+ cache.set(rule._id, buildCanonicalRuleDates(rule, exportDates, registryOptions));
38
+ }
39
+ const canonical = cache.get(rule._id);
40
+ return canonical?.size ? canonical : accumulated;
41
+ }
42
+ /**
43
+ * Resolves calendar membership for an OFF include row (canonical base minus canonical excludes).
44
+ *
45
+ * `allIncludeAccumulated` — union of all accumulated dates for this include rule at this
46
+ * timepoint (plain + off). Canonical expansion of the base rule can include dates the rule
47
+ * never actually accumulated on — e.g. Easter dates leak into a JUN_U2_SEM OFF row because
48
+ * Easter is within the same year-period, but the event_replacement redirected the rule away
49
+ * from that timepoint on Easter. Capping to accumulated dates prevents those phantoms.
50
+ */
51
+ export function computeOffRowDates(includeRule, overlappingExcludeSchedules, canonicalCache, exportDates, registryOptions, offOperationalDates, allIncludeAccumulated) {
52
+ // Defensive copy: resolveCanonicalDatesForRule returns the cached Set directly, and we
53
+ // mutate below. Without a copy, repeated calls for the same rule (different timepoints)
54
+ // would start from a previously-reduced canonical.
55
+ const resultingDates = new Set(resolveCanonicalDatesForRule(includeRule, offOperationalDates, canonicalCache, exportDates, registryOptions));
56
+ for (const excludeSchedule of overlappingExcludeSchedules) {
57
+ const excludeDates = resolveCanonicalDatesForRule(excludeSchedule.rule, excludeSchedule.dates, canonicalCache, exportDates, registryOptions);
58
+ for (const excludedDate of excludeDates) {
59
+ resultingDates.delete(excludedDate);
60
+ }
61
+ }
62
+ // Cap to dates the include rule actually accumulated. Dates in canonical but not accumulated
63
+ // were redirected away (e.g. by event_replacement) and must not appear in the OFF row.
64
+ if (allIncludeAccumulated) {
65
+ for (const date of resultingDates) {
66
+ if (!allIncludeAccumulated.has(date))
67
+ resultingDates.delete(date);
68
+ }
69
+ }
70
+ return resultingDates;
71
+ }
@@ -0,0 +1,17 @@
1
+ import type { HHMM, ScheduleRule } from '@tmlmobilidade/types';
2
+ /** How a rule binds to a timepoint in GTFS export. */
3
+ export type GtfsIncludeContributionKind = 'base_off' | 'operating';
4
+ /**
5
+ * One rule × timepoint binding for GTFS `trips.txt` / `calendar_dates.txt`.
6
+ *
7
+ * - `operating` — active include row for `rule` on `timepoint`.
8
+ * - `base_off` — `rule` stays in the calendar definition but is off on this date;
9
+ * `excludeRule` is what displaces it (event manual or replacement).
10
+ */
11
+ export interface GtfsIncludeContribution {
12
+ /** Present when `kind` is `base_off`: the rule that displaces `rule`. */
13
+ excludeRule?: ScheduleRule;
14
+ kind: GtfsIncludeContributionKind;
15
+ rule: ScheduleRule;
16
+ timepoint: HHMM;
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,7 @@
1
+ export * from './calculation/filters.js';
1
2
  export * from './calculation/index.js';
2
3
  export * from './calculation/types.js';
4
+ export * from './export-attribution/index.js';
3
5
  export * from './formatting/parameter-summary.js';
4
6
  export * from './formatting/rule-summary.js';
5
7
  export * from './formatting/sort.js';
@@ -1,5 +1,7 @@
1
+ export * from './calculation/filters.js';
1
2
  export * from './calculation/index.js';
2
3
  export * from './calculation/types.js';
4
+ export * from './export-attribution/index.js';
3
5
  export * from './formatting/parameter-summary.js';
4
6
  export * from './formatting/rule-summary.js';
5
7
  export * from './formatting/sort.js';
@@ -31,6 +31,7 @@ function buildEventDerivedRestriction(args) {
31
31
  lines_to_include: rule.lines_to_include,
32
32
  name: rule.name,
33
33
  start_time: rule.start_time,
34
+ timepoints: rule.timepoints,
34
35
  };
35
36
  }
36
37
  function buildEventDerivedReplacement(args) {
@@ -5,7 +5,7 @@ import type { Event, Holiday, ScheduleRule, YearPeriod } from '@tmlmobilidade/ty
5
5
  *
6
6
  * - Manual include rules: return their timepoints directly
7
7
  * - Manual exclude rules: return their timepoints directly
8
- * - Event replacement rules: return timepoints that apply on the target weekdays
8
+ * - Event replacement rules: return operating timepoints on forced-retarget dates only
9
9
  * - Event restriction rules: return timepoints that are removed on the affected weekdays
10
10
  */
11
11
  export declare function computeRuleTimePoints(rule: ScheduleRule, allRules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
@@ -1,6 +1,7 @@
1
1
  import { calendarKey, calendarWeekday } from '../../utils/index.js';
2
2
  import { Dates } from '../../../dates.js';
3
3
  import { computeActiveRules } from '../calculation/index.js';
4
+ import { isForcedRetargetDay, resolveEffectiveReplacement } from '../export-attribution/replacement.js';
4
5
  import { buildOperationalDateRange } from '../utils/date.js';
5
6
  // USED IN RulesScheduleView
6
7
  /**
@@ -9,7 +10,7 @@ import { buildOperationalDateRange } from '../utils/date.js';
9
10
  *
10
11
  * - Manual include rules: return their timepoints directly
11
12
  * - Manual exclude rules: return their timepoints directly
12
- * - Event replacement rules: return timepoints that apply on the target weekdays
13
+ * - Event replacement rules: return operating timepoints on forced-retarget dates only
13
14
  * - Event restriction rules: return timepoints that are removed on the affected weekdays
14
15
  */
15
16
  export function computeRuleTimePoints(rule, allRules, periods, holidays, options) {
@@ -32,21 +33,19 @@ export function computeRuleTimePoints(rule, allRules, periods, holidays, options
32
33
  const key = calendarKey(Dates.fromOperationalDate(opDate, 'Europe/Lisbon'));
33
34
  affectedWeekdays.add(calendarWeekday(key, holidays));
34
35
  }
35
- // For replacement rules: return timepoints from the TARGET weekdays
36
+ // For replacement rules: operating schedule on dates forcibly retargeted (e.g. Tue → Sat)
36
37
  if (rule.kind === 'event_replacement') {
37
- const addedTimepoints = new Set();
38
+ const labelTimepoints = new Set();
38
39
  for (const date of rule.dates ?? []) {
39
- const withRule = computeActiveRules(date, allRules, periods, holidays, options);
40
- const withoutRule = computeActiveRules(date, allRules.filter(r => r._id !== rule._id), periods, holidays, options);
41
- const withSet = new Set(withRule.timepoints);
42
- const withoutSet = new Set(withoutRule.timepoints);
43
- for (const tp of withSet) {
44
- if (!withoutSet.has(tp)) {
45
- addedTimepoints.add(tp);
46
- }
40
+ const effectiveReplacement = resolveEffectiveReplacement(date, rule, holidays);
41
+ if (!isForcedRetargetDay(date, effectiveReplacement, holidays))
42
+ continue;
43
+ const operating = computeActiveRules(date, allRules, periods, holidays, options);
44
+ for (const tp of operating.timepoints) {
45
+ labelTimepoints.add(tp);
47
46
  }
48
47
  }
49
- return addedTimepoints;
48
+ return labelTimepoints;
50
49
  }
51
50
  // For restriction rules: compute what was removed on affected weekdays
52
51
  // Generate a sample date range to check (1 year from now)
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export * from './lib/date-format.js';
5
5
  export * from './lib/time-slot.js';
6
6
  export * from './lib/timezone-identified.js';
7
7
  export * from './utils/index.js';
8
+ export * from './vkm.js';
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export * from './lib/date-format.js';
5
5
  export * from './lib/time-slot.js';
6
6
  export * from './lib/timezone-identified.js';
7
7
  export * from './utils/index.js';
8
+ export * from './vkm.js';
package/dist/vkm.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Agency, CalculateVkmDto, Event, Holiday, Pattern, VkmCalculationResult, YearPeriod } from '@tmlmobilidade/types';
2
+ export interface CalculateAgencyVkmArgs {
3
+ agency: Agency;
4
+ events: Event[];
5
+ holidays: Holiday[];
6
+ patterns: Pattern[];
7
+ periods: YearPeriod[];
8
+ request: CalculateVkmDto;
9
+ }
10
+ export declare function getPatternExtensionMeters(pattern: Pattern, source: CalculateVkmDto['extension_source']): number;
11
+ export declare function calculateAgencyVkm({ agency, events, holidays, patterns, periods, request }: CalculateAgencyVkmArgs): VkmCalculationResult;
package/dist/vkm.js ADDED
@@ -0,0 +1,177 @@
1
+ import { buildOperationalDateRange } from './calendar/rules/utils/date.js';
2
+ import { calendarWeekday } from './calendar/utils/index.js';
3
+ import { Dates } from './dates.js';
4
+ import { computeActiveRules } from './calendar/rules/calculation/index.js';
5
+ import { resolvePatternRules } from './calendar/rules/merging/index.js';
6
+ const UNASSIGNED_PERIOD_KEY = '__unassigned__';
7
+ const UNASSIGNED_PERIOD_NAME = 'Sem período definido';
8
+ function resolveDayTypeBucket(date, holidays) {
9
+ const weekday = calendarWeekday(Dates.fromOperationalDate(date, 'Europe/Lisbon'), holidays);
10
+ if (weekday === 6)
11
+ return '2';
12
+ if (weekday === 7)
13
+ return '3';
14
+ return '1';
15
+ }
16
+ function sortPeriodsByStartDate(periods) {
17
+ return [...periods].sort((left, right) => {
18
+ const leftStart = left.dates?.slice().sort()[0] ?? '99999999';
19
+ const rightStart = right.dates?.slice().sort()[0] ?? '99999999';
20
+ return leftStart.localeCompare(rightStart);
21
+ });
22
+ }
23
+ function createPeriodAccumulator(period, fallbackName = UNASSIGNED_PERIOD_NAME) {
24
+ return {
25
+ code: period?.code ?? null,
26
+ day_type_one: 0,
27
+ day_type_three: 0,
28
+ day_type_two: 0,
29
+ id: period?._id ?? null,
30
+ name: period?.name ?? fallbackName,
31
+ total: 0,
32
+ };
33
+ }
34
+ function createAccumulator(periods) {
35
+ const periodAccumulators = new Map();
36
+ for (const period of periods) {
37
+ periodAccumulators.set(period._id, createPeriodAccumulator(period));
38
+ }
39
+ return { day_type_one: 0, day_type_three: 0, day_type_two: 0, periods: periodAccumulators, total_from_distance: 0 };
40
+ }
41
+ function buildPeriodMetadata(operationalDates, periods) {
42
+ const operationalDateSet = new Set(operationalDates);
43
+ const periodIdByDate = new Map();
44
+ const relevantPeriods = [];
45
+ for (const period of sortPeriodsByStartDate(periods)) {
46
+ const relevantDates = (period.dates ?? []).filter(date => operationalDateSet.has(date));
47
+ if (!relevantDates.length)
48
+ continue;
49
+ relevantPeriods.push(period);
50
+ for (const date of relevantDates) {
51
+ if (!periodIdByDate.has(date))
52
+ periodIdByDate.set(date, period._id);
53
+ }
54
+ }
55
+ return { periodIdByDate, relevantPeriods };
56
+ }
57
+ function addDistanceToDayType(acc, distanceMeters, dayType) {
58
+ acc.total += distanceMeters;
59
+ if (dayType === '1')
60
+ acc.day_type_one += distanceMeters;
61
+ if (dayType === '2')
62
+ acc.day_type_two += distanceMeters;
63
+ if (dayType === '3')
64
+ acc.day_type_three += distanceMeters;
65
+ }
66
+ function addToAccumulator(accumulator, distanceMeters, dayType, periodId) {
67
+ accumulator.total_from_distance += distanceMeters;
68
+ if (dayType === '1')
69
+ accumulator.day_type_one += distanceMeters;
70
+ if (dayType === '2')
71
+ accumulator.day_type_two += distanceMeters;
72
+ if (dayType === '3')
73
+ accumulator.day_type_three += distanceMeters;
74
+ const key = periodId ?? UNASSIGNED_PERIOD_KEY;
75
+ const existing = accumulator.periods.get(key);
76
+ if (existing) {
77
+ addDistanceToDayType(existing, distanceMeters, dayType);
78
+ return;
79
+ }
80
+ const unassigned = createPeriodAccumulator(null);
81
+ addDistanceToDayType(unassigned, distanceMeters, dayType);
82
+ accumulator.periods.set(UNASSIGNED_PERIOD_KEY, unassigned);
83
+ }
84
+ function metersToKilometers(value) {
85
+ return value / 1000;
86
+ }
87
+ function buildPeriodResults(periods) {
88
+ return [...periods.entries()]
89
+ .filter(([key, period]) => key !== UNASSIGNED_PERIOD_KEY || period.total > 0)
90
+ .map(([, period]) => ({
91
+ code: period.code,
92
+ day_type_one: metersToKilometers(period.day_type_one),
93
+ day_type_three: metersToKilometers(period.day_type_three),
94
+ day_type_two: metersToKilometers(period.day_type_two),
95
+ id: period.id,
96
+ name: period.name,
97
+ total: metersToKilometers(period.total),
98
+ }));
99
+ }
100
+ function buildDayMetadata(operationalDates, holidays, periods) {
101
+ const { periodIdByDate, relevantPeriods } = buildPeriodMetadata(operationalDates, periods);
102
+ const metadataByDate = new Map();
103
+ for (const date of operationalDates) {
104
+ metadataByDate.set(date, {
105
+ dayType: resolveDayTypeBucket(date, holidays),
106
+ periodId: periodIdByDate.get(date) ?? null,
107
+ });
108
+ }
109
+ return { metadataByDate, relevantPeriods };
110
+ }
111
+ function resolveCalculationEndDate(request) {
112
+ const startDate = Dates.fromOperationalDate(request.start_date, 'Europe/Lisbon');
113
+ const endDate = request.calculation_method === 'rolling_year'
114
+ ? startDate.plus({ years: 1 })
115
+ : Dates.fromOperationalDate(request.end_date ?? request.start_date, 'Europe/Lisbon');
116
+ return { endDate, startDate };
117
+ }
118
+ function buildCalculationContext(request, holidays, periods) {
119
+ const { endDate, startDate } = resolveCalculationEndDate(request);
120
+ const operationalDates = buildOperationalDateRange(startDate.js_date, endDate.js_date);
121
+ const { metadataByDate, relevantPeriods } = buildDayMetadata(operationalDates, holidays, periods);
122
+ return { endDate, metadataByDate, operationalDates, relevantPeriods };
123
+ }
124
+ function buildCalculationInputs(agency, request, endDate) {
125
+ const totalVkmPerYear = agency.financials.vkm_per_month.reduce((sum, value) => sum + Number(value ?? 0), 0);
126
+ const pricePerKm = Number(agency.financials.price_per_km ?? 0);
127
+ return {
128
+ agency_id: agency._id,
129
+ agency_name: agency.name,
130
+ calculation_method: request.calculation_method,
131
+ end_date: endDate.operational_date,
132
+ price_per_km: pricePerKm,
133
+ start_date: request.start_date,
134
+ total_vkm_per_year: totalVkmPerYear,
135
+ };
136
+ }
137
+ export function getPatternExtensionMeters(pattern, source) {
138
+ if (source === 'stop_times') {
139
+ return (pattern.path ?? []).reduce((total, item) => total + Number(item.distance_delta ?? 0), 0);
140
+ }
141
+ return Number(pattern.shape?.extension ?? 0);
142
+ }
143
+ export function calculateAgencyVkm({ agency, events, holidays, patterns, periods, request }) {
144
+ const { endDate, metadataByDate, operationalDates, relevantPeriods } = buildCalculationContext(request, holidays, periods);
145
+ const accumulator = createAccumulator(relevantPeriods);
146
+ for (const pattern of patterns) {
147
+ const extensionMeters = getPatternExtensionMeters(pattern, request.extension_source);
148
+ if (!extensionMeters)
149
+ continue;
150
+ const mergedRules = resolvePatternRules(pattern, events);
151
+ if (!mergedRules.length)
152
+ continue;
153
+ for (const date of operationalDates) {
154
+ const activeRules = computeActiveRules(date, mergedRules, periods, holidays, { events });
155
+ const activeTripCount = activeRules.timepoints.length;
156
+ if (!activeTripCount)
157
+ continue;
158
+ const dayMetadata = metadataByDate.get(date);
159
+ if (!dayMetadata)
160
+ continue;
161
+ addToAccumulator(accumulator, extensionMeters * activeTripCount, dayMetadata.dayType, dayMetadata.periodId);
162
+ }
163
+ }
164
+ const totalDistanceKm = metersToKilometers(accumulator.total_from_distance);
165
+ const inputs = buildCalculationInputs(agency, request, endDate);
166
+ return {
167
+ day_type_one: metersToKilometers(accumulator.day_type_one),
168
+ day_type_three: metersToKilometers(accumulator.day_type_three),
169
+ day_type_two: metersToKilometers(accumulator.day_type_two),
170
+ inputs,
171
+ periods: buildPeriodResults(accumulator.periods),
172
+ total_from_distance: totalDistanceKm,
173
+ total_from_shape: 0,
174
+ total_in_euros: totalDistanceKm * inputs.price_per_km,
175
+ total_relative_to_contract: inputs.total_vkm_per_year ? totalDistanceKm / inputs.total_vkm_per_year : 0,
176
+ };
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmlmobilidade/dates",
3
- "version": "20260612.214.31",
3
+ "version": "20260612.1126.8",
4
4
  "author": {
5
5
  "email": "iso@tmlmobilidade.pt",
6
6
  "name": "TML-ISO"
@@ -30,10 +30,11 @@
30
30
  "main": "./dist/index.js",
31
31
  "types": "./dist/index.d.ts",
32
32
  "scripts": {
33
- "build": "tsc && resolve-tspaths",
34
- "lint": "eslint ./src/ && tsc --noEmit",
33
+ "build": "tsc -p tsconfig.build.json && resolve-tspaths -p tsconfig.build.json",
34
+ "lint": "eslint ./src/ ./tests/ && tsc --noEmit",
35
35
  "lint:fix": "eslint ./src/ --fix",
36
- "watch": "tsc-watch --onSuccess 'resolve-tspaths'"
36
+ "test": "tsx --test tests/*.test.ts",
37
+ "watch": "tsc-watch -p tsconfig.build.json --onSuccess 'resolve-tspaths -p tsconfig.build.json'"
37
38
  },
38
39
  "dependencies": {
39
40
  "@tmlmobilidade/types": "*",
@@ -45,6 +46,7 @@
45
46
  "@types/node": "25.9.3",
46
47
  "resolve-tspaths": "0.8.23",
47
48
  "tsc-watch": "7.2.0",
49
+ "tsx": "4.22.3",
48
50
  "typescript": "6.0.3"
49
51
  }
50
52
  }