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
+3 -9
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -131,7 +131,7 @@ header {
overflow: visible;
position: absolute;
top: 0;
right: 0;
inset-inline-end: 0;
width: 100%;
}
.month-name-label > span {
+2 -2
View File
@@ -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
+42 -38
View File
@@ -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>
+1 -2
View File
@@ -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
View File
@@ -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>
+23 -48
View File
@@ -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
View File
@@ -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>
+3 -5
View File
@@ -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>
+1 -1
View File
@@ -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;
+18 -12
View File
@@ -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;
+3 -3
View File
@@ -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
View File
@@ -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 -8
View File
@@ -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
View File
@@ -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,
+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)