From 22a744b971627604c61f16f77c55ebfad00d357b Mon Sep 17 00:00:00 2001 From: Arindam Sahoo Date: Sun, 31 May 2026 21:04:36 +0530 Subject: [PATCH 1/2] feat(calendar): Implement event notifications and recurrence utilities This commit introduces a new feature that provides desktop notifications for upcoming calendar events. Changes include: - **Event Notifications:** A background process (useEffect in App.tsx) periodically checks for local events with notifications enabled. If an event is scheduled to start soon (based on `notificationMinutes`), a desktop notification is triggered. - **Calendar Event Properties:** Added `notificationEnabled` and `notificationMinutes` to `CalendarEvent` interface, allowing users to configure notifications per event. - **Calendar Utilities (`calendarUtils.ts`):** Introduced a set of comprehensive utility functions to handle recurring events, calculate specific occurrences on a given date, and retrieve upcoming events. These functions consolidate logic previously spread across multiple components. - **Component Integration:** Updated `KoCalendarPopup` and `KoCalendarWidget` to utilize the new `calendarUtils` for event display and management. - **Internationalization:** Added new translation keys for notification messages and recurrence deletion options. This enhancement significantly improves the user experience by proactively informing them about important events. --- src/App.tsx | 68 +++++ src/components/calendar/KoCalendarPopup.tsx | 296 ++++++++++++++++++-- src/i18n/translations.ts | 160 +++++++++++ src/store/useAppStore.ts | 6 + src/utils/calendarUtils.ts | 161 +++++++++++ 5 files changed, 675 insertions(+), 16 deletions(-) create mode 100644 src/utils/calendarUtils.ts diff --git a/src/App.tsx b/src/App.tsx index f1bb440..b3eafe1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import KoPlayerPopup from './components/layout/KoPlayerPopup'; import KoCalendarPopup from './components/calendar/KoCalendarPopup'; import { useScreenshotStore } from './store/useScreenshotStore'; import { useExtensionRegistry } from './components/extensions/extensionRegistry'; +import { isEventOccurringOnDate, getEventOccurrenceOnDate } from './utils/calendarUtils'; // Global flag: when true, the ghost-window logic won't steal focus // Exported so ResizerHandle can set it during drags @@ -177,6 +178,73 @@ const App: React.FC = () => { return () => clearInterval(interval); }, []); + // Calendar Event Notification Check + useEffect(() => { + const notifiedEvents = new Set(); // Keep track of already notified event occurrences during this session + + const checkNotifications = () => { + const state = useAppStore.getState(); + if (!state.isKoCalendarEnabled) return; + + const now = new Date(); + const localEvents = state.localEvents; + + // Check events occurring today and tomorrow (in case of midnight/timezone boundaries) + const targetDays = [now, new Date(now.getTime() + 24 * 60 * 60 * 1000)]; + + targetDays.forEach(day => { + const dayEvents = localEvents.filter(ev => isEventOccurringOnDate(ev, day)); + + dayEvents.forEach(event => { + if (!event.notificationEnabled) return; + + const occurrence = getEventOccurrenceOnDate(event, day); + const startTime = new Date(occurrence.startTime); + + const minutesBefore = event.notificationMinutes ?? 15; + const notificationTime = new Date(startTime.getTime() - minutesBefore * 60 * 1000); + + // Unique key for this specific occurrence: event ID + start time ISO + const occurrenceKey = `${event.id}_${occurrence.startTime}`; + + // If current time is past notification time but before start time (plus a 5 minute grace window) + // and we haven't notified it yet + if (now >= notificationTime && now <= new Date(startTime.getTime() + 5 * 60 * 1000)) { + if (!notifiedEvents.has(occurrenceKey)) { + notifiedEvents.add(occurrenceKey); + + // Format start time nicely + const timeStr = startTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const bodyMsg = `${state.t('eventStartingSoon') || 'Event starting soon'} at ${timeStr}`; + window.api?.sendNotification?.(event.title, bodyMsg); + } + } + }); + }); + + // Memory cleanup: remove keys that are older than 1 hour in the past to keep the set small + notifiedEvents.forEach(key => { + const parts = key.split('_'); + if (parts.length > 1) { + const startTimeStr = parts[1]; + try { + const startTime = new Date(startTimeStr); + if (now.getTime() - startTime.getTime() > 60 * 60 * 1000) { + notifiedEvents.delete(key); + } + } catch (e) { + // ignore + } + } + }); + }; + + // Check immediately on mount, then check every 30 seconds + checkNotifications(); + const interval = setInterval(checkNotifications, 30000); + return () => clearInterval(interval); + }, []); + // KoBox cleanup triggers useEffect(() => { window.api?.cleanKoBox?.(useAppStore.getState().koBoxCleanupMode); diff --git a/src/components/calendar/KoCalendarPopup.tsx b/src/components/calendar/KoCalendarPopup.tsx index afa2863..2a11f00 100644 --- a/src/components/calendar/KoCalendarPopup.tsx +++ b/src/components/calendar/KoCalendarPopup.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect } from 'react'; import { useAppStore } from '../../store/useAppStore'; +import type { CalendarEvent } from '../../store/useAppStore'; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, format, isSameMonth, isSameDay, addMonths, subMonths, eachDayOfInterval, parseISO } from 'date-fns'; +import { getEventsForDate, getUpcomingOccurrences } from '../../utils/calendarUtils'; interface HolidayData { date: string; @@ -37,6 +39,13 @@ const KoCalendarPopup: React.FC = () => { const [newEventMinutes, setNewEventMinutes] = useState('00'); const [newEventNotification, setNewEventNotification] = useState(true); const [newEventColor, setNewEventColor] = useState(koCalendarColor); + const [newEventRecurrence, setNewEventRecurrence] = useState<'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('none'); + const [newEventRecurrenceEndDate, setNewEventRecurrenceEndDate] = useState(''); + const [newEventRecurrenceEndType, setNewEventRecurrenceEndType] = useState<'never' | 'date' | 'count'>('never'); + const [newEventRecurrenceCount, setNewEventRecurrenceCount] = useState(10); + const [newEventRecurrenceDays, setNewEventRecurrenceDays] = useState([]); + const [newEventRecurrenceDatesInput, setNewEventRecurrenceDatesInput] = useState(''); + const [deletingOccurrence, setDeletingOccurrence] = useState<{ event: CalendarEvent, date: Date } | null>(null); const [pendingHolidays, setPendingHolidays] = useState(null); const [importColor, setImportColor] = useState(koCalendarColor); @@ -262,7 +271,7 @@ const KoCalendarPopup: React.FC = () => { const isSelected = isSameDay(day, selectedDate); // Match events - const dayEvents = localEvents.filter(ev => ev.startTime && isSameDay(parseISO(ev.startTime), day)); + const dayEvents = getEventsForDate(localEvents, day); // Match todos const dayTodos = todos.filter(t => t.dueDate && isSameDay(parseISO(t.dueDate), day)); @@ -377,6 +386,12 @@ const KoCalendarPopup: React.FC = () => { setNewEventTitle(''); setNewEventDescription(''); setNewEventMeetingLink(''); + setNewEventRecurrence('none'); + setNewEventRecurrenceEndDate(''); + setNewEventRecurrenceEndType('never'); + setNewEventRecurrenceCount(10); + setNewEventRecurrenceDays([]); + setNewEventRecurrenceDatesInput(''); }} className="w-5 h-5 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-all"> close @@ -388,6 +403,22 @@ const KoCalendarPopup: React.FC = () => { eventStart.setMinutes(parseInt(newEventMinutes, 10)); eventStart.setSeconds(0); + const recEndDateISO = newEventRecurrence !== 'none' && newEventRecurrenceEndType === 'date' && newEventRecurrenceEndDate + ? new Date(newEventRecurrenceEndDate + 'T23:59:59').toISOString() + : undefined; + + const recCount = newEventRecurrence !== 'none' && newEventRecurrenceEndType === 'count' + ? newEventRecurrenceCount + : undefined; + + const recDays = newEventRecurrence === 'weekly' && newEventRecurrenceDays.length > 0 + ? newEventRecurrenceDays + : undefined; + + const recDates = newEventRecurrence === 'monthly' && newEventRecurrenceDatesInput.trim() + ? newEventRecurrenceDatesInput.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 31) + : undefined; + if (editingEventId) { updateCalendarEvent(editingEventId, { title: newEventTitle.trim(), @@ -396,7 +427,12 @@ const KoCalendarPopup: React.FC = () => { startTime: eventStart.toISOString(), endTime: eventStart.toISOString(), notificationEnabled: newEventNotification, - colorId: newEventColor + colorId: newEventColor, + recurrence: newEventRecurrence, + recurrenceEndDate: recEndDateISO, + recurrenceCount: recCount, + recurrenceDays: recDays, + recurrenceDates: recDates }); } else { addCalendarEvent({ @@ -407,12 +443,23 @@ const KoCalendarPopup: React.FC = () => { endTime: eventStart.toISOString(), notificationEnabled: newEventNotification, notificationMinutes: 15, - colorId: newEventColor + colorId: newEventColor, + recurrence: newEventRecurrence, + recurrenceEndDate: recEndDateISO, + recurrenceCount: recCount, + recurrenceDays: recDays, + recurrenceDates: recDates }); } setNewEventTitle(''); setNewEventDescription(''); setNewEventMeetingLink(''); + setNewEventRecurrence('none'); + setNewEventRecurrenceEndDate(''); + setNewEventRecurrenceEndType('never'); + setNewEventRecurrenceCount(10); + setNewEventRecurrenceDays([]); + setNewEventRecurrenceDatesInput(''); setEditingEventDate(null); setEditingEventId(null); }} className="flex flex-col gap-2"> @@ -494,6 +541,119 @@ const KoCalendarPopup: React.FC = () => { +
+ {/* Recurrence Selector */} +
+ repeat + +
+ + {/* Recurrence End Conditions Selector & Inputs */} + {newEventRecurrence !== 'none' && ( + <> +
+ event_busy + +
+ + {newEventRecurrenceEndType === 'date' && ( +
+ setNewEventRecurrenceEndDate(e.target.value)} + className="bg-transparent text-white border-0 outline-none font-semibold text-xs py-0.5 px-1 cursor-pointer [color-scheme:dark]" + /> +
+ )} + + {newEventRecurrenceEndType === 'count' && ( +
+ setNewEventRecurrenceCount(parseInt(e.target.value, 10) || 1)} + className="w-8 bg-transparent text-center text-white border-0 outline-none font-bold text-xs" + /> + {t('occurrences') || 'occurrences'} +
+ )} + + )} +
+ + {/* Weekly: Select days of the week */} + {newEventRecurrence === 'weekly' && ( +
+ Repeat on +
+ {[ + { label: 'M', value: 1 }, + { label: 'T', value: 2 }, + { label: 'W', value: 3 }, + { label: 'T', value: 4 }, + { label: 'F', value: 5 }, + { label: 'S', value: 6 }, + { label: 'S', value: 0 }, + ].map(day => { + const isSelected = newEventRecurrenceDays.includes(day.value); + return ( + + ); + })} +
+
+ )} + + {/* Monthly: Comma-separated days of month */} + {newEventRecurrence === 'monthly' && ( +
+ Repeat on dates (e.g. 1, 15) + setNewEventRecurrenceDatesInput(e.target.value)} + className="w-full bg-black/40 border border-white/10 rounded-lg px-2.5 py-1 text-white text-xs focus:outline-none focus:border-primary no-drag-region" + /> +
+ )} +
{['#60a5fa', '#f87171', '#4ade80', '#fbbf24', '#a78bfa'].map(color => ( @@ -518,9 +678,9 @@ const KoCalendarPopup: React.FC = () => { // Filter events starting from the start of the selected date const targetStartOfDay = new Date(selectedDate); targetStartOfDay.setHours(0,0,0,0); - const agendaData = localEvents - .filter(e => e.startTime && new Date(e.startTime) >= targetStartOfDay) - .sort((a, b) => new Date(a.startTime!).getTime() - new Date(b.startTime!).getTime()); + + const agendaData = getUpcomingOccurrences(localEvents, targetStartOfDay, 30) + .sort((a, b) => parseISO(a.startTime!).getTime() - parseISO(b.startTime!).getTime()); const selectedDayHasEvent = agendaData.length > 0 && isSameDay(parseISO(agendaData[0].startTime!), selectedDate); @@ -550,6 +710,9 @@ const KoCalendarPopup: React.FC = () => {
{ev.title} + {ev.recurrence && ev.recurrence !== 'none' && ( + repeat + )} {ev.meetingLink && (
)} + + {deletingOccurrence && ( +
+
+
+ + {t('deleteOccurrencePromptTitle') || 'Delete Recurring Event'} + + + "{deletingOccurrence.event.title}" + +
+
+ + + + + +
+
+ +
+
+
+ )}
); }; diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 6a08147..a33c033 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -182,6 +182,22 @@ export const translations = { events: "Events", noEventsFound: "No events found from this date.", today: "Today", + recurrence: "Recurrence", + recurrenceNone: "Does not repeat", + recurrenceDaily: "Daily", + recurrenceWeekly: "Weekly", + recurrenceMonthly: "Monthly", + recurrenceYearly: "Yearly", + recurrenceEnds: "Ends", + recurrenceEndsNever: "Never", + recurrenceEndsOn: "On Date", + recurrenceEndsAfter: "After", + occurrences: "occurrences", + eventStartingSoon: "Event starting soon", + deleteOccurrencePromptTitle: "Delete Recurring Event", + deleteOnlyThisOccurrence: "Only this occurrence", + deleteThisAndFollowingOccurrences: "This and all following events", + deleteAllOccurrences: "All events in the series", // KoPlayer noMediaPlaying: "No media playing", playSomething: "Play something in Spotify, YouTube, or any media player", @@ -429,6 +445,22 @@ export const translations = { events: "Etkinlikler", noEventsFound: "Bu tarihte etkinlik bulunamadı.", today: "Bugün", + recurrence: "Tekrarlama", + recurrenceNone: "Tekrarlanmaz", + recurrenceDaily: "Günlük", + recurrenceWeekly: "Haftalık", + recurrenceMonthly: "Aylık", + recurrenceYearly: "Yıllık", + recurrenceEnds: "Bitiş", + recurrenceEndsNever: "Asla", + recurrenceEndsOn: "Tarihte", + recurrenceEndsAfter: "Sonra", + occurrences: "tekrardan", + eventStartingSoon: "Etkinlik yakında başlıyor", + deleteOccurrencePromptTitle: "Tekrarlanan Etkinliği Sil", + deleteOnlyThisOccurrence: "Yalnızca bu etkinlik", + deleteThisAndFollowingOccurrences: "Bu ve sonraki tüm etkinlikler", + deleteAllOccurrences: "Serideki tüm etkinlikler", // KoPlayer noMediaPlaying: "Medya oynatılmıyor", playSomething: "Spotify, YouTube veya herhangi bir oynatıcıdan bir şeyler açın", @@ -676,6 +708,22 @@ export const translations = { events: "Ereignisse", noEventsFound: "Keine Ereignisse für dieses Datum gefunden.", today: "Heute", + recurrence: "Wiederholung", + recurrenceNone: "Wiederholt sich nicht", + recurrenceDaily: "Täglich", + recurrenceWeekly: "Wöchentlich", + recurrenceMonthly: "Monatlich", + recurrenceYearly: "Jährlich", + recurrenceEnds: "Ende", + recurrenceEndsNever: "Nie", + recurrenceEndsOn: "Am Datum", + recurrenceEndsAfter: "Nach", + occurrences: "Wiederholungen", + eventStartingSoon: "Ereignis beginnt bald", + deleteOccurrencePromptTitle: "Wiederkehrendes Ereignis löschen", + deleteOnlyThisOccurrence: "Nur dieses Ereignis", + deleteThisAndFollowingOccurrences: "Dieses und alle folgenden Ereignisse", + deleteAllOccurrences: "Alle Ereignisse der Serie", // KoPlayer noMediaPlaying: "Keine Medienwiedergabe", playSomething: "Spielen Sie etwas in Spotify, YouTube oder einem Mediaplayer ab", @@ -923,6 +971,22 @@ export const translations = { events: "Événements", noEventsFound: "Aucun événement trouvé pour cette date.", today: "Aujourd'hui", + recurrence: "Répétition", + recurrenceNone: "Ne se répète pas", + recurrenceDaily: "Quotidien", + recurrenceWeekly: "Hebdomadaire", + recurrenceMonthly: "Mensuel", + recurrenceYearly: "Annuel", + recurrenceEnds: "Fin", + recurrenceEndsNever: "Jamais", + recurrenceEndsOn: "À une date", + recurrenceEndsAfter: "Après", + occurrences: "occurrences", + eventStartingSoon: "L'événement commence bientôt", + deleteOccurrencePromptTitle: "Supprimer l'événement récurrent", + deleteOnlyThisOccurrence: "Seulement cette occurrence", + deleteThisAndFollowingOccurrences: "Cette occurrence et toutes les suivantes", + deleteAllOccurrences: "Tous les événements de la série", // KoPlayer noMediaPlaying: "Aucun média en cours", playSomething: "Lancez de la musique sur Spotify, YouTube veya tout autre lecteur", @@ -1170,6 +1234,22 @@ export const translations = { events: "Eventos", noEventsFound: "No se encontraron eventos para esta fecha.", today: "Hoy", + recurrence: "Recurrencia", + recurrenceNone: "No se repite", + recurrenceDaily: "Diariamente", + recurrenceWeekly: "Semanalmente", + recurrenceMonthly: "Mensualmente", + recurrenceYearly: "Anualmente", + recurrenceEnds: "Finaliza", + recurrenceEndsNever: "Nunca", + recurrenceEndsOn: "En fecha", + recurrenceEndsAfter: "Después de", + occurrences: "ocurrencias", + eventStartingSoon: "El evento comenzará pronto", + deleteOccurrencePromptTitle: "Eliminar evento recurrente", + deleteOnlyThisOccurrence: "Solo esta ocurrencia", + deleteThisAndFollowingOccurrences: "Este y todos los eventos suivants", + deleteAllOccurrences: "Todos los eventos de la serie", // KoPlayer noMediaPlaying: "No hay medios en reproducción", playSomething: "Reproduce algo en Spotify, YouTube o cualquier reproductor", @@ -1417,6 +1497,22 @@ export const translations = { events: "События", noEventsFound: "Событий на эту дату не найдено.", today: "Сегодня", + recurrence: "Повторение", + recurrenceNone: "Не повторяется", + recurrenceDaily: "Ежедневно", + recurrenceWeekly: "Еженедельно", + recurrenceMonthly: "Ежемесячно", + recurrenceYearly: "Ежегодно", + recurrenceEnds: "Окончание", + recurrenceEndsNever: "Никогда", + recurrenceEndsOn: "В дату", + recurrenceEndsAfter: "После", + occurrences: "повторений", + eventStartingSoon: "Событие скоро начнется", + deleteOccurrencePromptTitle: "Удалить повторяющееся событие", + deleteOnlyThisOccurrence: "Только это событие", + deleteThisAndFollowingOccurrences: "Это и все последующие события", + deleteAllOccurrences: "Все события серии", // KoPlayer noMediaPlaying: "Медиа не воспроизводится", playSomething: "Включите что-нибудь в Spotify, YouTube или любом плеере", @@ -1664,6 +1760,22 @@ export const translations = { events: "الأحداث", noEventsFound: "لا توجد أحداث في هذا التاريخ.", today: "اليوم", + recurrence: "التكرار", + recurrenceNone: "لا يتكرر", + recurrenceDaily: "يوميًا", + recurrenceWeekly: "أسبوعيًا", + recurrenceMonthly: "شهريًا", + recurrenceYearly: "سنويًا", + recurrenceEnds: "ينتهي", + recurrenceEndsNever: "أبدًا", + recurrenceEndsOn: "في تاريخ", + recurrenceEndsAfter: "بعد", + occurrences: "تكرارات", + eventStartingSoon: "الحدث سيبدأ قريبًا", + deleteOccurrencePromptTitle: "حذف الحدث المتكرر", + deleteOnlyThisOccurrence: "هذا الحدث فقط", + deleteThisAndFollowingOccurrences: "هذا والأحداث التالية", + deleteAllOccurrences: "كل الأحداث في السلسلة", // KoPlayer noMediaPlaying: "لا يوجد وسائط قيد التشغيل", playSomething: "قم بتشغيل شيء ما في Spotify أو YouTube أو أي مشغل وسائط", @@ -1911,6 +2023,22 @@ export const translations = { events: "事件", noEventsFound: "此日期未找到事件。", today: "今天", + recurrence: "重复", + recurrenceNone: "不重复", + recurrenceDaily: "每天", + recurrenceWeekly: "每周", + recurrenceMonthly: "每月", + recurrenceYearly: "每年", + recurrenceEnds: "结束于", + recurrenceEndsNever: "从不", + recurrenceEndsOn: "在日期", + recurrenceEndsAfter: "重复", + occurrences: "次后", + eventStartingSoon: "活动即将开始", + deleteOccurrencePromptTitle: "删除重复事件", + deleteOnlyThisOccurrence: "仅此事件", + deleteThisAndFollowingOccurrences: "此事件及后续事件", + deleteAllOccurrences: "系列中的所有事件", // KoPlayer noMediaPlaying: "未在播放媒体", playSomething: "在 Spotify、YouTube 或任何播放器中播放内容", @@ -2145,6 +2273,22 @@ export const translations = { events: "イベント", noEventsFound: "この日のイベントは見つかりませんでした。", today: "今日", + recurrence: "繰り返し", + recurrenceNone: "繰り返さない", + recurrenceDaily: "毎日", + recurrenceWeekly: "毎週", + recurrenceMonthly: "毎月", + recurrenceYearly: "毎年", + recurrenceEnds: "終了", + recurrenceEndsNever: "なし", + recurrenceEndsOn: "日付指定", + recurrenceEndsAfter: "後", + occurrences: "回", + eventStartingSoon: "イベントがもうすぐ始まります", + deleteOccurrencePromptTitle: "繰り返しイベントの削除", + deleteOnlyThisOccurrence: "このイベントのみ", + deleteThisAndFollowingOccurrences: "これ以降のすべてのイベント", + deleteAllOccurrences: "すべてのシリーズイベント", // KoPlayer noMediaPlaying: "再生中のメディアはありません", playSomething: "SpotifyやYouTubeなどのメディアプレーヤーで何か再生してください", @@ -2392,6 +2536,22 @@ export const translations = { events: "इवेंट", noEventsFound: "इस तारीख को कोई इवेंट नहीं मिला।", today: "आज", + recurrence: "पुनरावृत्ति", + recurrenceNone: "पुनरावृत्ति नहीं", + recurrenceDaily: "दैनिक", + recurrenceWeekly: "साप्ताहिक", + recurrenceMonthly: "मासिक", + recurrenceYearly: "वार्षिक", + recurrenceEnds: "समाप्त", + recurrenceEndsNever: "कभी नहीं", + recurrenceEndsOn: "दिनांक को", + recurrenceEndsAfter: "के बाद", + occurrences: "बार", + eventStartingSoon: "कार्यक्रम जल्द ही शुरू हो रहा है", + deleteOccurrencePromptTitle: "पुनरावर्ती कार्यक्रम हटाएं", + deleteOnlyThisOccurrence: "केवल यह कार्यक्रम", + deleteThisAndFollowingOccurrences: "यह और इसके बाद के सभी कार्यक्रम", + deleteAllOccurrences: "श्रृंखला के सभी कार्यक्रम", // KoPlayer noMediaPlaying: "कोई मीडिया नहीं चल रहा", playSomething: "Spotify, YouTube या किसी भी मीडिया प्लेयर में कुछ चलाएं", diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 6581669..323ac12 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -97,6 +97,12 @@ export interface CalendarEvent { colorId?: string; notificationEnabled?: boolean; notificationMinutes?: number; + recurrence?: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; + recurrenceEndDate?: string; // ISO string + recurrenceCount?: number; + recurrenceDays?: number[]; + recurrenceDates?: number[]; + recurrenceExceptions?: string[]; // Date strings format 'yyyy-MM-dd' } export interface Snippet { diff --git a/src/utils/calendarUtils.ts b/src/utils/calendarUtils.ts new file mode 100644 index 0000000..cf6840e --- /dev/null +++ b/src/utils/calendarUtils.ts @@ -0,0 +1,161 @@ +import { isSameDay, startOfDay, endOfDay, parseISO, addDays } from 'date-fns'; +import type { CalendarEvent } from '../store/useAppStore'; + +export function isEventOccurringOnDateIgnoreCount(event: CalendarEvent, date: Date): boolean { + if (!event.startTime) return false; + + const eventStart = startOfDay(parseISO(event.startTime)); + const targetDate = startOfDay(date); + + // Event cannot occur before its start date + if (targetDate < eventStart) return false; + + // Check recurrence exceptions + const year = targetDate.getFullYear(); + const month = String(targetDate.getMonth() + 1).padStart(2, '0'); + const day = String(targetDate.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; + if (event.recurrenceExceptions && event.recurrenceExceptions.includes(dateStr)) { + return false; + } + + // If there is an end date for recurrence, the event cannot occur after it + if (event.recurrence && event.recurrence !== 'none' && event.recurrenceEndDate) { + const recurrenceEnd = endOfDay(parseISO(event.recurrenceEndDate)); + if (targetDate > recurrenceEnd) return false; + } + + // Check occurrence based on recurrence type + if (!event.recurrence || event.recurrence === 'none') { + return isSameDay(eventStart, targetDate); + } + + switch (event.recurrence) { + case 'daily': + return true; + + case 'weekly': + if (event.recurrenceDays && event.recurrenceDays.length > 0) { + return event.recurrenceDays.includes(targetDate.getDay()); + } + // Fallback to original start day + return targetDate.getDay() === eventStart.getDay(); + + case 'monthly': { + const targetDay = targetDate.getDate(); + if (event.recurrenceDates && event.recurrenceDates.length > 0) { + if (event.recurrenceDates.includes(targetDay)) return true; + + // Last day of month fallback + const lastDayOfTargetMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate(); + if (targetDay === lastDayOfTargetMonth) { + return event.recurrenceDates.some(d => d >= lastDayOfTargetMonth); + } + return false; + } + + const startDay = eventStart.getDate(); + if (targetDay === startDay) return true; + const lastDayOfTargetMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate(); + if (targetDay === lastDayOfTargetMonth && startDay > lastDayOfTargetMonth) { + return true; + } + return false; + } + + case 'yearly': { + const targetMonth = targetDate.getMonth(); + const targetDayVal = targetDate.getDate(); + const startMonth = eventStart.getMonth(); + const startDayVal = eventStart.getDate(); + if (targetMonth === startMonth && targetDayVal === startDayVal) return true; + + // Leap year handling + if (startMonth === 1 && startDayVal === 29 && targetMonth === 1 && targetDayVal === 28) { + const isLeap = (year: number) => (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); + if (!isLeap(targetDate.getFullYear())) { + return true; + } + } + return false; + } + + default: + return false; + } +} + +export function isEventOccurringOnDate(event: CalendarEvent, date: Date): boolean { + if (!isEventOccurringOnDateIgnoreCount(event, date)) return false; + + // Check recurrence count limit + if (event.recurrenceCount !== undefined && event.recurrenceCount !== null && event.recurrence && event.recurrence !== 'none') { + const eventStart = startOfDay(parseISO(event.startTime)); + const targetDate = startOfDay(date); + + let occurrenceIndex = 0; + const curr = new Date(eventStart); + while (curr <= targetDate) { + if (isEventOccurringOnDateIgnoreCount(event, curr)) { + occurrenceIndex++; + } + curr.setDate(curr.getDate() + 1); + } + + if (occurrenceIndex > event.recurrenceCount) { + return false; + } + } + + return true; +} + +export function getEventOccurrenceOnDate(event: CalendarEvent, targetDate: Date): CalendarEvent { + const originalStart = parseISO(event.startTime); + const originalEnd = parseISO(event.endTime); + + // Calculate duration in ms + const duration = originalEnd.getTime() - originalStart.getTime(); + + // Construct new start time keeping hours, minutes, seconds, ms of original, but year, month, date of target + const occurrenceStart = new Date(targetDate); + occurrenceStart.setHours(originalStart.getHours()); + occurrenceStart.setMinutes(originalStart.getMinutes()); + occurrenceStart.setSeconds(originalStart.getSeconds()); + occurrenceStart.setMilliseconds(originalStart.getMilliseconds()); + + const occurrenceEnd = new Date(occurrenceStart.getTime() + duration); + + return { + ...event, + startTime: occurrenceStart.toISOString(), + endTime: occurrenceEnd.toISOString(), + }; +} + +export function getEventsForDate(events: CalendarEvent[], date: Date): CalendarEvent[] { + return events + .filter(event => isEventOccurringOnDate(event, date)) + .map(event => getEventOccurrenceOnDate(event, date)); +} + +export function getEventsForInterval(events: CalendarEvent[], start: Date, end: Date): CalendarEvent[] { + const occurrences: CalendarEvent[] = []; + + // Iterate over each day in the interval + const current = new Date(start); + while (current <= end) { + const dayEvents = getEventsForDate(events, current); + occurrences.push(...dayEvents); + current.setDate(current.getDate() + 1); + } + + return occurrences; +} + +export function getUpcomingOccurrences(events: CalendarEvent[], fromDate: Date, daysCount: number = 30): CalendarEvent[] { + const start = startOfDay(fromDate); + const end = endOfDay(addDays(start, daysCount)); + + return getEventsForInterval(events, start, end); +} From b8c75f109ac9c4d2642165f03af2ad93c642a9ca Mon Sep 17 00:00:00 2001 From: Arindam Sahoo Date: Mon, 1 Jun 2026 08:47:02 +0530 Subject: [PATCH 2/2] fix: Update event key in calendar popup to ensure uniqueness The `key` prop for event items rendered within the `KoCalendarPopup` component was updated. Previously, the key was based solely on `ev.id`. This could lead to React warnings about duplicate keys and potential rendering inconsistencies if `ev.id` was not unique for all items in the list. The key has been updated to `${ev.id}-${ev.startTime}` to provide a more robust and unique identifier for each event item. This ensures rendering stability and prevents React warnings related to non-unique list keys. --- src/components/calendar/KoCalendarPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/calendar/KoCalendarPopup.tsx b/src/components/calendar/KoCalendarPopup.tsx index 2a11f00..7984369 100644 --- a/src/components/calendar/KoCalendarPopup.tsx +++ b/src/components/calendar/KoCalendarPopup.tsx @@ -704,7 +704,7 @@ const KoCalendarPopup: React.FC = () => { const isEvSelected = isSameDay(eventDate, selectedDate); return ( -
+