@tmlmobilidade/dates 20260324.1728.13 → 20260324.1820.40

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.
@@ -58,12 +58,17 @@ export function computeActiveRules(date, rules, periods, options) {
58
58
  let timepoints;
59
59
  let appliedRuleIds;
60
60
  if (replacement) {
61
+ // When same_weekday is true, each event date functions as its own actual weekday
62
+ // within the replacement's target periods, rather than all dates acting as one fixed weekday.
63
+ const effectiveReplacement = replacement.same_weekday
64
+ ? { ...replacement, weekdays: [calendarWeekday(key)] }
65
+ : replacement;
61
66
  // Replacement mode: match manuals by intersection (Option A)
62
- const base = collectReplacementManualIncludes(replacement, filteredManualRules);
67
+ const base = collectReplacementManualIncludes(effectiveReplacement, filteredManualRules);
63
68
  timepoints = base.timepoints;
64
69
  appliedRuleIds = base.appliedRuleIds;
65
70
  // Apply manual excludes *also* by intersection with replacement targets
66
- applyReplacementManualExcludes(timepoints, appliedRuleIds, replacement, filteredManualRules);
71
+ applyReplacementManualExcludes(timepoints, appliedRuleIds, effectiveReplacement, filteredManualRules);
67
72
  }
68
73
  else {
69
74
  // Normal mode: day resolves to a single weekday + single yearPeriodId
@@ -16,6 +16,16 @@
16
16
  * ```
17
17
  */
18
18
  export function manualRuleMatchesContext(rule, ctx) {
19
+ if (rule.event_id) {
20
+ // Event-exception rules: date matching already done by filteredManualRules in computeActiveRules.
21
+ // year_period_ids is cleared for these rules, so skip that check.
22
+ // If weekdays is non-empty, the user narrowed the event to specific days — check it.
23
+ // If weekdays is empty, all days of the event are included.
24
+ if (rule.weekdays.length > 0) {
25
+ return rule.weekdays.includes(ctx.weekday);
26
+ }
27
+ return true;
28
+ }
19
29
  // Strict by design: rule must include the weekday and the yearPeriodId.
20
30
  return rule.weekdays.includes(ctx.weekday) && rule.year_period_ids.includes(ctx.yearPeriodId);
21
31
  }
@@ -62,7 +62,11 @@ function buildRuleSummaryShort(rule, options) {
62
62
  return rule.event?.title ?? '';
63
63
  }
64
64
  if (rule.kind === 'manual' && rule.event_id) {
65
- return getEventForManualRule(rule, options?.events)?.title ?? '';
65
+ const title = getEventForManualRule(rule, options?.events)?.title ?? '';
66
+ if (!rule.weekdays?.length)
67
+ return title;
68
+ const weekdayPart = buildWeekdaysPart(rule, { mode: 'short' });
69
+ return [title, weekdayPart].filter(Boolean).join(' · ');
66
70
  }
67
71
  // manual
68
72
  const parts = [];
@@ -85,7 +89,11 @@ function buildRuleSummaryLong(rule, options) {
85
89
  return rule.event?.title ?? '';
86
90
  }
87
91
  if (rule.kind === 'manual' && rule.event_id) {
88
- return getEventForManualRule(rule, options?.events)?.title ?? '';
92
+ const title = getEventForManualRule(rule, options?.events)?.title ?? '';
93
+ if (!rule.weekdays?.length)
94
+ return title;
95
+ const weekdayPart = buildWeekdaysPart(rule, { mode: 'long' });
96
+ return [title, weekdayPart].filter(Boolean).join(', ');
89
97
  }
90
98
  // manual
91
99
  const parts = [];
@@ -108,6 +116,13 @@ function formatDateWithWeekday(date) {
108
116
  const weekdayShort = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'][weekday];
109
117
  return `${formattedDate} (${weekdayShort})`;
110
118
  }
119
+ function truncateDates(dates, max = 5) {
120
+ if (dates.length <= max)
121
+ return dates.join(', ');
122
+ const visible = dates.slice(0, max);
123
+ const remaining = dates.length - max;
124
+ return `${visible.join(', ')} e mais ${remaining}`;
125
+ }
111
126
  /**
112
127
  * Builds detailed tooltip text for event rules.
113
128
  *
@@ -117,7 +132,8 @@ function formatDateWithWeekday(date) {
117
132
  */
118
133
  function buildRuleSummaryTooltip(rule, options) {
119
134
  if (isEventRestriction(rule)) {
120
- const dates = (rule.dates ?? []).map(formatDateWithWeekday).join(', ');
135
+ const formattedDates = (rule.dates ?? []).map(formatDateWithWeekday);
136
+ const dates = truncateDates(formattedDates);
121
137
  const datesText = (rule.dates?.length ?? 0) > 1 ? `nos dias ${dates}` : `no dia ${dates}`;
122
138
  const timeWindow = (rule?.start_time && rule?.end_time)
123
139
  ? `entre as ${rule.start_time}h e ${rule.end_time}h`
@@ -125,24 +141,36 @@ function buildRuleSummaryTooltip(rule, options) {
125
141
  return `Oferta excluída ${datesText}, ${timeWindow}`.trim();
126
142
  }
127
143
  if (isEventReplacement(rule)) {
128
- const dates = (rule.dates ?? []).map(formatDateWithWeekday).join(', ');
144
+ const formattedDates = [...(rule.dates ?? [])].sort().map(formatDateWithWeekday);
145
+ const dates = truncateDates(formattedDates);
129
146
  const datesText = (rule.dates?.length ?? 0) > 1 ? `nos dias ${dates}` : `no dia ${dates}`;
147
+ const periods = rule.year_period_ids
148
+ ?.map(id => options?.periods?.find(p => p._id === id)?.name || id)
149
+ .join(', ') || '';
150
+ if (rule.same_weekday) {
151
+ const parts = ['mesmo dia da semana', periods].filter(Boolean);
152
+ return `Funcionará como ${parts.join(' · ')}, ${datesText}`;
153
+ }
130
154
  const weekdays = rule.weekdays
131
155
  ?.map(wd => WEEKDAY_OPTIONS.find(opt => opt.value === wd)?.label)
132
156
  .filter(Boolean)
133
157
  .join(', ') ?? '';
134
- const periods = rule.year_period_ids
135
- ?.map(id => options?.periods?.find(p => p._id === id)?.name || id)
136
- .join(', ') || '';
137
158
  const parts = [weekdays, periods].filter(Boolean);
138
159
  return `Funcionará como ${parts.join(' · ')}, ${datesText}`;
139
160
  }
140
161
  if (rule.kind === 'manual' && rule.event_id) {
141
162
  const event = getEventForManualRule(rule, options?.events);
142
- const dates = (event?.dates ?? []).map(formatDateWithWeekday).join(', ');
163
+ const filteredDates = (event?.dates ?? []).filter((date) => {
164
+ if (!rule.weekdays?.length)
165
+ return true;
166
+ const jsDay = Dates.fromOperationalDate(date, 'Europe/Lisbon').js_date.getDay();
167
+ const isoWeekday = (jsDay === 0 ? 7 : jsDay);
168
+ return rule.weekdays.includes(isoWeekday);
169
+ });
170
+ const dates = truncateDates(filteredDates.map(formatDateWithWeekday));
143
171
  if (!dates)
144
172
  return '';
145
- const datesText = (event?.dates?.length ?? 0) > 1 ? `nos dias ${dates}` : `no dia ${dates}`;
173
+ const datesText = filteredDates.length > 1 ? `nos dias ${dates}` : `no dia ${dates}`;
146
174
  return `Aplicável ${datesText}`;
147
175
  }
148
176
  return '';
@@ -53,6 +53,12 @@ export function getManualRuleAffectedDates(rule, ctx) {
53
53
  for (const opDate of event.dates) {
54
54
  const key = calendarKey(Dates.fromOperationalDate(opDate, 'Europe/Lisbon'));
55
55
  if (key >= from && key <= to) {
56
+ // If weekdays are specified, narrow event dates to matching weekdays only
57
+ if (rule.weekdays?.length) {
58
+ const weekday = calendarWeekday(key);
59
+ if (!rule.weekdays.includes(weekday))
60
+ continue;
61
+ }
56
62
  affected.push(key);
57
63
  }
58
64
  }
@@ -34,7 +34,10 @@ export function computeRuleTimePoints(rule, allRules, periods, options) {
34
34
  }
35
35
  // For replacement rules: return timepoints from the TARGET weekdays
36
36
  if (rule.kind === 'event_replacement') {
37
- const targetWeekdays = new Set(rule.weekdays || []);
37
+ // When same_weekday is true, each event date maps to its own actual weekday
38
+ const targetWeekdays = rule.same_weekday
39
+ ? affectedWeekdays
40
+ : new Set(rule.weekdays || []);
38
41
  const targetPeriods = new Set(rule.year_period_ids || []);
39
42
  // Find manual include rules that match the target pattern
40
43
  const timepoints = new Set();
@@ -99,7 +99,7 @@ export function isKeyInRange(key, start, end) {
99
99
  export function generateMonthGrid(year, month, fixedWeeks = false, tz = DEFAULT_CAL_TZ) {
100
100
  // Create the first day of the target month at noon in tz to avoid DST edge cases
101
101
  const firstDayOfMonth = Dates.fromFormat(`${year}-${String(month).padStart(2, '0')}-01 12:00`, 'yyyy-MM-dd HH:mm', tz);
102
- const lastDayOfMonth = firstDayOfMonth.endOf('month');
102
+ const lastDayOfMonth = firstDayOfMonth.endOf('month').set({ hour: 12, millisecond: 0, minute: 0, second: 0 });
103
103
  // Today (civil)
104
104
  const todayKey = calendarKey(Dates.now(tz), tz);
105
105
  // ISO weekday for first day (1=Mon..7=Sun)
@@ -136,9 +136,12 @@ export function generateMonthGrid(year, month, fixedWeeks = false, tz = DEFAULT_
136
136
  pushDay(firstDayOfMonth.plus({ days: d - 1 }), true);
137
137
  }
138
138
  // Next month overflow
139
+ // Use firstDayOfMonth (noon, tz-stable) as anchor instead of lastDayOfMonth (23:59 UTC),
140
+ // which crosses midnight into the next civil day when converted to a UTC+ timezone (e.g.
141
+ // Lisbon entering DST on March 29: 23:59 UTC = 00:59+01:00 = next day).
139
142
  const daysFromNextMonth = fixedWeeks ? 42 - days.length : naturalDaysFromNextMonth;
140
143
  for (let i = 1; i <= daysFromNextMonth; i++) {
141
- pushDay(lastDayOfMonth.plus({ days: i }), false);
144
+ pushDay(firstDayOfMonth.plus({ days: daysInMonth + i - 1 }), false);
142
145
  }
143
146
  // Weeks (rows)
144
147
  const weeks = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmlmobilidade/dates",
3
- "version": "20260324.1728.13",
3
+ "version": "20260324.1820.40",
4
4
  "author": {
5
5
  "email": "iso@tmlmobilidade.pt",
6
6
  "name": "TML-ISO"