Event search (Ctrl+F), locale/RTL handling, weekday selector workday/weekend, refactored event handling, Firefox compatibility (#3)
Major refactoring for cleanup, with various bugfixes. Weekday selector in Settings now shows workdays/weekend bars based on current locale (also used to choose default values). Weekday selector in event dialog uses the days set in settings, as expected.
This commit was merged in pull request #3.
This commit is contained in:
+3
-9
@@ -1,12 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<meta charset="utf-8">
|
||||
<title>Calendar</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<div id="app"></div>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 10 KiB |
+16
-26
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import CalendarView from './components/CalendarView.vue'
|
||||
import EventDialog from './components/EventDialog.vue'
|
||||
import { useCalendarStore } from './stores/CalendarStore'
|
||||
import { lang } from './utils/locale'
|
||||
import { formatTodayString } from './utils/date'
|
||||
|
||||
const eventDialog = ref(null)
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
// Initialize holidays when app starts
|
||||
@@ -35,38 +35,28 @@ function handleGlobalKey(e) {
|
||||
onMounted(() => {
|
||||
calendarStore.initializeHolidaysFromConfig()
|
||||
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
||||
// Set document language via shared util
|
||||
if (lang) document.documentElement.setAttribute('lang', lang)
|
||||
// Initialize title
|
||||
document.title = formatTodayString(new Date(calendarStore.now))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKey)
|
||||
})
|
||||
|
||||
const handleCreateEvent = (eventData) => {
|
||||
if (eventDialog.value) {
|
||||
const selectionData = {
|
||||
startDate: eventData.startDate,
|
||||
dayCount: eventData.dayCount,
|
||||
}
|
||||
setTimeout(() => eventDialog.value.openCreateDialog(selectionData), 50)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditEvent = (eventClickPayload) => {
|
||||
if (eventDialog.value) {
|
||||
eventDialog.value.openEditDialog(eventClickPayload)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearSelection = () => {}
|
||||
// Watch today's date to update document title
|
||||
watch(
|
||||
() => calendarStore.now,
|
||||
(val) => {
|
||||
document.title = formatTodayString(new Date(val))
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarView @create-event="handleCreateEvent" @edit-event="handleEditEvent" />
|
||||
<EventDialog
|
||||
ref="eventDialog"
|
||||
:selection="{ startDate: null, dayCount: 0 }"
|
||||
@clear-selection="handleClearSelection"
|
||||
/>
|
||||
<CalendarView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -131,7 +131,7 @@ header {
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.month-name-label > span {
|
||||
|
||||
@@ -79,10 +79,10 @@ const modalStyle = computed(() => {
|
||||
if (modalRef.value && props.modelValue) {
|
||||
const style = {
|
||||
transform: 'none',
|
||||
left: modalPosition.value.x + 'px',
|
||||
insetInlineStart: modalPosition.value.x + 'px',
|
||||
top: modalPosition.value.y + 'px',
|
||||
bottom: 'auto',
|
||||
right: 'auto',
|
||||
insetInlineEnd: 'auto',
|
||||
}
|
||||
if (hasMoved.value) {
|
||||
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
|
||||
|
||||
@@ -21,13 +21,10 @@ const props = defineProps({
|
||||
]"
|
||||
:data-date="props.day.date"
|
||||
>
|
||||
<h1>{{ props.day.displayText }}</h1>
|
||||
<h1 class="day-number">{{ props.day.displayText }}</h1>
|
||||
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
||||
|
||||
<div v-if="props.day.holiday" class="holiday-info">
|
||||
<span class="holiday-name" :title="props.day.holiday.name">
|
||||
{{ props.day.holiday.name }}
|
||||
</span>
|
||||
<div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name">
|
||||
{{ props.day.holiday.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,22 +32,30 @@ const props = defineProps({
|
||||
<style scoped>
|
||||
.cell {
|
||||
position: relative;
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-inline-end: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
display: grid;
|
||||
/* 3 columns: day number, flexible space, lunar phase */
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
/* 3 rows: header, flexible filler, holiday label */
|
||||
grid-template-rows: auto 1fr auto;
|
||||
/* Named grid areas (only ones actually used) */
|
||||
grid-template-areas:
|
||||
'day-number . lunar-phase'
|
||||
'day-number . lunar-phase'
|
||||
'holiday-info holiday-info holiday-info';
|
||||
/* Explicit areas mainly for clarity */
|
||||
grid-auto-flow: row;
|
||||
padding: 0.25em;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: var(--row-h);
|
||||
font-weight: 700;
|
||||
transition: background-color 0.15s ease;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cell h1 {
|
||||
.cell h1.day-number {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 1.5em;
|
||||
@@ -58,15 +63,16 @@ const props = defineProps({
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
transition: background-color 0.15s ease;
|
||||
grid-area: day-number;
|
||||
}
|
||||
.cell.weekend h1 {
|
||||
.cell.weekend h1.day-number {
|
||||
color: var(--weekend);
|
||||
}
|
||||
.cell.firstday h1 {
|
||||
.cell.firstday h1.day-number {
|
||||
color: var(--firstday);
|
||||
text-shadow: 0 0 0.1em var(--strong);
|
||||
}
|
||||
.cell.today h1 {
|
||||
.cell.today h1.day-number {
|
||||
border-radius: 2em;
|
||||
background: var(--today);
|
||||
border: 0.2em solid var(--today);
|
||||
@@ -77,16 +83,9 @@ const props = defineProps({
|
||||
.cell.selected {
|
||||
filter: hue-rotate(180deg);
|
||||
}
|
||||
.cell.selected h1 {
|
||||
.cell.selected h1.day-number {
|
||||
color: var(--strong);
|
||||
}
|
||||
.lunar-phase {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.2em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.cell.holiday {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
@@ -103,27 +102,32 @@ const props = defineProps({
|
||||
);
|
||||
}
|
||||
}
|
||||
.cell.holiday h1 {
|
||||
.cell.holiday h1.day-number {
|
||||
/* Slight emphasis without forcing a specific hue */
|
||||
color: var(--holiday);
|
||||
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.holiday-info {
|
||||
position: absolute;
|
||||
bottom: 0.1em;
|
||||
left: 0.1em;
|
||||
right: 0.1em;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
font-size: clamp(1.2vw, 0.6em, 1em);
|
||||
.lunar-phase {
|
||||
grid-area: lunar-phase;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
margin-top: 0.5em;
|
||||
margin-inline-end: 0.2em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.holiday-name {
|
||||
display: block;
|
||||
color: var(--holiday-label);
|
||||
padding: 0.15em 0.35em 0.15em 0.25em;
|
||||
.holiday-info {
|
||||
grid-area: holiday-info;
|
||||
align-self: end;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--holiday-label);
|
||||
font-size: clamp(1.2vw, 0.6em, 1em);
|
||||
line-height: 1;
|
||||
padding-inline: 0.15em;
|
||||
padding-block-end: 0.05em;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,8 +78,7 @@ function changeYear(y) {
|
||||
|
||||
const weekdayNames = computed(() => {
|
||||
// Reorder names & weekend flags
|
||||
const mondayFirstNames = getLocalizedWeekdayNames()
|
||||
const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)]
|
||||
const sundayFirstNames = getLocalizedWeekdayNames()
|
||||
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
|
||||
const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day)
|
||||
|
||||
|
||||
+320
-46
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||
@@ -13,9 +13,21 @@ import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date'
|
||||
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
||||
import { addDays, differenceInWeeks } from 'date-fns'
|
||||
import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
|
||||
import { rtl } from '@/utils/locale'
|
||||
import EventDialog from '@/components/EventDialog.vue'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const emit = defineEmits(['create-event', 'edit-event'])
|
||||
defineEmits([]) // previously emitted create/edit events externally
|
||||
import { shallowRef } from 'vue'
|
||||
const eventDialogRef = shallowRef(null)
|
||||
function openCreateEventDialog(eventData) {
|
||||
if (!eventDialogRef.value) return
|
||||
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
|
||||
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
|
||||
}
|
||||
function openEditEventDialog(eventClickPayload) {
|
||||
eventDialogRef.value?.openEditDialog(eventClickPayload)
|
||||
}
|
||||
const viewport = ref(null)
|
||||
const viewportHeight = ref(600)
|
||||
const rowHeight = ref(64)
|
||||
@@ -87,7 +99,7 @@ const vwm = createVirtualWeekManager({
|
||||
contentHeight,
|
||||
})
|
||||
const visibleWeeks = vwm.visibleWeeks
|
||||
const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm
|
||||
const { scheduleWindowUpdate, resetWeeks, refreshEvents, refreshHolidays } = vwm
|
||||
|
||||
// Scroll managers (after scheduleWindowUpdate available)
|
||||
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
||||
@@ -98,8 +110,7 @@ const weekColumnScrollManager = createWeekColumnScrollManager({
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
|
||||
weekColumnScrollManager
|
||||
const { handleWeekColMouseDown, handlePointerLockChange } = weekColumnScrollManager
|
||||
const monthScrollManager = createMonthScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
@@ -160,6 +171,25 @@ function clearSelection() {
|
||||
selection.value = { startDate: null, dayCount: 0 }
|
||||
}
|
||||
|
||||
// React to holiday config changes: rebuild or refresh holidays
|
||||
watch(
|
||||
() => [
|
||||
calendarStore.config.holidays.enabled,
|
||||
calendarStore.config.holidays.country,
|
||||
calendarStore.config.holidays.state,
|
||||
calendarStore.config.holidays.region,
|
||||
],
|
||||
(_newVals, _oldVals) => {
|
||||
// If weeks already built, just refresh holiday info
|
||||
if (visibleWeeks.value.length) {
|
||||
refreshHolidays('config-change')
|
||||
} else {
|
||||
resetWeeks('holiday-config-change')
|
||||
}
|
||||
},
|
||||
{ deep: false },
|
||||
)
|
||||
|
||||
function startDrag(dateStr) {
|
||||
dateStr = normalizeDate(dateStr)
|
||||
if (calendarStore.config.select_days === 0) return
|
||||
@@ -188,7 +218,7 @@ function finalizeDragAndCreate() {
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
openCreateEventDialog(eventData)
|
||||
}
|
||||
removeGlobalTouchListeners()
|
||||
}
|
||||
@@ -294,15 +324,6 @@ function calculateSelection(anchorStr, otherStr) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Week label column drag scrolling ----------------
|
||||
function getWeekLabelRect() {
|
||||
// Prefer header year label width as stable reference
|
||||
const headerYear = document.querySelector('.calendar-header .year-label')
|
||||
if (headerYear) return headerYear.getBoundingClientRect()
|
||||
const weekLabel = viewport.value?.querySelector('.week-row .week-label')
|
||||
return weekLabel ? weekLabel.getBoundingClientRect() : null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
computeRowHeight()
|
||||
calendarStore.updateCurrentDate()
|
||||
@@ -363,7 +384,7 @@ const handleDayMouseUp = (d) => {
|
||||
const ev = createEventFromSelection()
|
||||
if (ev) {
|
||||
clearSelection()
|
||||
emit('create-event', ev)
|
||||
openCreateEventDialog(ev)
|
||||
}
|
||||
}
|
||||
const handleDayTouchStart = (d) => {
|
||||
@@ -373,10 +394,156 @@ const handleDayTouchStart = (d) => {
|
||||
}
|
||||
|
||||
const handleEventClick = (payload) => {
|
||||
emit('edit-event', payload)
|
||||
openEditEventDialog(payload)
|
||||
}
|
||||
|
||||
// header year change delegated to manager
|
||||
// ------------------------------
|
||||
// Event Search (Ctrl/Cmd+F)
|
||||
// ------------------------------
|
||||
const searchOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([]) // [{ id, title, startDate }]
|
||||
const searchIndex = ref(0)
|
||||
const searchInputRef = ref(null)
|
||||
|
||||
function isEditableElement(el) {
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function buildSearchResults() {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) {
|
||||
searchResults.value = []
|
||||
searchIndex.value = 0
|
||||
return
|
||||
}
|
||||
const out = []
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
const title = (ev.title || '').trim()
|
||||
if (!title) continue
|
||||
if (title.toLowerCase().includes(q)) {
|
||||
out.push({ id: ev.id, title: title, startDate: ev.startDate })
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
||||
searchResults.value = out
|
||||
if (searchIndex.value >= out.length) searchIndex.value = 0
|
||||
}
|
||||
|
||||
watch(searchQuery, buildSearchResults)
|
||||
watch(
|
||||
() => calendarStore.eventsMutation,
|
||||
() => {
|
||||
if (searchOpen.value && searchQuery.value.trim()) buildSearchResults()
|
||||
},
|
||||
)
|
||||
|
||||
function openSearch(prefill = '') {
|
||||
searchOpen.value = true
|
||||
if (prefill) searchQuery.value = prefill
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
buildSearchResults()
|
||||
}
|
||||
function closeSearch() {
|
||||
searchOpen.value = false
|
||||
}
|
||||
function navigateSearch(delta) {
|
||||
const n = searchResults.value.length
|
||||
if (!n) return
|
||||
searchIndex.value = (searchIndex.value + delta + n) % n
|
||||
scrollToCurrentResult()
|
||||
}
|
||||
function scrollToCurrentResult() {
|
||||
const cur = searchResults.value[searchIndex.value]
|
||||
if (!cur) return
|
||||
// Scroll so week containing event is near top (offset 2 weeks for context)
|
||||
try {
|
||||
const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ)
|
||||
const weekIndex = getWeekIndex(dateObj)
|
||||
const offsetWeeks = 2
|
||||
const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks)
|
||||
const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value
|
||||
setScrollTop(newScrollTop, 'search-jump')
|
||||
scheduleWindowUpdate('search-jump')
|
||||
} catch {}
|
||||
}
|
||||
function activateCurrentResult() {
|
||||
scrollToCurrentResult()
|
||||
}
|
||||
|
||||
function handleGlobalFind(e) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return
|
||||
const k = e.key
|
||||
if (k === 'f' || k === 'F') {
|
||||
if (isEditableElement(e.target)) return
|
||||
e.preventDefault()
|
||||
if (!searchOpen.value) openSearch('')
|
||||
else {
|
||||
// If already open, select input text for quick overwrite
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// While open: Enter confirms current selection & closes dialog
|
||||
if (searchOpen.value && (k === 'Enter' || k === 'Return')) {
|
||||
e.preventDefault()
|
||||
activateCurrentResult()
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchKeydown(e) {
|
||||
if (!searchOpen.value) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeSearch()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
navigateSearch(1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
navigateSearch(-1)
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter inside input: activate current and close
|
||||
e.preventDefault()
|
||||
activateCurrentResult()
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleGlobalFind, { passive: false })
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleGlobalFind)
|
||||
})
|
||||
|
||||
// Ensure focus when (re)opening via reactive watch (catches programmatic toggles too)
|
||||
watch(
|
||||
() => searchOpen.value,
|
||||
(v) => {
|
||||
if (v) {
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||
// We explicitly avoid locale detection; rely solely on characters present.
|
||||
@@ -402,7 +569,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// Watch lightweight mutation counter only (not deep events map) and rebuild lazily
|
||||
// Event changes
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
@@ -426,7 +593,7 @@ window.addEventListener('resize', () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view-root">
|
||||
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
<HeaderControls @go-to-today="goToToday" />
|
||||
@@ -438,27 +605,22 @@ window.addEventListener('resize', () => {
|
||||
/>
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-viewport" ref="viewport">
|
||||
<!-- Main calendar content (weeks and days) -->
|
||||
<div class="main-calendar-area">
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:dragging="isDragging"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:dragging="isDragging"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
</div>
|
||||
<!-- Month column area -->
|
||||
<div class="month-column-area">
|
||||
<!-- Month labels -->
|
||||
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
||||
<div class="month-column-area" :style="{ height: contentHeight + 'px' }">
|
||||
<div class="month-labels-container" :style="{ height: '100%' }">
|
||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
||||
<div
|
||||
v-if="monthWeek && monthWeek.monthLabel"
|
||||
@@ -481,6 +643,34 @@ window.addEventListener('resize', () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
|
||||
<!-- Event Search Overlay -->
|
||||
<div v-if="searchOpen" class="event-search" @keydown.capture="handleSearchKeydown">
|
||||
<div class="search-row">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search events..."
|
||||
aria-label="Search events"
|
||||
autofocus
|
||||
/>
|
||||
<button type="button" @click="closeSearch" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
<ul class="results" v-if="searchResults.length">
|
||||
<li
|
||||
v-for="(r, i) in searchResults"
|
||||
:key="r.id"
|
||||
:class="{ active: i === searchIndex }"
|
||||
@click="((searchIndex = i), activateCurrentResult(), closeSearch())"
|
||||
>
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="date">{{ r.startDate }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="no-results">{{ searchQuery ? 'No matches' : 'Type to search' }}</div>
|
||||
<div class="hint">Enter to go, Esc to close, ↑/↓ to browse</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -529,11 +719,6 @@ header h1 {
|
||||
grid-template-columns: 1fr var(--month-w);
|
||||
}
|
||||
|
||||
.main-calendar-area {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -552,7 +737,7 @@ header h1 {
|
||||
|
||||
.month-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
||||
font-size: 2em;
|
||||
@@ -587,4 +772,93 @@ header h1 {
|
||||
height: var(--row-h);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Search overlay */
|
||||
.event-search {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
inset-inline-end: 0.75rem;
|
||||
z-index: 1200;
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
backdrop-filter: blur(0.75em);
|
||||
-webkit-backdrop-filter: blur(0.75em);
|
||||
color: var(--ink);
|
||||
padding: 0.75rem 0.75rem 0.6rem 0.75rem;
|
||||
border-radius: 0.6rem;
|
||||
width: min(28rem, 80vw);
|
||||
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.event-search .search-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.event-search input[type='text'] {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--panel) 85%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
.event-search button {
|
||||
background: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
|
||||
color: var(--ink, #111);
|
||||
border: 0;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.event-search button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.event-search .results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.event-search .results li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.event-search .results li.active {
|
||||
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
||||
color: var(--ink, #111);
|
||||
font-weight: 600;
|
||||
}
|
||||
.event-search .results li:hover:not(.active) {
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
}
|
||||
.event-search .results .title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.event-search .results .date {
|
||||
opacity: 0.6;
|
||||
font-family: monospace;
|
||||
}
|
||||
.event-search .no-results {
|
||||
padding: 0.25rem 0.1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.event-search .hint {
|
||||
opacity: 0.55;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatDateLong,
|
||||
DEFAULT_TZ,
|
||||
} from '@/utils/date'
|
||||
import { getDate as getOccurrenceDate } from '@/utils/events'
|
||||
import { addDays, addMonths } from 'date-fns'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -301,48 +302,18 @@ function openEditDialog(payload) {
|
||||
if (!payload) return
|
||||
|
||||
const baseId = payload.id
|
||||
let occurrenceIndex = payload.occurrenceIndex || 0
|
||||
let n = payload.n || 0
|
||||
let weekday = null
|
||||
let occurrenceDate = null
|
||||
|
||||
const event = calendarStore.getEventById(baseId)
|
||||
if (!event) return
|
||||
|
||||
if (event.recur) {
|
||||
if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
|
||||
const pattern = event.recur.weekdays || []
|
||||
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
|
||||
const baseEnd = new Date(fromLocalString(event.startDate, DEFAULT_TZ))
|
||||
baseEnd.setDate(baseEnd.getDate() + (event.days || 1) - 1)
|
||||
if (occurrenceIndex === 0) {
|
||||
occurrenceDate = baseStart
|
||||
weekday = baseStart.getDay()
|
||||
} else {
|
||||
const interval = event.recur.interval || 1
|
||||
const WEEK_MS = 7 * 86400000
|
||||
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
||||
function isAligned(d) {
|
||||
const blk = getMondayOfISOWeek(d)
|
||||
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
|
||||
return diff % interval === 0
|
||||
}
|
||||
let cur = addDays(baseEnd, 1)
|
||||
let found = 0
|
||||
let safety = 0
|
||||
while (found < occurrenceIndex && safety < 20000) {
|
||||
if (pattern[cur.getDay()] && isAligned(cur)) {
|
||||
found++
|
||||
if (found === occurrenceIndex) break
|
||||
}
|
||||
cur = addDays(cur, 1)
|
||||
safety++
|
||||
}
|
||||
occurrenceDate = cur
|
||||
weekday = cur.getDay()
|
||||
}
|
||||
} else if (event.recur.freq === 'months' && occurrenceIndex >= 0) {
|
||||
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
|
||||
occurrenceDate = addMonths(baseDate, occurrenceIndex)
|
||||
if (event.recur && n >= 0) {
|
||||
const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
|
||||
if (occStr) {
|
||||
occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
|
||||
weekday = occurrenceDate.getDay()
|
||||
}
|
||||
}
|
||||
dialogMode.value = 'edit'
|
||||
@@ -372,10 +343,10 @@ function openEditDialog(payload) {
|
||||
eventSaved.value = false
|
||||
|
||||
if (event.recur) {
|
||||
if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
|
||||
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||
} else if (event.recur.freq === 'months' && occurrenceIndex > 0) {
|
||||
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
|
||||
if (event.recur.freq === 'weeks' && n >= 0) {
|
||||
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate }
|
||||
} else if (event.recur.freq === 'months' && n > 0) {
|
||||
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate }
|
||||
}
|
||||
}
|
||||
// anchor to base event start date
|
||||
@@ -594,8 +565,10 @@ const recurrenceSummary = computed(() => {
|
||||
<template>
|
||||
<BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent">
|
||||
<template #title>
|
||||
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event'
|
||||
}}<template v-if="headerDateShort"> · {{ headerDateShort }}</template>
|
||||
<div class="dialog-title-row">
|
||||
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }}
|
||||
<span> · {{ headerDateShort }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<label class="ec-field">
|
||||
<input type="text" v-model="title" autocomplete="off" ref="titleInput" />
|
||||
@@ -620,9 +593,7 @@ const recurrenceSummary = computed(() => {
|
||||
</label>
|
||||
<span class="recurrence-summary" v-if="recurrenceEnabled">
|
||||
{{ recurrenceSummary }}
|
||||
<template v-if="recurrenceOccurrences > 0">
|
||||
until {{ formattedFinalOccurrence }}</template
|
||||
>
|
||||
<span v-if="recurrenceOccurrences > 0"> until {{ formattedFinalOccurrence }} </span>
|
||||
</span>
|
||||
<span class="recurrence-summary muted" v-else>Does not recur</span>
|
||||
</div>
|
||||
@@ -655,6 +626,7 @@ const recurrenceSummary = computed(() => {
|
||||
v-model="recurrenceWeekdays"
|
||||
:fallback="fallbackWeekdays"
|
||||
:first-day="calendarStore.config.first_day"
|
||||
:weekend="calendarStore.weekend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -668,7 +640,7 @@ const recurrenceSummary = computed(() => {
|
||||
<template v-if="showDeleteVariants">
|
||||
<div class="ec-delete-group">
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
||||
Delete {{ formattedOccurrenceShort }}
|
||||
Delete <span>{{ formattedOccurrenceShort }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isLastOccurrence"
|
||||
@@ -676,7 +648,7 @@ const recurrenceSummary = computed(() => {
|
||||
class="ec-btn delete-btn"
|
||||
@click="deleteEventFrom"
|
||||
>
|
||||
Rest
|
||||
+ Rest
|
||||
</button>
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||
</div>
|
||||
@@ -943,7 +915,7 @@ const recurrenceSummary = computed(() => {
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.ec-recurrence-toggle:hover {
|
||||
@@ -1003,4 +975,7 @@ const recurrenceSummary = computed(() => {
|
||||
.ec-occurrences-field .ec-field input[type='number'] {
|
||||
max-width: 6rem;
|
||||
}
|
||||
span {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
</style>
|
||||
|
||||
+180
-130
@@ -1,33 +1,41 @@
|
||||
<template>
|
||||
<div class="week-overlay">
|
||||
<div class="week-overlay" :style="{ gridColumn: '1 / -1' }" ref="weekOverlayRef">
|
||||
<div
|
||||
v-for="span in eventSpans"
|
||||
:key="span.id"
|
||||
class="event-span"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
:data-id="span.id"
|
||||
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
|
||||
:style="{
|
||||
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
|
||||
gridRow: `${span.row}`,
|
||||
}"
|
||||
@click="handleEventClick(span)"
|
||||
@pointerdown="handleEventPointerDown(span, $event)"
|
||||
v-for="seg in eventSegments"
|
||||
:key="'seg-' + seg.startIdx + '-' + seg.endIdx"
|
||||
:class="['segment-grid', { compress: isSegmentCompressed(seg) }]"
|
||||
:style="segmentStyle(seg)"
|
||||
>
|
||||
<span class="event-title">{{ span.title }}</span>
|
||||
<div
|
||||
class="resize-handle left"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle right"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
|
||||
></div>
|
||||
v-for="span in seg.events"
|
||||
:key="span.id + '-' + (span.n != null ? span.n : 0)"
|
||||
class="event-span"
|
||||
dir="auto"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
:data-id="span.id"
|
||||
:data-n="span.n != null ? span.n : 0"
|
||||
:style="{
|
||||
gridColumn: `${span.startIdxRel + 1} / ${span.endIdxRel + 2}`,
|
||||
gridRow: `${span.row}`,
|
||||
}"
|
||||
@click="handleEventClick(span)"
|
||||
@pointerdown="handleEventPointerDown(span, $event)"
|
||||
>
|
||||
<span class="event-title">{{ span.title }}</span>
|
||||
<div
|
||||
class="resize-handle left"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle right"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { daysInclusive, addDaysStr } from '@/utils/date'
|
||||
|
||||
@@ -40,68 +48,139 @@ const store = useCalendarStore()
|
||||
// Drag state
|
||||
const dragState = ref(null)
|
||||
const justDragged = ref(false)
|
||||
const weekOverlayRef = ref(null)
|
||||
const segmentCompression = ref({}) // key -> boolean
|
||||
|
||||
// Consolidate already-provided day.events into contiguous spans (no recurrence generation)
|
||||
const eventSpans = computed(() => {
|
||||
const weekEvents = new Map()
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
// Build event segments: each segment is a contiguous day range with at least one bridging event between any adjacent days within it.
|
||||
const eventSegments = computed(() => {
|
||||
// Construct spans across the week
|
||||
const spanMap = new Map()
|
||||
props.week.days.forEach((day, di) => {
|
||||
day.events.forEach((ev) => {
|
||||
const key = ev.id
|
||||
if (!weekEvents.has(key)) {
|
||||
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
|
||||
} else {
|
||||
const ref = weekEvents.get(key)
|
||||
ref.endIdx = Math.max(ref.endIdx, dayIndex)
|
||||
}
|
||||
const key = ev.id + '|' + (ev.n ?? 0)
|
||||
if (!spanMap.has(key)) spanMap.set(key, { ...ev, startIdx: di, endIdx: di })
|
||||
else spanMap.get(key).endIdx = Math.max(spanMap.get(key).endIdx, di)
|
||||
})
|
||||
})
|
||||
const arr = Array.from(weekEvents.values())
|
||||
arr.sort((a, b) => {
|
||||
const spanA = a.endIdx - a.startIdx
|
||||
const spanB = b.endIdx - b.startIdx
|
||||
if (spanA !== spanB) return spanB - spanA
|
||||
const spans = Array.from(spanMap.values())
|
||||
// Derive span start/end date strings from week day indices (removes need for per-day stored endDate)
|
||||
spans.forEach((sp) => {
|
||||
sp.startDate = props.week.days[sp.startIdx].date
|
||||
sp.endDate = props.week.days[sp.endIdx].date
|
||||
})
|
||||
// Sort so longer multi-day first, then earlier, then id for stability
|
||||
spans.sort((a, b) => {
|
||||
const la = a.endIdx - a.startIdx
|
||||
const lb = b.endIdx - b.startIdx
|
||||
if (la !== lb) return lb - la
|
||||
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||
// For one-day events that are otherwise equal, sort by color (0 first)
|
||||
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) {
|
||||
const colorA = a.colorId || 0
|
||||
const colorB = b.colorId || 0
|
||||
if (colorA !== colorB) return colorA - colorB
|
||||
}
|
||||
const ca = a.colorId != null ? a.colorId : 0
|
||||
const cb = b.colorId != null ? b.colorId : 0
|
||||
if (ca !== cb) return ca - cb
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
// Assign non-overlapping rows
|
||||
const rowsLastEnd = []
|
||||
arr.forEach((ev) => {
|
||||
let row = 0
|
||||
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
|
||||
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
|
||||
rowsLastEnd[row] = ev.endIdx
|
||||
ev.row = row + 1
|
||||
// Identify breaks
|
||||
const breaks = []
|
||||
for (let d = 0; d < 6; d++) {
|
||||
const bridged = spans.some((sp) => sp.startIdx <= d && sp.endIdx >= d + 1)
|
||||
if (!bridged) breaks.push(d)
|
||||
}
|
||||
const rawSegments = []
|
||||
let segStart = 0
|
||||
for (const b of breaks) {
|
||||
rawSegments.push([segStart, b])
|
||||
segStart = b + 1
|
||||
}
|
||||
rawSegments.push([segStart, 6])
|
||||
|
||||
const segments = rawSegments.map(([s, e]) => {
|
||||
const evs = spans.filter((sp) => sp.startIdx >= s && sp.endIdx <= e)
|
||||
// Row packing in this segment (gap fill)
|
||||
const rows = [] // each row: intervals
|
||||
function fits(row, a, b) {
|
||||
return row.every((iv) => b < iv.start || a > iv.end)
|
||||
}
|
||||
function addInterval(row, a, b) {
|
||||
let inserted = false
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
if (b < row[i].start) {
|
||||
row.splice(i, 0, { start: a, end: b })
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!inserted) row.push({ start: a, end: b })
|
||||
}
|
||||
evs.forEach((ev) => {
|
||||
let placed = false
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
if (fits(rows[r], ev.startIdx, ev.endIdx)) {
|
||||
addInterval(rows[r], ev.startIdx, ev.endIdx)
|
||||
ev.row = r + 1
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
rows.push([{ start: ev.startIdx, end: ev.endIdx }])
|
||||
ev.row = rows.length
|
||||
}
|
||||
ev.startIdxRel = ev.startIdx - s
|
||||
ev.endIdxRel = ev.endIdx - s
|
||||
})
|
||||
return { startIdx: s, endIdx: e, events: evs, rowsCount: rows.length }
|
||||
})
|
||||
return arr
|
||||
return segments
|
||||
})
|
||||
|
||||
function segmentStyle(seg) {
|
||||
return { gridColumn: `${seg.startIdx + 1} / ${seg.endIdx + 2}` }
|
||||
}
|
||||
|
||||
function segmentKey(seg) {
|
||||
return seg.startIdx + '-' + seg.endIdx
|
||||
}
|
||||
|
||||
function isSegmentCompressed(seg) {
|
||||
return !!segmentCompression.value[segmentKey(seg)]
|
||||
}
|
||||
|
||||
function recomputeCompression() {
|
||||
const el = weekOverlayRef.value
|
||||
if (!el) return
|
||||
const available = el.clientHeight || 0
|
||||
if (!available) return
|
||||
const cs = getComputedStyle(el)
|
||||
const fontSize = parseFloat(cs.fontSize) || 16
|
||||
const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em)
|
||||
const marginTop = 0 // already applied outside height
|
||||
const usable = Math.max(0, available - marginTop)
|
||||
const nextMap = {}
|
||||
for (const seg of eventSegments.value) {
|
||||
const desired = (seg.rowsCount || 1) * baseRowPx
|
||||
nextMap[segmentKey(seg)] = desired > usable
|
||||
}
|
||||
segmentCompression.value = nextMap
|
||||
}
|
||||
|
||||
watch(eventSegments, () => nextTick(() => recomputeCompression()))
|
||||
onMounted(() => {
|
||||
nextTick(() => recomputeCompression())
|
||||
window.addEventListener('resize', recomputeCompression)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', recomputeCompression)
|
||||
})
|
||||
|
||||
function handleEventClick(span) {
|
||||
if (justDragged.value) return
|
||||
// Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n)
|
||||
const idStr = span.id
|
||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||
emit('event-click', {
|
||||
id: baseId,
|
||||
instanceId: span.id,
|
||||
occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0,
|
||||
})
|
||||
emit('event-click', { id: span.id, n: span.n != null ? span.n : 0 })
|
||||
}
|
||||
|
||||
function handleEventPointerDown(span, event) {
|
||||
if (event.target.classList.contains('resize-handle')) return
|
||||
event.stopPropagation()
|
||||
const idStr = span.id
|
||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||
const isVirtual = hasVirtualMarker
|
||||
// Determine which day within the span was grabbed so we maintain relative position
|
||||
const baseId = span.id
|
||||
let anchorDate = span.startDate
|
||||
try {
|
||||
const spanDays = daysInclusive(span.startDate, span.endDate)
|
||||
@@ -116,14 +195,11 @@ function handleEventPointerDown(span, event) {
|
||||
if (dayIndex >= spanDays) dayIndex = spanDays - 1
|
||||
anchorDate = addDaysStr(span.startDate, dayIndex)
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to startDate if any calculation fails
|
||||
}
|
||||
} catch (e) {}
|
||||
startLocalDrag(
|
||||
{
|
||||
id: baseId,
|
||||
originalId: span.id,
|
||||
isVirtual,
|
||||
mode: 'move',
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
@@ -137,15 +213,11 @@ function handleEventPointerDown(span, event) {
|
||||
|
||||
function handleResizePointerDown(span, mode, event) {
|
||||
event.stopPropagation()
|
||||
const idStr = span.id
|
||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||
const isVirtual = hasVirtualMarker
|
||||
const baseId = span.id
|
||||
startLocalDrag(
|
||||
{
|
||||
id: baseId,
|
||||
originalId: span.id,
|
||||
isVirtual,
|
||||
mode,
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
@@ -167,7 +239,6 @@ function startLocalDrag(init, evt) {
|
||||
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
|
||||
}
|
||||
|
||||
// Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original
|
||||
let originalWeekday = null
|
||||
let originalPattern = null
|
||||
if (init.mode === 'move') {
|
||||
@@ -194,13 +265,11 @@ function startLocalDrag(init, evt) {
|
||||
tentativeEnd: init.endDate,
|
||||
originalWeekday,
|
||||
originalPattern,
|
||||
realizedId: null, // for virtual occurrence converted to real during drag
|
||||
realizedId: null,
|
||||
}
|
||||
|
||||
// Begin compound history session (single snapshot after drag completes)
|
||||
store.$history?.beginCompound()
|
||||
|
||||
// Capture pointer events globally
|
||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||
try {
|
||||
evt.currentTarget.setPointerCapture(evt.pointerId)
|
||||
@@ -209,7 +278,6 @@ function startLocalDrag(init, evt) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll.
|
||||
if (!(evt.pointerType === 'touch')) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
@@ -221,19 +289,15 @@ function startLocalDrag(init, evt) {
|
||||
|
||||
// Determine date under pointer: traverse DOM to find day cell carrying data-date attribute
|
||||
function getDateUnderPointer(x, y, el) {
|
||||
let cur = el
|
||||
while (cur) {
|
||||
if (cur.dataset && cur.dataset.date) {
|
||||
return { date: cur.dataset.date }
|
||||
for (let cur = el; cur; cur = cur.parentElement)
|
||||
if (cur.dataset?.date) return { date: cur.dataset.date }
|
||||
const overlayEl = weekOverlayRef.value
|
||||
const container = overlayEl?.parentElement // .days-grid
|
||||
if (container) {
|
||||
for (const d of container.querySelectorAll('[data-date]')) {
|
||||
const { left, right, top, bottom } = d.getBoundingClientRect()
|
||||
if (y >= top && y <= bottom && x >= left && x <= right) return { date: d.dataset.date }
|
||||
}
|
||||
cur = cur.parentElement
|
||||
}
|
||||
// Fallback: elementFromPoint scan
|
||||
const probe = document.elementFromPoint(x, y)
|
||||
let p = probe
|
||||
while (p) {
|
||||
if (p.dataset && p.dataset.date) return { date: p.dataset.date }
|
||||
p = p.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -250,7 +314,6 @@ function onDragPointerMove(e) {
|
||||
const hitEl = document.elementFromPoint(e.clientX, e.clientY)
|
||||
const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl)
|
||||
|
||||
// If we can't find a date, don't update the range but keep the drag active
|
||||
if (!hit || !hit.date) return
|
||||
|
||||
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||
@@ -260,26 +323,22 @@ function onDragPointerMove(e) {
|
||||
st.tentativeStart = ns
|
||||
st.tentativeEnd = ne
|
||||
if (st.mode === 'move') {
|
||||
if (st.isVirtual) {
|
||||
// On first movement convert virtual occurrence into a real new event (split series)
|
||||
if (st.n && st.n > 0) {
|
||||
if (!st.realizedId) {
|
||||
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
|
||||
if (newId) {
|
||||
st.realizedId = newId
|
||||
st.id = newId
|
||||
st.isVirtual = false
|
||||
// converted to standalone event
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Subsequent moves: update range without rotating pattern automatically
|
||||
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
||||
}
|
||||
} else {
|
||||
// Normal non-virtual move; rotate handled in setEventRange
|
||||
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
||||
}
|
||||
// Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday)
|
||||
if (st.originalPattern && st.originalWeekday != null) {
|
||||
try {
|
||||
const currentWeekday = new Date(ns + 'T00:00:00').getDay()
|
||||
@@ -292,15 +351,9 @@ function onDragPointerMove(e) {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} else if (!st.isVirtual) {
|
||||
// Resizes on real events update immediately
|
||||
applyRangeDuringDrag(
|
||||
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
||||
ns,
|
||||
ne,
|
||||
)
|
||||
} else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
|
||||
// For virtual occurrence resize: convert to real once, then adjust range
|
||||
} else if (!(st.n && st.n > 0)) {
|
||||
applyRangeDuringDrag({ id: st.id, mode: st.mode, startDate: ns, endDate: ne }, ns, ne)
|
||||
} else if (st.n && st.n > 0 && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
|
||||
if (!st.realizedId) {
|
||||
const initialStart = ns
|
||||
const initialEnd = ne
|
||||
@@ -308,10 +361,9 @@ function onDragPointerMove(e) {
|
||||
if (newId) {
|
||||
st.realizedId = newId
|
||||
st.id = newId
|
||||
st.isVirtual = false
|
||||
// converted
|
||||
} else return
|
||||
}
|
||||
// Apply range change; rotate if left edge moved and weekday changed
|
||||
const rotate = st.mode === 'resize-left'
|
||||
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
|
||||
}
|
||||
@@ -321,7 +373,6 @@ function onDragPointerUp(e) {
|
||||
const st = dragState.value
|
||||
if (!st) return
|
||||
|
||||
// Release pointer capture if it was set
|
||||
if (e.target && e.pointerId !== undefined) {
|
||||
try {
|
||||
e.target.releasePointerCapture(e.pointerId)
|
||||
@@ -341,11 +392,10 @@ function onDragPointerUp(e) {
|
||||
|
||||
if (moved) {
|
||||
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare)
|
||||
if (st.isVirtual) {
|
||||
if (st.n && st.n > 0) {
|
||||
applyRangeDuringDrag(
|
||||
{
|
||||
id: st.id,
|
||||
isVirtual: st.isVirtual,
|
||||
mode: st.mode,
|
||||
startDate: finalStart,
|
||||
endDate: finalEnd,
|
||||
@@ -359,7 +409,6 @@ function onDragPointerUp(e) {
|
||||
justDragged.value = false
|
||||
}, 120)
|
||||
}
|
||||
// End compound session (snapshot if changed)
|
||||
store.$history?.endCompound()
|
||||
}
|
||||
|
||||
@@ -388,7 +437,7 @@ function normalizeDateOrder(aStr, bStr) {
|
||||
}
|
||||
|
||||
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
if (st.isVirtual) {
|
||||
if (st.n && st.n > 0) {
|
||||
if (st.mode !== 'move') return // no resize for virtual occurrence
|
||||
// Split-move: occurrence being dragged treated as first of new series
|
||||
store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate)
|
||||
@@ -402,16 +451,22 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
.week-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
display: grid;
|
||||
/* Prevent content from expanding tracks beyond container width */
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(0, 1.5em);
|
||||
|
||||
row-gap: 0.05em;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-top: 1.8em;
|
||||
pointer-events: none;
|
||||
}
|
||||
.segment-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
align-content: start;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-auto-rows: 1.5em;
|
||||
}
|
||||
.segment-grid.compress {
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
.event-span {
|
||||
@@ -429,13 +484,8 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
align-items: center;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
|
||||
@@ -463,10 +513,10 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
}
|
||||
|
||||
.event-span .resize-handle.left {
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.event-span .resize-handle.right {
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<!-- Settings dialog now lives here -->
|
||||
<SettingsDialog ref="settingsDialog" />
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -101,12 +100,12 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
margin-right: 1.5rem;
|
||||
margin-inline-end: 2rem;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
@@ -157,7 +156,6 @@ onBeforeUnmount(() => {
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-right: 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
@@ -205,6 +203,6 @@ onBeforeUnmount(() => {
|
||||
.today-date {
|
||||
white-space: pre-line;
|
||||
text-align: center;
|
||||
margin-right: 2rem;
|
||||
margin-inline-end: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -181,7 +181,7 @@ defineExpose({
|
||||
.jogwheel-viewport {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
width: var(--month-w);
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -3,10 +3,14 @@ import { ref, computed } from 'vue'
|
||||
import BaseDialog from './BaseDialog.vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import WeekdaySelector from './WeekdaySelector.vue'
|
||||
import { getLocalizedWeekdayNamesLong } from '@/utils/date'
|
||||
|
||||
const show = ref(false)
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
// Localized weekday names (now Sunday-first from util) for select 0=Sunday ..6=Saturday
|
||||
const weekdayNames = getLocalizedWeekdayNamesLong()
|
||||
|
||||
// Reactive bindings to store
|
||||
const firstDay = computed({
|
||||
get: () => calendarStore.config.first_day,
|
||||
@@ -159,19 +163,21 @@ defineExpose({ open })
|
||||
v-model="show"
|
||||
title="Settings"
|
||||
class="settings-modal"
|
||||
:style="{ top: '4.5rem', right: '2rem', bottom: 'auto', left: 'auto', transform: 'none' }"
|
||||
:style="{
|
||||
top: '4.5rem',
|
||||
insetInlineEnd: '2rem',
|
||||
bottom: 'auto',
|
||||
insetInlineStart: 'auto',
|
||||
transform: 'none',
|
||||
}"
|
||||
>
|
||||
<div class="setting-group">
|
||||
<label class="ec-field">
|
||||
<span>First day of week</span>
|
||||
<select v-model.number="firstDay">
|
||||
<option :value="0">Sunday</option>
|
||||
<option :value="1">Monday</option>
|
||||
<option :value="2">Tuesday</option>
|
||||
<option :value="3">Wednesday</option>
|
||||
<option :value="4">Thursday</option>
|
||||
<option :value="5">Friday</option>
|
||||
<option :value="6">Saturday</option>
|
||||
<option v-for="(name, idx) in weekdayNames" :key="idx" :value="idx">
|
||||
{{ name.charAt(0).toUpperCase() + name.slice(1) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="weekend-select ec-field">
|
||||
@@ -242,9 +248,9 @@ defineExpose({ open })
|
||||
.holiday-settings {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-left: 1rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
margin-inline-start: 1rem;
|
||||
padding-inline-start: 1rem;
|
||||
border-inline-start: 2px solid var(--border-color);
|
||||
}
|
||||
select {
|
||||
border: 1px solid var(--muted);
|
||||
@@ -269,7 +275,7 @@ select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
/* WeekdaySelector display tweaks */
|
||||
|
||||
.footer-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@pointerenter="onDragOver(di)"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
{{ d.slice(0, 3) }}
|
||||
{{ d }}
|
||||
</button>
|
||||
<button
|
||||
v-for="g in barGroups"
|
||||
@@ -60,8 +60,8 @@ const props = defineProps({
|
||||
|
||||
// Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
|
||||
if (model.value?.some?.(Boolean)) internal.value = [...model.value]
|
||||
const labelsMondayFirst = getLocalizedWeekdayNames()
|
||||
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
||||
// getLocalizedWeekdayNames now returns Sunday-first already
|
||||
const labels = getLocalizedWeekdayNames()
|
||||
const anySelected = computed(() => internal.value.some(Boolean))
|
||||
const localeFirst = getLocaleFirstDay()
|
||||
const localeWeekend = getLocaleWeekendDays()
|
||||
|
||||
+73
-159
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import { addDays, differenceInWeeks } from 'date-fns'
|
||||
import { addDays, differenceInWeeks, isBefore, isAfter } from 'date-fns'
|
||||
import {
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
MAX_YEAR,
|
||||
getOccurrenceIndex,
|
||||
getVirtualOccurrenceEndDate,
|
||||
} from '@/utils/date'
|
||||
import { buildDayEvents } from '@/utils/events'
|
||||
import { getHolidayForDate } from '@/utils/holidays'
|
||||
|
||||
/**
|
||||
@@ -54,77 +53,16 @@ export function createVirtualWeekManager({
|
||||
|
||||
function createWeek(virtualWeek) {
|
||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
||||
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
|
||||
const weekNumber = getISOWeek(isoAnchor)
|
||||
const thu = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
|
||||
const weekNumber = getISOWeek(thu)
|
||||
const days = []
|
||||
let cur = new Date(firstDay)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
const repeatingBases = []
|
||||
if (calendarStore.events) {
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (ev.recur) repeatingBases.push(ev)
|
||||
}
|
||||
}
|
||||
|
||||
const collectEventsForDate = (dateStr, curDateObj) => {
|
||||
const storedEvents = []
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.recur) {
|
||||
const evEnd = toLocalString(
|
||||
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= ev.startDate && dateStr <= evEnd) {
|
||||
storedEvents.push({ ...ev, endDate: evEnd })
|
||||
}
|
||||
}
|
||||
}
|
||||
const dayEvents = [...storedEvents]
|
||||
for (const base of repeatingBases) {
|
||||
const baseEnd = toLocalString(
|
||||
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= base.startDate && dateStr <= baseEnd) {
|
||||
dayEvents.push({ ...base, endDate: baseEnd, _recurrenceIndex: 0, _baseId: base.id })
|
||||
continue
|
||||
}
|
||||
const spanDays = (base.days || 1) - 1
|
||||
const currentDate = curDateObj
|
||||
let occurrenceFound = false
|
||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||
if (!alreadyExists) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: virtualId,
|
||||
startDate: candidateStartStr,
|
||||
endDate: virtualEndDate,
|
||||
_recurrenceIndex: occurrenceIndex,
|
||||
_baseId: base.id,
|
||||
})
|
||||
}
|
||||
occurrenceFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dayEvents
|
||||
}
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||
const dayEvents = collectEventsForDate(dateStr, fromLocalString(dateStr, DEFAULT_TZ))
|
||||
const events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ)
|
||||
const dow = cur.getDay()
|
||||
const isFirst = cur.getDate() === 1
|
||||
if (isFirst) {
|
||||
@@ -133,10 +71,11 @@ export function createVirtualWeekManager({
|
||||
labelYear = cur.getFullYear()
|
||||
}
|
||||
let displayText = String(cur.getDate())
|
||||
if (isFirst) {
|
||||
if (cur.getMonth() === 0) displayText = cur.getFullYear()
|
||||
else displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
|
||||
}
|
||||
if (isFirst)
|
||||
displayText =
|
||||
cur.getMonth() === 0
|
||||
? cur.getFullYear()
|
||||
: monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
|
||||
let holiday = null
|
||||
if (calendarStore.config.holidays.enabled) {
|
||||
calendarStore._ensureHolidaysInitialized?.()
|
||||
@@ -153,36 +92,12 @@ export function createVirtualWeekManager({
|
||||
lunarPhase: lunarPhaseSymbol(cur),
|
||||
holiday,
|
||||
isHoliday: holiday !== null,
|
||||
isSelected:
|
||||
selection.value.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: dayEvents,
|
||||
isSelected: isDateSelected(dateStr),
|
||||
events,
|
||||
})
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= MAX_YEAR) {
|
||||
let weeksSpan = 0
|
||||
const d = addDays(cur, -1)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const probe = addDays(cur, -1 + i * 7)
|
||||
d.setTime(probe.getTime())
|
||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||
}
|
||||
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
|
||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
||||
const year = String(labelYear).slice(-2)
|
||||
monthLabel = {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan,
|
||||
monthClass: monthAbbr[monthToLabel],
|
||||
}
|
||||
}
|
||||
}
|
||||
const monthLabel = buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek })
|
||||
return {
|
||||
virtualWeek,
|
||||
weekNumber: pad(weekNumber),
|
||||
@@ -192,6 +107,36 @@ export function createVirtualWeekManager({
|
||||
}
|
||||
}
|
||||
|
||||
function isDateSelected(dateStr) {
|
||||
if (!selection.value.startDate || selection.value.dayCount <= 0) return false
|
||||
const startDateObj = fromLocalString(selection.value.startDate, DEFAULT_TZ)
|
||||
const endDateStr = addDaysStr(selection.value.startDate, selection.value.dayCount - 1)
|
||||
const endDateObj = fromLocalString(endDateStr, DEFAULT_TZ)
|
||||
const d = fromLocalString(dateStr, DEFAULT_TZ)
|
||||
return !isBefore(d, startDateObj) && !isAfter(d, endDateObj)
|
||||
}
|
||||
|
||||
function buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek }) {
|
||||
if (!hasFirst || monthToLabel === null) return null
|
||||
if (!labelYear || labelYear > MAX_YEAR) return null
|
||||
let weeksSpan = 0
|
||||
const d = addDays(cur, -1)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const probe = addDays(cur, -1 + i * 7)
|
||||
d.setTime(probe.getTime())
|
||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||
}
|
||||
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
|
||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
||||
const year = String(labelYear).slice(-2)
|
||||
return {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan,
|
||||
monthClass: monthAbbr[monthToLabel],
|
||||
}
|
||||
}
|
||||
|
||||
function internalWindowCalc() {
|
||||
const buffer = 6
|
||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0
|
||||
@@ -295,10 +240,6 @@ export function createVirtualWeekManager({
|
||||
// Reflective update of only events inside currently visible weeks (keeps week objects stable)
|
||||
function refreshEvents(reason = 'events-refresh') {
|
||||
if (!visibleWeeks.value.length) return
|
||||
const repeatingBases = []
|
||||
if (calendarStore.events) {
|
||||
for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev)
|
||||
}
|
||||
const selStart = selection.value.startDate
|
||||
const selCount = selection.value.dayCount
|
||||
const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null
|
||||
@@ -306,63 +247,13 @@ export function createVirtualWeekManager({
|
||||
for (const day of week.days) {
|
||||
const dateStr = day.date
|
||||
// Update selection flag
|
||||
if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd
|
||||
else day.isSelected = false
|
||||
// Rebuild events list for this day
|
||||
const storedEvents = []
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.recur) {
|
||||
const evEnd = toLocalString(
|
||||
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= ev.startDate && dateStr <= evEnd) {
|
||||
storedEvents.push({ ...ev, endDate: evEnd })
|
||||
}
|
||||
}
|
||||
}
|
||||
const dayEvents = [...storedEvents]
|
||||
for (const base of repeatingBases) {
|
||||
const baseEndStr = toLocalString(
|
||||
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= base.startDate && dateStr <= baseEndStr) {
|
||||
dayEvents.push({ ...base, endDate: baseEndStr, _recurrenceIndex: 0, _baseId: base.id })
|
||||
continue
|
||||
}
|
||||
const spanDays = (base.days || 1) - 1
|
||||
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
|
||||
let occurrenceFound = false
|
||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(
|
||||
base,
|
||||
candidateStartStr,
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||
if (!alreadyExists) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: virtualId,
|
||||
startDate: candidateStartStr,
|
||||
endDate: virtualEndDate,
|
||||
_recurrenceIndex: occurrenceIndex,
|
||||
_baseId: base.id,
|
||||
})
|
||||
}
|
||||
occurrenceFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
day.events = dayEvents
|
||||
if (selStart && selEnd) {
|
||||
const d = fromLocalString(dateStr, DEFAULT_TZ),
|
||||
s = fromLocalString(selStart, DEFAULT_TZ),
|
||||
e = fromLocalString(selEnd, DEFAULT_TZ)
|
||||
day.isSelected = !isBefore(d, s) && !isAfter(d, e)
|
||||
} else day.isSelected = false
|
||||
day.events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ)
|
||||
}
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
@@ -371,6 +262,28 @@ export function createVirtualWeekManager({
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh holiday data for currently visible weeks without rebuilding structure
|
||||
function refreshHolidays(reason = 'holidays-refresh') {
|
||||
if (!visibleWeeks.value.length) return
|
||||
const enabled = calendarStore.config.holidays.enabled
|
||||
if (enabled) calendarStore._ensureHolidaysInitialized?.()
|
||||
for (const week of visibleWeeks.value) {
|
||||
for (const day of week.days) {
|
||||
if (enabled) {
|
||||
const holiday = getHolidayForDate(day.date)
|
||||
;((day.holiday = holiday), (day.isHoliday = holiday !== null))
|
||||
} else {
|
||||
day.holiday = null
|
||||
day.isHoliday = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[VirtualWeeks] refreshHolidays', reason, { weeks: visibleWeeks.value.length })
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = addDays(new Date(calendarStore.now), -21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
@@ -391,6 +304,7 @@ export function createVirtualWeekManager({
|
||||
resetWeeks,
|
||||
updateVisibleWeeks,
|
||||
refreshEvents,
|
||||
refreshHolidays,
|
||||
getWeekIndex,
|
||||
getFirstDayForVirtualWeek,
|
||||
goToToday,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
fromLocalString,
|
||||
getLocaleWeekendDays,
|
||||
getMondayOfISOWeek,
|
||||
getOccurrenceDate,
|
||||
DEFAULT_TZ,
|
||||
} from '@/utils/date'
|
||||
import { differenceInCalendarDays, addDays } from 'date-fns'
|
||||
@@ -201,11 +200,6 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
|
||||
if (!nextStartStr) {
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
base.startDate = nextStartStr
|
||||
// keep same days length
|
||||
if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1))
|
||||
@@ -228,9 +222,11 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
}
|
||||
const snapshot = { ...base }
|
||||
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
|
||||
if (base.recur.count === occurrenceIndex + 1) {
|
||||
base.recur.count = occurrenceIndex
|
||||
return
|
||||
}
|
||||
base.recur.count = occurrenceIndex
|
||||
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
|
||||
if (!nextStartStr) return
|
||||
const originalNumeric =
|
||||
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
|
||||
let remainingCount = 'unlimited'
|
||||
|
||||
+16
-169
@@ -23,7 +23,7 @@ const monthAbbr = [
|
||||
'nov',
|
||||
'dec',
|
||||
]
|
||||
const MIN_YEAR = 100 // less than 100 is interpreted as 19xx
|
||||
const MIN_YEAR = 1000
|
||||
const MAX_YEAR = 9999
|
||||
|
||||
// Core helpers ------------------------------------------------------------
|
||||
@@ -70,169 +70,7 @@ function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
|
||||
|
||||
const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7
|
||||
|
||||
// Count how many days in [startDate..endDate] match the boolean `pattern` array
|
||||
function countPatternDaysInInterval(startDate, endDate, patternArr) {
|
||||
const days = dateFns.eachDayOfInterval({
|
||||
start: dateFns.startOfDay(startDate),
|
||||
end: dateFns.startOfDay(endDate),
|
||||
})
|
||||
return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
// Recurrence: Weekly ------------------------------------------------------
|
||||
function _getRecur(event) {
|
||||
return event?.recur ?? null
|
||||
}
|
||||
|
||||
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const recur = _getRecur(event)
|
||||
if (!recur || recur.freq !== 'weeks') return null
|
||||
const pattern = recur.weekdays || []
|
||||
if (!pattern.some(Boolean)) return null
|
||||
|
||||
const target = fromLocalString(dateStr, timeZone)
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
if (target < baseStart) return null
|
||||
|
||||
const dow = dateFns.getDay(target)
|
||||
if (!pattern[dow]) return null // target not active
|
||||
|
||||
const interval = recur.interval || 1
|
||||
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
|
||||
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
|
||||
// Number of weeks between block starts (each block start is a Monday)
|
||||
const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
|
||||
if (weekDiff < 0 || weekDiff % interval !== 0) return null
|
||||
|
||||
const baseDow = dateFns.getDay(baseStart)
|
||||
const baseCountsAsPattern = !!pattern[baseDow]
|
||||
|
||||
// Same ISO week as base: count pattern days from baseStart up to target (inclusive)
|
||||
if (weekDiff === 0) {
|
||||
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
|
||||
if (!baseCountsAsPattern) n += 1
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
return n < 0 || n >= maxCount ? null : n
|
||||
}
|
||||
|
||||
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
|
||||
// Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd
|
||||
const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern)
|
||||
const alignedWeeksBetween = weekDiff / interval - 1
|
||||
const fullPatternWeekCount = pattern.filter(Boolean).length
|
||||
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
|
||||
// Count pattern days in the current (possibly partial) week from currentBlockStart..target
|
||||
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
|
||||
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
|
||||
if (!baseCountsAsPattern) n += 1
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
return n >= maxCount ? null : n
|
||||
}
|
||||
|
||||
// Recurrence: Monthly -----------------------------------------------------
|
||||
function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const recur = _getRecur(event)
|
||||
if (!recur || recur.freq !== 'months') return null
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
const d = fromLocalString(dateStr, timeZone)
|
||||
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
|
||||
if (diffMonths < 0) return null
|
||||
const interval = recur.interval || 1
|
||||
if (diffMonths % interval !== 0) return null
|
||||
const baseDay = dateFns.getDate(baseStart)
|
||||
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
|
||||
if (dateFns.getDate(d) !== effectiveDay) return null
|
||||
const n = diffMonths / interval
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
return n >= maxCount ? null : n
|
||||
}
|
||||
|
||||
function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const recur = _getRecur(event)
|
||||
if (!recur) return null
|
||||
if (dateStr < event.startDate) return null
|
||||
if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
|
||||
if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
|
||||
return null
|
||||
}
|
||||
|
||||
// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string.
|
||||
// Returns null if the index is out of range or the event is not repeating.
|
||||
function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
||||
const recur = _getRecur(event)
|
||||
if (!recur || recur.freq !== 'weeks') return null
|
||||
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
if (occurrenceIndex >= maxCount) return null
|
||||
const pattern = recur.weekdays || []
|
||||
if (!pattern.some(Boolean)) return null
|
||||
const interval = recur.interval || 1
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
|
||||
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
|
||||
const baseDow = dateFns.getDay(baseStart)
|
||||
const baseCountsAsPattern = !!pattern[baseDow]
|
||||
// Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1)
|
||||
let occ = occurrenceIndex
|
||||
if (!baseCountsAsPattern) occ -= 1
|
||||
if (occ < 0) return null
|
||||
// Sorted list of active weekday indices
|
||||
const patternDays = []
|
||||
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
|
||||
// First (possibly partial) week: only pattern days >= baseDow and >= baseStart date
|
||||
const firstWeekDates = []
|
||||
for (const d of patternDays) {
|
||||
if (d < baseDow) continue
|
||||
const date = dateFns.addDays(baseWeekMonday, d)
|
||||
if (date < baseStart) continue
|
||||
firstWeekDates.push(date)
|
||||
}
|
||||
const F = firstWeekDates.length
|
||||
if (occ < F) {
|
||||
return toLocalString(firstWeekDates[occ], timeZone)
|
||||
}
|
||||
const remaining = occ - F
|
||||
const P = patternDays.length
|
||||
if (P === 0) return null
|
||||
// Determine aligned week group (k >= 1) in which the remaining-th occurrence lies
|
||||
const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week
|
||||
const indexInWeek = remaining % P
|
||||
const dow = patternDays[indexInWeek]
|
||||
const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow)
|
||||
return toLocalString(occurrenceDate, timeZone)
|
||||
}
|
||||
|
||||
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
||||
const recur = _getRecur(event)
|
||||
if (!recur || recur.freq !== 'months') return null
|
||||
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
if (occurrenceIndex >= maxCount) return null
|
||||
const interval = recur.interval || 1
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
const targetMonthOffset = occurrenceIndex * interval
|
||||
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
|
||||
// Adjust day for shorter months (clamp like forward logic)
|
||||
const baseDay = dateFns.getDate(baseStart)
|
||||
const daysInTargetMonth = dateFns.getDaysInMonth(monthDate)
|
||||
const day = Math.min(baseDay, daysInTargetMonth)
|
||||
const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone)
|
||||
return toLocalString(actual, timeZone)
|
||||
}
|
||||
|
||||
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
||||
const recur = _getRecur(event)
|
||||
if (!recur) return null
|
||||
if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
|
||||
if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
|
||||
return null
|
||||
}
|
||||
|
||||
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
|
||||
const spanDays = Math.max(0, (event.days || 1) - 1)
|
||||
const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
|
||||
return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone)
|
||||
}
|
||||
// (Recurrence utilities moved to events.js)
|
||||
|
||||
// Utility formatting & localization ---------------------------------------
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
@@ -249,11 +87,22 @@ function addDaysStr(str, n, timeZone = DEFAULT_TZ) {
|
||||
return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone)
|
||||
}
|
||||
|
||||
// Weekday name helpers now return Sunday-first ordering (index 0 = Sunday ... 6 = Saturday)
|
||||
function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
|
||||
const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday
|
||||
const sunday = makeTZDate(2025, 0, 5, timeZone) // a Sunday
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(
|
||||
dateFns.addDays(monday, i),
|
||||
dateFns.addDays(sunday, i),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Long (wide) localized weekday names, Sunday-first ordering
|
||||
function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) {
|
||||
const sunday = makeTZDate(2025, 0, 5, timeZone)
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Intl.DateTimeFormat(undefined, { weekday: 'long', timeZone }).format(
|
||||
dateFns.addDays(sunday, i),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -355,14 +204,12 @@ export {
|
||||
// recurrence
|
||||
getMondayOfISOWeek,
|
||||
mondayIndex,
|
||||
getOccurrenceIndex,
|
||||
getOccurrenceDate,
|
||||
getVirtualOccurrenceEndDate,
|
||||
// formatting & localization
|
||||
pad,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
getLocalizedWeekdayNames,
|
||||
getLocalizedWeekdayNamesLong,
|
||||
getLocaleFirstDay,
|
||||
getLocaleWeekendDays,
|
||||
reorderByFirstDay,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as dateFns from 'date-fns'
|
||||
import { fromLocalString, toLocalString, getMondayOfISOWeek, makeTZDate, DEFAULT_TZ } from './date'
|
||||
import { addDays, isBefore, isAfter, differenceInCalendarDays } from 'date-fns'
|
||||
|
||||
function countPatternDaysInInterval(startDate, endDate, patternArr) {
|
||||
const days = dateFns.eachDayOfInterval({
|
||||
start: dateFns.startOfDay(startDate),
|
||||
end: dateFns.startOfDay(endDate),
|
||||
})
|
||||
return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
export function getNWeekly(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const { recur } = event
|
||||
if (!recur || recur.freq !== 'weeks') return null
|
||||
const pattern = recur.weekdays || []
|
||||
if (!pattern.some(Boolean)) return null
|
||||
const target = fromLocalString(dateStr, timeZone)
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
if (dateFns.isBefore(target, baseStart)) return null
|
||||
const dow = dateFns.getDay(target)
|
||||
if (!pattern[dow]) return null
|
||||
const interval = recur.interval || 1
|
||||
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
|
||||
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
|
||||
const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
|
||||
if (weekDiff < 0 || weekDiff % interval !== 0) return null
|
||||
const baseDow = dateFns.getDay(baseStart)
|
||||
const baseCountsAsPattern = !!pattern[baseDow]
|
||||
if (weekDiff === 0) {
|
||||
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
|
||||
if (!baseCountsAsPattern) n += 1
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
return n < 0 || n >= maxCount ? null : n
|
||||
}
|
||||
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
|
||||
const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern)
|
||||
const alignedWeeksBetween = weekDiff / interval - 1
|
||||
const fullPatternWeekCount = pattern.filter(Boolean).length
|
||||
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
|
||||
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
|
||||
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
|
||||
if (!baseCountsAsPattern) n += 1
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
return n >= maxCount ? null : n
|
||||
}
|
||||
|
||||
function getNMonthly(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const { recur } = event
|
||||
if (!recur || recur.freq !== 'months') return null
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
const d = fromLocalString(dateStr, timeZone)
|
||||
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
|
||||
if (diffMonths < 0) return null
|
||||
const interval = recur.interval || 1
|
||||
if (diffMonths % interval !== 0) return null
|
||||
const baseDay = dateFns.getDate(baseStart)
|
||||
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
|
||||
if (dateFns.getDate(d) !== effectiveDay) return null
|
||||
const n = diffMonths / interval
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
return n >= maxCount ? null : n
|
||||
}
|
||||
|
||||
function getN(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const { recur } = event
|
||||
if (!recur) return null
|
||||
const targetDate = fromLocalString(dateStr, timeZone)
|
||||
const eventStartDate = fromLocalString(event.startDate, timeZone)
|
||||
if (dateFns.isBefore(targetDate, eventStartDate)) return null
|
||||
if (recur.freq === 'weeks') return getNWeekly(event, dateStr, timeZone)
|
||||
if (recur.freq === 'months') return getNMonthly(event, dateStr, timeZone)
|
||||
return null
|
||||
}
|
||||
|
||||
// Reverse lookup: occurrence index -> start date string
|
||||
function getDateWeekly(event, n, timeZone = DEFAULT_TZ) {
|
||||
const { recur } = event
|
||||
if (!recur || recur.freq !== 'weeks') return null
|
||||
if (n < 0 || !Number.isInteger(n)) return null
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
if (n >= maxCount) return null
|
||||
const pattern = recur.weekdays || []
|
||||
if (!pattern.some(Boolean)) return null
|
||||
const interval = recur.interval || 1
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
if (n === 0) return toLocalString(baseStart, timeZone)
|
||||
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
|
||||
const baseDow = dateFns.getDay(baseStart)
|
||||
const baseCountsAsPattern = !!pattern[baseDow]
|
||||
let occ = n
|
||||
if (!baseCountsAsPattern) occ -= 1
|
||||
if (occ < 0) return null
|
||||
const patternDays = []
|
||||
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
|
||||
const firstWeekDates = []
|
||||
for (const d of patternDays) {
|
||||
if (d < baseDow) continue
|
||||
const date = dateFns.addDays(baseWeekMonday, d)
|
||||
if (date < baseStart) continue
|
||||
firstWeekDates.push(date)
|
||||
}
|
||||
const F = firstWeekDates.length
|
||||
if (occ < F) return toLocalString(firstWeekDates[occ], timeZone)
|
||||
const remaining = occ - F
|
||||
const P = patternDays.length
|
||||
if (P === 0) return null
|
||||
const k = Math.floor(remaining / P) + 1
|
||||
const indexInWeek = remaining % P
|
||||
const dow = patternDays[indexInWeek]
|
||||
const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow)
|
||||
return toLocalString(occurrenceDate, timeZone)
|
||||
}
|
||||
|
||||
function getDateMonthly(event, n, timeZone = DEFAULT_TZ) {
|
||||
const { recur } = event
|
||||
if (!recur || recur.freq !== 'months') return null
|
||||
if (n < 0 || !Number.isInteger(n)) return null
|
||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
||||
if (n >= maxCount) return null
|
||||
const interval = recur.interval || 1
|
||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||
const targetMonthOffset = n * interval
|
||||
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
|
||||
const baseDay = dateFns.getDate(baseStart)
|
||||
const daysInTargetMonth = dateFns.getDaysInMonth(monthDate)
|
||||
const day = Math.min(baseDay, daysInTargetMonth)
|
||||
const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone)
|
||||
return toLocalString(actual, timeZone)
|
||||
}
|
||||
|
||||
export function getDate(event, n, timeZone = DEFAULT_TZ) {
|
||||
const { recur } = event
|
||||
if (!recur) return null
|
||||
if (recur.freq === 'weeks') return getDateWeekly(event, n, timeZone)
|
||||
if (recur.freq === 'months') return getDateMonthly(event, n, timeZone)
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const date = fromLocalString(dateStr, timeZone)
|
||||
const out = []
|
||||
for (const ev of events) {
|
||||
const spanDays = ev.days || 1
|
||||
if (!ev.recur) {
|
||||
const baseStart = fromLocalString(ev.startDate, timeZone)
|
||||
const baseEnd = addDays(baseStart, spanDays - 1)
|
||||
if (!isBefore(date, baseStart) && !isAfter(date, baseEnd)) {
|
||||
const diffDays = differenceInCalendarDays(date, baseStart)
|
||||
out.push({ ...ev, n: 0, nDay: diffDays })
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Recurring: gather all events whose start for any recurrence lies within spanDays window
|
||||
const maxBack = Math.min(spanDays - 1, spanScanCap(spanDays))
|
||||
for (let back = 0; back <= maxBack; back++) {
|
||||
const candidateStart = addDays(date, -back)
|
||||
const candidateStartStr = toLocalString(candidateStart, timeZone)
|
||||
const n = getN(ev, candidateStartStr, timeZone)
|
||||
if (n === null) continue
|
||||
if (back >= spanDays) continue
|
||||
out.push({ ...ev, n, nDay: back })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function spanScanCap(spanDays) {
|
||||
if (spanDays <= 31) return spanDays - 1
|
||||
return Math.min(spanDays - 1, 90)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const lang = navigator.language
|
||||
const rtlLangs = new Set(['ar', 'fa', 'he', 'iw', 'ur', 'ps', 'sd', 'ug', 'dv', 'ku', 'yi'])
|
||||
const primary = lang.toLowerCase().split(/[-_]/)[0]
|
||||
export const rtl = rtlLangs.has(primary)
|
||||
Reference in New Issue
Block a user