Adds fuzzy search filter system

Implements a fuzzy search filter system using Fuse.js to enhance event searching.

This system allows users to quickly find events by typing partial matches of event titles or descriptions, providing visual feedback by dimming non-matching events. The filter persists during navigation and includes escape key support for quick clearing. It also includes performance optimizations like requestAnimationFrame debouncing.
This commit is contained in:
Janus Knudsen 2025-08-23 00:01:59 +02:00
parent a3ed03ff44
commit 12df6a9b06
9 changed files with 513 additions and 1 deletions

View file

@ -7,6 +7,7 @@ import { GridManager } from './GridManager.js';
import { EventRenderingService } from '../renderers/EventRendererManager.js';
import { ScrollManager } from './ScrollManager.js';
import { DateCalculator } from '../utils/DateCalculator.js';
import { EventFilterManager } from './EventFilterManager.js';
/**
* CalendarManager - Main coordinator for all calendar managers
@ -19,6 +20,7 @@ export class CalendarManager {
private gridManager: GridManager;
private eventRenderer: EventRenderingService;
private scrollManager: ScrollManager;
private eventFilterManager: EventFilterManager;
private dateCalculator: DateCalculator;
private currentView: CalendarView = 'week';
private currentDate: Date = new Date();
@ -38,6 +40,7 @@ export class CalendarManager {
this.gridManager = gridManager;
this.eventRenderer = eventRenderer;
this.scrollManager = scrollManager;
this.eventFilterManager = new EventFilterManager();
this.dateCalculator = new DateCalculator(config);
this.setupEventListeners();
console.log('📋 CalendarManager: Created with proper dependency injection');

View file

@ -0,0 +1,238 @@
/**
* EventFilterManager - Handles fuzzy search filtering of calendar events
* Uses Fuse.js for fuzzy matching (Apache 2.0 License)
*/
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { CalendarEvent } from '../types/CalendarTypes';
// Import Fuse.js ES module
// @ts-ignore - Fuse.js types not available for local file
import Fuse from '../../wwwroot/js/lib/fuse.min.mjs';
export class EventFilterManager {
private searchInput: HTMLInputElement | null = null;
private allEvents: CalendarEvent[] = [];
private matchingEventIds: Set<string> = new Set();
private isFilterActive: boolean = false;
private frameRequest: number | null = null;
private fuse: any = null;
constructor() {
// Wait for DOM to be ready before initializing
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.init();
});
} else {
this.init();
}
}
private init(): void {
// Find search input
this.searchInput = document.querySelector('swp-search-container input[type="search"]');
if (!this.searchInput) {
console.warn('EventFilterManager: Search input not found');
return;
}
// Set up event listeners
this.setupSearchListeners();
this.subscribeToEvents();
// Initialization complete
}
private setupSearchListeners(): void {
if (!this.searchInput) return;
// Listen for input changes
this.searchInput.addEventListener('input', (e) => {
const query = (e.target as HTMLInputElement).value;
this.handleSearchInput(query);
});
// Listen for escape key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.clearFilter();
}
});
}
private subscribeToEvents(): void {
// Listen for events data updates
eventBus.on(CoreEvents.EVENTS_RENDERED, (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.events) {
this.updateEventsList(detail.events);
}
});
}
private updateEventsList(events: CalendarEvent[]): void {
this.allEvents = events;
// Initialize Fuse with the new events list
this.fuse = new Fuse(this.allEvents, {
keys: ['title', 'description'],
threshold: 0.3,
includeScore: true,
minMatchCharLength: 2, // Minimum 2 characters for a match
shouldSort: true,
ignoreLocation: true // Search anywhere in the string
});
// Re-apply filter if active
if (this.isFilterActive && this.searchInput) {
this.applyFilter(this.searchInput.value);
}
}
private handleSearchInput(query: string): void {
// Cancel any pending filter
if (this.frameRequest) {
cancelAnimationFrame(this.frameRequest);
}
// Debounce with requestAnimationFrame
this.frameRequest = requestAnimationFrame(() => {
if (query.length === 0) {
// Only clear when input is completely empty
this.clearFilter();
} else {
// Let Fuse.js handle minimum character length via minMatchCharLength
this.applyFilter(query);
}
});
}
private applyFilter(query: string): void {
if (!this.fuse) {
console.warn('EventFilterManager: Cannot filter - Fuse not initialized');
return;
}
// Perform fuzzy search
const results = this.fuse.search(query);
// Extract matching event IDs
this.matchingEventIds.clear();
results.forEach((result: any) => {
if (result.item && result.item.id) {
this.matchingEventIds.add(result.item.id);
}
});
// Update filter state
this.isFilterActive = true;
// Update visual state
this.updateVisualState();
// Emit filter changed event
eventBus.emit(CoreEvents.FILTER_CHANGED, {
active: true,
query: query,
matchingIds: Array.from(this.matchingEventIds)
});
}
private clearFilter(): void {
this.isFilterActive = false;
this.matchingEventIds.clear();
// Clear search input
if (this.searchInput) {
this.searchInput.value = '';
}
// Update visual state
this.updateVisualState();
// Emit filter cleared event
eventBus.emit(CoreEvents.FILTER_CHANGED, {
active: false,
query: '',
matchingIds: []
});
}
private updateVisualState(): void {
// Update search container styling
const searchContainer = document.querySelector('swp-search-container');
if (searchContainer) {
if (this.isFilterActive) {
searchContainer.classList.add('filter-active');
} else {
searchContainer.classList.remove('filter-active');
}
}
// Update all events layers
const eventsLayers = document.querySelectorAll('swp-events-layer');
eventsLayers.forEach(layer => {
if (this.isFilterActive) {
layer.setAttribute('data-filter-active', 'true');
// Mark matching events
const events = layer.querySelectorAll('swp-event');
events.forEach(event => {
const eventId = event.getAttribute('data-event-id');
if (eventId && this.matchingEventIds.has(eventId)) {
event.setAttribute('data-matches', 'true');
} else {
event.removeAttribute('data-matches');
}
});
} else {
layer.removeAttribute('data-filter-active');
// Remove all match attributes
const events = layer.querySelectorAll('swp-event');
events.forEach(event => {
event.removeAttribute('data-matches');
});
}
});
}
/**
* Check if an event matches the current filter
*/
public eventMatchesFilter(eventId: string): boolean {
if (!this.isFilterActive) {
return true; // No filter active, all events match
}
return this.matchingEventIds.has(eventId);
}
/**
* Get current filter state
*/
public getFilterState(): { active: boolean; matchingIds: string[] } {
return {
active: this.isFilterActive,
matchingIds: Array.from(this.matchingEventIds)
};
}
/**
* Clean up
*/
public destroy(): void {
// Note: We can't easily remove anonymous event listeners
// In production, we'd store references to the bound functions
if (this.frameRequest) {
cancelAnimationFrame(this.frameRequest);
}
}
}

View file

@ -39,6 +39,12 @@ export class NavigationManager {
console.log('NavigationManager: Received CALENDAR_INITIALIZED, updating week info');
this.updateWeekInfo();
});
// Listen for filter changes and apply to pre-rendered grids
this.eventBus.on(CoreEvents.FILTER_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.navigationRenderer.applyFilterToPreRenderedGrids(detail);
});
// Listen for navigation button clicks
document.addEventListener('click', (e) => {