Skip to content

Commit 02802ad

Browse files
committed
add complex repeat rules modal
1 parent d872719 commit 02802ad

4 files changed

Lines changed: 449 additions & 41 deletions

File tree

src/assets/locales/cs.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@
3333
"daily": "Denně",
3434
"weekly": "Týdně",
3535
"monthly": "Měsíčně",
36-
"yearly": "Ročně"
36+
"yearly": "Ročně",
37+
"custom": "Vlastní",
38+
"edit": "Upravit",
39+
"every": "Každých",
40+
"on": "Ve dnech",
41+
"ends": "Končí",
42+
"times": "opakováních"
3743
}
3844
},
3945
"calendar": {

src/assets/locales/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@
3333
"daily": "Daily",
3434
"weekly": "Weekly",
3535
"monthly": "Monthly",
36-
"yearly": "Yearly"
36+
"yearly": "Yearly",
37+
"custom": "Custom",
38+
"edit": "Edit",
39+
"every": "Every",
40+
"on": "On",
41+
"ends": "Ends",
42+
"times": "times"
3743
}
3844
},
3945
"calendar": {

src/components/modals/EventModal.vue

Lines changed: 183 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
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';
33
import { UpdateStrategy, type CalendarEvent } from '@/types/core';
44
import { DateTime } from 'luxon';
55
import { RRule, RRuleSet, rrulestr, type Frequency } from 'rrule';
66
import { CalendarCore } from '@/wasm/core-wrapper';
77
import { useEventModal } from '@/composables/modals/useEventModal';
88
import StrategyModal from '@/components/modals/StrategyModal.vue';
9+
import RepeatModal from '@/components/modals/RepeatModal.vue';
910
import { useStrategyModal } from '@/composables/modals/useStrategyModal';
1011
import { useAlertModal } from '@/composables/modals/useAlertModal';
1112
import { syncAllWrapper } from '@/services/gitSync';
@@ -18,17 +19,43 @@ const repeatEndOptions = [
1819
{ value: 'after', label: 'After' },
1920
];
2021
const 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+
2849
const thisModal = useEventModal();
2950
const strategyModal = useStrategyModal();
3051
const { 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+
3259
const isSaving = ref(false);
3360
const isDeleting = ref(false);
3461
const 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
});
5481
const 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+
125205
function reconstructEvent(): CalendarEvent {
126206
const event: CalendarEvent = {
127207
id: thisModal.event.value?.id,
@@ -148,25 +228,52 @@ function parseRepeat(repeat: string): RRuleSet {
148228
}
149229
150230
function 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
318446
const titleInputField = useTemplateRef('title-input-field');
447+
const repeatSelect = useTemplateRef('repeat-select');
319448
onMounted(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

Comments
 (0)