feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -18,15 +19,25 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { groupEventsByDate } from "./calendar-events";
|
||||
import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
events: CalendarEvent[];
|
||||
groupByDate?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
|
||||
const headingIdPrefix = useId();
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
offsetScrollbars
|
||||
@@ -38,129 +49,155 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{t("widget.calendar.duration.allDay")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
<Stack gap="md">
|
||||
{groups ? (
|
||||
groups.map(([date, groupedEvents]) => {
|
||||
const headingId = `${headingIdPrefix}-${date}`;
|
||||
return (
|
||||
<Box key={date} component="section" aria-labelledby={headingId}>
|
||||
<Text id={headingId} component="h3" size="sm" fw={600} tt="capitalize">
|
||||
{dateFormatter.format(groupedEvents[0]?.startDate)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
<Stack mt="xs">
|
||||
<CalendarEventRows events={groupedEvents} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CalendarEventRows events={events} />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{t("widget.calendar.duration.allDay")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const isAllDay = (event: Pick<CalendarEvent, "startDate" | "endDate">) => {
|
||||
if (!event.endDate) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
|
||||
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
|
||||
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
|
||||
|
||||
export const getCalendarAgendaEvents = (
|
||||
events: CalendarEvent[],
|
||||
viewedMonth: Date,
|
||||
releaseTypes: readonly string[],
|
||||
): CalendarEvent[] => {
|
||||
const monthStart = dayjs(viewedMonth).startOf("month");
|
||||
const monthEnd = monthStart.add(1, "month");
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (!matchesReleaseType(event, releaseTypes)) return false;
|
||||
|
||||
const start = dayjs(event.startDate);
|
||||
if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
|
||||
|
||||
// Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
|
||||
return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
|
||||
})
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
};
|
||||
|
||||
export const groupEventsByDate = (events: CalendarEvent[]): Map<string, CalendarEvent[]> => {
|
||||
const result = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = toCalendarDateKey(event.startDate);
|
||||
const entries = result.get(key) ?? [];
|
||||
entries.push(event);
|
||||
result.set(key, entries);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...result.entries()]
|
||||
.toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
|
||||
.map(([date, entries]) => [
|
||||
date,
|
||||
entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
/** Splits multi-day events into single-day calendar tiles. */
|
||||
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
|
||||
const splitEventEntries: CalendarEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (!event.endDate) {
|
||||
splitEventEntries.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
|
||||
splitEventEntries.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isAfter(event.endDate)) continue;
|
||||
|
||||
let currentStart = dayjs(event.startDate);
|
||||
while (currentStart.isBefore(event.endDate)) {
|
||||
splitEventEntries.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 splitEventEntries;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
import { splitEvents } from "./component";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
|
||||
|
||||
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", () => {
|
||||
@@ -53,6 +53,45 @@ describe("splitEvents should split multi-day events into multiple single-day eve
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupEventsByDate", () => {
|
||||
test("groups once per day and sorts each bucket", () => {
|
||||
const later = createEvent(new Date(2025, 0, 1, 18));
|
||||
const earlier = createEvent(new Date(2025, 0, 1, 9));
|
||||
const nextDay = createEvent(new Date(2025, 0, 2, 9));
|
||||
|
||||
const result = groupEventsByDate([later, nextDay, earlier]);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect([...result.values()][0]).toEqual([earlier, later]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCalendarAgendaEvents", () => {
|
||||
test("keeps overlapping original events once and excludes adjacent-month events", () => {
|
||||
const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
|
||||
const inMonth = createEvent(new Date(2025, 0, 20, 9));
|
||||
const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
|
||||
const nextMonth = createEvent(new Date(2025, 1, 1));
|
||||
|
||||
const result = getCalendarAgendaEvents(
|
||||
[nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
|
||||
new Date(2025, 0, 15),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual([spansIntoMonth, inMonth]);
|
||||
});
|
||||
|
||||
test("applies Radarr release filters without splitting multi-day events", () => {
|
||||
const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
|
||||
cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
|
||||
const digital = createEvent(new Date(2025, 0, 7));
|
||||
digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
|
||||
|
||||
expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
|
||||
});
|
||||
});
|
||||
|
||||
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
|
||||
title: "Test",
|
||||
subTitle: null,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import dayjs from "dayjs";
|
||||
@@ -14,10 +14,24 @@ import { useSettings } from "@homarr/settings";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const persistedState = props.widgetStateRef?.current?.calendar as { monthTimestamp?: number } | undefined;
|
||||
const [month, setMonth] = useState(() => {
|
||||
const timestamp = persistedState?.monthTimestamp;
|
||||
return typeof timestamp === "number" && Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.widgetStateRef) return;
|
||||
props.widgetStateRef.current = {
|
||||
...props.widgetStateRef.current,
|
||||
calendar: { monthTimestamp: month.getTime() },
|
||||
};
|
||||
}, [month, props.widgetStateRef]);
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
|
||||
@@ -31,7 +45,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -43,7 +57,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
|
||||
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarBaseProps {
|
||||
@@ -52,9 +75,10 @@ interface CalendarBaseProps {
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
@@ -65,124 +89,101 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
const isSmall = width < 256;
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
const visibleEvents = useMemo(
|
||||
() =>
|
||||
normalizedEvents.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
),
|
||||
[normalizedEvents, options.releaseType],
|
||||
);
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
|
||||
const agendaEvents = useMemo(
|
||||
() => getCalendarAgendaEvents(events, month, options.releaseType),
|
||||
[events, month, options.releaseType],
|
||||
);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(month) => setMonth(new Date(month))}
|
||||
onNextMonth={(month) => setMonth(new Date(month))}
|
||||
highlightToday
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h="100%"
|
||||
ref={ref}
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
pointerEvents: "none",
|
||||
fontSize: isSmall ? "0.75rem" : undefined,
|
||||
height: "100%",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "md",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
padding: 0,
|
||||
},
|
||||
weekdaysRow: {
|
||||
height: 22,
|
||||
},
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = normalizedEvents
|
||||
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
|
||||
.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
)
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
|
||||
highlightToday
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h={displayMode === "advanced" ? "62%" : "100%"}
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
pointerEvents: "none",
|
||||
fontSize: isSmall ? "0.75rem" : undefined,
|
||||
height: "100%",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "md",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
padding: 0,
|
||||
},
|
||||
weekdaysRow: {
|
||||
height: 22,
|
||||
},
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
|
||||
|
||||
return (
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{displayMode === "advanced" && (
|
||||
<Box px="md" pb="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
|
||||
</Text>
|
||||
<CalendarEventList events={agendaEvents} groupByDate locale={locale} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
queryKey: [["widget", "calendar"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
|
||||
Reference in new issue
Block a user