mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Enhanced the recurring event functionality (#9475)
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "休日"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,17 @@
|
||||
"MeetingRescheduledNotification": "Время встречи изменено",
|
||||
"MeetingCanceledNotification": "Встреча отменена: {title}",
|
||||
"PrimaryCalendar": "Основной календарь",
|
||||
"Hidden": "Скрыт"
|
||||
"Hidden": "Скрыт",
|
||||
"Each": "Каждый",
|
||||
"OnThe": "В",
|
||||
"First": "Первый",
|
||||
"Second": "Второй",
|
||||
"Third": "Третий",
|
||||
"Fourth": "Четвертый",
|
||||
"Fifth": "Пятый",
|
||||
"NextToLast": "Предпоследний",
|
||||
"Last": "Последний",
|
||||
"Weekday": "Будний день",
|
||||
"WeekendDay": "Выходной день"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user