Enhanced the recurring event functionality (#9475)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-07-07 13:49:14 +07:00
committed by GitHub
parent b886b48d61
commit 4e90bd83be
19 changed files with 745 additions and 105 deletions
+12 -1
View File
@@ -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"
}
}
+12 -1
View File
@@ -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"
}
}
+12 -1
View File
@@ -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"
}
}
+12 -1
View File
@@ -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"
}
}
+12 -1
View File
@@ -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é"
}
}
+12 -1
View File
@@ -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"
}
}
+12 -1
View File
@@ -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": "休日"
}
}
+12 -1
View File
@@ -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"
}
}
+12 -1
View File
@@ -110,6 +110,17 @@
"MeetingRescheduledNotification": "Время встречи изменено",
"MeetingCanceledNotification": "Встреча отменена: {title}",
"PrimaryCalendar": "Основной календарь",
"Hidden": "Скрыт"
"Hidden": "Скрыт",
"Each": "Каждый",
"OnThe": "В",
"First": "Первый",
"Second": "Второй",
"Third": "Третий",
"Fourth": "Четвертый",
"Fifth": "Пятый",
"NextToLast": "Предпоследний",
"Last": "Последний",
"Weekday": "Будний день",
"WeekendDay": "Выходной день"
}
}
+12 -1
View File
@@ -110,6 +110,17 @@
"MeetingRescheduledNotification": "会议时间已更改",
"MeetingCanceledNotification": "会议已取消: {title}",
"PrimaryCalendar": "主日历",
"Hidden": "隐藏"
"Hidden": "隐藏",
"Each": "每个",
"OnThe": "在",
"First": "第一个",
"Second": "第二个",
"Third": "第三个",
"Fourth": "第四个",
"Fifth": "第五个",
"NextToLast": "倒数第二个",
"Last": "最后一个",
"Weekday": "工作日",
"WeekendDay": "周末日"
}
}
@@ -0,0 +1,97 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Button } from '@hcengineering/ui'
export let disabled: boolean
export let selected: number[]
function onSelect (val: number): void {
if (disabled) return
if (selected.includes(val)) {
selected = selected.filter((v) => v !== val)
} else {
selected.push(val)
}
selected = selected
}
</script>
<div class="days-of-month" style:grid-template-rows={`repeat(${5}, ${'1.5rem'})`}>
{#each [...Array(5).keys()] as weekIndex}
{#each [...Array(7).keys()] as dayOfWeek}
{@const value = weekIndex * 7 + dayOfWeek + 1}
{#if value <= 31}
<div
class="cell"
class:selected={selected.includes(value)}
class:top-border={weekIndex === 0}
class:left-border={dayOfWeek === 0}
style:grid-column-start={dayOfWeek + 1}
style:grid-row-start={weekIndex + 1}
>
<Button
width={'100%'}
on:click={() => {
onSelect(value)
}}
{disabled}
padding={'0'}
size={'inline'}
kind={'ghost'}
>
<svelte:fragment slot="content">
{value}
</svelte:fragment>
</Button>
</div>
{/if}
{/each}
{/each}
</div>
<style lang="scss">
.days-of-month {
display: grid;
grid-auto-columns: max-content;
grid-template-columns: repeat(7, minmax(0, 1fr));
width: 100%;
}
.cell {
border-bottom: 1px solid var(--theme-divider-color);
border-right: 1px solid var(--theme-divider-color);
display: flex;
justify-content: center;
align-items: center;
&.left-border {
border-left: 1px solid var(--theme-divider-color);
}
&.top-border {
border-top: 1px solid var(--theme-divider-color);
}
&.selected {
background-color: var(--theme-navpanel-selected);
}
}
.days-of-month {
justify-items: stretch;
align-items: stretch;
}
</style>
@@ -193,7 +193,7 @@
on:repeat={setRecurrance}
{readOnly}
on:allday={allDayChangeHandler}
noRepeat
noRepeat={object._class !== calendar.class.ReccuringInstance}
/>
</div>
<div class="block rightCropPadding">
@@ -0,0 +1,88 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Button } from '@hcengineering/ui'
export let selected: number[]
function onSelect (val: number): void {
if (selected.includes(val)) {
selected = selected.filter((v) => v !== val)
} else {
selected.push(val)
}
selected = selected
}
</script>
<div class="month" style:grid-template-rows={`repeat(${3}, ${'1.5rem'})`}>
{#each [...Array(12).keys()] as month}
{@const value = month + 1}
<div
class="cell"
class:selected={selected.includes(value)}
class:top-border={month < 4}
class:left-border={month % 4 === 0}
>
<Button
width={'100%'}
on:click={() => {
onSelect(value)
}}
padding={'0'}
size={'inline'}
kind={'ghost'}
>
<svelte:fragment slot="content">
{new Date(new Date().setMonth(month)).toLocaleString('default', { month: 'short' })}
</svelte:fragment>
</Button>
</div>
{/each}
</div>
<style lang="scss">
.month {
display: grid;
grid-auto-columns: max-content;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
}
.cell {
border-bottom: 1px solid var(--theme-divider-color);
border-right: 1px solid var(--theme-divider-color);
display: flex;
justify-content: center;
align-items: center;
&.left-border {
border-left: 1px solid var(--theme-divider-color);
}
&.top-border {
border-top: 1px solid var(--theme-divider-color);
}
&.selected {
background-color: var(--theme-navpanel-selected);
}
}
.days-of-month {
justify-items: stretch;
align-items: stretch;
}
</style>
@@ -14,21 +14,26 @@
-->
<script lang="ts">
import { RecurringRule, getWeekday } from '@hcengineering/calendar'
import { Timestamp } from '@hcengineering/core'
import ui, {
Button,
CheckBox,
CircleButton,
DropdownIntlItem,
DropdownLabelsIntl,
Grid,
Row,
Label,
NumberInput,
RadioButton
RadioButton,
Row
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import calendar from '../plugin'
import { SetPosRules } from '../types'
import ByDateSelector from './ByDateSelector.svelte'
import DateEditor from './DateEditor.svelte'
import { Timestamp } from '@hcengineering/core'
import SetPosSelector from './SetPosSelector.svelte'
import MonthSelector from './MonthSelector.svelte'
export let rules: RecurringRule[]
export let startDate: Timestamp
@@ -37,8 +42,18 @@
let periodType: Freq = (rules[0]?.freq as Freq) ?? 'WEEKLY'
let interval: number = rules[0]?.interval ?? 1
let isByMonthDay: boolean = rules[0] === undefined || rules[0]?.byMonthDay !== undefined
let byMonthDay = rules[0]?.byMonthDay ?? [new Date(startDate).getDate()]
let isYearBySetPos = rules[0]?.bySetPos !== undefined
let byMonth = rules[0]?.byMonth ?? [new Date(startDate).getMonth()]
const dispatch = createEventDispatcher()
let setPos: SetPosRules = {
bySetPos: rules[0]?.bySetPos ?? [1],
byDay: rules[0]?.byDay ?? ['SU,MO,TU,WE,TH,FR,SA']
}
let selected = rules[0]?.endDate !== undefined ? 'on' : rules[0]?.count ? 'after' : 'never'
const ddItems = [
@@ -73,6 +88,21 @@
if (periodType === 'WEEKLY') {
res.byDay = selectedWeekdays
}
if (periodType === 'MONTHLY') {
if (isByMonthDay) {
res.byMonthDay = byMonthDay
} else {
res.bySetPos = setPos.bySetPos
res.byDay = setPos.byDay
}
}
if (periodType === 'YEARLY') {
res.byMonth = byMonth
if (isYearBySetPos) {
res.bySetPos = setPos.bySetPos
res.byDay = setPos.byDay
}
}
rules = [res]
dispatch('close', rules)
}
@@ -112,7 +142,7 @@
<div class="header">
<Label label={calendar.string.Repeat} />
</div>
<div class="content flex-col">
<div class="content flex-col flex-gap-2">
<div class="flex-row-center gap-1-5">
<span class="min-w-12"><Label label={calendar.string.Every} /></span>
<NumberInput bind:value={interval} maxWidth={'4rem'} maxDigitsAfterPoint={0} minValue={1} />
@@ -141,7 +171,34 @@
</div>
</div>
{/if}
<div class="flex-row-center mt-3 mb-3 min-h-8">
{#if periodType === 'MONTHLY'}
<RadioButton
group={isByMonthDay}
value={true}
labelIntl={calendar.string.Each}
action={() => {
isByMonthDay = true
}}
/>
<ByDateSelector bind:selected={byMonthDay} disabled={!isByMonthDay} />
<RadioButton
group={isByMonthDay}
value={false}
labelIntl={calendar.string.OnThe}
action={() => {
isByMonthDay = false
}}
/>
<SetPosSelector bind:setPos disabled={isByMonthDay} />
{:else if periodType === 'YEARLY'}
<MonthSelector bind:selected={byMonth} />
<div class="flex-row-center flex-gap-1">
<CheckBox bind:checked={isYearBySetPos} />
<Label label={calendar.string.OnThe} />
</div>
<SetPosSelector bind:setPos disabled={!isYearBySetPos} />
{/if}
<div class="flex-row-center min-h-8">
<Label label={calendar.string.Ends} />
</div>
<Grid columnGap={0.375} rowGap={0.75} equalHeight>
@@ -0,0 +1,119 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { DropdownIntlItem, DropdownLabelsIntl, Label } from '@hcengineering/ui'
import calendar from '../plugin'
import { SetPosRules } from '../types'
export let setPos: SetPosRules
export let disabled: boolean
const setPosItems: DropdownIntlItem[] = [
{
id: 1,
label: calendar.string.First
},
{
id: 2,
label: calendar.string.Second
},
{
id: 3,
label: calendar.string.Third
},
{
id: 4,
label: calendar.string.Fourth
},
{
id: 5,
label: calendar.string.Fifth
},
{
id: -2,
label: calendar.string.NextToLast
},
{
id: -1,
label: calendar.string.Last
}
]
const byDayItems = [
{
id: 'SU',
label: calendar.string.MondayShort
},
{
id: 'MO',
label: calendar.string.TuesdayShort
},
{
id: 'TU',
label: calendar.string.WednesdayShort
},
{
id: 'WE',
label: calendar.string.ThursdayShort
},
{
id: 'TH',
label: calendar.string.FridayShort
},
{
id: 'FR',
label: calendar.string.SaturdayShort
},
{
id: 'SA',
label: calendar.string.SundayShort
},
{
id: 'SU,MO,TU,WE,TH,FR,SA',
label: calendar.string.ModeDay
},
{
id: 'MO,TU,WE,TH,FR',
label: calendar.string.Weekday
},
{
id: 'SU,SA',
label: calendar.string.WeekendDay
}
]
$: selectedByDay = byDayItems.find((p) => p.id === setPos.byDay.join(','))?.id ?? 'SU,MO,TU,WE,TH,FR,SA'
function byDaySelect (e: CustomEvent<string | undefined>) {
const val = e.detail
if (val !== undefined) {
setPos.byDay = val.split(',')
}
}
</script>
<div class="flex-row-center flex-gap-2">
<DropdownLabelsIntl
items={setPosItems}
{disabled}
selected={setPos.bySetPos[0] ?? 1}
on:selected={(e) => {
if (e.detail !== undefined) {
setPos.bySetPos = [e.detail]
}
}}
/>
<DropdownLabelsIntl items={byDayItems} {disabled} selected={selectedByDay} on:selected={byDaySelect} />
</div>
+12 -1
View File
@@ -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
}
})
+5
View File
@@ -42,3 +42,8 @@ export interface CalendarCell {
hourOfDay: number
minutes: number
}
export interface SetPosRules {
bySetPos: number[]
byDay: string[]
}
@@ -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')
})
})
+77 -88
View File
@@ -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)
}
}