From 4e90bd83be6ff612984eab56743a6a64bfcd5219 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Mon, 7 Jul 2025 11:49:14 +0500 Subject: [PATCH] Enhanced the recurring event functionality (#9475) Signed-off-by: Denis Bykhov --- plugins/calendar-assets/lang/cs.json | 13 +- plugins/calendar-assets/lang/de.json | 13 +- plugins/calendar-assets/lang/en.json | 13 +- plugins/calendar-assets/lang/es.json | 13 +- plugins/calendar-assets/lang/fr.json | 13 +- plugins/calendar-assets/lang/it.json | 13 +- plugins/calendar-assets/lang/ja.json | 13 +- plugins/calendar-assets/lang/pt.json | 13 +- plugins/calendar-assets/lang/ru.json | 13 +- plugins/calendar-assets/lang/zh.json | 13 +- .../src/components/ByDateSelector.svelte | 97 ++++++++++ .../src/components/EditEvent.svelte | 2 +- .../src/components/MonthSelector.svelte | 88 ++++++++++ .../src/components/ReccurancePopup.svelte | 67 ++++++- .../src/components/SetPosSelector.svelte | 119 +++++++++++++ plugins/calendar-resources/src/plugin.ts | 13 +- plugins/calendar-resources/src/types.ts | 5 + plugins/calendar/src/__tests__/utils.test.ts | 164 +++++++++++++++++ plugins/calendar/src/utils.ts | 165 ++++++++---------- 19 files changed, 745 insertions(+), 105 deletions(-) create mode 100644 plugins/calendar-resources/src/components/ByDateSelector.svelte create mode 100644 plugins/calendar-resources/src/components/MonthSelector.svelte create mode 100644 plugins/calendar-resources/src/components/SetPosSelector.svelte create mode 100644 plugins/calendar/src/__tests__/utils.test.ts diff --git a/plugins/calendar-assets/lang/cs.json b/plugins/calendar-assets/lang/cs.json index db09ffe034..891adaddea 100644 --- a/plugins/calendar-assets/lang/cs.json +++ b/plugins/calendar-assets/lang/cs.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Čas schůzky změněn", "MeetingCanceledNotification": "Schůzka zrušena: {title}", "PrimaryCalendar": "Hlavní kalendář", - "Hidden": "Skrytý" + "Hidden": "Skrytý", + "Each": "Každý", + "OnThe": "V", + "First": "První", + "Second": "Druhý", + "Third": "Třetí", + "Fourth": "Čtvrtý", + "Fifth": "Pátý", + "NextToLast": "Předposlední", + "Last": "Poslední", + "Weekday": "Všední den", + "WeekendDay": "Víkendový den" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/de.json b/plugins/calendar-assets/lang/de.json index 31799cf0ef..2d9894b341 100644 --- a/plugins/calendar-assets/lang/de.json +++ b/plugins/calendar-assets/lang/de.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Besprechungszeit geändert", "MeetingCanceledNotification": "Besprechung abgesagt: {title}", "PrimaryCalendar": "Primärer Kalender", - "Hidden": "Versteckt" + "Hidden": "Versteckt", + "Each": "Jeder", + "OnThe": "Am", + "First": "Erster", + "Second": "Zweiter", + "Third": "Dritter", + "Fourth": "Vierter", + "Fifth": "Fünfter", + "NextToLast": "Vorletzter", + "Last": "Letzter", + "Weekday": "Wochentag", + "WeekendDay": "Wochenendtag" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/en.json b/plugins/calendar-assets/lang/en.json index 3160a207e6..a68208f6e0 100644 --- a/plugins/calendar-assets/lang/en.json +++ b/plugins/calendar-assets/lang/en.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Meeting time changed", "MeetingCanceledNotification": "Meeting canceled: {title}", "PrimaryCalendar": "Primary calendar", - "Hidden": "Hidden" + "Hidden": "Hidden", + "Each": "Each", + "OnThe": "On the", + "First": "First", + "Second": "Second", + "Third": "Third", + "Fourth": "Fourth", + "Fifth": "Fifth", + "NextToLast": "Next to last", + "Last": "Last", + "Weekday": "Weekday", + "WeekendDay": "Weekend day" } } diff --git a/plugins/calendar-assets/lang/es.json b/plugins/calendar-assets/lang/es.json index be022d7780..1e844973a4 100644 --- a/plugins/calendar-assets/lang/es.json +++ b/plugins/calendar-assets/lang/es.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Se modificó la hora de la reunión", "MeetingCanceledNotification": "Reunión cancelada: {title}", "PrimaryCalendar": "Calendario principal", - "Hidden": "Oculto" + "Hidden": "Oculto", + "Each": "Cada", + "OnThe": "En el", + "First": "Primero", + "Second": "Segundo", + "Third": "Tercero", + "Fourth": "Cuarto", + "Fifth": "Quinto", + "NextToLast": "Penúltimo", + "Last": "Último", + "Weekday": "Día laborable", + "WeekendDay": "Día de descanso" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/fr.json b/plugins/calendar-assets/lang/fr.json index 146b6604bb..d60fcf5325 100644 --- a/plugins/calendar-assets/lang/fr.json +++ b/plugins/calendar-assets/lang/fr.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Heure de la réunion modifiée", "MeetingCanceledNotification": "Réunion annulée: {title}", "PrimaryCalendar": "Calendrier principal", - "Hidden": "Caché" + "Hidden": "Caché", + "Each": "Chaque", + "OnThe": "Le", + "First": "Premier", + "Second": "Deuxième", + "Third": "Troisième", + "Fourth": "Quatrième", + "Fifth": "Cinquième", + "NextToLast": "Avant-dernier", + "Last": "Dernier", + "Weekday": "Jour de la semaine", + "WeekendDay": "Jour de congé" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/it.json b/plugins/calendar-assets/lang/it.json index b46620e2c4..455d26c5e2 100644 --- a/plugins/calendar-assets/lang/it.json +++ b/plugins/calendar-assets/lang/it.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Orario della riunione modificato", "MeetingCanceledNotification": "Riunione annullata: {title}", "PrimaryCalendar": "Calendario principale", - "Hidden": "Nascosto" + "Hidden": "Nascosto", + "Each": "Ogni", + "OnThe": "Il", + "First": "Primo", + "Second": "Secondo", + "Third": "Terzo", + "Fourth": "Quarto", + "Fifth": "Quinto", + "NextToLast": "Penultimo", + "Last": "Ultimo", + "Weekday": "Giorno lavorativo", + "WeekendDay": "Giorno libero" } } diff --git a/plugins/calendar-assets/lang/ja.json b/plugins/calendar-assets/lang/ja.json index 351987e607..87922765be 100644 --- a/plugins/calendar-assets/lang/ja.json +++ b/plugins/calendar-assets/lang/ja.json @@ -106,6 +106,17 @@ "CalDavAccessAccount": "Huly アカウント", "CalDavAccessPassword": "パスワード(自動生成)", "PrimaryCalendar": "プライマリカレンダー", - "Hidden": "非表示" + "Hidden": "非表示", + "Each": "各", + "OnThe": "の", + "First": "最初の", + "Second": "2番目の", + "Third": "3番目の", + "Fourth": "4番目の", + "Fifth": "5番目の", + "NextToLast": "最後から2番目", + "Last": "最後", + "Weekday": "平日", + "WeekendDay": "休日" } } diff --git a/plugins/calendar-assets/lang/pt.json b/plugins/calendar-assets/lang/pt.json index 4147ff1c51..a839833e12 100644 --- a/plugins/calendar-assets/lang/pt.json +++ b/plugins/calendar-assets/lang/pt.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Horário da reunião alterado", "MeetingCanceledNotification": "Reunião cancelada: {title}", "PrimaryCalendar": "Calendário principal", - "Hidden": "Oculto" + "Hidden": "Oculto", + "Each": "Cada", + "OnThe": "No dia", + "First": "Primeiro", + "Second": "Segundo", + "Third": "Terceiro", + "Fourth": "Quarto", + "Fifth": "Quinto", + "NextToLast": "Penúltimo", + "Last": "Último", + "Weekday": "Dia da semana", + "WeekendDay": "Folga" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/ru.json b/plugins/calendar-assets/lang/ru.json index 198c233dee..ed55140673 100644 --- a/plugins/calendar-assets/lang/ru.json +++ b/plugins/calendar-assets/lang/ru.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "Время встречи изменено", "MeetingCanceledNotification": "Встреча отменена: {title}", "PrimaryCalendar": "Основной календарь", - "Hidden": "Скрыт" + "Hidden": "Скрыт", + "Each": "Каждый", + "OnThe": "В", + "First": "Первый", + "Second": "Второй", + "Third": "Третий", + "Fourth": "Четвертый", + "Fifth": "Пятый", + "NextToLast": "Предпоследний", + "Last": "Последний", + "Weekday": "Будний день", + "WeekendDay": "Выходной день" } } diff --git a/plugins/calendar-assets/lang/zh.json b/plugins/calendar-assets/lang/zh.json index c1c3265037..d17e252afb 100644 --- a/plugins/calendar-assets/lang/zh.json +++ b/plugins/calendar-assets/lang/zh.json @@ -110,6 +110,17 @@ "MeetingRescheduledNotification": "会议时间已更改", "MeetingCanceledNotification": "会议已取消: {title}", "PrimaryCalendar": "主日历", - "Hidden": "隐藏" + "Hidden": "隐藏", + "Each": "每个", + "OnThe": "在", + "First": "第一个", + "Second": "第二个", + "Third": "第三个", + "Fourth": "第四个", + "Fifth": "第五个", + "NextToLast": "倒数第二个", + "Last": "最后一个", + "Weekday": "工作日", + "WeekendDay": "周末日" } } \ No newline at end of file diff --git a/plugins/calendar-resources/src/components/ByDateSelector.svelte b/plugins/calendar-resources/src/components/ByDateSelector.svelte new file mode 100644 index 0000000000..3f09e5d82f --- /dev/null +++ b/plugins/calendar-resources/src/components/ByDateSelector.svelte @@ -0,0 +1,97 @@ + + + +
+ {#each [...Array(5).keys()] as weekIndex} + {#each [...Array(7).keys()] as dayOfWeek} + {@const value = weekIndex * 7 + dayOfWeek + 1} + {#if value <= 31} +
+ +
+ {/if} + {/each} + {/each} +
+ + diff --git a/plugins/calendar-resources/src/components/EditEvent.svelte b/plugins/calendar-resources/src/components/EditEvent.svelte index a3931fb3c4..d5da070439 100644 --- a/plugins/calendar-resources/src/components/EditEvent.svelte +++ b/plugins/calendar-resources/src/components/EditEvent.svelte @@ -193,7 +193,7 @@ on:repeat={setRecurrance} {readOnly} on:allday={allDayChangeHandler} - noRepeat + noRepeat={object._class !== calendar.class.ReccuringInstance} />
diff --git a/plugins/calendar-resources/src/components/MonthSelector.svelte b/plugins/calendar-resources/src/components/MonthSelector.svelte new file mode 100644 index 0000000000..597b1ee716 --- /dev/null +++ b/plugins/calendar-resources/src/components/MonthSelector.svelte @@ -0,0 +1,88 @@ + + + +
+ {#each [...Array(12).keys()] as month} + {@const value = month + 1} +
+ +
+ {/each} +
+ + diff --git a/plugins/calendar-resources/src/components/ReccurancePopup.svelte b/plugins/calendar-resources/src/components/ReccurancePopup.svelte index b4f53b808a..67cdb3bcb6 100644 --- a/plugins/calendar-resources/src/components/ReccurancePopup.svelte +++ b/plugins/calendar-resources/src/components/ReccurancePopup.svelte @@ -14,21 +14,26 @@ --> + +
+ { + if (e.detail !== undefined) { + setPos.bySetPos = [e.detail] + } + }} + /> + +
diff --git a/plugins/calendar-resources/src/plugin.ts b/plugins/calendar-resources/src/plugin.ts index 4c8188113a..d3c8116738 100644 --- a/plugins/calendar-resources/src/plugin.ts +++ b/plugins/calendar-resources/src/plugin.ts @@ -72,6 +72,17 @@ export default mergeIds(calendarId, calendar, { SeeAllNumberReminders: '' as IntlString, DefaultVisibility: '' as IntlString, GooglePrivacy: '' as IntlString, - PrimaryCalendar: '' as IntlString + PrimaryCalendar: '' as IntlString, + Each: '' as IntlString, + OnThe: '' as IntlString, + First: '' as IntlString, + Second: '' as IntlString, + Third: '' as IntlString, + Fourth: '' as IntlString, + Fifth: '' as IntlString, + NextToLast: '' as IntlString, + Last: '' as IntlString, + Weekday: '' as IntlString, + WeekendDay: '' as IntlString } }) diff --git a/plugins/calendar-resources/src/types.ts b/plugins/calendar-resources/src/types.ts index 4d9f817f34..73d03b4713 100644 --- a/plugins/calendar-resources/src/types.ts +++ b/plugins/calendar-resources/src/types.ts @@ -42,3 +42,8 @@ export interface CalendarCell { hourOfDay: number minutes: number } + +export interface SetPosRules { + bySetPos: number[] + byDay: string[] +} diff --git a/plugins/calendar/src/__tests__/utils.test.ts b/plugins/calendar/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..82fa51fc43 --- /dev/null +++ b/plugins/calendar/src/__tests__/utils.test.ts @@ -0,0 +1,164 @@ +import { RecurringRule } from '..' +import { generateRecurringValues } from '../utils' + +describe('generateRecurringValues', () => { + const baseDate = new Date('2024-01-01T00:00:00Z').getTime() + const from = new Date('2024-01-01T00:00:00Z').getTime() + const to = new Date('2024-01-10T00:00:00Z').getTime() + + it('generates daily recurring values', () => { + const rule: RecurringRule = { + freq: 'DAILY', + interval: 1 + } + const result = generateRecurringValues(rule, baseDate, from, to) + // Should include each day from Jan 1 to Jan 10 + expect(result.length).toBe(10) + expect(result[0]).toBe(baseDate) + expect(result[9]).toBe(new Date('2024-01-10T00:00:00Z').getTime()) + }) + + it('supports bySetPos (positive) for monthly frequency', () => { + // 1st Monday of each month in the range + const rule: RecurringRule = { + freq: 'MONTHLY', + interval: 1, + byDay: ['MO'], + bySetPos: [1] + } + const from = new Date('2024-01-01T00:00:00Z').getTime() + const to = new Date('2024-03-31T00:00:00Z').getTime() + const baseDate = from + const result = generateRecurringValues(rule, baseDate, from, to) + // 1st Monday of Jan, Feb, Mar 2024 + expect(result.length).toBe(3) + expect(new Date(result[0]).toISOString().slice(0, 10)).toBe('2024-01-01') // Jan 1, 2024 is Monday + expect(new Date(result[1]).toISOString().slice(0, 10)).toBe('2024-02-05') // Feb 5, 2024 is Monday + expect(new Date(result[2]).toISOString().slice(0, 10)).toBe('2024-03-04') // Mar 4, 2024 is Monday + }) + + it('supports bySetPos (negative) for monthly frequency', () => { + // Last Friday of each month in the range + const rule: RecurringRule = { + freq: 'MONTHLY', + interval: 1, + byDay: ['FR'], + bySetPos: [-1] + } + const from = new Date('2024-01-01T00:00:00Z').getTime() + const to = new Date('2024-03-31T00:00:00Z').getTime() + const baseDate = from + const result = generateRecurringValues(rule, baseDate, from, to) + // Last Friday of Jan, Feb, Mar 2024 + expect(result.length).toBe(3) + expect(new Date(result[0]).toISOString().slice(0, 10)).toBe('2024-01-26') + expect(new Date(result[1]).toISOString().slice(0, 10)).toBe('2024-02-23') + expect(new Date(result[2]).toISOString().slice(0, 10)).toBe('2024-03-29') + }) + + it('supports bySetPos (positive) for yearly frequency', () => { + // 1st Sunday of January for each year in range + const rule: RecurringRule = { + freq: 'YEARLY', + interval: 1, + byMonth: [0], // January + byDay: ['SU'], + bySetPos: [1] + } + const from = new Date('2024-01-01T00:00:00Z').getTime() + const to = new Date('2026-12-31T00:00:00Z').getTime() + const baseDate = from + const result = generateRecurringValues(rule, baseDate, from, to) + // 1st Sunday of Jan 2024, 2025, 2026 + expect(result.length).toBe(3) + expect(new Date(result[0]).toISOString().slice(0, 10)).toBe('2024-01-07') + expect(new Date(result[1]).toISOString().slice(0, 10)).toBe('2025-01-05') + expect(new Date(result[2]).toISOString().slice(0, 10)).toBe('2026-01-04') + }) + + it('supports bySetPos (negative) for yearly frequency', () => { + // Last Wednesday of December for each year in range + const rule: RecurringRule = { + freq: 'YEARLY', + interval: 1, + byMonth: [11], // December + byDay: ['WE'], + bySetPos: [-1] + } + const from = new Date('2024-01-01T00:00:00Z').getTime() + const to = new Date('2026-12-31T00:00:00Z').getTime() + const baseDate = from + const result = generateRecurringValues(rule, baseDate, from, to) + // Last Wednesday of Dec 2024, 2025, 2026 + expect(result.length).toBe(3) + expect(new Date(result[0]).toISOString().slice(0, 10)).toBe('2024-12-25') + expect(new Date(result[1]).toISOString().slice(0, 10)).toBe('2025-12-31') + expect(new Date(result[2]).toISOString().slice(0, 10)).toBe('2026-12-30') + }) + + it('generates weekly recurring values', () => { + const rule: RecurringRule = { + freq: 'WEEKLY', + interval: 1, + byDay: ['TU'] // Only Tuesdays + } + const result = generateRecurringValues(rule, baseDate, from, to) + // Jan 2 and Jan 9 are Tuesdays in 2024 + expect(result.length).toBe(2) + expect(new Date(result[0]).getUTCDay()).toBe(2) + expect(new Date(result[1]).getUTCDay()).toBe(2) + }) + + it('generates monthly recurring values', () => { + const rule: RecurringRule = { + freq: 'MONTHLY', + interval: 1, + byMonthDay: [1] + } + const result = generateRecurringValues(rule, baseDate, from, to) + // Only Jan 1 falls in the range + expect(result.length).toBe(1) + expect(result[0]).toBe(baseDate) + }) + + it('generates yearly recurring values', () => { + const rule: RecurringRule = { + freq: 'YEARLY', + interval: 1, + byMonth: [0], // January + byMonthDay: [1] + } + const result = generateRecurringValues(rule, baseDate, from, to) + // Only Jan 1, 2024 + expect(result.length).toBe(1) + expect(result[0]).toBe(baseDate) + }) + + it('respects count limit', () => { + const rule: RecurringRule = { + freq: 'DAILY', + interval: 1, + count: 3 + } + const result = generateRecurringValues(rule, baseDate, from, to) + expect(result.length).toBe(3) + }) + + it('respects endDate limit', () => { + const rule: RecurringRule = { + freq: 'DAILY', + interval: 1, + endDate: new Date('2024-01-03T00:00:00Z').getTime() + } + const result = generateRecurringValues(rule, baseDate, from, to) + expect(result.length).toBe(3) + expect(result[2]).toBe(new Date('2024-01-03T00:00:00Z').getTime()) + }) + + it('throws on invalid frequency', () => { + const rule = { + freq: 'INVALID' + } + expect(() => generateRecurringValues(rule as any, baseDate, from, to)).toThrow('Invalid recurring rule frequency') + }) +}) diff --git a/plugins/calendar/src/utils.ts b/plugins/calendar/src/utils.ts index 9a67d54357..24027b7330 100644 --- a/plugins/calendar/src/utils.ts +++ b/plugins/calendar/src/utils.ts @@ -24,40 +24,29 @@ function getInstance (event: ReccuringEvent, date: Timestamp): ReccuringInstance } } -function generateRecurringValues ( +export function generateRecurringValues ( rule: RecurringRule, startDate: Timestamp, from: Timestamp, to: Timestamp ): Timestamp[] { - const values: Timestamp[] = [] const currentDate = new Date(startDate) switch (rule.freq) { case 'DAILY': - generateDailyValues(rule, currentDate, values, from, to) - break + return generateDailyValues(rule, currentDate, from, to) case 'WEEKLY': - generateWeeklyValues(rule, currentDate, values, from, to) - break + return generateWeeklyValues(rule, currentDate, from, to) case 'MONTHLY': - generateMonthlyValues(rule, currentDate, values, from, to) - break + return generateMonthlyValues(rule, currentDate, from, to) case 'YEARLY': - generateYearlyValues(rule, currentDate, values, from, to) - break + return generateYearlyValues(rule, currentDate, from, to) default: throw new Error('Invalid recurring rule frequency') } - return values } -function generateDailyValues ( - rule: RecurringRule, - currentDate: Date, - values: Timestamp[], - from: Timestamp, - to: Timestamp -): void { +function generateDailyValues (rule: RecurringRule, currentDate: Date, from: Timestamp, to: Timestamp): Timestamp[] { + const values: Timestamp[] = [] const { count, endDate, interval } = rule const { bySetPos } = rule let i = 0 @@ -76,15 +65,11 @@ function generateDailyValues ( currentDate.setDate(currentDate.getDate() + (interval ?? 1)) if (count !== undefined && i === count) break } + return values } -function generateWeeklyValues ( - rule: RecurringRule, - currentDate: Date, - values: Timestamp[], - from: Timestamp, - to: Timestamp -): void { +function generateWeeklyValues (rule: RecurringRule, currentDate: Date, from: Timestamp, to: Timestamp): Timestamp[] { + const values: Timestamp[] = [] const { count, endDate, interval } = rule let { byDay, bySetPos } = rule let i = 0 @@ -98,8 +83,8 @@ function generateWeeklyValues ( const end = new Date(new Date(currentDate).setDate(currentDate.getDate() + 7)) let date = currentDate while (date < end) { - if (endDate != null && date.getTime() > endDate) return - if (date.getTime() > to) return + if (endDate != null && date.getTime() > endDate) return values + if (date.getTime() > to) return values if ((byDay == null || matchesByDay(date, byDay)) && (bySetPos == null || bySetPos.includes(getSetPos(date)))) { const res = date.getTime() if (res >= from && res <= to) { @@ -108,7 +93,7 @@ function generateWeeklyValues ( i++ } date = new Date(date.setDate(date.getDate() + 1)) - if (count !== undefined && i === count) return + if (count !== undefined && i === count) return values } currentDate = new Date(next) @@ -154,13 +139,8 @@ function getNegativePosition (date: Date, weekday: string, pos: number): number throw new Error(`Unable to calculate negative position ${pos}`) } -function generateMonthlyValues ( - rule: RecurringRule, - currentDate: Date, - values: Timestamp[], - from: Timestamp, - to: Timestamp -): void { +function generateMonthlyValues (rule: RecurringRule, currentDate: Date, from: Timestamp, to: Timestamp): Timestamp[] { + const values: Timestamp[] = [] const { count, endDate, interval } = rule let { byDay, byMonthDay, bySetPos } = rule let i = 0 @@ -170,82 +150,91 @@ function generateMonthlyValues ( } while (true) { - const next = new Date(currentDate).setMonth(currentDate.getMonth() + (interval ?? 1)) - const end = new Date(new Date(currentDate).setMonth(currentDate.getMonth() + 1)) + const next = new Date(currentDate).setMonth(currentDate.getMonth() + (interval ?? 1), 1) + const end = new Date(new Date(currentDate).setMonth(currentDate.getMonth() + 1, 1)) let date = currentDate + const candidates: Date[] = [] while (date < end) { - if (endDate != null && date.getTime() > endDate) return - if (date.getTime() >= to) return - if ( - (byDay == null || matchesByDay(date, byDay)) && - (byMonthDay == null || byMonthDay.includes(new Date(currentDate).getDate())) && - (bySetPos == null || bySetPos.includes(getSetPos(currentDate))) - ) { - const res = currentDate.getTime() - if (res >= from && res <= to) { - values.push(res) - } - i++ + if ((byDay == null || matchesByDay(date, byDay)) && (byMonthDay == null || byMonthDay.includes(date.getDate()))) { + candidates.push(new Date(date)) } date = new Date(date.setDate(date.getDate() + 1)) - - if (count !== undefined && i === count) return } + + let filtered: Date[] = candidates + if (bySetPos != null) { + filtered = bySetPos + .map((pos) => { + if (pos === 0) return null // invalid + const index = pos > 0 ? pos - 1 : candidates.length + pos + return candidates[index] ?? null + }) + .filter((d): d is Date => d != null) + } + + for (const d of filtered) { + const res = d.getTime() + if (res >= from && res <= to) { + values.push(res) + i++ + if (count !== undefined && i === count) return values + } + } + if (endDate != null && next > endDate) return values + if (next >= to) return values currentDate = new Date(next) } } -function generateYearlyValues ( - rule: RecurringRule, - currentDate: Date, - values: Timestamp[], - from: Timestamp, - to: Timestamp -): void { +function generateYearlyValues (rule: RecurringRule, currentDate: Date, from: Timestamp, to: Timestamp): Timestamp[] { + const values: Timestamp[] = [] const { count, endDate, interval } = rule const { byDay, byMonthDay, byYearDay, byWeekNo, byMonth, bySetPos } = rule let i = 0 while (true) { - const next = new Date(currentDate).setFullYear(currentDate.getFullYear() + (interval ?? 1)) - const end = new Date(new Date(currentDate).setFullYear(currentDate.getFullYear() + 1)) + const next = new Date(currentDate).setFullYear(currentDate.getFullYear() + (interval ?? 1), 0, 1) + const end = new Date(new Date(currentDate).setFullYear(currentDate.getFullYear() + 1, 0, 1)) let date = currentDate + const candidates: Date[] = [] while (date < end) { - if (endDate != null && date.getTime() > endDate) return - if (date.getTime() > to) return if ( - byDay == null && - byMonthDay == null && - byYearDay == null && - byWeekNo == null && - byMonth == null && - bySetPos == null + (byDay == null || matchesByDay(date, byDay)) && + (byMonthDay == null || byMonthDay.includes(date.getDate())) && + (byYearDay == null || byYearDay.includes(getYearDay(date))) && + (byWeekNo == null || byWeekNo.includes(getWeekNumber(date))) && + (byMonth == null || byMonth.includes(date.getMonth())) ) { - date = new Date(next) - const res = currentDate.getTime() + const res = date.getTime() if (res >= from && res <= to) { - values.push(res) + candidates.push(new Date(res)) } i++ - } else { - if ( - (byDay == null || matchesByDay(date, byDay)) && - (byMonthDay == null || byMonthDay.includes(currentDate.getDate())) && - (byYearDay == null || byYearDay.includes(getYearDay(currentDate))) && - (byWeekNo == null || byWeekNo.includes(getWeekNumber(currentDate))) && - (byMonth == null || byMonth.includes(currentDate.getMonth())) && - (bySetPos == null || bySetPos.includes(getSetPos(currentDate))) - ) { - const res = currentDate.getTime() - if (res >= from && res <= to) { - values.push(res) - } - i++ - } - date = new Date(date.setDate(date.getDate() + 1)) } - if (count !== undefined && i === count) return + date = new Date(date.setDate(date.getDate() + 1)) } + + let filtered: Date[] = candidates + if (bySetPos != null) { + filtered = bySetPos + .map((pos) => { + if (pos === 0) return null // invalid + const index = pos > 0 ? pos - 1 : candidates.length + pos + return candidates[index] ?? null + }) + .filter((d): d is Date => d != null) + } + + for (const d of filtered) { + const res = d.getTime() + if (res >= from && res <= to) { + values.push(res) + i++ + if (count !== undefined && i === count) return values + } + } + if (endDate != null && next > endDate) return values + if (next >= to) return values currentDate = new Date(next) } }