diff --git a/frontend/src/components/calendar/CalendarCard.tsx b/frontend/src/components/calendar/CalendarCard.tsx index 041026da..6fffe7d1 100644 --- a/frontend/src/components/calendar/CalendarCard.tsx +++ b/frontend/src/components/calendar/CalendarCard.tsx @@ -8,7 +8,6 @@ import { useCalendar } from "@/contexts/CalendarContext"; import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents"; import { calendarService, - formatDateKey, type CalendarEvent, type CalendarEventDraft, toCalendarEventDraft, @@ -17,6 +16,11 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import CalendarNotificationSettingsModal from "@/components/CalendarNotificationSettingsModal"; import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu"; import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors"; +import { + calendarEventOverlapsRange, + formatLocalDateKey, + getCalendarEventDateKeys, +} from "@/lib/calendar/eventRange"; import { buildCalendarOptions, canOpenParticipantsModal, @@ -247,18 +251,8 @@ export const CalendarCard = memo(function CalendarCard({ const eventDays = useMemo(() => { const days = new Set(); events.forEach((ev) => { - if (!ev.start) return; - const eventStart = new Date(ev.start); - if (Number.isNaN(eventStart.getTime())) return; - - const eventEnd = ev.end ? new Date(ev.end) : eventStart; - eventStart.setHours(0, 0, 0, 0); - eventEnd.setHours(0, 0, 0, 0); - - const currentDay = new Date(eventStart); - while (currentDay <= eventEnd) { - days.add(formatDateKey(currentDay)); - currentDay.setDate(currentDay.getDate() + 1); + for (const dateKey of getCalendarEventDateKeys(ev)) { + days.add(dateKey); } }); return days; @@ -277,7 +271,7 @@ export const CalendarCard = memo(function CalendarCard({ const date = new Date(gridStart); date.setDate(gridStart.getDate() + i); - const key = formatDateKey(date); + const key = formatLocalDateKey(date); return { key, day: date.getDate(), @@ -300,12 +294,7 @@ export const CalendarCard = memo(function CalendarCard({ weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); return [...events] - .filter((ev) => ev.start) - .filter((ev) => { - const start = new Date(ev.start || ""); - if (Number.isNaN(start.getTime())) return false; - return start >= weekStart && start < weekEndExclusive; - }) + .filter((ev) => calendarEventOverlapsRange(ev, weekStart, weekEndExclusive)) .sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime()) .slice(0, 12); }, [events]); @@ -315,16 +304,10 @@ export const CalendarCard = memo(function CalendarCard({ const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); monthStart.setHours(0, 0, 0, 0); - const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0); - monthEnd.setHours(23, 59, 59, 999); + const monthEndExclusive = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1); return [...events] - .filter((ev) => ev.start) - .filter((ev) => { - const start = new Date(ev.start || ""); - if (Number.isNaN(start.getTime())) return false; - return start >= monthStart && start <= monthEnd; - }) + .filter((ev) => calendarEventOverlapsRange(ev, monthStart, monthEndExclusive)) .sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime()) .slice(0, 30); }, [events, monthDate]); diff --git a/frontend/src/lib/calendar/eventRange.test.js b/frontend/src/lib/calendar/eventRange.test.js new file mode 100644 index 00000000..927f1450 --- /dev/null +++ b/frontend/src/lib/calendar/eventRange.test.js @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + calendarEventOverlapsRange, + getCalendarEventDateKeys, +} from "./eventRange.ts"; + +test("event crossing the week start is included in the current week", () => { + const event = { + start: new Date(2026, 7, 20, 10, 0), + end: new Date(2026, 8, 1, 10, 0), + }; + + assert.equal( + calendarEventOverlapsRange( + event, + new Date(2026, 7, 31, 0, 0), + new Date(2026, 8, 7, 0, 0), + ), + true, + ); +}); + +test("event crossing the month start is included in the selected month", () => { + const event = { + start: new Date(2026, 7, 20, 10, 0), + end: new Date(2026, 8, 1, 10, 0), + }; + + assert.equal( + calendarEventOverlapsRange( + event, + new Date(2026, 8, 1, 0, 0), + new Date(2026, 9, 1, 0, 0), + ), + true, + ); +}); + +test("event ending exactly at the range start does not overlap the range", () => { + const event = { + start: new Date(2026, 7, 20, 10, 0), + end: new Date(2026, 8, 1, 0, 0), + }; + + assert.equal( + calendarEventOverlapsRange( + event, + new Date(2026, 8, 1, 0, 0), + new Date(2026, 9, 1, 0, 0), + ), + false, + ); +}); + +test("calendar day markers exclude an end boundary at midnight", () => { + assert.deepEqual( + getCalendarEventDateKeys({ + start: new Date(2026, 7, 31, 10, 0), + end: new Date(2026, 8, 1, 0, 0), + }), + ["2026-08-31"], + ); +}); + +test("calendar day markers include a day when the event continues after midnight", () => { + assert.deepEqual( + getCalendarEventDateKeys({ + start: new Date(2026, 7, 31, 10, 0), + end: new Date(2026, 8, 1, 10, 0), + }), + ["2026-08-31", "2026-09-01"], + ); +}); diff --git a/frontend/src/lib/calendar/eventRange.ts b/frontend/src/lib/calendar/eventRange.ts new file mode 100644 index 00000000..3dfbdd4c --- /dev/null +++ b/frontend/src/lib/calendar/eventRange.ts @@ -0,0 +1,70 @@ +export type CalendarEventInterval = { + start?: string | Date | null; + end?: string | Date | null; +}; + +const pad = (value: number) => String(value).padStart(2, "0"); + +export const formatLocalDateKey = (date: Date): string => + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + +const parseEventDate = (value: string | Date | null | undefined): Date | null => { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +/** + * Проверяет пересечение события с полуоткрытым диапазоном [start, end). + * Для событий без корректного окончания используется дата начала как момент времени. + */ +export function calendarEventOverlapsRange( + event: CalendarEventInterval, + rangeStart: Date, + rangeEndExclusive: Date, +): boolean { + const eventStart = parseEventDate(event.start); + if ( + !eventStart + || Number.isNaN(rangeStart.getTime()) + || Number.isNaN(rangeEndExclusive.getTime()) + || rangeEndExclusive <= rangeStart + ) { + return false; + } + + const eventEnd = parseEventDate(event.end); + if (!eventEnd || eventEnd <= eventStart) { + return eventStart >= rangeStart && eventStart < rangeEndExclusive; + } + + return eventStart < rangeEndExclusive && eventEnd > rangeStart; +} + +/** + * Возвращает локальные календарные дни, затронутые событием. + * Окончание события считается исключительной границей: событие до 00:00 + * следующего дня не создаёт отметку на следующем дне. + */ +export function getCalendarEventDateKeys(event: CalendarEventInterval): string[] { + const eventStart = parseEventDate(event.start); + if (!eventStart) return []; + + const parsedEnd = parseEventDate(event.end); + const inclusiveEnd = parsedEnd && parsedEnd > eventStart + ? new Date(parsedEnd.getTime() - 1) + : eventStart; + + const currentDay = new Date(eventStart); + currentDay.setHours(0, 0, 0, 0); + + const lastDay = new Date(inclusiveEnd); + lastDay.setHours(0, 0, 0, 0); + + const keys: string[] = []; + while (currentDay <= lastDay) { + keys.push(formatLocalDateKey(currentDay)); + currentDay.setDate(currentDay.getDate() + 1); + } + return keys; +}