fix(calendar): support multi-day events (#4151)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
66
packages/widgets/src/calendar/calendar.spec.ts
Normal file
66
packages/widgets/src/calendar/calendar.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user