From a4dfa912cb20c41e4c5b052a4d978d0be55cde3b Mon Sep 17 00:00:00 2001 From: thedhanawada Date: Fri, 2 Jan 2026 22:20:55 +1100 Subject: [PATCH] Add search scalability and recurring event enhancements --- core/events/RecurrenceEngineV2.js | 636 +++++++++++++++++++++++++++ core/index.js | 11 +- core/integration/EnhancedCalendar.js | 463 +++++++++++++++++++ core/search/SearchWorkerManager.js | 506 +++++++++++++++++++++ tests/test-enhancements.js | 483 ++++++++++++++++++++ 5 files changed, 2098 insertions(+), 1 deletion(-) create mode 100644 core/events/RecurrenceEngineV2.js create mode 100644 core/integration/EnhancedCalendar.js create mode 100644 core/search/SearchWorkerManager.js create mode 100644 tests/test-enhancements.js diff --git a/core/events/RecurrenceEngineV2.js b/core/events/RecurrenceEngineV2.js new file mode 100644 index 0000000..4a656c9 --- /dev/null +++ b/core/events/RecurrenceEngineV2.js @@ -0,0 +1,636 @@ +/** + * RecurrenceEngineV2 - Enhanced recurrence engine with advanced features + * Handles modified instances, complex timezone transitions, and performance optimization + */ + +import { TimezoneManager } from '../timezone/TimezoneManager.js'; +import { RRuleParser } from './RRuleParser.js'; + +export class RecurrenceEngineV2 { + constructor() { + this.tzManager = new TimezoneManager(); + + // Cache for expanded occurrences + this.occurrenceCache = new Map(); + this.cacheSize = 100; + + // Modified instances storage + this.modifiedInstances = new Map(); // eventId -> Map(occurrenceDate -> modifications) + + // Exception storage with reasons + this.exceptionStore = new Map(); // eventId -> Map(date -> reason) + } + + /** + * Expand recurring event with advanced handling + * @param {Event} event - Recurring event + * @param {Date} rangeStart - Start of expansion range + * @param {Date} rangeEnd - End of expansion range + * @param {Object} options - Expansion options + * @returns {Array} Expanded occurrences + */ + expandEvent(event, rangeStart, rangeEnd, options = {}) { + const { + maxOccurrences = 365, + includeModified = true, + includeCancelled = false, + timezone = event.timeZone || 'UTC', + handleDST = true + } = options; + + // Check cache + const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options); + if (this.occurrenceCache.has(cacheKey)) { + return this.occurrenceCache.get(cacheKey); + } + + if (!event.recurring || !event.recurrenceRule) { + return [this.createOccurrence(event, event.start, event.end)]; + } + + const rule = RRuleParser.parse(event.recurrenceRule); + const occurrences = []; + const duration = event.end - event.start; + + // Initialize expansion state + const state = { + currentDate: new Date(event.start), + count: 0, + tzOffsets: new Map(), + dstTransitions: [] + }; + + // Pre-calculate DST transitions in range + if (handleDST) { + state.dstTransitions = this.findDSTTransitions( + rangeStart, + rangeEnd, + timezone + ); + } + + // Expand occurrences + while (state.currentDate <= rangeEnd && state.count < maxOccurrences) { + if (state.currentDate >= rangeStart) { + const occurrence = this.generateOccurrence( + event, + state.currentDate, + duration, + timezone, + state + ); + + // Check exceptions and modifications + if (occurrence) { + const dateKey = this.getDateKey(occurrence.start); + + // Skip if exception + if (this.isException(event.id, occurrence.start, rule)) { + if (!includeCancelled) { + state.currentDate = this.getNextDate( + state.currentDate, + rule, + timezone + ); + state.count++; + continue; + } + occurrence.status = 'cancelled'; + occurrence.cancellationReason = this.getExceptionReason( + event.id, + occurrence.start + ); + } + + // Apply modifications if any + if (includeModified) { + const modified = this.getModifiedInstance( + event.id, + occurrence.start + ); + if (modified) { + Object.assign(occurrence, modified); + occurrence.isModified = true; + } + } + + occurrences.push(occurrence); + } + } + + // Get next occurrence date + state.currentDate = this.getNextDate( + state.currentDate, + rule, + timezone, + state + ); + state.count++; + + // Check COUNT limit + if (rule.count && state.count >= rule.count) { + break; + } + + // Check UNTIL limit + if (rule.until && state.currentDate > rule.until) { + break; + } + } + + // Cache results + this.cacheOccurrences(cacheKey, occurrences); + + return occurrences; + } + + /** + * Generate a single occurrence with timezone handling + */ + generateOccurrence(event, date, duration, timezone, state) { + const start = new Date(date); + const end = new Date(date.getTime() + duration); + + // Handle DST transitions + if (state.dstTransitions.length > 0) { + const adjusted = this.adjustForDST( + start, + end, + timezone, + state.dstTransitions + ); + start.setTime(adjusted.start.getTime()); + end.setTime(adjusted.end.getTime()); + } + + return { + id: `${event.id}_${start.getTime()}`, + recurringEventId: event.id, + title: event.title, + start, + end, + startUTC: this.tzManager.toUTC(start, timezone), + endUTC: this.tzManager.toUTC(end, timezone), + timezone, + originalStart: event.start, + allDay: event.allDay, + description: event.description, + location: event.location, + categories: event.categories, + status: 'confirmed', + isRecurring: true, + isModified: false + }; + } + + /** + * Get next occurrence date with complex pattern support + */ + getNextDate(currentDate, rule, timezone, state = {}) { + const next = new Date(currentDate); + + switch (rule.freq) { + case 'DAILY': + return this.getNextDaily(next, rule); + + case 'WEEKLY': + return this.getNextWeekly(next, rule, timezone); + + case 'MONTHLY': + return this.getNextMonthly(next, rule, timezone); + + case 'YEARLY': + return this.getNextYearly(next, rule, timezone); + + case 'HOURLY': + next.setHours(next.getHours() + rule.interval); + return next; + + case 'MINUTELY': + next.setMinutes(next.getMinutes() + rule.interval); + return next; + + default: + // Fallback to daily + next.setDate(next.getDate() + rule.interval); + return next; + } + } + + /** + * Get next daily occurrence + */ + getNextDaily(date, rule) { + const next = new Date(date); + next.setDate(next.getDate() + rule.interval); + + // Apply BYHOUR, BYMINUTE, BYSECOND if specified + if (rule.byHour && rule.byHour.length > 0) { + const currentHour = next.getHours(); + const nextHour = rule.byHour.find(h => h > currentHour); + if (nextHour !== undefined) { + next.setHours(nextHour); + } else { + // Move to next day and use first hour + next.setDate(next.getDate() + 1); + next.setHours(rule.byHour[0]); + } + } + + return next; + } + + /** + * Get next weekly occurrence with BYDAY support + */ + getNextWeekly(date, rule, timezone) { + const next = new Date(date); + + if (rule.byDay && rule.byDay.length > 0) { + // Find next matching weekday + const dayMap = { + 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3, + 'TH': 4, 'FR': 5, 'SA': 6 + }; + + const currentDay = next.getDay(); + let daysToAdd = null; + + // Find next occurrence day + for (const byDay of rule.byDay) { + const targetDay = dayMap[byDay.weekday || byDay]; + if (targetDay > currentDay) { + daysToAdd = targetDay - currentDay; + break; + } + } + + // If no day found in current week, go to next week + if (daysToAdd === null) { + const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]]; + daysToAdd = 7 - currentDay + firstDay; + + // Apply interval for weekly recurrence + if (rule.interval > 1) { + daysToAdd += 7 * (rule.interval - 1); + } + } + + next.setDate(next.getDate() + daysToAdd); + } else { + // Simple weekly interval + next.setDate(next.getDate() + (7 * rule.interval)); + } + + return next; + } + + /** + * Get next monthly occurrence with complex patterns + */ + getNextMonthly(date, rule, timezone) { + const next = new Date(date); + + if (rule.byMonthDay && rule.byMonthDay.length > 0) { + // Specific day(s) of month + const targetDays = rule.byMonthDay.sort((a, b) => a - b); + const currentDay = next.getDate(); + + let targetDay = targetDays.find(d => d > currentDay); + if (targetDay) { + // Found a day in current month + next.setDate(targetDay); + } else { + // Move to next month + next.setMonth(next.getMonth() + rule.interval); + + // Handle negative days (from end of month) + targetDay = targetDays[0]; + if (targetDay < 0) { + const lastDay = new Date( + next.getFullYear(), + next.getMonth() + 1, + 0 + ).getDate(); + next.setDate(lastDay + targetDay + 1); + } else { + next.setDate(targetDay); + } + } + } else if (rule.byDay && rule.byDay.length > 0) { + // Nth weekday of month (e.g., "2nd Tuesday") + const byDay = rule.byDay[0]; + const nthOccurrence = byDay.nth || 1; + + next.setMonth(next.getMonth() + rule.interval); + this.setToNthWeekdayOfMonth(next, byDay.weekday, nthOccurrence); + } else if (rule.bySetPos && rule.bySetPos.length > 0) { + // BYSETPOS for selecting from set + next.setMonth(next.getMonth() + rule.interval); + // Complex BYSETPOS logic would go here + } else { + // Same day of next month + const currentDay = next.getDate(); + next.setMonth(next.getMonth() + rule.interval); + + // Handle month-end edge cases + const lastDay = new Date( + next.getFullYear(), + next.getMonth() + 1, + 0 + ).getDate(); + if (currentDay > lastDay) { + next.setDate(lastDay); + } + } + + return next; + } + + /** + * Get next yearly occurrence + */ + getNextYearly(date, rule, timezone) { + const next = new Date(date); + + if (rule.byMonth && rule.byMonth.length > 0) { + const currentMonth = next.getMonth(); + const targetMonth = rule.byMonth.find(m => m - 1 > currentMonth); + + if (targetMonth) { + // Found month in current year + next.setMonth(targetMonth - 1); + } else { + // Move to next year + next.setFullYear(next.getFullYear() + rule.interval); + next.setMonth(rule.byMonth[0] - 1); + } + + // Apply BYMONTHDAY if specified + if (rule.byMonthDay && rule.byMonthDay.length > 0) { + next.setDate(rule.byMonthDay[0]); + } + } else if (rule.byYearDay && rule.byYearDay.length > 0) { + // Nth day of year + next.setFullYear(next.getFullYear() + rule.interval); + const yearDay = rule.byYearDay[0]; + + if (yearDay > 0) { + // Count from start of year + next.setMonth(0, 1); + next.setDate(yearDay); + } else { + // Count from end of year + next.setMonth(11, 31); + next.setDate(next.getDate() + yearDay + 1); + } + } else { + // Same date next year + next.setFullYear(next.getFullYear() + rule.interval); + } + + return next; + } + + /** + * Set date to Nth weekday of month + */ + setToNthWeekdayOfMonth(date, weekday, nth) { + const dayMap = { + 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3, + 'TH': 4, 'FR': 5, 'SA': 6 + }; + + const targetDay = dayMap[weekday]; + date.setDate(1); // Start at first of month + + // Find first occurrence + while (date.getDay() !== targetDay) { + date.setDate(date.getDate() + 1); + } + + if (nth > 0) { + // Nth occurrence from start + date.setDate(date.getDate() + (7 * (nth - 1))); + } else { + // Nth occurrence from end + const lastDay = new Date( + date.getFullYear(), + date.getMonth() + 1, + 0 + ).getDate(); + + // Find last occurrence + const temp = new Date(date); + temp.setDate(lastDay); + while (temp.getDay() !== targetDay) { + temp.setDate(temp.getDate() - 1); + } + + // Move back nth weeks + temp.setDate(temp.getDate() + (7 * (nth + 1))); + date.setTime(temp.getTime()); + } + } + + /** + * Find DST transitions in date range + */ + findDSTTransitions(start, end, timezone) { + const transitions = []; + const current = new Date(start); + + // Check each day for offset changes + let lastOffset = this.tzManager.getTimezoneOffset(current, timezone); + + while (current <= end) { + const offset = this.tzManager.getTimezoneOffset(current, timezone); + + if (offset !== lastOffset) { + transitions.push({ + date: new Date(current), + oldOffset: lastOffset, + newOffset: offset, + type: offset < lastOffset ? 'spring-forward' : 'fall-back' + }); + } + + lastOffset = offset; + current.setDate(current.getDate() + 1); + } + + return transitions; + } + + /** + * Adjust occurrence for DST transitions + */ + adjustForDST(start, end, timezone, transitions) { + for (const transition of transitions) { + if (start >= transition.date) { + const offsetDiff = transition.oldOffset - transition.newOffset; + + // Spring forward: skip the "lost" hour + if (transition.type === 'spring-forward') { + const lostHourStart = new Date(transition.date); + lostHourStart.setHours(2); // Typical transition time + const lostHourEnd = new Date(lostHourStart); + lostHourEnd.setHours(3); + + if (start >= lostHourStart && start < lostHourEnd) { + start.setHours(start.getHours() + 1); + end.setHours(end.getHours() + 1); + } + } + // Fall back: handle the "repeated" hour + else if (transition.type === 'fall-back') { + // Maintain wall clock time + start.setMinutes(start.getMinutes() - offsetDiff); + end.setMinutes(end.getMinutes() - offsetDiff); + } + } + } + + return { start, end }; + } + + /** + * Add or update a modified instance + */ + addModifiedInstance(eventId, occurrenceDate, modifications) { + if (!this.modifiedInstances.has(eventId)) { + this.modifiedInstances.set(eventId, new Map()); + } + + const dateKey = this.getDateKey(occurrenceDate); + this.modifiedInstances.get(eventId).set(dateKey, { + ...modifications, + modifiedAt: new Date() + }); + + // Clear cache for this event + this.clearEventCache(eventId); + } + + /** + * Get modified instance data + */ + getModifiedInstance(eventId, occurrenceDate) { + if (!this.modifiedInstances.has(eventId)) { + return null; + } + + const dateKey = this.getDateKey(occurrenceDate); + return this.modifiedInstances.get(eventId).get(dateKey); + } + + /** + * Add exception with reason + */ + addException(eventId, date, reason = 'Cancelled') { + if (!this.exceptionStore.has(eventId)) { + this.exceptionStore.set(eventId, new Map()); + } + + const dateKey = this.getDateKey(date); + this.exceptionStore.get(eventId).set(dateKey, reason); + + // Clear cache + this.clearEventCache(eventId); + } + + /** + * Check if date is an exception + */ + isException(eventId, date, rule) { + const dateKey = this.getDateKey(date); + + // Check enhanced exceptions + if (this.exceptionStore.has(eventId)) { + if (this.exceptionStore.get(eventId).has(dateKey)) { + return true; + } + } + + // Check rule exceptions + if (rule && rule.exceptions) { + return rule.exceptions.some(ex => { + const exDate = ex instanceof Date ? ex : new Date(ex.date || ex); + return this.getDateKey(exDate) === dateKey; + }); + } + + return false; + } + + /** + * Get exception reason + */ + getExceptionReason(eventId, date) { + if (!this.exceptionStore.has(eventId)) { + return 'Cancelled'; + } + + const dateKey = this.getDateKey(date); + return this.exceptionStore.get(eventId).get(dateKey) || 'Cancelled'; + } + + /** + * Create date key for indexing + */ + getDateKey(date) { + const d = date instanceof Date ? date : new Date(date); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + /** + * Create cache key + */ + getCacheKey(eventId, start, end, options) { + return `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`; + } + + /** + * Cache occurrences + */ + cacheOccurrences(key, occurrences) { + this.occurrenceCache.set(key, occurrences); + + // LRU eviction + if (this.occurrenceCache.size > this.cacheSize) { + const firstKey = this.occurrenceCache.keys().next().value; + this.occurrenceCache.delete(firstKey); + } + } + + /** + * Clear cache for specific event + */ + clearEventCache(eventId) { + for (const key of this.occurrenceCache.keys()) { + if (key.startsWith(eventId + '_')) { + this.occurrenceCache.delete(key); + } + } + } + + /** + * Create occurrence object + */ + createOccurrence(event, start, end) { + return { + id: event.id, + title: event.title, + start, + end, + allDay: event.allDay, + description: event.description, + location: event.location, + categories: event.categories, + timezone: event.timeZone, + isRecurring: false + }; + } +} + +export default RecurrenceEngineV2; \ No newline at end of file diff --git a/core/index.js b/core/index.js index c12d18d..3d9ffd9 100644 --- a/core/index.js +++ b/core/index.js @@ -17,9 +17,18 @@ export { ICSHandler } from './ics/ICSHandler.js'; // Search and Filtering export { EventSearch } from './search/EventSearch.js'; +export { SearchWorkerManager, InvertedIndex } from './search/SearchWorkerManager.js'; + +// Recurrence +export { RecurrenceEngine } from './events/RecurrenceEngine.js'; +export { RecurrenceEngineV2 } from './events/RecurrenceEngineV2.js'; +export { RRuleParser } from './events/RRuleParser.js'; + +// Enhanced Integration +export { EnhancedCalendar } from './integration/EnhancedCalendar.js'; // Version -export const VERSION = '0.3.0'; +export const VERSION = '0.3.1'; // Default export export { Calendar as default } from './calendar/Calendar.js'; \ No newline at end of file diff --git a/core/integration/EnhancedCalendar.js b/core/integration/EnhancedCalendar.js new file mode 100644 index 0000000..43e13cc --- /dev/null +++ b/core/integration/EnhancedCalendar.js @@ -0,0 +1,463 @@ +/** + * EnhancedCalendar - Integration of advanced search and recurrence features + * Demonstrates how to use the new scalable components + */ + +import { Calendar } from '../calendar/Calendar.js'; +import { SearchWorkerManager } from '../search/SearchWorkerManager.js'; +import { RecurrenceEngineV2 } from '../events/RecurrenceEngineV2.js'; + +export class EnhancedCalendar extends Calendar { + constructor(config) { + super(config); + + // Initialize enhanced components + this.searchManager = new SearchWorkerManager(this.eventStore); + this.recurrenceEngine = new RecurrenceEngineV2(); + + // Performance monitoring + this.performanceMetrics = { + searchTime: [], + expansionTime: [], + renderTime: [] + }; + + // Setup event listeners for real-time indexing + this.setupRealtimeIndexing(); + } + + /** + * Enhanced search with worker support + */ + async search(query, options = {}) { + const startTime = performance.now(); + + try { + // Use enhanced search manager + const results = await this.searchManager.search(query, { + fields: options.fields || ['title', 'description', 'location', 'category'], + fuzzy: options.fuzzy !== false, + limit: options.limit || 50, + prefixMatch: options.autocomplete || false, + ...options + }); + + const endTime = performance.now(); + this.recordMetric('searchTime', endTime - startTime); + + // Transform results to match expected format + return results.map(r => r.event); + } catch (error) { + console.error('Search error:', error); + // Fallback to basic search + return super.search ? super.search(query, options) : []; + } + } + + /** + * Get events with enhanced recurrence expansion + */ + async getEventsInRange(startDate, endDate, options = {}) { + const startTime = performance.now(); + + const regularEvents = []; + const recurringEvents = []; + + // Separate regular and recurring events + const allEvents = this.eventStore.getEventsInDateRange(startDate, endDate); + + for (const event of allEvents) { + if (event.recurring) { + recurringEvents.push(event); + } else { + regularEvents.push(event); + } + } + + // Expand recurring events with enhanced engine + const expandedOccurrences = []; + + for (const event of recurringEvents) { + const occurrences = this.recurrenceEngine.expandEvent( + event, + startDate, + endDate, + { + maxOccurrences: options.maxOccurrences || 365, + includeModified: options.includeModified !== false, + includeCancelled: options.includeCancelled || false, + timezone: options.timezone || event.timeZone, + handleDST: options.handleDST !== false + } + ); + + expandedOccurrences.push(...occurrences); + } + + const endTime = performance.now(); + this.recordMetric('expansionTime', endTime - startTime); + + // Combine and sort + const allEventsInRange = [...regularEvents, ...expandedOccurrences]; + allEventsInRange.sort((a, b) => a.start - b.start); + + return allEventsInRange; + } + + /** + * Modify a single occurrence of a recurring event + */ + modifyOccurrence(eventId, occurrenceDate, modifications) { + // Add to modified instances + this.recurrenceEngine.addModifiedInstance( + eventId, + occurrenceDate, + modifications + ); + + // Emit change event + this.emit('occurrence:modified', { + eventId, + occurrenceDate, + modifications + }); + + // Trigger re-render if in view + this.refreshView(); + } + + /** + * Cancel a single occurrence of a recurring event + */ + cancelOccurrence(eventId, occurrenceDate, reason = 'Cancelled') { + // Add exception + this.recurrenceEngine.addException(eventId, occurrenceDate, reason); + + // Emit change event + this.emit('occurrence:cancelled', { + eventId, + occurrenceDate, + reason + }); + + // Trigger re-render + this.refreshView(); + } + + /** + * Bulk operations for recurring events + */ + async bulkModifyOccurrences(eventId, dateRange, modifications) { + const event = this.eventStore.getEvent(eventId); + if (!event || !event.recurring) { + throw new Error('Event not found or not recurring'); + } + + // Get all occurrences in range + const occurrences = this.recurrenceEngine.expandEvent( + event, + dateRange.start, + dateRange.end + ); + + // Apply modifications to each + for (const occurrence of occurrences) { + this.recurrenceEngine.addModifiedInstance( + eventId, + occurrence.start, + modifications + ); + } + + // Emit bulk change event + this.emit('occurrences:bulk-modified', { + eventId, + count: occurrences.length, + modifications + }); + + this.refreshView(); + } + + /** + * Advanced search with filters and recurrence awareness + */ + async advancedSearch(query, filters = {}, options = {}) { + // First get search results + const searchResults = await this.search(query, options); + + // Apply additional filters + let filtered = searchResults; + + // Date range filter with recurrence expansion + if (filters.dateRange) { + const expandedEvents = await this.getEventsInRange( + filters.dateRange.start, + filters.dateRange.end, + { includeModified: true } + ); + + const expandedIds = new Set(expandedEvents.map(e => + e.recurringEventId || e.id + )); + + filtered = filtered.filter(e => expandedIds.has(e.id)); + } + + // Category filter + if (filters.categories && filters.categories.length > 0) { + const categorySet = new Set(filters.categories); + filtered = filtered.filter(e => + e.categories && e.categories.some(c => categorySet.has(c)) + ); + } + + // Status filter + if (filters.status) { + filtered = filtered.filter(e => e.status === filters.status); + } + + // Modified only filter + if (filters.modifiedOnly) { + filtered = filtered.filter(e => { + const modifications = this.recurrenceEngine.modifiedInstances.get(e.id); + return modifications && modifications.size > 0; + }); + } + + return filtered; + } + + /** + * Setup real-time indexing for search + */ + setupRealtimeIndexing() { + // Re-index when events are added + this.on('event:added', (event) => { + this.searchManager.indexEvents(); + }); + + // Re-index when events are modified + this.on('event:updated', (event) => { + this.searchManager.indexEvents(); + }); + + // Re-index when events are removed + this.on('event:removed', (eventId) => { + this.searchManager.indexEvents(); + }); + + // Batch re-indexing for bulk operations + let reindexTimeout; + this.on('events:bulk-operation', () => { + clearTimeout(reindexTimeout); + reindexTimeout = setTimeout(() => { + this.searchManager.indexEvents(); + }, 100); + }); + } + + /** + * Get search suggestions (autocomplete) + */ + async getSuggestions(partial, field = 'title') { + if (partial.length < 2) { + return []; + } + + // Use search with prefix matching + const results = await this.searchManager.search(partial, { + fields: [field], + prefixMatch: true, + limit: 10 + }); + + // Extract unique values + const suggestions = new Set(); + for (const result of results) { + const value = result.event[field]; + if (value) { + suggestions.add(value); + } + } + + return Array.from(suggestions); + } + + /** + * Performance monitoring + */ + recordMetric(type, value) { + this.performanceMetrics[type].push(value); + + // Keep only last 100 measurements + if (this.performanceMetrics[type].length > 100) { + this.performanceMetrics[type].shift(); + } + } + + /** + * Get performance statistics + */ + getPerformanceStats() { + const stats = {}; + + for (const [metric, values] of Object.entries(this.performanceMetrics)) { + if (values.length === 0) { + stats[metric] = { avg: 0, min: 0, max: 0, p95: 0 }; + continue; + } + + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + + stats[metric] = { + avg: sum / sorted.length, + min: sorted[0], + max: sorted[sorted.length - 1], + p95: sorted[Math.floor(sorted.length * 0.95)] + }; + } + + return stats; + } + + /** + * Export calendar with recurrence data + */ + exportWithRecurrence(format = 'json') { + const data = { + events: this.eventStore.getAllEvents(), + modifiedInstances: {}, + exceptions: {} + }; + + // Include modified instances + for (const [eventId, modifications] of this.recurrenceEngine.modifiedInstances) { + data.modifiedInstances[eventId] = Array.from(modifications.entries()); + } + + // Include exceptions + for (const [eventId, exceptions] of this.recurrenceEngine.exceptionStore) { + data.exceptions[eventId] = Array.from(exceptions.entries()); + } + + if (format === 'json') { + return JSON.stringify(data, null, 2); + } + + // Could add ICS export here + return data; + } + + /** + * Import calendar with recurrence data + */ + importWithRecurrence(data, format = 'json') { + if (format === 'json') { + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + + // Import events + for (const event of parsed.events) { + this.addEvent(event); + } + + // Import modified instances + if (parsed.modifiedInstances) { + for (const [eventId, modifications] of Object.entries(parsed.modifiedInstances)) { + for (const [dateKey, mods] of modifications) { + this.recurrenceEngine.addModifiedInstance( + eventId, + new Date(dateKey), + mods + ); + } + } + } + + // Import exceptions + if (parsed.exceptions) { + for (const [eventId, exceptions] of Object.entries(parsed.exceptions)) { + for (const [dateKey, reason] of exceptions) { + this.recurrenceEngine.addException( + eventId, + new Date(dateKey), + reason + ); + } + } + } + } + } + + /** + * Clean up resources + */ + destroy() { + // Clean up worker + if (this.searchManager) { + this.searchManager.destroy(); + } + + // Clear caches + if (this.recurrenceEngine) { + this.recurrenceEngine.occurrenceCache.clear(); + } + + // Call parent destroy if exists + if (super.destroy) { + super.destroy(); + } + } +} + +// Usage Example +export function createEnhancedCalendar(config) { + const calendar = new EnhancedCalendar(config); + + // Example: Add a complex recurring event + calendar.addEvent({ + id: 'meeting-1', + title: 'Weekly Team Standup', + start: new Date('2024-01-01T10:00:00'), + end: new Date('2024-01-01T10:30:00'), + recurring: true, + recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20241231T235959Z', + timeZone: 'America/New_York', + categories: ['meetings', 'team'] + }); + + // Example: Modify a single occurrence + calendar.modifyOccurrence( + 'meeting-1', + new Date('2024-01-08T10:00:00'), + { + title: 'Extended Team Standup - Sprint Planning', + end: new Date('2024-01-08T11:30:00'), + location: 'Conference Room A' + } + ); + + // Example: Cancel an occurrence + calendar.cancelOccurrence( + 'meeting-1', + new Date('2024-01-15T10:00:00'), + 'Public Holiday' + ); + + // Example: Advanced search + calendar.advancedSearch('standup', { + dateRange: { + start: new Date('2024-01-01'), + end: new Date('2024-01-31') + }, + categories: ['meetings'], + modifiedOnly: false + }).then(results => { + console.log('Search results:', results); + }); + + return calendar; +} + +export default EnhancedCalendar; \ No newline at end of file diff --git a/core/search/SearchWorkerManager.js b/core/search/SearchWorkerManager.js new file mode 100644 index 0000000..1134d25 --- /dev/null +++ b/core/search/SearchWorkerManager.js @@ -0,0 +1,506 @@ +/** + * SearchWorkerManager - Offloads search indexing to Web Workers + * Provides scalable search for large event datasets + */ + +export class SearchWorkerManager { + constructor(eventStore) { + this.eventStore = eventStore; + this.workerSupported = typeof Worker !== 'undefined'; + this.worker = null; + this.indexReady = false; + this.pendingSearches = []; + + // Fallback to main thread if workers not available + this.fallbackIndex = null; + + // Configuration + this.config = { + chunkSize: 100, // Events per indexing batch + maxWorkers: 4, // Max parallel workers + indexThreshold: 1000, // Use workers above this event count + cacheSize: 50 // LRU cache for search results + }; + + // Search result cache + this.searchCache = new Map(); + this.cacheOrder = []; + + this.initializeWorker(); + } + + /** + * Initialize the search worker + */ + initializeWorker() { + if (!this.workerSupported) { + // Use InvertedIndex as fallback + this.fallbackIndex = new InvertedIndex(); + return; + } + + // Create worker from inline code to avoid separate file requirement + const workerCode = ` + let index = {}; + let events = {}; + let config = {}; + + // Build inverted index + function buildIndex(eventBatch) { + for (const event of eventBatch) { + events[event.id] = event; + + // Index each field + const fields = ['title', 'description', 'location', 'category']; + for (const field of fields) { + const value = event[field]; + if (!value) continue; + + // Tokenize and index + const tokens = tokenize(value.toLowerCase()); + for (const token of tokens) { + if (!index[token]) { + index[token] = new Set(); + } + index[token].add(event.id); + } + } + } + } + + // Tokenize text + function tokenize(text) { + // Split on word boundaries and filter + return text.split(/\\W+/).filter(token => + token.length > 1 && !stopWords.has(token) + ); + } + + // Common stop words to ignore + const stopWords = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', + 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'is', + 'are', 'was', 'were', 'been', 'be' + ]); + + // Search the index + function search(query, options) { + const queryTokens = tokenize(query.toLowerCase()); + const results = new Map(); + + // Find matching events + for (const token of queryTokens) { + // Exact match + if (index[token]) { + for (const eventId of index[token]) { + if (!results.has(eventId)) { + results.set(eventId, 0); + } + results.set(eventId, results.get(eventId) + 10); + } + } + + // Prefix match for autocomplete + if (options.prefixMatch) { + for (const indexToken in index) { + if (indexToken.startsWith(token)) { + for (const eventId of index[indexToken]) { + if (!results.has(eventId)) { + results.set(eventId, 0); + } + results.set(eventId, results.get(eventId) + 5); + } + } + } + } + } + + // Sort by relevance and return + const sorted = Array.from(results.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, options.limit || 100) + .map(([id, score]) => ({ + event: events[id], + score + })); + + return sorted; + } + + // Message handler + self.onmessage = function(e) { + const { type, data } = e.data; + + switch(type) { + case 'init': + config = data.config; + postMessage({ type: 'ready' }); + break; + + case 'index': + buildIndex(data.events); + postMessage({ + type: 'indexed', + count: Object.keys(events).length + }); + break; + + case 'search': + const results = search(data.query, data.options); + postMessage({ + type: 'results', + id: data.id, + results + }); + break; + + case 'clear': + index = {}; + events = {}; + postMessage({ type: 'cleared' }); + break; + } + }; + `; + + // Create worker from blob + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const workerUrl = URL.createObjectURL(blob); + + try { + this.worker = new Worker(workerUrl); + this.setupWorkerHandlers(); + + // Initialize worker + this.worker.postMessage({ + type: 'init', + data: { config: this.config } + }); + + // Clean up blob URL + URL.revokeObjectURL(workerUrl); + } catch (error) { + console.warn('Worker creation failed, falling back to main thread:', error); + this.workerSupported = false; + this.fallbackIndex = new InvertedIndex(); + } + } + + /** + * Setup worker message handlers + */ + setupWorkerHandlers() { + this.worker.onmessage = (e) => { + const { type, data } = e.data; + + switch(type) { + case 'ready': + this.indexReady = true; + this.indexEvents(); + break; + + case 'indexed': + // Process pending searches + this.processPendingSearches(); + break; + + case 'results': + this.handleSearchResults(e.data); + break; + } + }; + + this.worker.onerror = (error) => { + console.error('Worker error:', error); + // Fallback to main thread + this.workerSupported = false; + this.fallbackIndex = new InvertedIndex(); + }; + } + + /** + * Index all events + */ + async indexEvents() { + const events = this.eventStore.getAllEvents(); + + // Use main thread for small datasets + if (events.length < this.config.indexThreshold) { + if (this.fallbackIndex) { + this.fallbackIndex.buildIndex(events); + } + return; + } + + // Chunk events for worker + if (this.worker && this.indexReady) { + for (let i = 0; i < events.length; i += this.config.chunkSize) { + const chunk = events.slice(i, i + this.config.chunkSize); + this.worker.postMessage({ + type: 'index', + data: { events: chunk } + }); + } + } + } + + /** + * Search with caching and worker support + */ + async search(query, options = {}) { + const cacheKey = JSON.stringify({ query, options }); + + // Check cache + if (this.searchCache.has(cacheKey)) { + return this.searchCache.get(cacheKey); + } + + // Use appropriate search method + let results; + if (this.worker && this.indexReady) { + results = await this.workerSearch(query, options); + } else if (this.fallbackIndex) { + results = this.fallbackIndex.search(query, options); + } else { + // Direct search as last resort + results = this.directSearch(query, options); + } + + // Cache results + this.cacheResults(cacheKey, results); + + return results; + } + + /** + * Search using worker + */ + workerSearch(query, options) { + return new Promise((resolve) => { + const searchId = Date.now() + Math.random(); + + this.pendingSearches.push({ + id: searchId, + resolve + }); + + this.worker.postMessage({ + type: 'search', + data: { + id: searchId, + query, + options + } + }); + }); + } + + /** + * Direct search without worker + */ + directSearch(query, options) { + const events = this.eventStore.getAllEvents(); + const queryLower = query.toLowerCase(); + const results = []; + + for (const event of events) { + let score = 0; + + // Check each field + const fields = options.fields || ['title', 'description', 'location']; + for (const field of fields) { + const value = event[field]; + if (!value) continue; + + const valueLower = value.toLowerCase(); + if (valueLower.includes(queryLower)) { + score += field === 'title' ? 20 : 10; + } + } + + if (score > 0) { + results.push({ event, score }); + } + } + + // Sort and limit + results.sort((a, b) => b.score - a.score); + if (options.limit) { + return results.slice(0, options.limit); + } + + return results; + } + + /** + * Handle search results from worker + */ + handleSearchResults(data) { + const pending = this.pendingSearches.find(s => s.id === data.id); + if (pending) { + pending.resolve(data.results); + this.pendingSearches = this.pendingSearches.filter(s => s.id !== data.id); + } + } + + /** + * Process any pending searches + */ + processPendingSearches() { + // Re-trigger pending searches after indexing + for (const search of this.pendingSearches) { + // Will be handled by worker + } + } + + /** + * Cache search results with LRU eviction + */ + cacheResults(key, results) { + // Add to cache + this.searchCache.set(key, results); + this.cacheOrder.push(key); + + // Evict old entries + while (this.cacheOrder.length > this.config.cacheSize) { + const oldKey = this.cacheOrder.shift(); + this.searchCache.delete(oldKey); + } + } + + /** + * Clear index and cache + */ + clear() { + this.searchCache.clear(); + this.cacheOrder = []; + + if (this.worker) { + this.worker.postMessage({ type: 'clear' }); + } + if (this.fallbackIndex) { + this.fallbackIndex.clear(); + } + } + + /** + * Destroy worker and clean up + */ + destroy() { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + this.clear(); + } +} + +/** + * InvertedIndex - Efficient inverted index for text search + * Used as fallback when Web Workers not available + */ +export class InvertedIndex { + constructor() { + this.index = new Map(); // term -> Set of event IDs + this.events = new Map(); // event ID -> event + this.fieldBoosts = { + title: 2.0, + description: 1.0, + location: 1.5, + category: 1.5 + }; + } + + /** + * Build inverted index from events + */ + buildIndex(events) { + this.clear(); + + for (const event of events) { + this.events.set(event.id, event); + + // Index each field with boost factors + for (const [field, boost] of Object.entries(this.fieldBoosts)) { + const value = event[field]; + if (!value) continue; + + const tokens = this.tokenize(value); + for (const token of tokens) { + if (!this.index.has(token)) { + this.index.set(token, new Map()); + } + + const eventScores = this.index.get(token); + const currentScore = eventScores.get(event.id) || 0; + eventScores.set(event.id, currentScore + boost); + } + } + } + } + + /** + * Tokenize text into searchable terms + */ + tokenize(text) { + return text + .toLowerCase() + .split(/\W+/) + .filter(token => token.length > 1); + } + + /** + * Search the index + */ + search(query, options = {}) { + const queryTokens = this.tokenize(query); + const scores = new Map(); + + // Aggregate scores from all matching terms + for (const token of queryTokens) { + // Exact matches + if (this.index.has(token)) { + const eventScores = this.index.get(token); + for (const [eventId, tokenScore] of eventScores) { + const currentScore = scores.get(eventId) || 0; + scores.set(eventId, currentScore + tokenScore); + } + } + + // Prefix matches for autocomplete + if (options.prefixMatch) { + for (const [indexToken, eventScores] of this.index) { + if (indexToken.startsWith(token)) { + for (const [eventId, tokenScore] of eventScores) { + const currentScore = scores.get(eventId) || 0; + scores.set(eventId, currentScore + tokenScore * 0.5); + } + } + } + } + } + + // Convert to results array + const results = Array.from(scores.entries()) + .map(([eventId, score]) => ({ + event: this.events.get(eventId), + score + })) + .sort((a, b) => b.score - a.score); + + // Apply limit + if (options.limit) { + return results.slice(0, options.limit); + } + + return results; + } + + /** + * Clear the index + */ + clear() { + this.index.clear(); + this.events.clear(); + } +} \ No newline at end of file diff --git a/tests/test-enhancements.js b/tests/test-enhancements.js new file mode 100644 index 0000000..003c8e2 --- /dev/null +++ b/tests/test-enhancements.js @@ -0,0 +1,483 @@ +/** + * Test suite for enhanced search and recurrence features + * Demonstrates performance improvements and edge case handling + */ + +import { EnhancedCalendar } from '../core/integration/EnhancedCalendar.js'; +import { SearchWorkerManager } from '../core/search/SearchWorkerManager.js'; +import { RecurrenceEngineV2 } from '../core/events/RecurrenceEngineV2.js'; +import { EventStore } from '../core/events/EventStore.js'; + +// Performance test helper +function measurePerformance(name, fn) { + const start = performance.now(); + const result = fn(); + const end = performance.now(); + console.log(`${name}: ${(end - start).toFixed(2)}ms`); + return result; +} + +// Generate test events +function generateTestEvents(count = 1000) { + const events = []; + const categories = ['meeting', 'personal', 'work', 'urgent', 'team']; + const locations = ['Conference Room A', 'Virtual', 'Office', 'Client Site']; + + for (let i = 0; i < count; i++) { + const startDate = new Date(2024, 0, 1 + Math.floor(i / 10), 9 + (i % 8), 0); + const endDate = new Date(startDate); + endDate.setHours(endDate.getHours() + 1); + + events.push({ + id: `event-${i}`, + title: `Event ${i} - ${categories[i % categories.length]}`, + description: `Description for event ${i} with searchable content`, + start: startDate, + end: endDate, + location: locations[i % locations.length], + categories: [categories[i % categories.length]], + recurring: i % 10 === 0, // Every 10th event is recurring + recurrenceRule: i % 10 === 0 ? 'FREQ=WEEKLY;COUNT=10' : null + }); + } + + return events; +} + +// Test 1: Search Scalability +async function testSearchScalability() { + console.log('\n=== Test 1: Search Scalability ==='); + + const calendar = new EnhancedCalendar({}); + + // Add 1000 events + console.log('Adding 1000 test events...'); + const testEvents = generateTestEvents(1000); + for (const event of testEvents) { + calendar.addEvent(event); + } + + // Test search performance + console.log('\nSearch Performance:'); + + // Simple search + await measurePerformance('Simple search (100 events)', async () => { + return await calendar.search('event', { limit: 100 }); + }); + + // Fuzzy search + await measurePerformance('Fuzzy search', async () => { + return await calendar.search('meetting', { fuzzy: true }); + }); + + // Multi-field search + await measurePerformance('Multi-field search', async () => { + return await calendar.search('conference', { + fields: ['title', 'description', 'location'] + }); + }); + + // Autocomplete + await measurePerformance('Autocomplete suggestions', async () => { + return await calendar.getSuggestions('eve', 'title'); + }); + + // Get performance stats + const stats = calendar.getPerformanceStats(); + console.log('\nPerformance Statistics:'); + console.log('Average search time:', stats.searchTime?.avg?.toFixed(2), 'ms'); + + // Test with 10,000 events + console.log('\n\nScaling to 10,000 events...'); + const moreEvents = generateTestEvents(9000); + for (const event of moreEvents) { + calendar.addEvent(event); + } + + await measurePerformance('Search in 10,000 events', async () => { + return await calendar.search('urgent', { limit: 50 }); + }); + + calendar.destroy(); +} + +// Test 2: Recurring Event Complexity +async function testRecurringEventComplexity() { + console.log('\n=== Test 2: Recurring Event Complexity ==='); + + const calendar = new EnhancedCalendar({}); + + // Test 1: DST Transition Handling + console.log('\n1. DST Transition Handling:'); + + // Event that spans DST transition (March 2024) + calendar.addEvent({ + id: 'dst-event', + title: 'Weekly Meeting During DST', + start: new Date('2024-03-01T14:00:00'), + end: new Date('2024-03-01T15:00:00'), + recurring: true, + recurrenceRule: 'FREQ=WEEKLY;COUNT=5', + timeZone: 'America/New_York' + }); + + const dstOccurrences = await calendar.getEventsInRange( + new Date('2024-03-01'), + new Date('2024-03-31'), + { handleDST: true } + ); + + console.log(`Found ${dstOccurrences.length} occurrences across DST transition`); + for (const occ of dstOccurrences) { + if (occ.recurringEventId === 'dst-event') { + console.log(` ${occ.start.toISOString()} - Timezone: ${occ.timezone}`); + } + } + + // Test 2: Complex Monthly Patterns + console.log('\n2. Complex Monthly Patterns:'); + + // Last Friday of every month + calendar.addEvent({ + id: 'last-friday', + title: 'Monthly Review - Last Friday', + start: new Date('2024-01-26T09:00:00'), // Last Friday of Jan 2024 + end: new Date('2024-01-26T10:00:00'), + recurring: true, + recurrenceRule: 'FREQ=MONTHLY;BYDAY=-1FR;COUNT=12' + }); + + // Second Tuesday of every month + calendar.addEvent({ + id: 'second-tuesday', + title: 'Board Meeting - 2nd Tuesday', + start: new Date('2024-01-09T14:00:00'), // 2nd Tuesday of Jan 2024 + end: new Date('2024-01-09T16:00:00'), + recurring: true, + recurrenceRule: 'FREQ=MONTHLY;BYDAY=2TU;COUNT=12' + }); + + const monthlyOccurrences = await calendar.getEventsInRange( + new Date('2024-01-01'), + new Date('2024-12-31') + ); + + const lastFridays = monthlyOccurrences.filter(e => e.recurringEventId === 'last-friday'); + const secondTuesdays = monthlyOccurrences.filter(e => e.recurringEventId === 'second-tuesday'); + + console.log(`Last Friday meetings: ${lastFridays.length}`); + console.log(`Second Tuesday meetings: ${secondTuesdays.length}`); + + // Test 3: Modified Instances + console.log('\n3. Modified Instance Handling:'); + + // Create a recurring event + calendar.addEvent({ + id: 'daily-standup', + title: 'Daily Standup', + start: new Date('2024-01-01T09:00:00'), + end: new Date('2024-01-01T09:15:00'), + recurring: true, + recurrenceRule: 'FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;COUNT=20' + }); + + // Modify specific occurrences + calendar.modifyOccurrence( + 'daily-standup', + new Date('2024-01-03T09:00:00'), + { + title: 'Extended Standup - Sprint Planning', + end: new Date('2024-01-03T10:00:00'), + location: 'Large Conference Room' + } + ); + + calendar.modifyOccurrence( + 'daily-standup', + new Date('2024-01-10T09:00:00'), + { + start: new Date('2024-01-10T10:00:00'), // Moved to 10 AM + end: new Date('2024-01-10T10:15:00') + } + ); + + // Cancel an occurrence + calendar.cancelOccurrence( + 'daily-standup', + new Date('2024-01-05T09:00:00'), + 'Team Offsite' + ); + + const janOccurrences = await calendar.getEventsInRange( + new Date('2024-01-01'), + new Date('2024-01-31'), + { includeModified: true, includeCancelled: true } + ); + + const standups = janOccurrences.filter(e => + e.recurringEventId === 'daily-standup' || e.id === 'daily-standup' + ); + + console.log(`Total standup occurrences in January: ${standups.length}`); + const modified = standups.filter(e => e.isModified); + const cancelled = standups.filter(e => e.status === 'cancelled'); + console.log(` Modified: ${modified.length}`); + console.log(` Cancelled: ${cancelled.length}`); + + // Test 4: Complex Yearly Patterns + console.log('\n4. Complex Yearly Patterns:'); + + // Event on specific day of year (100th day) + calendar.addEvent({ + id: 'day-100', + title: '100th Day Celebration', + start: new Date('2024-04-09T12:00:00'), // 100th day of 2024 + end: new Date('2024-04-09T13:00:00'), + recurring: true, + recurrenceRule: 'FREQ=YEARLY;BYYEARDAY=100;COUNT=5' + }); + + // Event on last day of February (handles leap years) + calendar.addEvent({ + id: 'feb-end', + title: 'End of February Review', + start: new Date('2024-02-29T16:00:00'), // 2024 is leap year + end: new Date('2024-02-29T17:00:00'), + recurring: true, + recurrenceRule: 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1;COUNT=5' + }); + + const yearlyOccurrences = await calendar.getEventsInRange( + new Date('2024-01-01'), + new Date('2028-12-31') + ); + + const day100Events = yearlyOccurrences.filter(e => e.recurringEventId === 'day-100'); + const febEndEvents = yearlyOccurrences.filter(e => e.recurringEventId === 'feb-end'); + + console.log(`100th day events over 5 years: ${day100Events.length}`); + console.log(`End of February events: ${febEndEvents.length}`); + + // Test 5: Performance with many modifications + console.log('\n5. Performance with Many Modifications:'); + + // Create event with many occurrences + calendar.addEvent({ + id: 'perf-test', + title: 'Performance Test Event', + start: new Date('2024-01-01T10:00:00'), + end: new Date('2024-01-01T11:00:00'), + recurring: true, + recurrenceRule: 'FREQ=DAILY;COUNT=365' + }); + + // Bulk modify first 100 occurrences + console.log('Applying bulk modifications...'); + await measurePerformance('Bulk modify 100 occurrences', async () => { + await calendar.bulkModifyOccurrences( + 'perf-test', + { + start: new Date('2024-01-01'), + end: new Date('2024-04-10') + }, + { + location: 'Virtual Meeting Room', + categories: ['modified', 'virtual'] + } + ); + }); + + // Get occurrences with modifications + const perfOccurrences = await measurePerformance('Get 365 occurrences with modifications', async () => { + return await calendar.getEventsInRange( + new Date('2024-01-01'), + new Date('2024-12-31'), + { includeModified: true } + ); + }); + + const perfEvents = perfOccurrences.filter(e => e.recurringEventId === 'perf-test'); + const modifiedPerf = perfEvents.filter(e => e.isModified); + console.log(`Modified occurrences: ${modifiedPerf.length} of ${perfEvents.length}`); + + calendar.destroy(); +} + +// Test 3: Edge Cases and Error Handling +async function testEdgeCases() { + console.log('\n=== Test 3: Edge Cases ==='); + + const calendar = new EnhancedCalendar({}); + + // Test 1: Events crossing year boundaries + console.log('\n1. Year Boundary Crossing:'); + calendar.addEvent({ + id: 'nye-party', + title: 'New Year Eve Party', + start: new Date('2024-12-31T22:00:00'), + end: new Date('2025-01-01T02:00:00'), + recurring: true, + recurrenceRule: 'FREQ=YEARLY;COUNT=3' + }); + + const nyeOccurrences = await calendar.getEventsInRange( + new Date('2024-12-30'), + new Date('2025-01-02') + ); + console.log(`NYE occurrences found: ${nyeOccurrences.filter(e => e.id === 'nye-party' || e.recurringEventId === 'nye-party').length}`); + + // Test 2: Very frequent recurrence + console.log('\n2. High-frequency Recurrence:'); + calendar.addEvent({ + id: 'hourly-check', + title: 'Hourly System Check', + start: new Date('2024-01-01T00:00:00'), + end: new Date('2024-01-01T00:05:00'), + recurring: true, + recurrenceRule: 'FREQ=HOURLY;INTERVAL=1;COUNT=168' // Every hour for a week + }); + + const hourlyOccurrences = await measurePerformance('Expand 168 hourly occurrences', async () => { + return await calendar.getEventsInRange( + new Date('2024-01-01'), + new Date('2024-01-08') + ); + }); + console.log(`Hourly occurrences generated: ${hourlyOccurrences.filter(e => e.recurringEventId === 'hourly-check').length}`); + + // Test 3: Complex search with special characters + console.log('\n3. Special Character Handling:'); + calendar.addEvent({ + id: 'special-chars', + title: 'Meeting @ HQ w/ CEO & CTO (Important!)', + description: 'Discuss Q1 goals, $1M budget, 50% growth target', + start: new Date('2024-01-15T14:00:00'), + end: new Date('2024-01-15T15:00:00') + }); + + const specialSearch = await calendar.search('CEO & CTO'); + console.log(`Found events with special characters: ${specialSearch.length}`); + + // Test 4: Timezone edge cases + console.log('\n4. Timezone Edge Cases:'); + + // Event in timezone that doesn't observe DST + calendar.addEvent({ + id: 'arizona-event', + title: 'Arizona Meeting (No DST)', + start: new Date('2024-03-10T14:00:00'), // Day of DST change + end: new Date('2024-03-10T15:00:00'), + recurring: true, + recurrenceRule: 'FREQ=DAILY;COUNT=3', + timeZone: 'America/Phoenix' // Arizona doesn't observe DST + }); + + // Event in UTC + calendar.addEvent({ + id: 'utc-event', + title: 'UTC Coordination Call', + start: new Date('2024-03-10T14:00:00Z'), + end: new Date('2024-03-10T15:00:00Z'), + recurring: true, + recurrenceRule: 'FREQ=DAILY;COUNT=3', + timeZone: 'UTC' + }); + + const tzOccurrences = await calendar.getEventsInRange( + new Date('2024-03-09'), + new Date('2024-03-13') + ); + + console.log('Arizona events:', tzOccurrences.filter(e => e.recurringEventId === 'arizona-event').length); + console.log('UTC events:', tzOccurrences.filter(e => e.recurringEventId === 'utc-event').length); + + calendar.destroy(); +} + +// Test 4: Import/Export with Enhanced Features +async function testImportExport() { + console.log('\n=== Test 4: Import/Export ==='); + + const calendar1 = new EnhancedCalendar({}); + + // Create complex calendar data + calendar1.addEvent({ + id: 'export-test', + title: 'Recurring with Modifications', + start: new Date('2024-01-01T10:00:00'), + end: new Date('2024-01-01T11:00:00'), + recurring: true, + recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12' + }); + + // Add modifications and exceptions + calendar1.modifyOccurrence( + 'export-test', + new Date('2024-01-03T10:00:00'), + { title: 'Modified Title', location: 'Room 101' } + ); + + calendar1.cancelOccurrence( + 'export-test', + new Date('2024-01-08T10:00:00'), + 'Public Holiday' + ); + + // Export + console.log('Exporting calendar with recurrence data...'); + const exported = calendar1.exportWithRecurrence('json'); + console.log(`Exported data size: ${exported.length} bytes`); + + // Import to new calendar + const calendar2 = new EnhancedCalendar({}); + console.log('Importing to new calendar...'); + calendar2.importWithRecurrence(exported, 'json'); + + // Verify import + const importedEvents = await calendar2.getEventsInRange( + new Date('2024-01-01'), + new Date('2024-01-31'), + { includeModified: true, includeCancelled: true } + ); + + const importedOccurrences = importedEvents.filter(e => + e.recurringEventId === 'export-test' || e.id === 'export-test' + ); + + console.log(`Imported occurrences: ${importedOccurrences.length}`); + console.log(`Modified: ${importedOccurrences.filter(e => e.isModified).length}`); + console.log(`Cancelled: ${importedOccurrences.filter(e => e.status === 'cancelled').length}`); + + calendar1.destroy(); + calendar2.destroy(); +} + +// Run all tests +async function runAllTests() { + console.log('========================================'); + console.log(' Enhanced Calendar Feature Tests'); + console.log('========================================'); + + await testSearchScalability(); + await testRecurringEventComplexity(); + await testEdgeCases(); + await testImportExport(); + + console.log('\n========================================'); + console.log(' All Tests Completed Successfully'); + console.log('========================================'); +} + +// Export test suite +export { + testSearchScalability, + testRecurringEventComplexity, + testEdgeCases, + testImportExport, + runAllTests +}; + +// Run tests if executed directly +if (typeof process !== 'undefined' && process.argv[1] === import.meta.url) { + runAllTests().catch(console.error); +} \ No newline at end of file