From 45939939f2a42c874b0f87ae7a6fd8cb6d58029c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 27 Aug 2025 11:15:27 -0600 Subject: [PATCH] Implement event/date search bar --- src/components/CalendarView.vue | 299 +++-------------------- src/components/HeaderControls.vue | 163 +++++++++---- src/components/Search.vue | 381 ++++++++++++++++++++++++++++++ src/plugins/virtualWeeks.js | 23 +- src/utils/events.js | 62 +++++ 5 files changed, 616 insertions(+), 312 deletions(-) create mode 100644 src/components/Search.vue diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index b335e0b..a9163dc 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -161,7 +161,27 @@ function measureFromProbe() { } } -const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm +const { + getWeekIndex, + getFirstDayForVirtualWeek, + goToToday, + handleHeaderYearChange, + scrollToWeekCentered, +} = vwm + +// Reference date for search: center of the current viewport (virtual week at vertical midpoint) +const centerVisibleWeek = computed(() => { + const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value + return Math.floor(midRow) + minVirtualWeek.value +}) +const centerVisibleDateStr = computed(() => { + try { + const d = getFirstDayForVirtualWeek(centerVisibleWeek.value) + return toLocalString(d, DEFAULT_TZ) + } catch { + return calendarStore.today + } +}) // createWeek logic moved to virtualWeeks plugin @@ -397,155 +417,25 @@ const handleEventClick = (payload) => { openEditEventDialog(payload) } -// ------------------------------ -// 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.events, - () => { - if (searchOpen.value && searchQuery.value.trim()) buildSearchResults() - }, - { deep: true }, -) - -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) +function scrollToEventStart(startDate, smooth = true) { try { - const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ) + const dateObj = fromLocalString(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') + scrollToWeekCentered(weekIndex, 'search-jump', smooth) } catch {} } -function activateCurrentResult() { - scrollToCurrentResult() +function handleHeaderSearchPreview(result) { + if (!result) return + scrollToEventStart(result.startDate, true) } - -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 handleHeaderSearchActivate(result) { + if (!result) return + scrollToEventStart(result.startDate, true) + // Open edit dialog for the event + const ev = calendarStore.getEventById(result.id) + if (ev) openEditEventDialog({ id: ev.id, event: ev }) } -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. // Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears. @@ -600,7 +490,12 @@ window.addEventListener('resize', () => {
- + {
- - @@ -776,93 +644,4 @@ 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; -} diff --git a/src/components/HeaderControls.vue b/src/components/HeaderControls.vue index 46ecdf6..7a4e304 100644 --- a/src/components/HeaderControls.vue +++ b/src/components/HeaderControls.vue @@ -1,54 +1,63 @@ diff --git a/src/plugins/virtualWeeks.js b/src/plugins/virtualWeeks.js index 59763c7..76c8605 100644 --- a/src/plugins/virtualWeeks.js +++ b/src/plugins/virtualWeeks.js @@ -285,10 +285,24 @@ export function createVirtualWeekManager({ } function goToToday() { - const top = addDays(new Date(calendarStore.now), -21) - const targetWeekIndex = getWeekIndex(top) - const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value - if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today') + const todayDate = new Date(calendarStore.now) + const targetWeekIndex = getWeekIndex(todayDate) + scrollToWeekCentered(targetWeekIndex, 'go-to-today', true) + } + + function scrollToWeekCentered(weekIndex, reason = 'center-scroll', smooth = true) { + if (weekIndex == null || !isFinite(weekIndex)) return + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const baseTop = (weekIndex - minVirtualWeek.value) * rowHeight.value + // Center: subtract half viewport minus half row height + let newScrollTop = baseTop - (viewportHeight.value / 2 - rowHeight.value / 2) + newScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)) + if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') { + viewport.value.scrollTo({ top: newScrollTop, behavior: 'smooth' }) + } else if (setScrollTopFn) { + setScrollTopFn(newScrollTop, reason) + scheduleWindowUpdate(reason) + } } function handleHeaderYearChange({ scrollTop }) { @@ -308,6 +322,7 @@ export function createVirtualWeekManager({ getWeekIndex, getFirstDayForVirtualWeek, goToToday, + scrollToWeekCentered, handleHeaderYearChange, attachScroll, } diff --git a/src/utils/events.js b/src/utils/events.js index a56fc4f..6ba2c2b 100644 --- a/src/utils/events.js +++ b/src/utils/events.js @@ -139,6 +139,68 @@ export function getDate(event, n, timeZone = DEFAULT_TZ) { return null } +/** + * Return nearest occurrence (past or future) relative to a reference date (date-string yyyy-MM-dd). + * Falls back to first/last when reference lies before first or after last (bounded by cap). + * Returns { n, dateStr } or null if no recurrence / invalid. + */ +export function getNearestOccurrence(event, referenceDateStr, timeZone = DEFAULT_TZ, cap = 5000) { + if (!event) return null + if (!event.recur) return { n: 0, dateStr: event.startDate } + const { recur } = event + if (!recur || !['weeks', 'months'].includes(recur.freq)) return { n: 0, dateStr: event.startDate } + const refDate = fromLocalString(referenceDateStr, timeZone) + const baseDate = fromLocalString(event.startDate, timeZone) + if (refDate <= baseDate) return { n: 0, dateStr: event.startDate } + const maxCount = recur.count === 'unlimited' ? cap : Math.min(parseInt(recur.count, 10) || 0, cap) + if (maxCount <= 0) return null + let low = 0 + let high = maxCount - 1 + let candidateGE = null + while (low <= high) { + const mid = (low + high) >> 1 + const midStr = getDate(event, mid, timeZone) + if (!midStr) { + // invalid mid (should rarely happen) shrink high + high = mid - 1 + continue + } + const midDate = fromLocalString(midStr, timeZone) + if (midDate >= refDate) { + candidateGE = { n: mid, dateStr: midStr, date: midDate } + high = mid - 1 + } else { + low = mid + 1 + } + } + let candidateLT = null + if (candidateGE) { + const prevN = candidateGE.n - 1 + if (prevN >= 0) { + const prevStr = getDate(event, prevN, timeZone) + if (prevStr) { + candidateLT = { n: prevN, dateStr: prevStr, date: fromLocalString(prevStr, timeZone) } + } + } + } else { + // All occurrences earlier than ref + const lastN = maxCount - 1 + const lastStr = getDate(event, lastN, timeZone) + if (lastStr) + candidateLT = { n: lastN, dateStr: lastStr, date: fromLocalString(lastStr, timeZone) } + } + if (candidateGE && candidateLT) { + const diffGE = candidateGE.date - refDate + const diffLT = refDate - candidateLT.date + return diffLT <= diffGE + ? { n: candidateLT.n, dateStr: candidateLT.dateStr } + : { n: candidateGE.n, dateStr: candidateGE.dateStr } + } + if (candidateGE) return { n: candidateGE.n, dateStr: candidateGE.dateStr } + if (candidateLT) return { n: candidateLT.n, dateStr: candidateLT.dateStr } + return null +} + export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) { const date = fromLocalString(dateStr, timeZone) const out = []