Files
chrono/src/components/editors/EventEditorOccurrence.vue
T
Sebastian df531908f3 refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-06 21:09:07 -04:00

608 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import type { EventOccurrence } from '@ChronoManager/types/event'
import { formatDisplayDate, formatLocalIsoDate } from '@/utils/format'
type Frequency = 'daily' | 'weekly' | 'monthly' | 'yearly'
type EndMode = 'never' | 'date' | 'count'
type RecurrencePattern = EventOccurrence & { toJson?: () => EventOccurrence }
interface Props {
mode: 'edit' | 'view'
pattern: RecurrencePattern | null
startsOn?: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:pattern': [pattern: EventOccurrence | null]
}>()
const precisionOptions: { title: string, value: Frequency }[] = [
{ title: 'Daily', value: 'daily' },
{ title: 'Weekly', value: 'weekly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Yearly', value: 'yearly' },
]
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 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 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())) {
updatePattern({ concludes: formatLocalIsoDate(date), 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>
<template>
<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"
size="small"
variant="outlined"
@click="addRecurrence"
>
<v-icon start icon="mdi-plus" />
Add
</v-btn>
<v-btn
v-else-if="mode === 'edit'"
icon="mdi-delete"
aria-label="Remove recurrence"
size="small"
color="error"
variant="text"
@click="$emit('update:pattern', null)"
/>
</div>
<div v-if="mode === 'view' && pattern" class="mb-4">
<div class="recurrence-summary pa-3 rounded">
<v-icon icon="mdi-repeat" size="small" class="mr-2" />
<strong>{{ formatRecurrence(pattern) }}</strong>
</div>
</div>
<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
:model-value="pattern.interval"
aria-label="Repeat interval"
type="number"
min="1"
max="999"
variant="outlined"
density="compact"
hide-details
class="interval-field"
@update:model-value="updatePattern({ interval: Math.max(1, Number($event) || 1) })"
/>
<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>
<style scoped>
.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>