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