Centralize calendar event range handling

This commit is contained in:
admin i 2026-09-01 14:54:03 +03:00
parent 09b789746f
commit dad9073e2a
3 changed files with 156 additions and 28 deletions

View File

@ -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<string>();
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]);

View File

@ -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"],
);
});

View File

@ -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;
}