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:
2025-08-27 13:41:46 +01:00
parent 9e3f7ddd57
commit d9352a9fb3
19 changed files with 881 additions and 659 deletions
+2 -8
View File
@@ -1,12 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <script type="module" src="/src/main.js"></script>
<head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Calendar</title> <title>Calendar</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1" />
</head>
<body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+16 -26
View File
@@ -1,10 +1,10 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import CalendarView from './components/CalendarView.vue' import CalendarView from './components/CalendarView.vue'
import EventDialog from './components/EventDialog.vue'
import { useCalendarStore } from './stores/CalendarStore' import { useCalendarStore } from './stores/CalendarStore'
import { lang } from './utils/locale'
import { formatTodayString } from './utils/date'
const eventDialog = ref(null)
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
// Initialize holidays when app starts // Initialize holidays when app starts
@@ -35,38 +35,28 @@ function handleGlobalKey(e) {
onMounted(() => { onMounted(() => {
calendarStore.initializeHolidaysFromConfig() calendarStore.initializeHolidaysFromConfig()
document.addEventListener('keydown', handleGlobalKey, { passive: false }) 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(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handleGlobalKey) document.removeEventListener('keydown', handleGlobalKey)
}) })
const handleCreateEvent = (eventData) => { // Watch today's date to update document title
if (eventDialog.value) { watch(
const selectionData = { () => calendarStore.now,
startDate: eventData.startDate, (val) => {
dayCount: eventData.dayCount, document.title = formatTodayString(new Date(val))
} },
setTimeout(() => eventDialog.value.openCreateDialog(selectionData), 50) { immediate: false },
} )
}
const handleEditEvent = (eventClickPayload) => {
if (eventDialog.value) {
eventDialog.value.openEditDialog(eventClickPayload)
}
}
const handleClearSelection = () => {}
</script> </script>
<template> <template>
<CalendarView @create-event="handleCreateEvent" @edit-event="handleEditEvent" /> <CalendarView />
<EventDialog
ref="eventDialog"
:selection="{ startDate: null, dayCount: 0 }"
@clear-selection="handleClearSelection"
/>
</template> </template>
<style scoped></style> <style scoped></style>
+1 -1
View File
@@ -131,7 +131,7 @@ header {
overflow: visible; overflow: visible;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; inset-inline-end: 0;
width: 100%; width: 100%;
} }
.month-name-label > span { .month-name-label > span {
+2 -2
View File
@@ -79,10 +79,10 @@ const modalStyle = computed(() => {
if (modalRef.value && props.modelValue) { if (modalRef.value && props.modelValue) {
const style = { const style = {
transform: 'none', transform: 'none',
left: modalPosition.value.x + 'px', insetInlineStart: modalPosition.value.x + 'px',
top: modalPosition.value.y + 'px', top: modalPosition.value.y + 'px',
bottom: 'auto', bottom: 'auto',
right: 'auto', insetInlineEnd: 'auto',
} }
if (hasMoved.value) { if (hasMoved.value) {
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
+42 -38
View File
@@ -21,13 +21,10 @@ const props = defineProps({
]" ]"
:data-date="props.day.date" :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> <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
<div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name">
<div v-if="props.day.holiday" class="holiday-info"> {{ props.day.holiday.name }}
<span class="holiday-name" :title="props.day.holiday.name">
{{ props.day.holiday.name }}
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -35,22 +32,30 @@ const props = defineProps({
<style scoped> <style scoped>
.cell { .cell {
position: relative; position: relative;
border-right: 1px solid var(--border-color); border-inline-end: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
user-select: none; user-select: none;
display: flex; display: grid;
flex-direction: row; /* 3 columns: day number, flexible space, lunar phase */
align-items: flex-start; grid-template-columns: min-content 1fr min-content;
justify-content: flex-start; /* 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; padding: 0.25em;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
height: var(--row-h); height: var(--row-h);
font-weight: 700; font-weight: 700;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
align-items: start;
} }
.cell h1.day-number {
.cell h1 {
margin: 0; margin: 0;
padding: 0; padding: 0;
min-width: 1.5em; min-width: 1.5em;
@@ -58,15 +63,16 @@ const props = defineProps({
font-weight: 700; font-weight: 700;
color: var(--ink); color: var(--ink);
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
grid-area: day-number;
} }
.cell.weekend h1 { .cell.weekend h1.day-number {
color: var(--weekend); color: var(--weekend);
} }
.cell.firstday h1 { .cell.firstday h1.day-number {
color: var(--firstday); color: var(--firstday);
text-shadow: 0 0 0.1em var(--strong); text-shadow: 0 0 0.1em var(--strong);
} }
.cell.today h1 { .cell.today h1.day-number {
border-radius: 2em; border-radius: 2em;
background: var(--today); background: var(--today);
border: 0.2em solid var(--today); border: 0.2em solid var(--today);
@@ -77,16 +83,9 @@ const props = defineProps({
.cell.selected { .cell.selected {
filter: hue-rotate(180deg); filter: hue-rotate(180deg);
} }
.cell.selected h1 { .cell.selected h1.day-number {
color: var(--strong); color: var(--strong);
} }
.lunar-phase {
position: absolute;
top: 0.5em;
right: 0.2em;
font-size: 0.8em;
opacity: 0.7;
}
.cell.holiday { .cell.holiday {
background-image: linear-gradient( background-image: linear-gradient(
135deg, 135deg,
@@ -103,27 +102,32 @@ const props = defineProps({
); );
} }
} }
.cell.holiday h1 { .cell.holiday h1.day-number {
/* Slight emphasis without forcing a specific hue */ /* Slight emphasis without forcing a specific hue */
color: var(--holiday); color: var(--holiday);
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
} }
.holiday-info { .lunar-phase {
position: absolute; grid-area: lunar-phase;
bottom: 0.1em; align-self: start;
left: 0.1em; justify-self: end;
right: 0.1em; margin-top: 0.5em;
line-height: 1; margin-inline-end: 0.2em;
overflow: hidden; font-size: 0.8em;
font-size: clamp(1.2vw, 0.6em, 1em); opacity: 0.7;
} }
.holiday-name { .holiday-info {
display: block; grid-area: holiday-info;
color: var(--holiday-label); align-self: end;
padding: 0.15em 0.35em 0.15em 0.25em; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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> </style>
+1 -2
View File
@@ -78,8 +78,7 @@ function changeYear(y) {
const weekdayNames = computed(() => { const weekdayNames = computed(() => {
// Reorder names & weekend flags // Reorder names & weekend flags
const mondayFirstNames = getLocalizedWeekdayNames() const sundayFirstNames = getLocalizedWeekdayNames()
const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)]
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day) const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day)
+320 -46
View File
@@ -1,5 +1,5 @@
<script setup> <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 { useCalendarStore } from '@/stores/CalendarStore'
import CalendarHeader from '@/components/CalendarHeader.vue' import CalendarHeader from '@/components/CalendarHeader.vue'
import CalendarWeek from '@/components/CalendarWeek.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 { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInWeeks } from 'date-fns' import { addDays, differenceInWeeks } from 'date-fns'
import { createVirtualWeekManager } from '@/plugins/virtualWeeks' import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
import { rtl } from '@/utils/locale'
import EventDialog from '@/components/EventDialog.vue'
const calendarStore = useCalendarStore() 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 viewport = ref(null)
const viewportHeight = ref(600) const viewportHeight = ref(600)
const rowHeight = ref(64) const rowHeight = ref(64)
@@ -87,7 +99,7 @@ const vwm = createVirtualWeekManager({
contentHeight, contentHeight,
}) })
const visibleWeeks = vwm.visibleWeeks const visibleWeeks = vwm.visibleWeeks
const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm const { scheduleWindowUpdate, resetWeeks, refreshEvents, refreshHolidays } = vwm
// Scroll managers (after scheduleWindowUpdate available) // Scroll managers (after scheduleWindowUpdate available)
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate }) const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
@@ -98,8 +110,7 @@ const weekColumnScrollManager = createWeekColumnScrollManager({
contentHeight, contentHeight,
setScrollTop, setScrollTop,
}) })
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } = const { handleWeekColMouseDown, handlePointerLockChange } = weekColumnScrollManager
weekColumnScrollManager
const monthScrollManager = createMonthScrollManager({ const monthScrollManager = createMonthScrollManager({
viewport, viewport,
viewportHeight, viewportHeight,
@@ -160,6 +171,25 @@ function clearSelection() {
selection.value = { startDate: null, dayCount: 0 } 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) { function startDrag(dateStr) {
dateStr = normalizeDate(dateStr) dateStr = normalizeDate(dateStr)
if (calendarStore.config.select_days === 0) return if (calendarStore.config.select_days === 0) return
@@ -188,7 +218,7 @@ function finalizeDragAndCreate() {
const eventData = createEventFromSelection() const eventData = createEventFromSelection()
if (eventData) { if (eventData) {
clearSelection() clearSelection()
emit('create-event', eventData) openCreateEventDialog(eventData)
} }
removeGlobalTouchListeners() 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(() => { onMounted(() => {
computeRowHeight() computeRowHeight()
calendarStore.updateCurrentDate() calendarStore.updateCurrentDate()
@@ -363,7 +384,7 @@ const handleDayMouseUp = (d) => {
const ev = createEventFromSelection() const ev = createEventFromSelection()
if (ev) { if (ev) {
clearSelection() clearSelection()
emit('create-event', ev) openCreateEventDialog(ev)
} }
} }
const handleDayTouchStart = (d) => { const handleDayTouchStart = (d) => {
@@ -373,10 +394,156 @@ const handleDayTouchStart = (d) => {
} }
const handleEventClick = (payload) => { 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. // Heuristic: rotate month label (180deg) only for predominantly Latin text.
// We explicitly avoid locale detection; rely solely on characters present. // 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( watch(
() => calendarStore.events, () => calendarStore.events,
() => { () => {
@@ -426,7 +593,7 @@ window.addEventListener('resize', () => {
</script> </script>
<template> <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 ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
<div class="wrap"> <div class="wrap">
<HeaderControls @go-to-today="goToToday" /> <HeaderControls @go-to-today="goToToday" />
@@ -438,27 +605,22 @@ window.addEventListener('resize', () => {
/> />
<div class="calendar-container"> <div class="calendar-container">
<div class="calendar-viewport" ref="viewport"> <div class="calendar-viewport" ref="viewport">
<!-- Main calendar content (weeks and days) --> <div class="calendar-content" :style="{ height: contentHeight + 'px' }">
<div class="main-calendar-area"> <CalendarWeek
<div class="calendar-content" :style="{ height: contentHeight + 'px' }"> v-for="week in visibleWeeks"
<CalendarWeek :key="week.virtualWeek"
v-for="week in visibleWeeks" :week="week"
:key="week.virtualWeek" :dragging="isDragging"
:week="week" :style="{ top: week.top + 'px' }"
:dragging="isDragging" @day-mousedown="handleDayMouseDown"
:style="{ top: week.top + 'px' }" @day-mouseenter="handleDayMouseEnter"
@day-mousedown="handleDayMouseDown" @day-mouseup="handleDayMouseUp"
@day-mouseenter="handleDayMouseEnter" @day-touchstart="handleDayTouchStart"
@day-mouseup="handleDayMouseUp" @event-click="handleEventClick"
@day-touchstart="handleDayTouchStart" />
@event-click="handleEventClick"
/>
</div>
</div> </div>
<!-- Month column area --> <div class="month-column-area" :style="{ height: contentHeight + 'px' }">
<div class="month-column-area"> <div class="month-labels-container" :style="{ height: '100%' }">
<!-- Month labels -->
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
<div <div
v-if="monthWeek && monthWeek.monthLabel" v-if="monthWeek && monthWeek.monthLabel"
@@ -481,6 +643,34 @@ window.addEventListener('resize', () => {
</div> </div>
</div> </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>
</div> </div>
</template> </template>
@@ -529,11 +719,6 @@ header h1 {
grid-template-columns: 1fr var(--month-w); grid-template-columns: 1fr var(--month-w);
} }
.main-calendar-area {
position: relative;
overflow: hidden;
}
.calendar-content { .calendar-content {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -552,7 +737,7 @@ header h1 {
.month-label { .month-label {
position: absolute; position: absolute;
left: 0; inset-inline-start: 0;
width: 100%; width: 100%;
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
font-size: 2em; font-size: 2em;
@@ -587,4 +772,93 @@ header h1 {
height: var(--row-h); height: var(--row-h);
pointer-events: none; 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> </style>
+23 -48
View File
@@ -12,6 +12,7 @@ import {
formatDateLong, formatDateLong,
DEFAULT_TZ, DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { getDate as getOccurrenceDate } from '@/utils/events'
import { addDays, addMonths } from 'date-fns' import { addDays, addMonths } from 'date-fns'
const props = defineProps({ const props = defineProps({
@@ -301,48 +302,18 @@ function openEditDialog(payload) {
if (!payload) return if (!payload) return
const baseId = payload.id const baseId = payload.id
let occurrenceIndex = payload.occurrenceIndex || 0 let n = payload.n || 0
let weekday = null let weekday = null
let occurrenceDate = null let occurrenceDate = null
const event = calendarStore.getEventById(baseId) const event = calendarStore.getEventById(baseId)
if (!event) return if (!event) return
if (event.recur) { if (event.recur && n >= 0) {
if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) { const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
const pattern = event.recur.weekdays || [] if (occStr) {
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ) occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
const baseEnd = new Date(fromLocalString(event.startDate, DEFAULT_TZ)) weekday = occurrenceDate.getDay()
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)
} }
} }
dialogMode.value = 'edit' dialogMode.value = 'edit'
@@ -372,10 +343,10 @@ function openEditDialog(payload) {
eventSaved.value = false eventSaved.value = false
if (event.recur) { if (event.recur) {
if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) { if (event.recur.freq === 'weeks' && n >= 0) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate }
} else if (event.recur.freq === 'months' && occurrenceIndex > 0) { } else if (event.recur.freq === 'months' && n > 0) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate } occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate }
} }
} }
// anchor to base event start date // anchor to base event start date
@@ -594,8 +565,10 @@ const recurrenceSummary = computed(() => {
<template> <template>
<BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent"> <BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent">
<template #title> <template #title>
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' <div class="dialog-title-row">
}}<template v-if="headerDateShort"> · {{ headerDateShort }}</template> {{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }}
<span> · {{ headerDateShort }}</span>
</div>
</template> </template>
<label class="ec-field"> <label class="ec-field">
<input type="text" v-model="title" autocomplete="off" ref="titleInput" /> <input type="text" v-model="title" autocomplete="off" ref="titleInput" />
@@ -620,9 +593,7 @@ const recurrenceSummary = computed(() => {
</label> </label>
<span class="recurrence-summary" v-if="recurrenceEnabled"> <span class="recurrence-summary" v-if="recurrenceEnabled">
{{ recurrenceSummary }} {{ recurrenceSummary }}
<template v-if="recurrenceOccurrences > 0"> <span v-if="recurrenceOccurrences > 0"> until {{ formattedFinalOccurrence }} </span>
until {{ formattedFinalOccurrence }}</template
>
</span> </span>
<span class="recurrence-summary muted" v-else>Does not recur</span> <span class="recurrence-summary muted" v-else>Does not recur</span>
</div> </div>
@@ -655,6 +626,7 @@ const recurrenceSummary = computed(() => {
v-model="recurrenceWeekdays" v-model="recurrenceWeekdays"
:fallback="fallbackWeekdays" :fallback="fallbackWeekdays"
:first-day="calendarStore.config.first_day" :first-day="calendarStore.config.first_day"
:weekend="calendarStore.weekend"
/> />
</div> </div>
</div> </div>
@@ -668,7 +640,7 @@ const recurrenceSummary = computed(() => {
<template v-if="showDeleteVariants"> <template v-if="showDeleteVariants">
<div class="ec-delete-group"> <div class="ec-delete-group">
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne"> <button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
Delete {{ formattedOccurrenceShort }} Delete <span>{{ formattedOccurrenceShort }}</span>
</button> </button>
<button <button
v-if="!isLastOccurrence" v-if="!isLastOccurrence"
@@ -676,7 +648,7 @@ const recurrenceSummary = computed(() => {
class="ec-btn delete-btn" class="ec-btn delete-btn"
@click="deleteEventFrom" @click="deleteEventFrom"
> >
Rest + Rest
</button> </button>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button> <button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
</div> </div>
@@ -943,7 +915,7 @@ const recurrenceSummary = computed(() => {
border-radius: 0.4rem; border-radius: 0.4rem;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
text-align: left; text-align: start;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
.ec-recurrence-toggle:hover { .ec-recurrence-toggle:hover {
@@ -1003,4 +975,7 @@ const recurrenceSummary = computed(() => {
.ec-occurrences-field .ec-field input[type='number'] { .ec-occurrences-field .ec-field input[type='number'] {
max-width: 6rem; max-width: 6rem;
} }
span {
unicode-bidi: isolate;
}
</style> </style>
+180 -130
View File
@@ -1,33 +1,41 @@
<template> <template>
<div class="week-overlay"> <div class="week-overlay" :style="{ gridColumn: '1 / -1' }" ref="weekOverlayRef">
<div <div
v-for="span in eventSpans" v-for="seg in eventSegments"
:key="span.id" :key="'seg-' + seg.startIdx + '-' + seg.endIdx"
class="event-span" :class="['segment-grid', { compress: isSegmentCompressed(seg) }]"
:class="[`event-color-${span.colorId}`]" :style="segmentStyle(seg)"
: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)"
> >
<span class="event-title">{{ span.title }}</span>
<div <div
class="resize-handle left" v-for="span in seg.events"
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)" :key="span.id + '-' + (span.n != null ? span.n : 0)"
></div> class="event-span"
<div dir="auto"
class="resize-handle right" :class="[`event-color-${span.colorId}`]"
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)" :data-id="span.id"
></div> :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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { daysInclusive, addDaysStr } from '@/utils/date' import { daysInclusive, addDaysStr } from '@/utils/date'
@@ -40,68 +48,139 @@ const store = useCalendarStore()
// Drag state // Drag state
const dragState = ref(null) const dragState = ref(null)
const justDragged = ref(false) const justDragged = ref(false)
const weekOverlayRef = ref(null)
const segmentCompression = ref({}) // key -> boolean
// Consolidate already-provided day.events into contiguous spans (no recurrence generation) // Build event segments: each segment is a contiguous day range with at least one bridging event between any adjacent days within it.
const eventSpans = computed(() => { const eventSegments = computed(() => {
const weekEvents = new Map() // Construct spans across the week
props.week.days.forEach((day, dayIndex) => { const spanMap = new Map()
props.week.days.forEach((day, di) => {
day.events.forEach((ev) => { day.events.forEach((ev) => {
const key = ev.id const key = ev.id + '|' + (ev.n ?? 0)
if (!weekEvents.has(key)) { if (!spanMap.has(key)) spanMap.set(key, { ...ev, startIdx: di, endIdx: di })
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex }) else spanMap.get(key).endIdx = Math.max(spanMap.get(key).endIdx, di)
} else {
const ref = weekEvents.get(key)
ref.endIdx = Math.max(ref.endIdx, dayIndex)
}
}) })
}) })
const arr = Array.from(weekEvents.values()) const spans = Array.from(spanMap.values())
arr.sort((a, b) => { // Derive span start/end date strings from week day indices (removes need for per-day stored endDate)
const spanA = a.endIdx - a.startIdx spans.forEach((sp) => {
const spanB = b.endIdx - b.startIdx sp.startDate = props.week.days[sp.startIdx].date
if (spanA !== spanB) return spanB - spanA 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 if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// For one-day events that are otherwise equal, sort by color (0 first) const ca = a.colorId != null ? a.colorId : 0
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) { const cb = b.colorId != null ? b.colorId : 0
const colorA = a.colorId || 0 if (ca !== cb) return ca - cb
const colorB = b.colorId || 0
if (colorA !== colorB) return colorA - colorB
}
return String(a.id).localeCompare(String(b.id)) return String(a.id).localeCompare(String(b.id))
}) })
// Assign non-overlapping rows // Identify breaks
const rowsLastEnd = [] const breaks = []
arr.forEach((ev) => { for (let d = 0; d < 6; d++) {
let row = 0 const bridged = spans.some((sp) => sp.startIdx <= d && sp.endIdx >= d + 1)
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++ if (!bridged) breaks.push(d)
if (row === rowsLastEnd.length) rowsLastEnd.push(-1) }
rowsLastEnd[row] = ev.endIdx const rawSegments = []
ev.row = row + 1 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) { function handleEventClick(span) {
if (justDragged.value) return if (justDragged.value) return
// Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n) emit('event-click', { id: span.id, n: span.n != null ? span.n : 0 })
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,
})
} }
function handleEventPointerDown(span, event) { function handleEventPointerDown(span, event) {
if (event.target.classList.contains('resize-handle')) return if (event.target.classList.contains('resize-handle')) return
event.stopPropagation() event.stopPropagation()
const idStr = span.id const baseId = 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
let anchorDate = span.startDate let anchorDate = span.startDate
try { try {
const spanDays = daysInclusive(span.startDate, span.endDate) const spanDays = daysInclusive(span.startDate, span.endDate)
@@ -116,14 +195,11 @@ function handleEventPointerDown(span, event) {
if (dayIndex >= spanDays) dayIndex = spanDays - 1 if (dayIndex >= spanDays) dayIndex = spanDays - 1
anchorDate = addDaysStr(span.startDate, dayIndex) anchorDate = addDaysStr(span.startDate, dayIndex)
} }
} catch (e) { } catch (e) {}
// Fallback to startDate if any calculation fails
}
startLocalDrag( startLocalDrag(
{ {
id: baseId, id: baseId,
originalId: span.id, originalId: span.id,
isVirtual,
mode: 'move', mode: 'move',
pointerStartX: event.clientX, pointerStartX: event.clientX,
pointerStartY: event.clientY, pointerStartY: event.clientY,
@@ -137,15 +213,11 @@ function handleEventPointerDown(span, event) {
function handleResizePointerDown(span, mode, event) { function handleResizePointerDown(span, mode, event) {
event.stopPropagation() event.stopPropagation()
const idStr = span.id const baseId = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const isVirtual = hasVirtualMarker
startLocalDrag( startLocalDrag(
{ {
id: baseId, id: baseId,
originalId: span.id, originalId: span.id,
isVirtual,
mode, mode,
pointerStartX: event.clientX, pointerStartX: event.clientX,
pointerStartY: event.clientY, pointerStartY: event.clientY,
@@ -167,7 +239,6 @@ function startLocalDrag(init, evt) {
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1 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 originalWeekday = null
let originalPattern = null let originalPattern = null
if (init.mode === 'move') { if (init.mode === 'move') {
@@ -194,13 +265,11 @@ function startLocalDrag(init, evt) {
tentativeEnd: init.endDate, tentativeEnd: init.endDate,
originalWeekday, originalWeekday,
originalPattern, 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() store.$history?.beginCompound()
// Capture pointer events globally
if (evt.currentTarget && evt.pointerId !== undefined) { if (evt.currentTarget && evt.pointerId !== undefined) {
try { try {
evt.currentTarget.setPointerCapture(evt.pointerId) 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')) { if (!(evt.pointerType === 'touch')) {
evt.preventDefault() evt.preventDefault()
} }
@@ -221,19 +289,15 @@ function startLocalDrag(init, evt) {
// Determine date under pointer: traverse DOM to find day cell carrying data-date attribute // Determine date under pointer: traverse DOM to find day cell carrying data-date attribute
function getDateUnderPointer(x, y, el) { function getDateUnderPointer(x, y, el) {
let cur = el for (let cur = el; cur; cur = cur.parentElement)
while (cur) { if (cur.dataset?.date) return { date: cur.dataset.date }
if (cur.dataset && cur.dataset.date) { const overlayEl = weekOverlayRef.value
return { date: cur.dataset.date } 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 return null
} }
@@ -250,7 +314,6 @@ function onDragPointerMove(e) {
const hitEl = document.elementFromPoint(e.clientX, e.clientY) const hitEl = document.elementFromPoint(e.clientX, e.clientY)
const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl) 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 if (!hit || !hit.date) return
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
@@ -260,26 +323,22 @@ function onDragPointerMove(e) {
st.tentativeStart = ns st.tentativeStart = ns
st.tentativeEnd = ne st.tentativeEnd = ne
if (st.mode === 'move') { if (st.mode === 'move') {
if (st.isVirtual) { if (st.n && st.n > 0) {
// On first movement convert virtual occurrence into a real new event (split series)
if (!st.realizedId) { if (!st.realizedId) {
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne) const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
if (newId) { if (newId) {
st.realizedId = newId st.realizedId = newId
st.id = newId st.id = newId
st.isVirtual = false // converted to standalone event
} else { } else {
return return
} }
} else { } else {
// Subsequent moves: update range without rotating pattern automatically
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
} }
} else { } else {
// Normal non-virtual move; rotate handled in setEventRange
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) 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) { if (st.originalPattern && st.originalWeekday != null) {
try { try {
const currentWeekday = new Date(ns + 'T00:00:00').getDay() const currentWeekday = new Date(ns + 'T00:00:00').getDay()
@@ -292,15 +351,9 @@ function onDragPointerMove(e) {
} }
} catch {} } catch {}
} }
} else if (!st.isVirtual) { } else if (!(st.n && st.n > 0)) {
// Resizes on real events update immediately applyRangeDuringDrag({ id: st.id, mode: st.mode, startDate: ns, endDate: ne }, ns, ne)
applyRangeDuringDrag( } else if (st.n && st.n > 0 && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
{ 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
if (!st.realizedId) { if (!st.realizedId) {
const initialStart = ns const initialStart = ns
const initialEnd = ne const initialEnd = ne
@@ -308,10 +361,9 @@ function onDragPointerMove(e) {
if (newId) { if (newId) {
st.realizedId = newId st.realizedId = newId
st.id = newId st.id = newId
st.isVirtual = false // converted
} else return } else return
} }
// Apply range change; rotate if left edge moved and weekday changed
const rotate = st.mode === 'resize-left' const rotate = st.mode === 'resize-left'
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate }) store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
} }
@@ -321,7 +373,6 @@ function onDragPointerUp(e) {
const st = dragState.value const st = dragState.value
if (!st) return if (!st) return
// Release pointer capture if it was set
if (e.target && e.pointerId !== undefined) { if (e.target && e.pointerId !== undefined) {
try { try {
e.target.releasePointerCapture(e.pointerId) e.target.releasePointerCapture(e.pointerId)
@@ -341,11 +392,10 @@ function onDragPointerUp(e) {
if (moved) { if (moved) {
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare) // 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( applyRangeDuringDrag(
{ {
id: st.id, id: st.id,
isVirtual: st.isVirtual,
mode: st.mode, mode: st.mode,
startDate: finalStart, startDate: finalStart,
endDate: finalEnd, endDate: finalEnd,
@@ -359,7 +409,6 @@ function onDragPointerUp(e) {
justDragged.value = false justDragged.value = false
}, 120) }, 120)
} }
// End compound session (snapshot if changed)
store.$history?.endCompound() store.$history?.endCompound()
} }
@@ -388,7 +437,7 @@ function normalizeDateOrder(aStr, bStr) {
} }
function applyRangeDuringDrag(st, startDate, endDate) { function applyRangeDuringDrag(st, startDate, endDate) {
if (st.isVirtual) { if (st.n && st.n > 0) {
if (st.mode !== 'move') return // no resize for virtual occurrence if (st.mode !== 'move') return // no resize for virtual occurrence
// Split-move: occurrence being dragged treated as first of new series // Split-move: occurrence being dragged treated as first of new series
store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate) store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate)
@@ -402,16 +451,22 @@ function applyRangeDuringDrag(st, startDate, endDate) {
.week-overlay { .week-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none;
z-index: 15;
display: grid; display: grid;
/* Prevent content from expanding tracks beyond container width */ grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1.5em);
row-gap: 0.05em;
margin-top: 1.8em; margin-top: 1.8em;
pointer-events: none;
}
.segment-grid {
display: grid;
gap: 2px;
align-content: start; 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 { .event-span {
@@ -429,13 +484,8 @@ function applyRangeDuringDrag(st, startDate, endDate) {
align-items: center; align-items: center;
position: relative; position: relative;
user-select: none; user-select: none;
height: 100%;
width: 100%;
max-width: 100%;
box-sizing: border-box;
z-index: 1; z-index: 1;
text-align: center; text-align: center;
min-width: 0;
} }
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
@@ -463,10 +513,10 @@ function applyRangeDuringDrag(st, startDate, endDate) {
} }
.event-span .resize-handle.left { .event-span .resize-handle.left {
left: 0; inset-inline-start: 0;
} }
.event-span .resize-handle.right { .event-span .resize-handle.right {
right: 0; inset-inline-end: 0;
} }
</style> </style>
+3 -5
View File
@@ -31,7 +31,6 @@
> >
</button> </button>
<!-- Settings dialog now lives here -->
<SettingsDialog ref="settingsDialog" /> <SettingsDialog ref="settingsDialog" />
</div> </div>
</Transition> </Transition>
@@ -101,12 +100,12 @@ onBeforeUnmount(() => {
display: flex; display: flex;
justify-content: end; justify-content: end;
align-items: center; align-items: center;
margin-right: 1.5rem; margin-inline-end: 2rem;
} }
.toggle-btn { .toggle-btn {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; inset-inline-end: 0;
background: transparent; background: transparent;
border: none; border: none;
color: var(--muted); color: var(--muted);
@@ -157,7 +156,6 @@ onBeforeUnmount(() => {
color: var(--muted); color: var(--muted);
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-right: 0.6rem;
cursor: pointer; cursor: pointer;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1; line-height: 1;
@@ -205,6 +203,6 @@ onBeforeUnmount(() => {
.today-date { .today-date {
white-space: pre-line; white-space: pre-line;
text-align: center; text-align: center;
margin-right: 2rem; margin-inline-end: 2rem;
} }
</style> </style>
+1 -1
View File
@@ -181,7 +181,7 @@ defineExpose({
.jogwheel-viewport { .jogwheel-viewport {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; inset-inline-end: 0;
bottom: 0; bottom: 0;
width: var(--month-w); width: var(--month-w);
overflow-y: auto; overflow-y: auto;
+18 -12
View File
@@ -3,10 +3,14 @@ import { ref, computed } from 'vue'
import BaseDialog from './BaseDialog.vue' import BaseDialog from './BaseDialog.vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import WeekdaySelector from './WeekdaySelector.vue' import WeekdaySelector from './WeekdaySelector.vue'
import { getLocalizedWeekdayNamesLong } from '@/utils/date'
const show = ref(false) const show = ref(false)
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
// Localized weekday names (now Sunday-first from util) for select 0=Sunday ..6=Saturday
const weekdayNames = getLocalizedWeekdayNamesLong()
// Reactive bindings to store // Reactive bindings to store
const firstDay = computed({ const firstDay = computed({
get: () => calendarStore.config.first_day, get: () => calendarStore.config.first_day,
@@ -159,19 +163,21 @@ defineExpose({ open })
v-model="show" v-model="show"
title="Settings" title="Settings"
class="settings-modal" 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"> <div class="setting-group">
<label class="ec-field"> <label class="ec-field">
<span>First day of week</span> <span>First day of week</span>
<select v-model.number="firstDay"> <select v-model.number="firstDay">
<option :value="0">Sunday</option> <option v-for="(name, idx) in weekdayNames" :key="idx" :value="idx">
<option :value="1">Monday</option> {{ name.charAt(0).toUpperCase() + name.slice(1) }}
<option :value="2">Tuesday</option> </option>
<option :value="3">Wednesday</option>
<option :value="4">Thursday</option>
<option :value="5">Friday</option>
<option :value="6">Saturday</option>
</select> </select>
</label> </label>
<div class="weekend-select ec-field"> <div class="weekend-select ec-field">
@@ -242,9 +248,9 @@ defineExpose({ open })
.holiday-settings { .holiday-settings {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
margin-left: 1rem; margin-inline-start: 1rem;
padding-left: 1rem; padding-inline-start: 1rem;
border-left: 2px solid var(--border-color); border-inline-start: 2px solid var(--border-color);
} }
select { select {
border: 1px solid var(--muted); border: 1px solid var(--muted);
@@ -269,7 +275,7 @@ select {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 120px; min-width: 120px;
} }
/* WeekdaySelector display tweaks */
.footer-row { .footer-row {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+3 -3
View File
@@ -16,7 +16,7 @@
@pointerenter="onDragOver(di)" @pointerenter="onDragOver(di)"
@pointerup="onPointerUp" @pointerup="onPointerUp"
> >
{{ d.slice(0, 3) }} {{ d }}
</button> </button>
<button <button
v-for="g in barGroups" 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) // 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] if (model.value?.some?.(Boolean)) internal.value = [...model.value]
const labelsMondayFirst = getLocalizedWeekdayNames() // getLocalizedWeekdayNames now returns Sunday-first already
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] const labels = getLocalizedWeekdayNames()
const anySelected = computed(() => internal.value.some(Boolean)) const anySelected = computed(() => internal.value.some(Boolean))
const localeFirst = getLocaleFirstDay() const localeFirst = getLocaleFirstDay()
const localeWeekend = getLocaleWeekendDays() const localeWeekend = getLocaleWeekendDays()
+73 -159
View File
@@ -1,5 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { addDays, differenceInWeeks } from 'date-fns' import { addDays, differenceInWeeks, isBefore, isAfter } from 'date-fns'
import { import {
toLocalString, toLocalString,
fromLocalString, fromLocalString,
@@ -11,9 +11,8 @@ import {
monthAbbr, monthAbbr,
lunarPhaseSymbol, lunarPhaseSymbol,
MAX_YEAR, MAX_YEAR,
getOccurrenceIndex,
getVirtualOccurrenceEndDate,
} from '@/utils/date' } from '@/utils/date'
import { buildDayEvents } from '@/utils/events'
import { getHolidayForDate } from '@/utils/holidays' import { getHolidayForDate } from '@/utils/holidays'
/** /**
@@ -54,77 +53,16 @@ export function createVirtualWeekManager({
function createWeek(virtualWeek) { function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek) const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) const thu = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
const weekNumber = getISOWeek(isoAnchor) const weekNumber = getISOWeek(thu)
const days = [] const days = []
let cur = new Date(firstDay) let cur = new Date(firstDay)
let hasFirst = false let hasFirst = false
let monthToLabel = null let monthToLabel = null
let labelYear = 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++) { for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur, DEFAULT_TZ) 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 dow = cur.getDay()
const isFirst = cur.getDate() === 1 const isFirst = cur.getDate() === 1
if (isFirst) { if (isFirst) {
@@ -133,10 +71,11 @@ export function createVirtualWeekManager({
labelYear = cur.getFullYear() labelYear = cur.getFullYear()
} }
let displayText = String(cur.getDate()) let displayText = String(cur.getDate())
if (isFirst) { if (isFirst)
if (cur.getMonth() === 0) displayText = cur.getFullYear() displayText =
else displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase() cur.getMonth() === 0
} ? cur.getFullYear()
: monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
let holiday = null let holiday = null
if (calendarStore.config.holidays.enabled) { if (calendarStore.config.holidays.enabled) {
calendarStore._ensureHolidaysInitialized?.() calendarStore._ensureHolidaysInitialized?.()
@@ -153,36 +92,12 @@ export function createVirtualWeekManager({
lunarPhase: lunarPhaseSymbol(cur), lunarPhase: lunarPhaseSymbol(cur),
holiday, holiday,
isHoliday: holiday !== null, isHoliday: holiday !== null,
isSelected: isSelected: isDateSelected(dateStr),
selection.value.startDate && events,
selection.value.dayCount > 0 &&
dateStr >= selection.value.startDate &&
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
events: dayEvents,
}) })
cur = addDays(cur, 1) cur = addDays(cur, 1)
} }
let monthLabel = null const monthLabel = buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek })
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],
}
}
}
return { return {
virtualWeek, virtualWeek,
weekNumber: pad(weekNumber), 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() { function internalWindowCalc() {
const buffer = 6 const buffer = 6
const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0 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) // Reflective update of only events inside currently visible weeks (keeps week objects stable)
function refreshEvents(reason = 'events-refresh') { function refreshEvents(reason = 'events-refresh') {
if (!visibleWeeks.value.length) return 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 selStart = selection.value.startDate
const selCount = selection.value.dayCount const selCount = selection.value.dayCount
const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null
@@ -306,63 +247,13 @@ export function createVirtualWeekManager({
for (const day of week.days) { for (const day of week.days) {
const dateStr = day.date const dateStr = day.date
// Update selection flag // Update selection flag
if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd if (selStart && selEnd) {
else day.isSelected = false const d = fromLocalString(dateStr, DEFAULT_TZ),
// Rebuild events list for this day s = fromLocalString(selStart, DEFAULT_TZ),
const storedEvents = [] e = fromLocalString(selEnd, DEFAULT_TZ)
for (const ev of calendarStore.events.values()) { day.isSelected = !isBefore(d, s) && !isAfter(d, e)
if (!ev.recur) { } else day.isSelected = false
const evEnd = toLocalString( day.events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ)
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 (process.env.NODE_ENV !== 'production') { 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() { function goToToday() {
const top = addDays(new Date(calendarStore.now), -21) const top = addDays(new Date(calendarStore.now), -21)
const targetWeekIndex = getWeekIndex(top) const targetWeekIndex = getWeekIndex(top)
@@ -391,6 +304,7 @@ export function createVirtualWeekManager({
resetWeeks, resetWeeks,
updateVisibleWeeks, updateVisibleWeeks,
refreshEvents, refreshEvents,
refreshHolidays,
getWeekIndex, getWeekIndex,
getFirstDayForVirtualWeek, getFirstDayForVirtualWeek,
goToToday, goToToday,
+4 -8
View File
@@ -4,7 +4,6 @@ import {
fromLocalString, fromLocalString,
getLocaleWeekendDays, getLocaleWeekendDays,
getMondayOfISOWeek, getMondayOfISOWeek,
getOccurrenceDate,
DEFAULT_TZ, DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { differenceInCalendarDays, addDays } from 'date-fns' import { differenceInCalendarDays, addDays } from 'date-fns'
@@ -201,11 +200,6 @@ export const useCalendarStore = defineStore('calendar', {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
if (!nextStartStr) {
this.deleteEvent(baseId)
return
}
base.startDate = nextStartStr base.startDate = nextStartStr
// keep same days length // keep same days length
if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1)) if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1))
@@ -228,9 +222,11 @@ export const useCalendarStore = defineStore('calendar', {
} }
const snapshot = { ...base } const snapshot = { ...base }
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
if (base.recur.count === occurrenceIndex + 1) {
base.recur.count = occurrenceIndex
return
}
base.recur.count = occurrenceIndex base.recur.count = occurrenceIndex
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
if (!nextStartStr) return
const originalNumeric = const originalNumeric =
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10) snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
let remainingCount = 'unlimited' let remainingCount = 'unlimited'
+16 -169
View File
@@ -23,7 +23,7 @@ const monthAbbr = [
'nov', 'nov',
'dec', 'dec',
] ]
const MIN_YEAR = 100 // less than 100 is interpreted as 19xx const MIN_YEAR = 1000
const MAX_YEAR = 9999 const MAX_YEAR = 9999
// Core helpers ------------------------------------------------------------ // Core helpers ------------------------------------------------------------
@@ -70,169 +70,7 @@ function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7
// Count how many days in [startDate..endDate] match the boolean `pattern` array // (Recurrence utilities moved to events.js)
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)
}
// Utility formatting & localization --------------------------------------- // Utility formatting & localization ---------------------------------------
const pad = (n) => String(n).padStart(2, '0') 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) 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) { 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) => return Array.from({ length: 7 }, (_, i) =>
new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( 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 // recurrence
getMondayOfISOWeek, getMondayOfISOWeek,
mondayIndex, mondayIndex,
getOccurrenceIndex,
getOccurrenceDate,
getVirtualOccurrenceEndDate,
// formatting & localization // formatting & localization
pad, pad,
daysInclusive, daysInclusive,
addDaysStr, addDaysStr,
getLocalizedWeekdayNames, getLocalizedWeekdayNames,
getLocalizedWeekdayNamesLong,
getLocaleFirstDay, getLocaleFirstDay,
getLocaleWeekendDays, getLocaleWeekendDays,
reorderByFirstDay, reorderByFirstDay,
+171
View File
@@ -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)
}
+4
View File
@@ -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)