Implement event/date search bar

This commit is contained in:
Leo Vasanko 2025-08-27 11:15:27 -06:00
parent 9183ffe873
commit 45939939f2
5 changed files with 616 additions and 312 deletions

View File

@ -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 // createWeek logic moved to virtualWeeks plugin
@ -397,155 +417,25 @@ const handleEventClick = (payload) => {
openEditEventDialog(payload) openEditEventDialog(payload)
} }
// ------------------------------ function scrollToEventStart(startDate, smooth = true) {
// 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)
try { try {
const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ) const dateObj = fromLocalString(startDate, DEFAULT_TZ)
const weekIndex = getWeekIndex(dateObj) const weekIndex = getWeekIndex(dateObj)
const offsetWeeks = 2 scrollToWeekCentered(weekIndex, 'search-jump', smooth)
const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks)
const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value
setScrollTop(newScrollTop, 'search-jump')
scheduleWindowUpdate('search-jump')
} catch {} } catch {}
} }
function activateCurrentResult() { function handleHeaderSearchPreview(result) {
scrollToCurrentResult() if (!result) return
scrollToEventStart(result.startDate, true)
} }
function handleHeaderSearchActivate(result) {
function handleGlobalFind(e) { if (!result) return
if (!(e.ctrlKey || e.metaKey)) return scrollToEventStart(result.startDate, true)
const k = e.key // Open edit dialog for the event
if (k === 'f' || k === 'F') { const ev = calendarStore.getEventById(result.id)
if (isEditableElement(e.target)) return if (ev) openEditEventDialog({ id: ev.id, event: ev })
e.preventDefault()
if (!searchOpen.value) openSearch('')
else {
// If already open, select input text for quick overwrite
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
searchInputRef.value.select()
}
})
}
}
// While open: Enter confirms current selection & closes dialog
if (searchOpen.value && (k === 'Enter' || k === 'Return')) {
e.preventDefault()
activateCurrentResult()
closeSearch()
}
} }
function handleSearchKeydown(e) {
if (!searchOpen.value) return
if (e.key === 'Escape') {
e.preventDefault()
closeSearch()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
navigateSearch(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
navigateSearch(-1)
} else if (e.key === 'Enter') {
// Enter inside input: activate current and close
e.preventDefault()
activateCurrentResult()
closeSearch()
}
}
onMounted(() => {
document.addEventListener('keydown', handleGlobalFind, { passive: false })
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleGlobalFind)
})
// Ensure focus when (re)opening via reactive watch (catches programmatic toggles too)
watch(
() => searchOpen.value,
(v) => {
if (v) {
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
searchInputRef.value.select()
}
})
}
},
)
// Heuristic: rotate month label (180deg) only for predominantly Latin text. // Heuristic: rotate month label (180deg) only for predominantly Latin text.
// We explicitly avoid locale detection; rely solely on characters present. // We explicitly avoid locale detection; rely solely on characters present.
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears. // Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
@ -600,7 +490,12 @@ window.addEventListener('resize', () => {
<div class="calendar-view-root" :dir="rtl && 'rtl'"> <div class="calendar-view-root" :dir="rtl && 'rtl'">
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
<div class="wrap"> <div class="wrap">
<HeaderControls @go-to-today="goToToday" /> <HeaderControls
:reference-date="centerVisibleDateStr"
@go-to-today="goToToday"
@search-preview="handleHeaderSearchPreview"
@search-activate="handleHeaderSearchActivate"
/>
<CalendarHeader <CalendarHeader
:scroll-top="scrollTop" :scroll-top="scrollTop"
:row-height="rowHeight" :row-height="rowHeight"
@ -648,33 +543,6 @@ window.addEventListener('resize', () => {
</div> </div>
</div> </div>
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
<!-- Event Search Overlay -->
<div v-if="searchOpen" class="event-search" @keydown.capture="handleSearchKeydown">
<div class="search-row">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="Search events..."
aria-label="Search events"
autofocus
/>
<button type="button" @click="closeSearch" title="Close (Esc)"></button>
</div>
<ul class="results" v-if="searchResults.length">
<li
v-for="(r, i) in searchResults"
:key="r.id"
:class="{ active: i === searchIndex }"
@click="((searchIndex = i), activateCurrentResult(), closeSearch())"
>
<span class="title">{{ r.title }}</span>
<span class="date">{{ r.startDate }}</span>
</li>
</ul>
<div v-else class="no-results">{{ searchQuery ? 'No matches' : 'Type to search' }}</div>
<div class="hint">Enter to go, Esc to close, / to browse</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -776,93 +644,4 @@ header h1 {
height: var(--row-h); height: var(--row-h);
pointer-events: none; pointer-events: none;
} }
/* Search overlay */
.event-search {
position: fixed;
top: 0.75rem;
inset-inline-end: 0.75rem;
z-index: 1200;
background: color-mix(in srgb, var(--panel) 90%, transparent);
backdrop-filter: blur(0.75em);
-webkit-backdrop-filter: blur(0.75em);
color: var(--ink);
padding: 0.75rem 0.75rem 0.6rem 0.75rem;
border-radius: 0.6rem;
width: min(28rem, 80vw);
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.35);
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
font-size: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.event-search .search-row {
display: flex;
gap: 0.4rem;
}
.event-search input[type='text'] {
flex: 1;
padding: 0.45rem 0.6rem;
border-radius: 0.4rem;
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
background: color-mix(in srgb, var(--panel) 85%, transparent);
color: inherit;
}
.event-search button {
background: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
color: var(--ink, #111);
border: 0;
border-radius: 0.4rem;
padding: 0.45rem 0.6rem;
cursor: pointer;
}
.event-search button:disabled {
opacity: 0.4;
cursor: default;
}
.event-search .results {
list-style: none;
margin: 0;
padding: 0;
max-height: 14rem;
overflow: auto;
border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
border-radius: 0.4rem;
}
.event-search .results li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.55rem;
cursor: pointer;
font-size: 0.85rem;
line-height: 1.2;
}
.event-search .results li.active {
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
color: var(--ink, #111);
font-weight: 600;
}
.event-search .results li:hover:not(.active) {
background: color-mix(in srgb, var(--panel) 70%, transparent);
}
.event-search .results .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-search .results .date {
opacity: 0.6;
font-family: monospace;
}
.event-search .no-results {
padding: 0.25rem 0.1rem;
opacity: 0.7;
}
.event-search .hint {
opacity: 0.55;
font-size: 0.7rem;
}
</style> </style>

View File

@ -1,6 +1,13 @@
<template> <template>
<div class="header-controls-wrapper">
<Transition name="header-controls" appear> <Transition name="header-controls" appear>
<div v-if="isVisible" class="header-controls"> <div v-if="isVisible" class="header-controls">
<EventSearch
ref="eventSearchRef"
:reference-date="referenceDate"
@activate="handleSearchActivate"
@preview="(r) => emit('search-preview', r)"
/>
<div class="today-date" @click="goToToday">{{ todayString }}</div> <div class="today-date" @click="goToToday">{{ todayString }}</div>
<button <button
type="button" type="button"
@ -43,12 +50,14 @@
> >
</button> </button>
</div>
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, onMounted, onBeforeUnmount, defineExpose, nextTick } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { formatTodayString } from '@/utils/date' import { formatTodayString } from '@/utils/date'
import EventSearch from '@/components/Search.vue'
import SettingsDialog from '@/components/SettingsDialog.vue' import SettingsDialog from '@/components/SettingsDialog.vue'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
@ -58,7 +67,8 @@ const todayString = computed(() => {
return formatTodayString(d) return formatTodayString(d)
}) })
const emit = defineEmits(['go-to-today']) const emit = defineEmits(['go-to-today', 'search-activate', 'search-preview'])
const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } })
function goToToday() { function goToToday() {
// Emit the event so the parent can handle the viewport scrolling logic // Emit the event so the parent can handle the viewport scrolling logic
@ -68,6 +78,8 @@ function goToToday() {
// Screen size detection and visibility toggle // Screen size detection and visibility toggle
const isVisible = ref(false) const isVisible = ref(false)
// Track if we auto-opened due to a find (Ctrl/Cmd+F)
const autoOpenedForSearch = ref(false)
function checkScreenSize() { function checkScreenSize() {
const isSmallScreen = window.innerHeight < 600 const isSmallScreen = window.innerHeight < 600
@ -77,6 +89,7 @@ function checkScreenSize() {
function toggleVisibility() { function toggleVisibility() {
isVisible.value = !isVisible.value isVisible.value = !isVisible.value
if (!isVisible.value) autoOpenedForSearch.value = false
} }
// Settings dialog integration // Settings dialog integration
@ -85,22 +98,75 @@ function openSettings() {
settingsDialog.value?.open() settingsDialog.value?.open()
} }
// Search component ref exposure
const eventSearchRef = ref(null)
function focusSearch(selectAll = true) {
eventSearchRef.value?.focusSearch(selectAll)
}
function isEditableElement(el) {
if (!el) return false
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
}
defineExpose({ focusSearch })
function handleGlobalFind(e) {
if (!(e.ctrlKey || e.metaKey)) return
if (e.key === 'f' || e.key === 'F') {
if (isEditableElement(e.target)) return
e.preventDefault()
if (!isVisible.value) {
isVisible.value = true
autoOpenedForSearch.value = true
} else {
autoOpenedForSearch.value = false
}
// Defer focus until after transition renders input
nextTick(() => requestAnimationFrame(() => focusSearch(true)))
}
}
function handleSearchActivate(r) {
emit('search-activate', r)
// Auto close only if we auto-opened for search shortcut
if (autoOpenedForSearch.value) {
isVisible.value = false
}
autoOpenedForSearch.value = false
}
onMounted(() => { onMounted(() => {
checkScreenSize() checkScreenSize()
window.addEventListener('resize', checkScreenSize) window.addEventListener('resize', checkScreenSize)
document.addEventListener('keydown', handleGlobalFind, { passive: false })
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', checkScreenSize) window.removeEventListener('resize', checkScreenSize)
document.removeEventListener('keydown', handleGlobalFind)
}) })
</script> </script>
<style scoped> <style scoped>
.header-controls-wrapper {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.4rem 0.5rem 0 0.5rem;
}
.header-controls { .header-controls {
display: flex; display: flex;
justify-content: end;
align-items: center; align-items: center;
margin-inline-end: 2rem; gap: 0.75rem;
width: 100%;
padding-inline-end: 2rem;
}
.header-controls :deep(.search-bar) {
flex: 1 1 clamp(14rem, 40vw, 30rem);
max-width: clamp(18rem, 40vw, 30rem);
min-width: 12rem;
margin-inline-end: auto;
} }
.toggle-btn { .toggle-btn {
position: fixed; position: fixed;
@ -201,6 +267,7 @@ onBeforeUnmount(() => {
} }
.today-date { .today-date {
font-size: 1.5em;
white-space: pre-line; white-space: pre-line;
text-align: center; text-align: center;
margin-inline-end: 2rem; margin-inline-end: 2rem;

381
src/components/Search.vue Normal file
View File

@ -0,0 +1,381 @@
<template>
<div class="search-bar" @keydown="onContainerKey">
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
placeholder="Date or event..."
aria-label="Search date and events"
@keydown="handleSearchKeydown"
/>
<ul
v-if="searchQuery.trim() && searchResults.length"
class="search-dropdown"
role="listbox"
:aria-activedescendant="activeResultId"
>
<li
v-for="(r, i) in searchResults"
:key="r.id"
:id="'sr-' + r.id"
:class="{ active: i === searchIndex }"
role="option"
@mousedown.prevent="selectResult(i)"
>
<span class="title">{{ r.title }}</span>
<span class="date">{{ r.startDate }}</span>
</li>
</ul>
<div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty">
No matches
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, computed, defineExpose } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import {
fromLocalString,
DEFAULT_TZ,
monthAbbr,
getLocalizedMonthName,
toLocalString,
getMondayOfISOWeek,
formatTodayString,
makeTZDate,
} from '@/utils/date'
import { addDays } from 'date-fns'
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events'
import * as dateFns from 'date-fns'
const emit = defineEmits(['activate', 'preview'])
const props = defineProps({ referenceDate: { type: String, default: null } })
const calendarStore = useCalendarStore()
const searchQuery = ref('')
const searchResults = ref([])
const searchIndex = ref(0)
const searchInputRef = ref(null)
function buildSearchResults() {
const raw = searchQuery.value.trim()
const q = raw.toLowerCase()
if (!q) {
searchResults.value = []
searchIndex.value = 0
return
}
const listAll = raw === '*'
const out = []
// Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local.
let refStr = props.referenceDate || calendarStore.today || calendarStore.now
// If it's full ISO (with time), slice date portion.
if (refStr.includes('T')) refStr = refStr.slice(0, 10)
const nowDate = fromLocalString(refStr, DEFAULT_TZ)
for (const ev of calendarStore.events.values()) {
const title = (ev.title || '').trim()
if (!title) continue
if (!(listAll || title.toLowerCase().includes(q))) continue
let displayStart = ev.startDate
if (ev.recur) {
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
if (nearest && nearest.dateStr) displayStart = nearest.dateStr
}
out.push({ id: ev.id, title, startDate: displayStart })
}
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
// Inject Go To Date option if query matches a date pattern (first item)
const gotoDateStr = parseGoToDateCandidate(raw)
if (gotoDateStr) {
const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
const label = formatTodayString(dateObj).replace(/\n+/g, ' ')
out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true })
}
searchResults.value = out
if (searchIndex.value >= out.length) searchIndex.value = 0
}
watch(searchQuery, buildSearchResults)
watch(
() => calendarStore.events,
() => {
if (searchQuery.value.trim()) buildSearchResults()
},
{ deep: true },
)
watch(
() => props.referenceDate,
() => {
if (searchQuery.value.trim()) buildSearchResults()
},
)
function focusSearch(selectAll = true) {
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
if (selectAll) searchInputRef.value.select()
}
})
}
function navigate(delta) {
const n = searchResults.value.length
if (!n) return
searchIndex.value = (searchIndex.value + delta + n) % n
const r = searchResults.value[searchIndex.value]
if (r) emit('preview', r)
}
function selectResult(idx) {
searchIndex.value = idx
const r = searchResults.value[searchIndex.value]
if (r) {
emit('activate', r)
// Clear query after activation (auto-close handled by parent visibility)
searchQuery.value = ''
}
}
function handleSearchKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault()
navigate(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
navigate(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
selectResult(searchIndex.value)
} else if (e.key === 'Escape') {
if (searchQuery.value) {
searchQuery.value = ''
e.preventDefault()
}
}
}
function onContainerKey(e) {
/* capture for list navigation if needed */
}
const activeResultId = computed(() => {
const r = searchResults.value[searchIndex.value]
return r ? 'sr-' + r.id : null
})
defineExpose({ focusSearch })
function parseGoToDateCandidate(input) {
const s = input.trim()
if (!s) return null
const today = new Date()
const currentYear = today.getFullYear()
const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
function monthFromToken(tok) {
if (!tok) return null
const t = tok.toLowerCase()
if (/^\d{1,2}$/.test(t)) {
const n = +t
return n >= 1 && n <= 12 ? n : null
}
for (let i = 0; i < 12; i++) {
const ab = monthAbbr[i]
if (t === ab || t === ab.slice(0, 3)) return i + 1
}
for (let i = 0; i < 12; i++) {
const full = localized[i].toLowerCase()
if (t === full || full.startsWith(t)) return i + 1
}
return null
}
// ISO full date or year-month (defaults day=1)
let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (mIsoFull) {
const y = +mIsoFull[1],
m = +mIsoFull[2],
d = +mIsoFull[3]
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
}
let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
if (mIsoMonth) {
const y = +mIsoMonth[1],
m = +mIsoMonth[2]
const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
}
// ISO week
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
if (mWeek) {
const wy = +mWeek[1],
w = +mWeek[2]
if (w >= 1 && w <= 53) {
const jan4 = new Date(Date.UTC(wy, 0, 4))
const target = addDays(jan4, (w - 1) * 7)
const monday = getMondayOfISOWeek(target)
return toLocalString(monday, DEFAULT_TZ)
}
return null
}
// Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year.
let d = null,
m = null,
y = null
let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/)
if (mDot) {
d = +mDot[1]
m = monthFromToken(mDot[2])
y = mDot[3] ? +mDot[3] : currentYear
} else {
// Slash month/day(/year) (month accepts names); year optional -> current year
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (mUSFull) {
m = monthFromToken(mUSFull[1])
d = +mUSFull[2]
y = +mUSFull[3]
} else {
let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/)
if (mUSShort) {
m = monthFromToken(mUSShort[1])
d = +mUSShort[2]
y = currentYear
}
}
}
// Free-form with spaces: tokens containing month names and numbers
if (!y && !m && !d) {
const tokens = s.split(/[ ,]+/).filter(Boolean)
if (tokens.length >= 2 && tokens.length <= 3) {
// Prefer a token with letters as month over numeric month
let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null)
if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
if (monthIdx !== -1) {
m = monthFromToken(tokens[monthIdx])
const others = tokens.filter((_t, i) => i !== monthIdx)
let dayExplicit = false
for (const rawTok of others) {
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation
if (!tok) continue
if (/^\d+$/.test(tok)) {
const num = +tok
if (num > 100) {
y = num
} else if (!d) {
d = num
dayExplicit = true
}
} else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
// salvage year with trailing punctuation
const num = parseInt(tok, 10)
if (num > 1000) y = num
}
}
if (!y) y = currentYear
// Only default day=1 if user didn't provide any day-ish numeric token
if (!d && !dayExplicit) d = 1
}
}
}
if (y != null && m != null && d != null) {
if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
}
return null
}
</script>
<style scoped>
.search-bar {
position: relative;
min-width: 14rem;
flex: 1 1 clamp(14rem, 40vw, 30rem);
max-width: clamp(18rem, 40vw, 30rem);
min-width: 12rem;
}
.search-bar input {
width: 100%;
padding: 0.32rem 0.5rem;
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
background: color-mix(in srgb, var(--panel) 88%, transparent);
font: inherit;
font-size: 0.8rem;
line-height: 1.1;
color: var(--ink);
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.2s;
}
.search-bar input:focus-visible {
border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
background: color-mix(in srgb, var(--panel) 95%, transparent);
}
.search-bar input::-webkit-search-cancel-button {
cursor: pointer;
}
.search-dropdown {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 1400;
list-style: none;
margin: 0;
padding: 0.2rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(0.6em);
-webkit-backdrop-filter: blur(0.6em);
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border-radius: 0.55rem;
max-height: 16rem;
overflow: auto;
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
font-size: 0.8rem;
}
.search-dropdown li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
border-radius: 0.4rem;
}
.search-dropdown li.active {
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
color: var(--ink, #111);
font-weight: 600;
}
.search-dropdown li:hover:not(.active) {
background: color-mix(in srgb, var(--panel) 70%, transparent);
}
.search-dropdown .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-dropdown .date {
opacity: 0.6;
font-family: monospace;
}
.search-empty {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
padding: 0.45rem 0.6rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(0.6em);
-webkit-backdrop-filter: blur(0.6em);
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border-radius: 0.55rem;
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
font-size: 0.7rem;
opacity: 0.65;
pointer-events: none;
text-align: center;
}
</style>

View File

@ -285,10 +285,24 @@ export function createVirtualWeekManager({
} }
function goToToday() { function goToToday() {
const top = addDays(new Date(calendarStore.now), -21) const todayDate = new Date(calendarStore.now)
const targetWeekIndex = getWeekIndex(top) const targetWeekIndex = getWeekIndex(todayDate)
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value scrollToWeekCentered(targetWeekIndex, 'go-to-today', true)
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today') }
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 }) { function handleHeaderYearChange({ scrollTop }) {
@ -308,6 +322,7 @@ export function createVirtualWeekManager({
getWeekIndex, getWeekIndex,
getFirstDayForVirtualWeek, getFirstDayForVirtualWeek,
goToToday, goToToday,
scrollToWeekCentered,
handleHeaderYearChange, handleHeaderYearChange,
attachScroll, attachScroll,
} }

View File

@ -139,6 +139,68 @@ export function getDate(event, n, timeZone = DEFAULT_TZ) {
return null 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) { export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) {
const date = fromLocalString(dateStr, timeZone) const date = fromLocalString(dateStr, timeZone)
const out = [] const out = []