Implement event/date search bar
This commit is contained in:
parent
9183ffe873
commit
45939939f2
@ -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,154 +417,24 @@ 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.
|
||||||
@ -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>
|
||||||
|
@ -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
381
src/components/Search.vue
Normal 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>
|
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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 = []
|
||||||
|
Loading…
x
Reference in New Issue
Block a user