Search improvements.

- Now finds holidays in addition to dates and events
- Emojis added to mark different result types
- Matching improvements: insensitive to diacritics, finds closest holiday/date to view, concise regex matching
- Avoid jumping to dates immediately while browsing the result dropdown
- Improved hotkey handling Ctrl+F (always focus and select)
This commit is contained in:
Leo Vasanko 2025-08-27 14:16:07 -06:00
parent 57aefc5b4c
commit abc7aba20f
2 changed files with 290 additions and 103 deletions

View File

@ -166,12 +166,6 @@ onBeforeUnmount(() => {
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 {
position: fixed;
top: 0;

View File

@ -4,8 +4,8 @@
ref="searchInputRef"
v-model="searchQuery"
type="search"
placeholder="Date or event..."
aria-label="Search date and events"
placeholder="Date or Event..."
aria-label="Search dates, holidays and events"
@keydown="handleSearchKeydown"
/>
<ul
@ -22,8 +22,8 @@
role="option"
@mousedown.prevent="selectResult(i)"
>
<span class="title">{{ r.title }}</span>
<span class="date">{{ r.startDate }}</span>
<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">
@ -33,7 +33,7 @@
</template>
<script setup>
import { ref, watch, nextTick, computed, defineExpose } from 'vue'
import { ref, watch, nextTick, computed, defineExpose, onUnmounted, onMounted } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import {
fromLocalString,
@ -44,10 +44,11 @@ import {
getMondayOfISOWeek,
formatTodayString,
makeTZDate,
getISOWeek,
} from '@/utils/date'
import { addDays } from 'date-fns'
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events'
import * as dateFns from 'date-fns'
import { getDate as getNearestOccurrence } from '@/utils/events'
import { getHolidaysForYear } from '@/utils/holidays'
const emit = defineEmits(['activate', 'preview'])
const props = defineProps({ referenceDate: { type: String, default: null } })
@ -57,57 +58,122 @@ const searchQuery = ref('')
const searchResults = ref([])
const searchIndex = ref(0)
const searchInputRef = ref(null)
let previewTimer = null
function buildSearchResults() {
// Accent-insensitive lowercasing
const norm = (s) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
let lastQuery = ''
let frozenRefStr = null // reference date frozen at last query change
const YEAR_SCAN_OFFSETS = [-4, -3, -2, -1, 0, 1, 2, 3, 4]
function buildSearchResults(queryChanged = false) {
const raw = searchQuery.value.trim()
const q = raw.toLowerCase()
if (!q) {
if (!raw) {
searchResults.value = []
searchIndex.value = 0
lastQuery = raw
return
}
const listAll = raw === '*'
const search = norm(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)
let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now
if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10)
if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive
const refStr = frozenRefStr
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
const title = '⚜️ ' + (ev.title || '').trim()
if (!(listAll || norm(title).includes(search))) continue
let displayStart = ev.startDate
if (ev.recur) {
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
if (nearest && nearest.dateStr) displayStart = nearest.dateStr
if (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 (calendarStore.config?.holidays?.enabled) {
try {
calendarStore._ensureHolidaysInitialized?.()
const refYear = nowDate.getFullYear()
const yearWindow = YEAR_SCAN_OFFSETS.map((o) => refYear + o)
const bestByName = Object.create(null)
for (const yr of yearWindow) {
for (const h of getHolidaysForYear(yr) || []) {
const name = (h.name || '').trim().split(/\s*\/\s*/)[0]
if (!name) continue
if (!listAll && !norm(name).includes(search)) continue
let dateObj
try {
dateObj = new Date(h.date)
} catch {
dateObj = null
}
if (!dateObj || isNaN(dateObj)) continue
const diff = Math.abs(dateObj - nowDate)
const key = name.toLowerCase()
const prev = bestByName[key]
if (!prev || diff < prev.diff) bestByName[key] = { name, dateObj, diff }
}
}
for (const key in bestByName) {
const { name, dateObj } = bestByName[key]
const dateStr = toLocalString(dateObj, DEFAULT_TZ)
out.push({
id: '__holiday__' + dateStr + ':' + key,
title: `${name}`,
startDate: dateStr,
_holiday: true,
_dupeKey: '__holiday__' + dateStr + ':' + key,
})
}
} catch (e) {
if (process.env.NODE_ENV !== 'production') console.debug('[Search] holiday search skipped', e)
}
}
if (queryChanged) {
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
} else if (searchResults.value.length) {
const order = new Map(searchResults.value.map((r, i) => [r.id, i]))
out.sort((a, b) => {
const ai = order.has(a.id) ? order.get(a.id) : 1e9
const bi = order.has(b.id) ? order.get(b.id) : 1e9
if (ai !== bi) return ai - bi
return a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0
})
}
const gotoDateStr = parseGoToDateCandidate(raw, refStr)
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 })
out.unshift({
id: '__goto__' + gotoDateStr,
title: '📅 ' + formatTodayString(dateObj),
startDate: gotoDateStr,
_goto: true,
})
}
searchResults.value = out
if (searchIndex.value >= out.length) searchIndex.value = 0
lastQuery = raw
}
watch(searchQuery, buildSearchResults)
watch(searchQuery, (nv, ov) => {
buildSearchResults(nv.trim() !== lastQuery)
})
watch(
() => calendarStore.events,
() => {
if (searchQuery.value.trim()) buildSearchResults()
if (searchQuery.value.trim()) buildSearchResults(false)
},
{ deep: true },
)
watch(
() => props.referenceDate,
() => {
if (searchQuery.value.trim()) buildSearchResults()
if (searchQuery.value.trim()) buildSearchResults(false)
},
)
@ -124,19 +190,38 @@ function navigate(delta) {
const n = searchResults.value.length
if (!n) return
searchIndex.value = (searchIndex.value + delta + n) % n
// Ensure active item is visible
const r = searchResults.value[searchIndex.value]
if (r) emit('preview', r)
if (r) {
const el = document.getElementById('sr-' + r.id)
if (el) el.scrollIntoView({ block: 'nearest' })
}
if (previewTimer) clearTimeout(previewTimer)
if (r)
previewTimer = setTimeout(() => {
if (r === searchResults.value[searchIndex.value]) emit('preview', r)
}, 200)
}
function selectResult(idx) {
searchIndex.value = idx
const r = searchResults.value[searchIndex.value]
if (r) {
if (previewTimer) {
clearTimeout(previewTimer)
previewTimer = null
}
emit('activate', r)
// Clear query after activation (auto-close handled by parent visibility)
searchQuery.value = ''
}
}
function handleSearchKeydown(e) {
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
e.stopPropagation()
focusSearch(true)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
navigate(1)
@ -163,122 +248,220 @@ const activeResultId = computed(() => {
})
defineExpose({ focusSearch })
onUnmounted(() => {
if (previewTimer) clearTimeout(previewTimer)
})
// global Ctrl/Cmd+F -> search
let globalFindHandler = null
onMounted(() => {
globalFindHandler = (e) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
e.stopPropagation()
focusSearch(true)
}
}
window.addEventListener('keydown', globalFindHandler, { capture: true })
})
onUnmounted(() => {
if (globalFindHandler) {
window.removeEventListener('keydown', globalFindHandler, { capture: true })
globalFindHandler = null
}
})
function parseGoToDateCandidate(input) {
function parseGoToDateCandidate(input, refStr) {
const s = input.trim()
if (!s) return null
const today = new Date()
const currentYear = today.getFullYear()
const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(),
baseYear = base.getFullYear()
// now/today -> system date
if (/^(now|today)$/i.test(s)) {
const sys = new Date()
return toLocalString(
makeTZDate(sys.getFullYear(), sys.getMonth(), sys.getDate(), DEFAULT_TZ),
DEFAULT_TZ,
)
}
const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
function monthFromToken(tok) {
const monthFromToken = (tok) => {
if (!tok) return null
const t = tok.toLowerCase()
if (/^\d{1,2}$/.test(t)) {
const n = +t
const tNorm = norm(tok.trim())
if (/^\d{1,2}$/.test(tok)) {
const n = +tok
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
if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1
}
for (let i = 0; i < 12; i++) {
const full = localized[i].toLowerCase()
if (t === full || full.startsWith(t)) return i + 1
const full = norm(localized[i])
if (full === tNorm || full.startsWith(tNorm)) 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)
// month token -> 15th of nearest year
const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u)
if (soleMonth) {
const rawMonthTok = soleMonth[1]
const m = monthFromToken(rawMonthTok)
if (m) {
let bestYear = baseYear
let best = Infinity
for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
const mid = new Date(cand, m - 1, 15)
const diff = Math.abs(mid - base)
if (diff < best) {
best = diff
bestYear = cand
}
}
return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), 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)
const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (isoFull) {
const y = +isoFull[1],
mm = +isoFull[2],
d = +isoFull[3]
return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ)
}
// ISO week
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
if (mWeek) {
const wy = +mWeek[1],
w = +mWeek[2]
// wNN -> Monday of nearest ISO week
const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i)
if (weekOnly) {
const wk = +weekOnly[1]
if (wk >= 1 && wk <= 53) {
const has53Weeks = (year) => getISOWeek(makeTZDate(year, 11, 28, DEFAULT_TZ)) === 53
let bestYear = baseYear,
bestDiff = Infinity,
bestDate = null
for (const off of YEAR_SCAN_OFFSETS) {
const cand = baseYear + off
if (wk === 53 && !has53Weeks(cand)) continue
const jan4 = makeTZDate(cand, 0, 4, DEFAULT_TZ)
const target = addDays(jan4, (wk - 1) * 7)
const monday = getMondayOfISOWeek(target)
const diff = Math.abs(monday - base)
if (diff < bestDiff) {
bestDiff = diff
bestYear = cand
bestDate = monday
}
}
if (bestDate) return toLocalString(bestDate, DEFAULT_TZ)
}
}
const isoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
if (isoMonth) {
const y = +isoMonth[1],
mm = +isoMonth[2]
return toLocalString(makeTZDate(y, mm - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
}
// year+week variants
let isoWeek = s.match(/^(\d{4})[-/]?w(\d{1,2})$/i)
if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})[-/]?(\d{4})$/i)
if (!isoWeek) isoWeek = s.match(/^(\d{4})\s+w(\d{1,2})$/i)
if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})\s+(\d{4})$/i)
if (isoWeek) {
const wy = +isoWeek[1]
const w = +isoWeek[2]
if (w >= 1 && w <= 53) {
const jan4 = new Date(Date.UTC(wy, 0, 4))
if (w === 53 && getISOWeek(makeTZDate(wy, 11, 28, DEFAULT_TZ)) !== 53) return null
const jan4 = makeTZDate(wy, 0, 4, DEFAULT_TZ)
const target = addDays(jan4, (w - 1) * 7)
const monday = getMondayOfISOWeek(target)
return toLocalString(monday, DEFAULT_TZ)
return toLocalString(getMondayOfISOWeek(target), 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]
y = null,
yearExplicit = false
const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u)
if (dot) {
d = +dot[1]
m = monthFromToken(dot[2])
if (dot[3]) {
y = +dot[3]
yearExplicit = true
}
}
if (m == null) {
const usFull = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/u)
if (usFull) {
m = monthFromToken(usFull[1])
d = +usFull[2]
y = +usFull[3]
yearExplicit = true
} 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
const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
if (usShort) {
m = monthFromToken(usShort[1])
d = +usShort[2]
}
}
}
// Free-form with spaces: tokens containing month names and numbers
if (!y && !m && !d) {
if (m == null) {
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)
let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null)
if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
if (monthIdx !== -1) {
const monthTok = tokens[monthIdx]
const monthTokIsNum = /^\d{1,2}$/.test(monthTok)
const hasNonMonthLetter = tokens.some(
(t, i) => i !== monthIdx && /\p{L}/u.test(t) && monthFromToken(t) == null,
)
const otherNumeric = tokens.some((t, i) => i !== monthIdx && /^\d{1,2}$/.test(t))
if (monthTokIsNum && hasNonMonthLetter && !otherNumeric) {
monthIdx = -1
}
}
if (monthIdx !== -1) {
m = monthFromToken(tokens[monthIdx])
const others = tokens.filter((_t, i) => i !== monthIdx)
const others = tokens.filter((_, i) => i !== monthIdx)
let dayExplicit = false
for (const rawTok of others) {
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '')
if (!tok) continue
if (/^\d+$/.test(tok)) {
const num = +tok
if (num > 100) {
y = num
yearExplicit = true
} 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 (num > 1000) {
y = num
yearExplicit = true
}
}
}
if (!y) y = currentYear
// Only default day=1 if user didn't provide any day-ish numeric token
if (!d && !dayExplicit) d = 1
if (!d && !dayExplicit) d = 15
}
}
}
if (m != null && d != null && !yearExplicit) {
let bestYear = baseYear,
bestDiff = Infinity
for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
const dt = new Date(cand, m - 1, d)
if (dt.getMonth() !== m - 1) continue
const diff = Math.abs(dt - base)
if (diff < bestDiff) {
bestDiff = diff
bestYear = cand
}
}
y = bestYear
}
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 toLocalString(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ)
}
return null
}
@ -286,20 +469,18 @@ function parseGoToDateCandidate(input) {
<style scoped>
.search-bar {
flex: 0 1 20rem;
margin-inline: auto; /* center with equal free-space on both sides */
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;
padding-inline-start: 2.05rem; /* increased space for icon */
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;
@ -308,6 +489,18 @@ function parseGoToDateCandidate(input) {
box-shadow 0.15s ease,
background 0.2s;
}
.search-bar::before {
content: '🔍';
position: absolute;
inset-inline-start: 0.55rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.85rem;
pointer-events: none;
opacity: 0.75;
line-height: 1;
filter: saturate(0.8);
}
.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);