fix: implement recurrance configuration properly

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-29 15:29:10 -04:00
parent ee9495487e
commit 460c31e224
3 changed files with 710 additions and 139 deletions
+7 -6
View File
@@ -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"
+139 -40
View File
@@ -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>
+564 -93
View File
@@ -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>