fix: implement recurrance configuration properly
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -223,6 +223,13 @@ watch(
|
||||
@update:time-zone="entityObject!.timeZone = $event"
|
||||
/>
|
||||
|
||||
<EventEditorOccurrence
|
||||
:mode="mode"
|
||||
:pattern="entityObject!.pattern"
|
||||
:starts-on="entityObject!.startsOn"
|
||||
@update:pattern="entityObject!.pattern = $event"
|
||||
/>
|
||||
|
||||
<EventEditorLocations
|
||||
:mode="mode"
|
||||
:locations-physical="entityObject!.locationsPhysical || {}"
|
||||
@@ -249,12 +256,6 @@ watch(
|
||||
@remove-notification="removeNotification"
|
||||
/>
|
||||
|
||||
<EventEditorOccurrence
|
||||
:mode="mode"
|
||||
:pattern="entityObject!.pattern"
|
||||
@update:pattern="entityObject!.pattern = $event"
|
||||
/>
|
||||
|
||||
<EventEditorTags
|
||||
:mode="mode"
|
||||
:tags="entityObject!.tags"
|
||||
|
||||
@@ -30,7 +30,10 @@ const timeZoneValue = computed({
|
||||
|
||||
// Format helpers
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toISOString().split('T')[0]
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
@@ -45,16 +48,57 @@ const formatDateTime = (isoString: string | null | undefined): string => {
|
||||
|
||||
const parseDateTime = (date: string, time: string): Date => {
|
||||
if (timelessValue.value) {
|
||||
return new Date(date)
|
||||
return new Date(`${date}T00:00:00`)
|
||||
}
|
||||
return new Date(`${date}T${time}`)
|
||||
}
|
||||
|
||||
const formatDisplayDate = (value: string): string => {
|
||||
if (!value) return ''
|
||||
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatDisplayTime = (value: string): string => {
|
||||
if (!value) return ''
|
||||
const [hour, minute] = value.split(':').map(Number)
|
||||
const date = new Date()
|
||||
date.setHours(hour, minute, 0, 0)
|
||||
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00:00:00`) : null
|
||||
|
||||
const normalizePickerDate = (value: unknown): string => {
|
||||
const selected = Array.isArray(value) ? value[0] : value
|
||||
const date = selected instanceof Date ? selected : new Date(String(selected))
|
||||
return Number.isNaN(date.getTime()) ? '' : formatDate(date)
|
||||
}
|
||||
|
||||
// Date/time fields
|
||||
const startDate = ref('')
|
||||
const startTime = ref('')
|
||||
const endDate = ref('')
|
||||
const endTime = ref('')
|
||||
const startDateMenu = ref(false)
|
||||
const startTimeMenu = ref(false)
|
||||
const endDateMenu = ref(false)
|
||||
const endTimeMenu = ref(false)
|
||||
|
||||
const selectStartDate = (value: unknown) => {
|
||||
const date = normalizePickerDate(value)
|
||||
if (date) startDate.value = date
|
||||
startDateMenu.value = false
|
||||
}
|
||||
|
||||
const selectEndDate = (value: unknown) => {
|
||||
const date = normalizePickerDate(value)
|
||||
if (date) endDate.value = date
|
||||
endDateMenu.value = false
|
||||
}
|
||||
|
||||
// Initialize date/time from props
|
||||
watch(() => props.startsOn, (newValue) => {
|
||||
@@ -81,17 +125,23 @@ watch(() => props.endsOn, (newValue) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Update parent when date/time changes
|
||||
watch([startDate, startTime], () => {
|
||||
if (props.mode === 'edit') {
|
||||
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
|
||||
}
|
||||
})
|
||||
const ensureValidRange = (): boolean => {
|
||||
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
|
||||
const start = parseDateTime(startDate.value, startTime.value)
|
||||
const end = parseDateTime(endDate.value, endTime.value)
|
||||
if (end.getTime() >= start.getTime()) return false
|
||||
|
||||
watch([endDate, endTime], () => {
|
||||
if (props.mode === 'edit') {
|
||||
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
|
||||
}
|
||||
const correctedEnd = new Date(start)
|
||||
if (!timelessValue.value) correctedEnd.setHours(correctedEnd.getHours() + 1)
|
||||
endDate.value = formatDate(correctedEnd)
|
||||
endTime.value = formatTime(correctedEnd)
|
||||
return true
|
||||
}
|
||||
|
||||
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
|
||||
if (props.mode !== 'edit' || ensureValidRange()) return
|
||||
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
|
||||
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -127,22 +177,46 @@ watch([endDate, endTime], () => {
|
||||
<div class="text-subtitle-2 mb-2">Start</div>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="startDate"
|
||||
label="Start Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-menu v-model="startDateMenu" :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-bind="activatorProps"
|
||||
:model-value="formatDisplayDate(startDate)"
|
||||
label="Start date"
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="pickerDate(startDate)"
|
||||
color="primary"
|
||||
show-adjacent-months
|
||||
@update:model-value="selectStartDate"
|
||||
/>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
<v-col v-if="!timelessValue" cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="startTime"
|
||||
label="Start Time"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-menu v-model="startTimeMenu" :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-bind="activatorProps"
|
||||
:model-value="formatDisplayTime(startTime)"
|
||||
label="Start time"
|
||||
prepend-inner-icon="mdi-clock-outline"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-time-picker v-model="startTime" color="primary" format="ampm" />
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="startTimeMenu = false">Done</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
@@ -151,22 +225,47 @@ watch([endDate, endTime], () => {
|
||||
<div class="text-subtitle-2 mb-2">End</div>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="endDate"
|
||||
label="End Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-menu v-model="endDateMenu" :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-bind="activatorProps"
|
||||
:model-value="formatDisplayDate(endDate)"
|
||||
label="End date"
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="pickerDate(endDate)"
|
||||
:min="pickerDate(startDate)"
|
||||
color="primary"
|
||||
show-adjacent-months
|
||||
@update:model-value="selectEndDate"
|
||||
/>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
<v-col v-if="!timelessValue" cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="endTime"
|
||||
label="End Time"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-menu v-model="endTimeMenu" :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-bind="activatorProps"
|
||||
:model-value="formatDisplayTime(endTime)"
|
||||
label="End time"
|
||||
prepend-inner-icon="mdi-clock-outline"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-time-picker v-model="endTime" color="primary" format="ampm" />
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="endTimeMenu = false">Done</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { EventOccurrence } from '@ChronoManager/types/event'
|
||||
|
||||
type Frequency = 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
type EndMode = 'never' | 'date' | 'count'
|
||||
type RecurrencePattern = EventOccurrence & { toJson?: () => EventOccurrence }
|
||||
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
pattern: any | null
|
||||
pattern: RecurrencePattern | null
|
||||
startsOn?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:pattern': [pattern: any | null]
|
||||
'update:pattern': [pattern: EventOccurrence | null]
|
||||
}>()
|
||||
|
||||
const precisionOptions = [
|
||||
{ title: 'Yearly', value: 'yearly' },
|
||||
{ title: 'Monthly', value: 'monthly' },
|
||||
{ title: 'Weekly', value: 'weekly' },
|
||||
const precisionOptions: { title: string, value: Frequency }[] = [
|
||||
{ title: 'Daily', value: 'daily' },
|
||||
{ title: 'Hourly', value: 'hourly' },
|
||||
{ title: 'Minutely', value: 'minutely' },
|
||||
{ title: 'Secondly', value: 'secondly' }
|
||||
{ title: 'Weekly', value: 'weekly' },
|
||||
{ title: 'Monthly', value: 'monthly' },
|
||||
{ title: 'Yearly', value: 'yearly' },
|
||||
]
|
||||
|
||||
const patternTypeOptions = [
|
||||
{ title: 'Absolute', value: 'absolute' },
|
||||
{ title: 'Relative', value: 'relative' }
|
||||
const weekdayOptions = [
|
||||
{ title: 'Monday', short: 'Mon', value: 1 },
|
||||
{ title: 'Tuesday', short: 'Tue', value: 2 },
|
||||
{ title: 'Wednesday', short: 'Wed', value: 3 },
|
||||
{ title: 'Thursday', short: 'Thu', value: 4 },
|
||||
{ title: 'Friday', short: 'Fri', value: 5 },
|
||||
{ title: 'Saturday', short: 'Sat', value: 6 },
|
||||
{ title: 'Sunday', short: 'Sun', value: 7 },
|
||||
]
|
||||
|
||||
const formatRecurrence = (pattern: any) => {
|
||||
if (!pattern) return 'No recurrence'
|
||||
|
||||
const parts = []
|
||||
parts.push(`Every ${pattern.interval || 1}`)
|
||||
parts.push(pattern.precision || 'day')
|
||||
|
||||
if (pattern.iterations) {
|
||||
parts.push(`(${pattern.iterations} times)`)
|
||||
} else if (pattern.concludes) {
|
||||
parts.push(`until ${new Date(pattern.concludes).toLocaleDateString()}`)
|
||||
const monthOptions = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
].map((title, index) => ({ title, value: index + 1 }))
|
||||
|
||||
const monthDayOptions = Array.from({ length: 31 }, (_, index) => index + 1)
|
||||
|
||||
const positionOptions = [
|
||||
{ title: 'First', value: 1 },
|
||||
{ title: 'Second', value: 2 },
|
||||
{ title: 'Third', value: 3 },
|
||||
{ title: 'Fourth', value: 4 },
|
||||
{ title: 'Last', value: -1 },
|
||||
]
|
||||
|
||||
const frequencyUnits: Record<Frequency, [string, string]> = {
|
||||
daily: ['day', 'days'],
|
||||
weekly: ['week', 'weeks'],
|
||||
monthly: ['month', 'months'],
|
||||
yearly: ['year', 'years'],
|
||||
}
|
||||
|
||||
const endOptions: { title: string, value: EndMode }[] = [
|
||||
{ title: 'Never', value: 'never' },
|
||||
{ title: 'On', value: 'date' },
|
||||
{ title: 'After', value: 'count' },
|
||||
]
|
||||
|
||||
const endDateMenu = ref(false)
|
||||
|
||||
const recurrenceRuleFields: (keyof EventOccurrence)[] = [
|
||||
'onDayOfWeek', 'onDayOfMonth', 'onDayOfYear', 'onWeekOfMonth',
|
||||
'onWeekOfYear', 'onMonthOfYear', 'onHour', 'onMinute', 'onSecond', 'onPosition',
|
||||
]
|
||||
|
||||
const toPlainPattern = (): EventOccurrence | null => {
|
||||
if (!props.pattern) return null
|
||||
return props.pattern.toJson ? props.pattern.toJson() : { ...props.pattern }
|
||||
}
|
||||
|
||||
const updatePattern = (changes: Partial<EventOccurrence>) => {
|
||||
const current = toPlainPattern() ?? { pattern: 'absolute', precision: 'daily', interval: 1 }
|
||||
emit('update:pattern', { ...current, ...changes })
|
||||
}
|
||||
|
||||
const getStartDate = () => {
|
||||
const date = props.startsOn ? new Date(props.startsOn) : new Date()
|
||||
return Number.isNaN(date.getTime()) ? new Date() : date
|
||||
}
|
||||
|
||||
const getDateDefaults = () => {
|
||||
const date = getStartDate()
|
||||
const day = date.getDate()
|
||||
const weekday = date.getDay() || 7
|
||||
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
||||
const position = day + 7 > daysInMonth ? -1 : Math.ceil(day / 7)
|
||||
return { day, month: date.getMonth() + 1, weekday, position }
|
||||
}
|
||||
|
||||
const repeatValue = computed<Frequency>(() => props.pattern?.precision as Frequency ?? 'daily')
|
||||
const endMode = computed<EndMode>(() => {
|
||||
if (props.pattern?.iterations) return 'count'
|
||||
if (props.pattern?.concludes) return 'date'
|
||||
return 'never'
|
||||
})
|
||||
|
||||
const intervalUnit = computed(() => {
|
||||
const frequency = props.pattern?.precision as Frequency ?? 'daily'
|
||||
const interval = props.pattern?.interval || 1
|
||||
const units = frequencyUnits[frequency] ?? ['period', 'periods']
|
||||
return units[interval === 1 ? 0 : 1]
|
||||
})
|
||||
|
||||
const changeFrequency = (frequency: Frequency) => {
|
||||
const defaults = getDateDefaults()
|
||||
const changes: Partial<EventOccurrence> = { precision: frequency, pattern: 'absolute' }
|
||||
for (const field of recurrenceRuleFields) {
|
||||
;(changes as Record<string, unknown>)[field] = null
|
||||
}
|
||||
|
||||
if (frequency === 'weekly') changes.onDayOfWeek = [defaults.weekday]
|
||||
if (frequency === 'monthly') changes.onDayOfMonth = [defaults.day]
|
||||
if (frequency === 'yearly') {
|
||||
changes.onMonthOfYear = [defaults.month]
|
||||
changes.onDayOfMonth = [defaults.day]
|
||||
}
|
||||
updatePattern(changes)
|
||||
}
|
||||
|
||||
const addRecurrence = () => {
|
||||
emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })
|
||||
}
|
||||
|
||||
const changePatternType = (patternType: 'absolute' | 'relative') => {
|
||||
const defaults = getDateDefaults()
|
||||
const yearly = props.pattern?.precision === 'yearly'
|
||||
updatePattern(patternType === 'absolute' ? {
|
||||
pattern: 'absolute',
|
||||
onDayOfWeek: null,
|
||||
onDayOfMonth: props.pattern?.onDayOfMonth?.length ? props.pattern.onDayOfMonth : [defaults.day],
|
||||
onMonthOfYear: yearly
|
||||
? (props.pattern?.onMonthOfYear?.length ? props.pattern.onMonthOfYear : [defaults.month])
|
||||
: null,
|
||||
onPosition: null,
|
||||
} : {
|
||||
pattern: 'relative',
|
||||
onDayOfWeek: props.pattern?.onDayOfWeek?.length ? props.pattern.onDayOfWeek : [defaults.weekday],
|
||||
onDayOfMonth: null,
|
||||
onMonthOfYear: yearly
|
||||
? (props.pattern?.onMonthOfYear?.length ? props.pattern.onMonthOfYear : [defaults.month])
|
||||
: null,
|
||||
onPosition: props.pattern?.onPosition?.length ? props.pattern.onPosition : [defaults.position],
|
||||
})
|
||||
}
|
||||
|
||||
const updatePosition = (position: number | null) => {
|
||||
updatePattern({ onPosition: position === null ? [] : [position] })
|
||||
}
|
||||
|
||||
const updateEndMode = (mode: EndMode) => {
|
||||
if (mode === 'never') {
|
||||
updatePattern({ iterations: null, concludes: null })
|
||||
} else if (mode === 'count') {
|
||||
updatePattern({ iterations: props.pattern?.iterations || 10, concludes: null })
|
||||
} else {
|
||||
const conclusion = new Date(getStartDate())
|
||||
conclusion.setMonth(conclusion.getMonth() + 1)
|
||||
updatePattern({ iterations: null, concludes: props.pattern?.concludes || conclusion.toISOString().slice(0, 10) })
|
||||
}
|
||||
}
|
||||
|
||||
const pickerDate = (value: string | null | undefined): Date | null => {
|
||||
return value ? new Date(`${value.slice(0, 10)}T00:00:00`) : null
|
||||
}
|
||||
|
||||
const formatDisplayDate = (value: string | null | undefined): string => {
|
||||
const date = pickerDate(value)
|
||||
return date ? date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : ''
|
||||
}
|
||||
|
||||
const selectConclusionDate = (value: unknown) => {
|
||||
const selected = Array.isArray(value) ? value[0] : value
|
||||
const date = selected instanceof Date ? selected : new Date(String(selected))
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
updatePattern({ concludes: `${year}-${month}-${day}`, iterations: null })
|
||||
}
|
||||
endDateMenu.value = false
|
||||
}
|
||||
|
||||
const weekdayName = (day: number) => weekdayOptions.find(option => option.value === day)?.short ?? String(day)
|
||||
const monthName = (month: number) => monthOptions.find(option => option.value === month)?.title ?? String(month)
|
||||
|
||||
const formatRecurrence = (pattern: RecurrencePattern) => {
|
||||
const interval = pattern.interval || 1
|
||||
const frequency = pattern.precision
|
||||
const units = frequencyUnits[frequency as Frequency] ?? [frequency, frequency]
|
||||
const unit = units[interval === 1 ? 0 : 1]
|
||||
const parts = [`Every ${interval > 1 ? `${interval} ` : ''}${unit}`]
|
||||
|
||||
if (frequency === 'weekly' && pattern.onDayOfWeek?.length) {
|
||||
parts.push(`on ${pattern.onDayOfWeek.map(weekdayName).join(', ')}`)
|
||||
}
|
||||
|
||||
if (frequency === 'monthly' || frequency === 'yearly') {
|
||||
if (frequency === 'yearly' && pattern.onMonthOfYear?.length) {
|
||||
parts.push(`in ${pattern.onMonthOfYear.map(monthName).join(', ')}`)
|
||||
}
|
||||
if (pattern.pattern === 'absolute' && pattern.onDayOfMonth?.length) {
|
||||
parts.push(`on day ${pattern.onDayOfMonth.join(', ')}`)
|
||||
} else if (pattern.onPosition?.length && pattern.onDayOfWeek?.length) {
|
||||
const position = positionOptions.find(option => option.value === pattern.onPosition?.[0])?.title
|
||||
parts.push(`on the ${position?.toLowerCase()} ${pattern.onDayOfWeek.map(weekdayName).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (pattern.iterations) {
|
||||
parts.push(`for ${pattern.iterations} occurrences`)
|
||||
} else if (pattern.concludes) {
|
||||
parts.push(`until ${new Date(`${pattern.concludes.slice(0, 10)}T00:00:00`).toLocaleDateString()}`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
</script>
|
||||
@@ -46,92 +226,265 @@ const formatRecurrence = (pattern: any) => {
|
||||
<div v-if="pattern || mode === 'edit'" class="event-editor-section">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-subtitle-1">Recurrence</div>
|
||||
<v-btn v-if="mode === 'edit' && !pattern"
|
||||
<v-btn
|
||||
v-if="mode === 'edit' && !pattern"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="$emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })">
|
||||
<v-icon left>mdi-plus</v-icon>
|
||||
Add Recurrence
|
||||
@click="addRecurrence"
|
||||
>
|
||||
<v-icon start icon="mdi-plus" />
|
||||
Add
|
||||
</v-btn>
|
||||
<v-btn v-else-if="mode === 'edit' && pattern"
|
||||
<v-btn
|
||||
v-else-if="mode === 'edit'"
|
||||
icon="mdi-delete"
|
||||
aria-label="Remove recurrence"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="$emit('update:pattern', null)">
|
||||
Remove
|
||||
</v-btn>
|
||||
variant="text"
|
||||
@click="$emit('update:pattern', null)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view' && pattern" class="mb-4">
|
||||
<div class="pa-2 border rounded">
|
||||
<div class="mb-1"><strong>{{ formatRecurrence(pattern) }}</strong></div>
|
||||
<div v-if="pattern.onDayOfWeek && pattern.onDayOfWeek.length > 0" class="text-caption text-grey">
|
||||
Days: {{ pattern.onDayOfWeek.join(', ') }}
|
||||
</div>
|
||||
<div class="recurrence-summary pa-3 rounded">
|
||||
<v-icon icon="mdi-repeat" size="small" class="mr-2" />
|
||||
<strong>{{ formatRecurrence(pattern) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else-if="mode === 'edit' && pattern" class="pa-3 border rounded">
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="pattern.pattern"
|
||||
:items="patternTypeOptions"
|
||||
label="Pattern Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="pattern.precision"
|
||||
:items="precisionOptions"
|
||||
label="Frequency"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="4">
|
||||
<div v-else-if="mode === 'edit' && pattern" class="recurrence-editor pa-3 rounded">
|
||||
<div class="recurrence-controls mb-4">
|
||||
<span>Repeat</span>
|
||||
<v-select
|
||||
:model-value="repeatValue"
|
||||
:items="precisionOptions"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="frequency-field"
|
||||
@update:model-value="changeFrequency"
|
||||
/>
|
||||
<template v-if="pattern">
|
||||
<span>Every</span>
|
||||
<v-text-field
|
||||
v-model.number="pattern.interval"
|
||||
label="Interval"
|
||||
:model-value="pattern.interval"
|
||||
aria-label="Repeat interval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => v > 0 || 'Must be greater than 0']"
|
||||
hide-details
|
||||
class="interval-field"
|
||||
@update:model-value="updatePattern({ interval: Math.max(1, Number($event) || 1) })"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model.number="pattern.iterations"
|
||||
label="Iterations"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="Leave empty for infinite"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="pattern.concludes"
|
||||
label="End Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="text-caption text-grey mt-2">
|
||||
Advanced recurrence rules (like specific days of week) can be added to the pattern object
|
||||
<span>{{ intervalUnit }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<v-expand-transition>
|
||||
<div v-if="pattern" class="mt-4">
|
||||
<div v-if="pattern.precision === 'weekly'" class="weekly-on-controls mb-4">
|
||||
<span>On days</span>
|
||||
<v-btn-toggle
|
||||
:model-value="pattern.onDayOfWeek || []"
|
||||
color="primary"
|
||||
multiple
|
||||
mandatory
|
||||
divided
|
||||
variant="outlined"
|
||||
class="weekday-toggle"
|
||||
@update:model-value="updatePattern({ onDayOfWeek: $event })"
|
||||
>
|
||||
<v-btn
|
||||
v-for="day in weekdayOptions"
|
||||
:key="day.value"
|
||||
:value="day.value"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ day.short.slice(0, 2) }}
|
||||
<v-tooltip activator="parent" location="top">{{ day.title }}</v-tooltip>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<div v-if="pattern.precision === 'monthly'" class="monthly-rule-controls mb-4">
|
||||
<span>Using</span>
|
||||
<v-btn-toggle
|
||||
:model-value="pattern.pattern"
|
||||
color="primary"
|
||||
mandatory
|
||||
divided
|
||||
variant="outlined"
|
||||
class="rule-toggle"
|
||||
@update:model-value="changePatternType"
|
||||
>
|
||||
<v-btn value="absolute">Calendar date</v-btn>
|
||||
<v-btn value="relative">Weekday pattern</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-select
|
||||
v-if="pattern.pattern === 'absolute'"
|
||||
:model-value="pattern.onDayOfMonth?.[0] ?? null"
|
||||
:items="monthDayOptions"
|
||||
label="Day"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="monthly-day-field"
|
||||
@update:model-value="updatePattern({ onDayOfMonth: [$event] })"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<v-select
|
||||
:model-value="pattern.onPosition?.[0] ?? null"
|
||||
:items="positionOptions"
|
||||
label="Which"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="monthly-position-field"
|
||||
@update:model-value="updatePosition"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="pattern.onDayOfWeek?.[0] ?? null"
|
||||
:items="weekdayOptions"
|
||||
label="Weekday"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="monthly-weekday-field"
|
||||
@update:model-value="updatePattern({ onDayOfWeek: [$event] })"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pattern.precision === 'yearly'" class="yearly-rule-controls mb-4">
|
||||
<span>Using</span>
|
||||
<v-btn-toggle
|
||||
:model-value="pattern.pattern"
|
||||
color="primary"
|
||||
mandatory
|
||||
divided
|
||||
variant="outlined"
|
||||
class="rule-toggle"
|
||||
@update:model-value="changePatternType"
|
||||
>
|
||||
<v-btn value="absolute">Calendar date</v-btn>
|
||||
<v-btn value="relative">Weekday pattern</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-select
|
||||
:model-value="pattern.onMonthOfYear?.[0] ?? null"
|
||||
:items="monthOptions"
|
||||
label="Month"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="yearly-month-field"
|
||||
@update:model-value="updatePattern({ onMonthOfYear: [$event] })"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-if="pattern.pattern === 'absolute'"
|
||||
:model-value="pattern.onDayOfMonth?.[0] ?? null"
|
||||
:items="monthDayOptions"
|
||||
label="Day"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="monthly-day-field"
|
||||
@update:model-value="updatePattern({ onDayOfMonth: [$event] })"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<v-select
|
||||
:model-value="pattern.onPosition?.[0] ?? null"
|
||||
:items="positionOptions"
|
||||
label="Which"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="monthly-position-field"
|
||||
@update:model-value="updatePosition"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="pattern.onDayOfWeek?.[0] ?? null"
|
||||
:items="weekdayOptions"
|
||||
label="Weekday"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="monthly-weekday-field"
|
||||
@update:model-value="updatePattern({ onDayOfWeek: [$event] })"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="recurrence-end-controls mb-4">
|
||||
<span>Ends</span>
|
||||
<v-select
|
||||
:model-value="endMode"
|
||||
:items="endOptions"
|
||||
aria-label="Recurrence end"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="end-mode-field"
|
||||
@update:model-value="updateEndMode"
|
||||
/>
|
||||
|
||||
<v-menu
|
||||
v-if="endMode === 'date'"
|
||||
v-model="endDateMenu"
|
||||
:close-on-content-click="false"
|
||||
location="bottom start"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-bind="activatorProps"
|
||||
:model-value="formatDisplayDate(pattern.concludes)"
|
||||
aria-label="End date"
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
readonly
|
||||
class="end-date-field"
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="pickerDate(pattern.concludes)"
|
||||
:min="pickerDate(startsOn)"
|
||||
color="primary"
|
||||
show-adjacent-months
|
||||
@update:model-value="selectConclusionDate"
|
||||
/>
|
||||
</v-menu>
|
||||
|
||||
<v-text-field
|
||||
v-else-if="endMode === 'count'"
|
||||
:model-value="pattern.iterations"
|
||||
aria-label="Number of occurrences"
|
||||
type="number"
|
||||
min="1"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="interval-field"
|
||||
@update:model-value="updatePattern({ iterations: Math.max(1, Number($event) || 1), concludes: null })"
|
||||
/>
|
||||
<span v-if="endMode === 'count'">occurrences</span>
|
||||
</div>
|
||||
|
||||
<div class="recurrence-summary pa-3 rounded">
|
||||
<v-icon icon="mdi-repeat" size="small" class="mr-2" />
|
||||
{{ formatRecurrence(pattern) }}
|
||||
</div>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,4 +493,122 @@ const formatRecurrence = (pattern: any) => {
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.recurrence-editor {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.recurrence-summary {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.recurrence-controls,
|
||||
.recurrence-end-controls,
|
||||
.weekly-on-controls,
|
||||
.monthly-rule-controls,
|
||||
.yearly-rule-controls,
|
||||
.recurrence-sentence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recurrence-controls,
|
||||
.recurrence-end-controls,
|
||||
.weekly-on-controls,
|
||||
.monthly-rule-controls,
|
||||
.yearly-rule-controls {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.frequency-field {
|
||||
flex: 0 1 9.5rem;
|
||||
min-width: 0;
|
||||
max-width: 9.5rem;
|
||||
}
|
||||
|
||||
.interval-field {
|
||||
flex: 0 0 5rem;
|
||||
}
|
||||
|
||||
.end-mode-field {
|
||||
flex: 0 0 7rem;
|
||||
max-width: 7rem;
|
||||
}
|
||||
|
||||
.end-date-field {
|
||||
flex: 0 1 12rem;
|
||||
min-width: 0;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.weekday-toggle,
|
||||
.rule-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.weekday-toggle :deep(.v-btn) {
|
||||
flex: 1 1 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
min-height: 40px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.35) !important;
|
||||
}
|
||||
|
||||
.weekday-toggle :deep(.v-btn--active) {
|
||||
border-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.weekly-on-controls .weekday-toggle {
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.monthly-rule-controls .rule-toggle,
|
||||
.yearly-rule-controls .rule-toggle {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.monthly-rule-controls .rule-toggle :deep(.v-btn),
|
||||
.yearly-rule-controls .rule-toggle :deep(.v-btn) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.monthly-day-field {
|
||||
flex: 0 0 6rem;
|
||||
max-width: 6rem;
|
||||
}
|
||||
|
||||
.monthly-position-field {
|
||||
flex: 0 0 6.5rem;
|
||||
max-width: 6.5rem;
|
||||
}
|
||||
|
||||
.monthly-weekday-field {
|
||||
flex: 0 1 8.5rem;
|
||||
min-width: 0;
|
||||
max-width: 8.5rem;
|
||||
}
|
||||
|
||||
.yearly-month-field {
|
||||
flex: 0 1 8rem;
|
||||
min-width: 0;
|
||||
max-width: 8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.rule-toggle :deep(.v-btn) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user