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', () => {
-
-
-
-
-
-
-
- -
- {{ r.title }}
- {{ r.startDate }}
-
-
-
{{ searchQuery ? 'No matches' : 'Type to search' }}
-
Enter to go, Esc to close, ↑/↓ to browse
-
@@ -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 = []