fix(calendar): support multi-day events (#4151)

This commit is contained in:
Meier Lukas
2025-09-25 21:55:20 +02:00
committed by GitHub
parent f0a0b9bca1
commit c8a6cdae81
4 changed files with 137 additions and 9 deletions

View File

@@ -1707,6 +1707,9 @@
"calendar": { "calendar": {
"name": "Calendar", "name": "Calendar",
"description": "Display events from your integrations in a calendar view within a certain relative time period", "description": "Display events from your integrations in a calendar view within a certain relative time period",
"duration": {
"allDay": "All day"
},
"option": { "option": {
"releaseType": { "releaseType": {
"label": "Radarr release type", "label": "Radarr release type",

View File

@@ -84,16 +84,24 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
<Group gap={3} wrap="nowrap" align={"center"}> <Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} /> <IconClock opacity={0.7} size={"1rem"} />
<Text c={"dimmed"} size={"sm"}> {isAllDay(event) ? (
{dayjs(event.startDate).format("HH:mm")} <Text c={"dimmed"} size={"sm"}>
</Text> {t("widget.calendar.duration.allDay")}
</Text>
{event.endDate !== null && ( ) : (
<> <>
-{" "}
<Text c={"dimmed"} size={"sm"}> <Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")} {dayjs(event.startDate).format("HH:mm")}
</Text> </Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</> </>
)} )}
</Group> </Group>
@@ -152,3 +160,12 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
</ScrollArea> </ScrollArea>
); );
}; };
const isAllDay = (event: Pick<CalendarEvent, "startDate" | "endDate">) => {
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);
};

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core"; import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates"; import { Calendar } from "@mantine/dates";
@@ -10,6 +10,7 @@ import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -69,6 +70,8 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const { ref, width, height } = useElementSize(); const { ref, width, height } = useElementSize();
const isSmall = width < 256; const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
return ( return (
<Calendar <Calendar
defaultDate={new Date()} defaultDate={new Date()}
@@ -122,7 +125,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
}, },
}} }}
renderDay={(tileDate) => { renderDay={(tileDate) => {
const eventsForDate = events const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day")) .filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter( .filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType), (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;
};