feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
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;
};
+40 -1
View File
@@ -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,
+123 -122
View File
@@ -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;
};
+1
View File
@@ -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({