11<script setup lang="ts">
2- import { ref , reactive , watch , onMounted , useTemplateRef , toRaw , computed } from ' vue' ;
2+ import { ref , shallowRef , reactive , watch , onMounted , useTemplateRef , toRaw , computed , nextTick } from ' vue' ;
33import { UpdateStrategy , type CalendarEvent } from ' @/types/core' ;
44import { DateTime } from ' luxon' ;
55import { RRule , RRuleSet , rrulestr , type Frequency } from ' rrule' ;
66import { CalendarCore } from ' @/wasm/core-wrapper' ;
77import { useEventModal } from ' @/composables/modals/useEventModal' ;
88import StrategyModal from ' @/components/modals/StrategyModal.vue' ;
9+ import RepeatModal from ' @/components/modals/RepeatModal.vue' ;
910import { useStrategyModal } from ' @/composables/modals/useStrategyModal' ;
1011import { useAlertModal } from ' @/composables/modals/useAlertModal' ;
1112import { syncAllWrapper } from ' @/services/gitSync' ;
@@ -18,17 +19,43 @@ const repeatEndOptions = [
1819 { value: ' after' , label: ' After' },
1920];
2021const frequencyOptions = [
21- { value: null , label: ' never' },
22- { value: RRule .DAILY , label: ' daily' },
23- { value: RRule .WEEKLY , label: ' weekly' },
24- { value: RRule .MONTHLY , label: ' monthly' },
25- { value: RRule .YEARLY , label: ' yearly' },
22+ { value: ' never' , frequency: null },
23+ { value: ' daily' , frequency: RRule .DAILY },
24+ { value: ' weekly' , frequency: RRule .WEEKLY },
25+ { value: ' monthly' , frequency: RRule .MONTHLY },
26+ { value: ' yearly' , frequency: RRule .YEARLY },
27+ { value: ' custom' , frequency: undefined },
2628];
2729
30+ function defaultCustomRepeat(from : DateTime = DateTime .now ()): RRule {
31+ const start = from .isValid ? from : DateTime .now ();
32+ return new RRule ({
33+ freq: RRule .WEEKLY ,
34+ dtstart: start .toJSDate (),
35+ interval: 2 ,
36+ byweekday: [start .weekday - 1 ],
37+ count: 5 ,
38+ });
39+ }
40+
41+ function frequencyForSelection(selection : string ): Frequency | null {
42+ return frequencyOptions .find ((option ) => option .value === selection )?.frequency ?? null ;
43+ }
44+
45+ function selectionForFrequency(frequency : Frequency ): string {
46+ return frequencyOptions .find ((option ) => option .frequency === frequency )?.value ?? ' never' ;
47+ }
48+
2849const thisModal = useEventModal ();
2950const strategyModal = useStrategyModal ();
3051const { alert } = useAlertModal ();
3152
53+ const isRepeatModalOpen = ref (false );
54+ let isNewCustomSelection = false ;
55+ let hasCustomRepeat = false ;
56+ let lastRegularRepeatSelection = ' never' ;
57+ const customRepeat = shallowRef (defaultCustomRepeat ());
58+
3259const isSaving = ref (false );
3360const isDeleting = ref (false );
3461const isUpdatingWithStrategy = ref (false );
@@ -46,9 +73,9 @@ const form = reactive({
4673 entireDay: false ,
4774 tagId: ' ' ,
4875
49- repeatFreq: null as Frequency | null ,
76+ repeatSelection: ' never ' ,
5077 repeatEnd: ' after' ,
51- repeatEndOn: DateTime .now ().plus ({ week: 1 }).toISODate (),
78+ repeatEndOn: DateTime .now ().plus ({ week: 1 }).toISODate () ?? ' ' ,
5279 repeatEndAfter: 5 ,
5380});
5481const errors = reactive ({
@@ -90,10 +117,13 @@ function updateFormFromEvent(event: CalendarEvent | undefined) {
90117
91118 [form .fromDate , form .fromTime ] = dateTimeToIsoDateAndTime (event .from );
92119 [form .toDate , form .toTime ] = dateTimeToIsoDateAndTime (event .to );
93- form .repeatFreq = null ;
120+ form .repeatSelection = ' never ' ;
94121 form .repeatEnd = ' after' ;
95122 form .repeatEndAfter = 5 ;
96123 form .repeatEndOn = event .from .plus ({ week: 1 }).toISODate () ?? ' ' ; // the default
124+ customRepeat .value = defaultCustomRepeat (event .from );
125+ hasCustomRepeat = false ;
126+ lastRegularRepeatSelection = ' never' ;
97127
98128 if (form .fromTime == ' 00:00' && form .toTime == ' 23:59' ) {
99129 form .entireDay = true ;
@@ -106,10 +136,15 @@ function updateFormFromEvent(event: CalendarEvent | undefined) {
106136 if (event .repeat ) {
107137 const rule = parseRepeat (event .repeat ).rrules ()[0 ];
108138 if (rule ) {
109- const { count, until } = rule .options ;
110- form .repeatFreq = rule .options .freq ;
111-
112- if (count && count > 0 ) {
139+ const { count, freq, interval, until } = rule .options ;
140+ const isCustom = interval > 1 || rule .origOptions .byweekday !== undefined ;
141+ form .repeatSelection = isCustom ? ' custom' : selectionForFrequency (freq );
142+ lastRegularRepeatSelection = selectionForFrequency (freq );
143+
144+ if (isCustom ) {
145+ hasCustomRepeat = true ;
146+ customRepeat .value = rule ;
147+ } else if (count && count > 0 ) {
113148 form .repeatEndAfter = count ;
114149 } else if (until ) {
115150 form .repeatEnd = ' on' ;
@@ -122,6 +157,51 @@ function updateFormFromEvent(event: CalendarEvent | undefined) {
122157 form .tagId = event .tagId ?? ' ' ;
123158}
124159
160+ function handleRepeatSelectionChange() {
161+ if (form .repeatSelection === ' custom' ) {
162+ if (! hasCustomRepeat ) {
163+ const from = DateTime .fromISO (form .fromDate );
164+ const start = from .isValid ? from : DateTime .now ();
165+ const frequency = frequencyForSelection (lastRegularRepeatSelection ) ?? RRule .WEEKLY ;
166+ const until = DateTime .fromISO (form .repeatEndOn );
167+ customRepeat .value = new RRule ({
168+ freq: frequency ,
169+ dtstart: start .toJSDate (),
170+ interval: 2 ,
171+ byweekday: frequency === RRule .WEEKLY ? [start .weekday - 1 ] : undefined ,
172+ count: form .repeatEnd === ' after' ? form .repeatEndAfter : undefined ,
173+ until: form .repeatEnd === ' on' && until .isValid ? until .toJSDate () : undefined ,
174+ });
175+ }
176+ isNewCustomSelection = true ;
177+ isRepeatModalOpen .value = true ;
178+ return ;
179+ }
180+
181+ lastRegularRepeatSelection = form .repeatSelection ;
182+ }
183+
184+ function editCustomRepeat() {
185+ isNewCustomSelection = false ;
186+ isRepeatModalOpen .value = true ;
187+ }
188+
189+ function saveCustomRepeat(value : RRule ) {
190+ customRepeat .value = value ;
191+ form .repeatSelection = ' custom' ;
192+ hasCustomRepeat = true ;
193+ isNewCustomSelection = false ;
194+ isRepeatModalOpen .value = false ;
195+ void nextTick (() => repeatSelect .value ?.focus ());
196+ }
197+
198+ function cancelCustomRepeat() {
199+ if (isNewCustomSelection ) form .repeatSelection = lastRegularRepeatSelection ;
200+ isNewCustomSelection = false ;
201+ isRepeatModalOpen .value = false ;
202+ void nextTick (() => repeatSelect .value ?.focus ());
203+ }
204+
125205function reconstructEvent(): CalendarEvent {
126206 const event: CalendarEvent = {
127207 id: thisModal .event .value ?.id ,
@@ -148,25 +228,52 @@ function parseRepeat(repeat: string): RRuleSet {
148228}
149229
150230function buildRepeat(from : DateTime ): string | undefined {
151- if (form .repeatFreq === null || ! from .isValid ) return ;
152-
153- const until = DateTime .fromISO (form .repeatEndOn , { zone: from .zone }).set ({
154- hour: from .hour ,
155- minute: from .minute ,
156- second: from .second ,
157- millisecond: from .millisecond ,
158- });
159- if (form .repeatEnd === ' on' && ! until .isValid ) return ;
231+ if (form .repeatSelection === ' never' || ! from .isValid ) return ;
160232
161233 const recurrence = new RRuleSet ();
162- recurrence .rrule (
163- new RRule ({
164- freq: form .repeatFreq ,
165- dtstart: from .toJSDate (),
166- count: form .repeatEnd === ' after' ? form .repeatEndAfter : undefined ,
167- until: form .repeatEnd === ' on' ? until .toJSDate () : undefined ,
168- }),
169- );
234+
235+ if (form .repeatSelection === ' custom' ) {
236+ const options = customRepeat .value .options ;
237+ const until = options .until
238+ ? DateTime .fromJSDate (options .until , { zone: from .zone }).set ({
239+ hour: from .hour ,
240+ minute: from .minute ,
241+ second: from .second ,
242+ millisecond: from .millisecond ,
243+ })
244+ : undefined ;
245+
246+ recurrence .rrule (
247+ new RRule ({
248+ freq: options .freq ,
249+ dtstart: from .toJSDate (),
250+ interval: options .interval ,
251+ byweekday: options .freq === RRule .WEEKLY ? options .byweekday : undefined ,
252+ count: options .count || undefined ,
253+ until: until ?.toJSDate (),
254+ }),
255+ );
256+ } else {
257+ const frequency = frequencyForSelection (form .repeatSelection );
258+ if (frequency === null ) return ;
259+
260+ const until = DateTime .fromISO (form .repeatEndOn , { zone: from .zone }).set ({
261+ hour: from .hour ,
262+ minute: from .minute ,
263+ second: from .second ,
264+ millisecond: from .millisecond ,
265+ });
266+ if (form .repeatEnd === ' on' && ! until .isValid ) return ;
267+
268+ recurrence .rrule (
269+ new RRule ({
270+ freq: frequency ,
271+ dtstart: from .toJSDate (),
272+ count: form .repeatEnd === ' after' ? form .repeatEndAfter : undefined ,
273+ until: form .repeatEnd === ' on' ? until .toJSDate () : undefined ,
274+ }),
275+ );
276+ }
170277
171278 if (originalEvent ?.repeat ) {
172279 for (const exception of parseRepeat (originalEvent .repeat ).exdates ()) recurrence .exdate (exception );
@@ -297,16 +404,37 @@ function validate(event: CalendarEvent): boolean {
297404 return false ;
298405 }
299406
300- if (form .repeatFreq !== null ) {
301- if (form .repeatEnd === ' after' ) {
302- if (! Number .isInteger (form .repeatEndAfter ) || form .repeatEndAfter < 1 ) {
407+ if (form .repeatSelection !== ' never' ) {
408+ const isCustom = form .repeatSelection === ' custom' ;
409+ const options = customRepeat .value .options ;
410+ const repeatEnd = isCustom ? (options .until ? ' on' : ' after' ) : form .repeatEnd ;
411+ const repeatEndAfter = isCustom ? options .count : form .repeatEndAfter ;
412+ const repeatEndOn =
413+ isCustom && options .until
414+ ? (DateTime .fromJSDate (options .until , { zone: event .from .zone }).toISODate () ?? ' ' )
415+ : form .repeatEndOn ;
416+
417+ if (
418+ isCustom &&
419+ (! Number .isInteger (options .interval ) ||
420+ options .interval < 1 ||
421+ (options .freq === RRule .WEEKLY && options .byweekday .length === 0 ))
422+ ) {
423+ isRepeatModalOpen .value = true ;
424+ return false ;
425+ }
426+
427+ if (repeatEnd === ' after' ) {
428+ if (! repeatEndAfter || ! Number .isInteger (repeatEndAfter ) || repeatEndAfter < 1 ) {
303429 errors .badRepeatCount = true ;
430+ if (isCustom ) isRepeatModalOpen .value = true ;
304431 return false ;
305432 }
306433 } else {
307- const until = DateTime .fromISO (form . repeatEndOn , { zone: event .from .zone });
434+ const until = DateTime .fromISO (repeatEndOn , { zone: event .from .zone });
308435 if (! until .isValid || until .startOf (' day' ) < event .from .startOf (' day' )) {
309436 errors .badUntilDate = true ;
437+ if (isCustom ) isRepeatModalOpen .value = true ;
310438 return false ;
311439 }
312440 }
@@ -316,6 +444,7 @@ function validate(event: CalendarEvent): boolean {
316444}
317445
318446const titleInputField = useTemplateRef (' title-input-field' );
447+ const repeatSelect = useTemplateRef (' repeat-select' );
319448onMounted (async () => {
320449 titleInputField .value ?.focus (); // focus title field
321450
@@ -331,7 +460,7 @@ onMounted(async () => {
331460 </script >
332461
333462<template >
334- <div id =" event-modal" class =" modal" >
463+ <div id =" event-modal" class =" modal" :inert = " isRepeatModalOpen " >
335464 <form @submit.prevent =" saveEvent" :aria-busy =" isLocked" >
336465 <fieldset :disabled =" isLocked" >
337466 <input
@@ -378,14 +507,22 @@ onMounted(async () => {
378507
379508 <label >
380509 {{ $t('event.repeat.repeat') }}:
381- <select name =" repeat" v-model =" form.repeatFreq" >
382- <option v-for =" freq in frequencyOptions" :value =" freq.value" :key =" freq.label" >
383- {{ $t(`event.repeat.${freq.label}`) }}
510+ <select
511+ name =" repeat"
512+ v-model =" form.repeatSelection"
513+ ref =" repeat-select"
514+ @change =" handleRepeatSelectionChange"
515+ >
516+ <option v-for =" freq in frequencyOptions" :value =" freq.value" :key =" freq.value" >
517+ {{ $t(`event.repeat.${freq.value}`) }}
384518 </option >
385519 </select >
520+ <button v-if =" form.repeatSelection === 'custom'" type =" button" @click =" editCustomRepeat" >
521+ {{ $t('event.repeat.edit') }}
522+ </button >
386523 </label >
387524
388- <label v-if =" form.repeatFreq !== null " >
525+ <label v-if =" form.repeatSelection !== 'never' && form.repeatSelection !== 'custom' " >
389526 {{ $t('event.repeat.end') }}:
390527 <select name =" end" v-model =" form.repeatEnd" >
391528 <option v-for =" end in repeatEndOptions" :value =" end.value" :key =" end.label" >
@@ -459,6 +596,13 @@ onMounted(async () => {
459596 </form >
460597 </div >
461598
599+ <RepeatModal
600+ v-if =" isRepeatModalOpen "
601+ :model-value =" customRepeat "
602+ :start-date =" form .fromDate "
603+ @save =" saveCustomRepeat "
604+ @cancel =" cancelCustomRepeat "
605+ />
462606 <StrategyModal v-if =" strategyModal .isOpen .value " @cancel-update =" strategyModal .close " @update =" updateWithStrategy " />
463607</template >
464608
0 commit comments