diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a8175679c..71d2f630a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1707,6 +1707,9 @@ "calendar": { "name": "Calendar", "description": "Display events from your integrations in a calendar view within a certain relative time period", + "duration": { + "allDay": "All day" + }, "option": { "releaseType": { "label": "Radarr release type", diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx index 0ad0a805f..992da9d71 100644 --- a/packages/widgets/src/calendar/calendar-event-list.tsx +++ b/packages/widgets/src/calendar/calendar-event-list.tsx @@ -84,16 +84,24 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => { - - {dayjs(event.startDate).format("HH:mm")} - - - {event.endDate !== null && ( + {isAllDay(event) ? ( + + {t("widget.calendar.duration.allDay")} + + ) : ( <> - -{" "} - {dayjs(event.endDate).format("HH:mm")} + {dayjs(event.startDate).format("HH:mm")} + + {event.endDate !== null && ( + <> + -{" "} + + {dayjs(event.endDate).format("HH:mm")} + + + )} )} @@ -152,3 +160,12 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => { ); }; + +const isAllDay = (event: Pick) => { + if (!event.endDate) return false; + + const start = dayjs(event.startDate); + const end = dayjs(event.endDate); + + return start.startOf("day").isSame(start) && end.endOf("day").isSame(end); +}; diff --git a/packages/widgets/src/calendar/calendar.spec.ts b/packages/widgets/src/calendar/calendar.spec.ts new file mode 100644 index 000000000..0d655adc6 --- /dev/null +++ b/packages/widgets/src/calendar/calendar.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; + +import type { CalendarEvent } from "@homarr/integrations/types"; + +import { splitEvents } from "./component"; + +describe("splitEvents should split multi-day events into multiple single-day events", () => { + test("2 day all-day event should be split up into two all-day events", () => { + const event = createEvent(new Date(2025, 0, 1), new Date(2025, 0, 3)); + + const result = splitEvents([event]); + + expect(result).toHaveLength(2); + expect(result[0]?.startDate).toEqual(event.startDate); + expect(result[0]?.endDate).toEqual(new Date(new Date(2025, 0, 2).getTime() - 1)); + expect(result[1]?.startDate).toEqual(new Date(2025, 0, 2)); + // Because we want to end the event on the previous day, we have not the same endDate. + // Otherwise there would be three single-day events, with the last being from 0:00 - 0:00 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(result[1]?.endDate).toEqual(new Date(event.endDate!.getTime() - 1)); + }); + test("2 day partial event should be split up into two events", () => { + const event = createEvent(new Date(2025, 0, 1, 15), new Date(2025, 0, 2, 9)); + + const result = splitEvents([event]); + + expect(result).toHaveLength(2); + expect(result[0]?.startDate).toEqual(event.startDate); + expect(result[0]?.endDate).toEqual(new Date(new Date(2025, 0, 2).getTime() - 1)); + expect(result[1]?.startDate).toEqual(new Date(2025, 0, 2)); + expect(result[1]?.endDate).toEqual(event.endDate); + }); + test("one day partial event should only have one event after split", () => { + const event = createEvent(new Date(2025, 0, 1), new Date(2025, 0, 2)); + + const result = splitEvents([event]); + + expect(result).toHaveLength(1); + }); + test("without endDate should not be split", () => { + const event = createEvent(new Date(2025, 0, 1)); + + const result = splitEvents([event]); + + expect(result).toHaveLength(1); + }); + test("startDate after endDate should not cause infinite loop", () => { + const event = createEvent(new Date(2025, 0, 2), new Date(2025, 0, 1)); + + const result = splitEvents([event]); + + expect(result).toHaveLength(0); + }); +}); + +const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({ + title: "Test", + subTitle: null, + description: null, + startDate, + endDate, + image: null, + indicatorColor: "red", + links: [], + location: null, +}); diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index d3534176d..7aa081b73 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useParams } from "next/navigation"; import { useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; @@ -10,6 +10,7 @@ import dayjs from "dayjs"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; import type { WidgetComponentProps } from "../definition"; @@ -69,6 +70,8 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar const { ref, width, height } = useElementSize(); const isSmall = width < 256; + const normalizedEvents = useMemo(() => splitEvents(events), [events]); + return ( { - const eventsForDate = events + const eventsForDate = normalizedEvents .filter((event) => dayjs(event.startDate).isSame(tileDate, "day")) .filter( (event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType), @@ -145,3 +148,42 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar /> ); }; + +/** + * Splits multi-day events into multiple single-day events. + * @param events The events to split. + * @returns The split events. + */ +export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => { + const splitEvents: CalendarEvent[] = []; + for (const event of events) { + if (!event.endDate) { + splitEvents.push(event); + continue; + } + + if (dayjs(event.startDate).isSame(event.endDate, "day")) { + splitEvents.push(event); + continue; + } + + if (dayjs(event.startDate).isAfter(event.endDate)) { + // Invalid event, skip it + continue; + } + + // Event spans multiple days, split it + let currentStart = dayjs(event.startDate); + + while (currentStart.isBefore(event.endDate)) { + splitEvents.push({ + ...event, + startDate: currentStart.toDate(), + endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(), + }); + + currentStart = currentStart.add(1, "day").startOf("day"); + } + } + return splitEvents; +};