Browse Source

Feature: Implement iCal integration for calendar, improve styling (#2376)

* Feature: Implement iCal integration, improve calendar/agenda styling

* Delete calendar.jsx

* Calendar proxy handler

* code style

* Add some basic error handling

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Denis Papec 1 year ago
parent
commit
95d6670

+ 13 - 1
docs/widgets/services/calendar.md

@@ -15,13 +15,20 @@ widget:
   firstDayInWeek: sunday # optional - defaults to monday
   firstDayInWeek: sunday # optional - defaults to monday
   view: monthly # optional - possible values monthly, agenda
   view: monthly # optional - possible values monthly, agenda
   maxEvents: 10 # optional - defaults to 10
   maxEvents: 10 # optional - defaults to 10
+  showTime: true # optional - show time for event happening today - defaults to false
   integrations: # optional
   integrations: # optional
-    - type: sonarr # active widget type that is currently enabled on homepage - possible values: radarr, sonarr, lidarr, readarr
+    - type: sonarr # active widget type that is currently enabled on homepage - possible values: radarr, sonarr, lidarr, readarr, ical
       service_group: Media # group name where widget exists
       service_group: Media # group name where widget exists
       service_name: Sonarr # service name for that widget
       service_name: Sonarr # service name for that widget
       color: teal # optional - defaults to pre-defined color for the service (teal for sonarr)
       color: teal # optional - defaults to pre-defined color for the service (teal for sonarr)
       params: # optional - additional params for the service
       params: # optional - additional params for the service
         unmonitored: true # optional - defaults to false, used with *arr stack
         unmonitored: true # optional - defaults to false, used with *arr stack
+    - type: ical # Show calendar events from another service
+      url: https://domain.url/with/link/to.ics # URL with calendar events
+      name: My Events # required - name for these calendar events
+      color: zinc # optional - defaults to pre-defined color for the service (zinc for ical)
+      params: # optional - additional params for the service
+        showName: true # optional - show name before event title in event line - defaults to false
 ```
 ```
 
 
 ## Agenda
 ## Agenda
@@ -33,6 +40,7 @@ widget:
   type: calendar
   type: calendar
   view: agenda
   view: agenda
   maxEvents: 10 # optional - defaults to 10
   maxEvents: 10 # optional - defaults to 10
+  showTime: true # optional - show time for event happening today - defaults to false
   previousDays: 3 # optional - shows events since three days ago - defaults to 0
   previousDays: 3 # optional - shows events since three days ago - defaults to 0
   integrations: # same as in Monthly view example
   integrations: # same as in Monthly view example
 ```
 ```
@@ -42,3 +50,7 @@ widget:
 Currently integrated widgets are [sonarr](sonarr.md), [radarr](radarr.md), [lidarr](lidarr.md) and [readarr](readarr.md).
 Currently integrated widgets are [sonarr](sonarr.md), [radarr](radarr.md), [lidarr](lidarr.md) and [readarr](readarr.md).
 
 
 Supported colors can be found on [color palette](../../configs/settings.md#color-palette).
 Supported colors can be found on [color palette](../../configs/settings.md#color-palette).
+
+### iCal
+
+This custom integration allows you to show events from any calendar that supports iCal format, for example, Google Calendar (go to `Settings`, select specific calendar, go to `Integrate calendar`, copy URL from `Public Address in iCal format`).

+ 28 - 0
package-lock.json

@@ -10,6 +10,7 @@
       "dependencies": {
       "dependencies": {
         "@headlessui/react": "^1.7.2",
         "@headlessui/react": "^1.7.2",
         "@kubernetes/client-node": "^0.17.1",
         "@kubernetes/client-node": "^0.17.1",
+        "cal-parser": "^1.0.2",
         "classnames": "^2.3.2",
         "classnames": "^2.3.2",
         "compare-versions": "^5.0.1",
         "compare-versions": "^5.0.1",
         "dockerode": "^3.3.4",
         "dockerode": "^3.3.4",
@@ -1250,6 +1251,15 @@
         "node": ">=14.16"
         "node": ">=14.16"
       }
       }
     },
     },
+    "node_modules/cal-parser": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/cal-parser/-/cal-parser-1.0.2.tgz",
+      "integrity": "sha512-wlQwcF0fl4eLclyGdncF9rcNNq0ipRYZGagG6h3LVgRXvCWE1fdMUaCLXwfC9YWoz9jKKbjQAq7TpO2Y3yrvmA==",
+      "dependencies": {
+        "ical-date-parser": "^4.0.0",
+        "rrule": "^2.6.8"
+      }
+    },
     "node_modules/call-bind": {
     "node_modules/call-bind": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -3382,6 +3392,11 @@
       "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.2.0.tgz",
       "integrity": "sha512-pUx3AcgXCbur0jpFA7U67Z2RJflAcIi698Y8VL+phdOqUchahxriV3Cs+M6UkPNQSS/zPEzWLfdJ8EgjB7HVxg=="
       "integrity": "sha512-pUx3AcgXCbur0jpFA7U67Z2RJflAcIi698Y8VL+phdOqUchahxriV3Cs+M6UkPNQSS/zPEzWLfdJ8EgjB7HVxg=="
     },
     },
+    "node_modules/ical-date-parser": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/ical-date-parser/-/ical-date-parser-4.0.0.tgz",
+      "integrity": "sha512-XRCK/FU1akC2ZaJOdKIeZI6BLLgzWUuE0pegSrrkEva89GOan5mNkLVqCU4EMhCJ9nkG5TLWdMXrVX1fNAkFzw=="
+    },
     "node_modules/iconv-lite": {
     "node_modules/iconv-lite": {
       "version": "0.4.24",
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -5485,6 +5500,19 @@
         "url": "https://github.com/sponsors/isaacs"
         "url": "https://github.com/sponsors/isaacs"
       }
       }
     },
     },
+    "node_modules/rrule": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.7.2.tgz",
+      "integrity": "sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/rrule/node_modules/tslib": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+    },
     "node_modules/run-parallel": {
     "node_modules/run-parallel": {
       "version": "1.2.0",
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
   "dependencies": {
   "dependencies": {
     "@headlessui/react": "^1.7.2",
     "@headlessui/react": "^1.7.2",
     "@kubernetes/client-node": "^0.17.1",
     "@kubernetes/client-node": "^0.17.1",
+    "cal-parser": "^1.0.2",
     "classnames": "^2.3.2",
     "classnames": "^2.3.2",
     "compare-versions": "^5.0.1",
     "compare-versions": "^5.0.1",
     "dockerode": "^3.3.4",
     "dockerode": "^3.3.4",

+ 20 - 0
pnpm-lock.yaml

@@ -11,6 +11,9 @@ dependencies:
   '@kubernetes/client-node':
   '@kubernetes/client-node':
     specifier: ^0.17.1
     specifier: ^0.17.1
     version: 0.17.1
     version: 0.17.1
+  cal-parser:
+    specifier: ^1.0.2
+    version: 1.0.2
   classnames:
   classnames:
     specifier: ^2.3.2
     specifier: ^2.3.2
     version: 2.3.2
     version: 2.3.2
@@ -864,6 +867,13 @@ packages:
       responselike: 3.0.0
       responselike: 3.0.0
     dev: false
     dev: false
 
 
+  /cal-parser@1.0.2:
+    resolution: {integrity: sha512-wlQwcF0fl4eLclyGdncF9rcNNq0ipRYZGagG6h3LVgRXvCWE1fdMUaCLXwfC9YWoz9jKKbjQAq7TpO2Y3yrvmA==}
+    dependencies:
+      ical-date-parser: 4.0.0
+      rrule: 2.7.2
+    dev: false
+
   /call-bind@1.0.2:
   /call-bind@1.0.2:
     resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
     resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
     dependencies:
     dependencies:
@@ -2232,6 +2242,10 @@ packages:
       '@babel/runtime': 7.21.0
       '@babel/runtime': 7.21.0
     dev: false
     dev: false
 
 
+  /ical-date-parser@4.0.0:
+    resolution: {integrity: sha512-XRCK/FU1akC2ZaJOdKIeZI6BLLgzWUuE0pegSrrkEva89GOan5mNkLVqCU4EMhCJ9nkG5TLWdMXrVX1fNAkFzw==}
+    dev: false
+
   /iconv-lite@0.4.24:
   /iconv-lite@0.4.24:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -3538,6 +3552,12 @@ packages:
     dependencies:
     dependencies:
       glob: 7.2.3
       glob: 7.2.3
 
 
+  /rrule@2.7.2:
+    resolution: {integrity: sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==}
+    dependencies:
+      tslib: 2.5.0
+    dev: false
+
   /run-parallel@1.2.0:
   /run-parallel@1.2.0:
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
     dependencies:
     dependencies:

+ 2 - 1
public/locales/en/common.json

@@ -765,6 +765,7 @@
         "inCinemas": "In cinemas",
         "inCinemas": "In cinemas",
         "physicalRelease": "Physical release",
         "physicalRelease": "Physical release",
         "digitalRelease": "Digital release",
         "digitalRelease": "Digital release",
-        "noEventsToday": "No events for today!"
+        "noEventsToday": "No events for today!",
+        "noEventsFound": "No events found"
     }
     }
 }
 }

+ 1 - 4
src/pages/_app.jsx

@@ -12,7 +12,6 @@ import { ColorProvider } from "utils/contexts/color";
 import { ThemeProvider } from "utils/contexts/theme";
 import { ThemeProvider } from "utils/contexts/theme";
 import { SettingsProvider } from "utils/contexts/settings";
 import { SettingsProvider } from "utils/contexts/settings";
 import { TabProvider } from "utils/contexts/tab";
 import { TabProvider } from "utils/contexts/tab";
-import { EventProvider } from "utils/contexts/calendar";
 
 
 function MyApp({ Component, pageProps }) {
 function MyApp({ Component, pageProps }) {
   return (
   return (
@@ -32,9 +31,7 @@ function MyApp({ Component, pageProps }) {
         <ThemeProvider>
         <ThemeProvider>
           <SettingsProvider>
           <SettingsProvider>
             <TabProvider>
             <TabProvider>
-              <EventProvider>
-                <Component {...pageProps} />
-              </EventProvider>
+              <Component {...pageProps} />
             </TabProvider>
             </TabProvider>
           </SettingsProvider>
           </SettingsProvider>
         </ThemeProvider>
         </ThemeProvider>

+ 2 - 0
src/utils/config/service-helpers.js

@@ -351,6 +351,7 @@ export function cleanServiceGroups(groups) {
           firstDayInWeek,
           firstDayInWeek,
           integrations,
           integrations,
           maxEvents,
           maxEvents,
+          showTime,
           previousDays,
           previousDays,
           view,
           view,
 
 
@@ -519,6 +520,7 @@ export function cleanServiceGroups(groups) {
           if (view) cleanedService.widget.view = view;
           if (view) cleanedService.widget.view = view;
           if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
           if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
           if (previousDays) cleanedService.widget.previousDays = previousDays;
           if (previousDays) cleanedService.widget.previousDays = previousDays;
+          if (showTime) cleanedService.widget.showTime = showTime;
         }
         }
       }
       }
 
 

+ 0 - 15
src/utils/contexts/calendar.jsx

@@ -1,15 +0,0 @@
-import { createContext, useState, useMemo } from "react";
-
-export const EventContext = createContext();
-
-export function EventProvider({ initialEvent, children }) {
-  const [events, setEvents] = useState({});
-
-  if (initialEvent) {
-    setEvents(initialEvent);
-  }
-
-  const value = useMemo(() => ({ events, setEvents }), [events]);
-
-  return <EventContext.Provider value={value}>{children}</EventContext.Provider>;
-}

+ 8 - 43
src/widgets/calendar/agenda.jsx

@@ -1,45 +1,11 @@
-import { useContext, useState } from "react";
 import { DateTime } from "luxon";
 import { DateTime } from "luxon";
 import classNames from "classnames";
 import classNames from "classnames";
 import { useTranslation } from "next-i18next";
 import { useTranslation } from "next-i18next";
-import { IoMdCheckmarkCircleOutline } from "react-icons/io";
 
 
-import { EventContext } from "../../utils/contexts/calendar";
+import Event from "./event";
 
 
-export function Event({ event, colorVariants, showDate = false }) {
-  const [hover, setHover] = useState(false);
-  const { i18n } = useTranslation();
-
-  return (
-    <div
-      className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs text-left h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
-      onMouseEnter={() => setHover(!hover)}
-      onMouseLeave={() => setHover(!hover)}
-    >
-      <span className="ml-2 w-10">
-        <span>
-          {showDate &&
-            event.date.setLocale(i18n.language).startOf("day").toLocaleString({ month: "short", day: "numeric" })}
-        </span>
-      </span>
-      <span className="ml-2 h-2 w-2">
-        <span className={classNames("block w-2 h-2 rounded", colorVariants[event.color] ?? "gray")} />
-      </span>
-      <div className="ml-2 h-5 text-left relative truncate" style={{ width: "70%" }}>
-        <div className="absolute mt-0.5 text-xs">{hover && event.additional ? event.additional : event.title}</div>
-      </div>
-      {event.isCompleted && (
-        <span className="text-xs mr-1 ml-auto z-10">
-          <IoMdCheckmarkCircleOutline />
-        </span>
-      )}
-    </div>
-  );
-}
-
-export default function Agenda({ service, colorVariants, showDate }) {
+export default function Agenda({ service, colorVariants, events, showDate }) {
   const { widget } = service;
   const { widget } = service;
-  const { events } = useContext(EventContext);
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   if (!showDate) {
   if (!showDate) {
@@ -59,10 +25,8 @@ export default function Agenda({ service, colorVariants, showDate }) {
   if (!eventsArray.length) {
   if (!eventsArray.length) {
     return (
     return (
       <div className="text-center">
       <div className="text-center">
-        <div className="p-2 ">
-          <div
-            className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}
-          >
+        <div className="pl-2 pr-2">
+          <div className={classNames("flex flex-col", !eventsArray.length && !events.length && "animate-pulse")}>
             <Event
             <Event
               key="no-event"
               key="no-event"
               event={{
               event={{
@@ -82,16 +46,17 @@ export default function Agenda({ service, colorVariants, showDate }) {
   const eventsByDay = days.map((d) => eventsArray.filter((e) => e.date.startOf("day").ts === d));
   const eventsByDay = days.map((d) => eventsArray.filter((e) => e.date.startOf("day").ts === d));
 
 
   return (
   return (
-    <div className="p-2">
-      <div className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}>
+    <div className="pl-1 pr-1 pb-1">
+      <div className={classNames("flex flex-col", !eventsArray.length && !events.length && "animate-pulse")}>
         {eventsByDay.map((eventsDay, i) => (
         {eventsByDay.map((eventsDay, i) => (
           <div key={days[i]}>
           <div key={days[i]}>
             {eventsDay.map((event, j) => (
             {eventsDay.map((event, j) => (
               <Event
               <Event
-                key={`event${event.title}-${event.date}`}
+                key={`event-agenda-${event.title}-${event.date}-${event.additional}`}
                 event={event}
                 event={event}
                 colorVariants={colorVariants}
                 colorVariants={colorVariants}
                 showDate={j === 0}
                 showDate={j === 0}
+                showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts}
               />
               />
             ))}
             ))}
           </div>
           </div>

+ 9 - 3
src/widgets/calendar/component.jsx

@@ -40,6 +40,7 @@ export default function Component({ service }) {
   const { widget } = service;
   const { widget } = service;
   const { i18n } = useTranslation();
   const { i18n } = useTranslation();
   const [showDate, setShowDate] = useState(null);
   const [showDate, setShowDate] = useState(null);
+  const [events, setEvents] = useState({});
   const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
   const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
   const { settings } = useContext(SettingsContext);
   const { settings } = useContext(SettingsContext);
 
 
@@ -69,9 +70,9 @@ export default function Component({ service }) {
         ?.filter((integration) => integration?.type)
         ?.filter((integration) => integration?.type)
         .map((integration) => ({
         .map((integration) => ({
           service: dynamic(() => import(`./integrations/${integration.type}`)),
           service: dynamic(() => import(`./integrations/${integration.type}`)),
-          widget: integration,
+          widget: { ...widget, ...integration },
         })) ?? [],
         })) ?? [],
-    [widget.integrations],
+    [widget],
   );
   );
 
 
   return (
   return (
@@ -80,13 +81,14 @@ export default function Component({ service }) {
         <div className="sticky top-0">
         <div className="sticky top-0">
           {integrations.map((integration) => {
           {integrations.map((integration) => {
             const Integration = integration.service;
             const Integration = integration.service;
-            const key = integration.widget.type + integration.widget.service_name + integration.widget.service_group;
+            const key = `integration-${integration.widget.type}-${integration.widget.service_name}-${integration.widget.service_group}-${integration.widget.name}`;
 
 
             return (
             return (
               <Integration
               <Integration
                 key={key}
                 key={key}
                 config={integration.widget}
                 config={integration.widget}
                 params={params}
                 params={params}
+                setEvents={setEvents}
                 hideErrors={settings.hideErrors}
                 hideErrors={settings.hideErrors}
                 className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
                 className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
               />
               />
@@ -95,8 +97,10 @@ export default function Component({ service }) {
         </div>
         </div>
         {(!widget?.view || widget?.view === "monthly") && (
         {(!widget?.view || widget?.view === "monthly") && (
           <Monthly
           <Monthly
+            key={`monthly-${showDate?.toFormat("yyyy-MM-dd")}`}
             service={service}
             service={service}
             colorVariants={colorVariants}
             colorVariants={colorVariants}
+            events={events}
             showDate={showDate}
             showDate={showDate}
             setShowDate={setShowDate}
             setShowDate={setShowDate}
             className="flex"
             className="flex"
@@ -104,8 +108,10 @@ export default function Component({ service }) {
         )}
         )}
         {widget?.view === "agenda" && (
         {widget?.view === "agenda" && (
           <Agenda
           <Agenda
+            key={`agenda-${showDate?.toFormat("yyyy-MM-dd")}`}
             service={service}
             service={service}
             colorVariants={colorVariants}
             colorVariants={colorVariants}
+            events={events}
             showDate={showDate}
             showDate={showDate}
             setShowDate={setShowDate}
             setShowDate={setShowDate}
             className="flex"
             className="flex"

+ 41 - 0
src/widgets/calendar/event.jsx

@@ -0,0 +1,41 @@
+import { useState } from "react";
+import { useTranslation } from "next-i18next";
+import { DateTime } from "luxon";
+import classNames from "classnames";
+import { IoMdCheckmarkCircleOutline } from "react-icons/io";
+
+export default function Event({ event, colorVariants, showDate = false, showTime = false, showDateColumn = true }) {
+  const [hover, setHover] = useState(false);
+  const { i18n } = useTranslation();
+
+  return (
+    <div
+      className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
+      onMouseEnter={() => setHover(!hover)}
+      onMouseLeave={() => setHover(!hover)}
+      key={`event-${event.title}-${event.date}-${event.additional}`}
+    >
+      {showDateColumn && (
+        <span className="ml-2 w-10">
+          <span>
+            {(showDate || showTime) &&
+              event.date
+                .setLocale(i18n.language)
+                .toLocaleString(showTime ? DateTime.TIME_24_SIMPLE : { month: "short", day: "numeric" })}
+          </span>
+        </span>
+      )}
+      <span className="ml-2 h-2 w-2">
+        <span className={classNames("block w-2 h-2 rounded", colorVariants[event.color] ?? "gray")} />
+      </span>
+      <div className="ml-2 h-5 text-left relative truncate" style={{ width: "70%" }}>
+        <div className="absolute mt-0.5 text-xs">{hover && event.additional ? event.additional : event.title}</div>
+      </div>
+      {event.isCompleted && (
+        <span className="text-xs mr-1 ml-auto z-10">
+          <IoMdCheckmarkCircleOutline />
+        </span>
+      )}
+    </div>
+  );
+}

+ 58 - 0
src/widgets/calendar/integrations/ical.jsx

@@ -0,0 +1,58 @@
+import { DateTime } from "luxon";
+import { parseString } from "cal-parser";
+import { useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "../../../utils/proxy/use-widget-api";
+import Error from "../../../components/services/widget/error";
+
+export default function Integration({ config, params, setEvents, hideErrors }) {
+  const { t } = useTranslation();
+  const { data: icalData, error: icalError } = useWidgetAPI(config, config.name, {
+    refreshInterval: 300000, // 5 minutes
+  });
+
+  useEffect(() => {
+    let parsedIcal;
+
+    if (!icalError && icalData && !icalData.error) {
+      parsedIcal = parseString(icalData.data);
+      if (parsedIcal.events.length === 0) {
+        icalData.error = { message: `'${config.name}': ${t("calendar.noEventsFound")}` };
+      }
+    }
+
+    if (icalError || !parsedIcal) {
+      return;
+    }
+
+    const eventsToAdd = {};
+    const events = parsedIcal?.getEventsBetweenDates(
+      DateTime.fromISO(params.start).toJSDate(),
+      DateTime.fromISO(params.end).toJSDate(),
+    );
+
+    events?.forEach((event) => {
+      let title = `${event?.summary?.value}`;
+      if (config?.params?.showName) {
+        title = `${config.name}: ${title}`;
+      }
+
+      event.matchingDates.forEach((date) => {
+        eventsToAdd[event?.uid?.value] = {
+          title,
+          date: DateTime.fromJSDate(date),
+          color: config?.color ?? "zinc",
+          isCompleted: DateTime.fromJSDate(date) < DateTime.now(),
+          additional: event.location?.value,
+          type: "ical",
+        };
+      });
+    });
+
+    setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
+  }, [icalData, icalError, config, params, setEvents, t]);
+
+  const error = icalError ?? icalData?.error;
+  return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
+}

+ 2 - 4
src/widgets/calendar/integrations/lidarr.jsx

@@ -1,12 +1,10 @@
 import { DateTime } from "luxon";
 import { DateTime } from "luxon";
-import { useContext, useEffect } from "react";
+import { useEffect } from "react";
 
 
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
-import { EventContext } from "../../../utils/contexts/calendar";
 import Error from "../../../components/services/widget/error";
 import Error from "../../../components/services/widget/error";
 
 
-export default function Integration({ config, params, hideErrors = false }) {
-  const { setEvents } = useContext(EventContext);
+export default function Integration({ config, params, setEvents, hideErrors = false }) {
   const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
   const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
     ...params,
     ...params,
     includeArtist: "false",
     includeArtist: "false",

+ 2 - 4
src/widgets/calendar/integrations/radarr.jsx

@@ -1,14 +1,12 @@
 import { DateTime } from "luxon";
 import { DateTime } from "luxon";
-import { useEffect, useContext } from "react";
+import { useEffect } from "react";
 import { useTranslation } from "next-i18next";
 import { useTranslation } from "next-i18next";
 
 
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
-import { EventContext } from "../../../utils/contexts/calendar";
 import Error from "../../../components/services/widget/error";
 import Error from "../../../components/services/widget/error";
 
 
-export default function Integration({ config, params, hideErrors = false }) {
+export default function Integration({ config, params, setEvents, hideErrors = false }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { setEvents } = useContext(EventContext);
   const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
   const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
     ...params,
     ...params,
     ...(config?.params ?? {}),
     ...(config?.params ?? {}),

+ 2 - 4
src/widgets/calendar/integrations/readarr.jsx

@@ -1,12 +1,10 @@
 import { DateTime } from "luxon";
 import { DateTime } from "luxon";
-import { useEffect, useContext } from "react";
+import { useEffect } from "react";
 
 
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
-import { EventContext } from "../../../utils/contexts/calendar";
 import Error from "../../../components/services/widget/error";
 import Error from "../../../components/services/widget/error";
 
 
-export default function Integration({ config, params, hideErrors = false }) {
-  const { setEvents } = useContext(EventContext);
+export default function Integration({ config, params, setEvents, hideErrors = false }) {
   const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", {
   const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", {
     ...params,
     ...params,
     includeAuthor: "true",
     includeAuthor: "true",

+ 2 - 4
src/widgets/calendar/integrations/sonarr.jsx

@@ -1,12 +1,10 @@
 import { DateTime } from "luxon";
 import { DateTime } from "luxon";
-import { useEffect, useContext } from "react";
+import { useEffect } from "react";
 
 
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
 import useWidgetAPI from "../../../utils/proxy/use-widget-api";
-import { EventContext } from "../../../utils/contexts/calendar";
 import Error from "../../../components/services/widget/error";
 import Error from "../../../components/services/widget/error";
 
 
-export default function Integration({ config, params, hideErrors = false }) {
-  const { setEvents } = useContext(EventContext);
+export default function Integration({ config, params, setEvents, hideErrors = false }) {
   const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", {
   const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", {
     ...params,
     ...params,
     includeSeries: "true",
     includeSeries: "true",

+ 17 - 32
src/widgets/calendar/monthly.jsx

@@ -1,10 +1,9 @@
-import { useContext, useMemo } from "react";
+import { useMemo } from "react";
 import { DateTime, Info } from "luxon";
 import { DateTime, Info } from "luxon";
 import classNames from "classnames";
 import classNames from "classnames";
 import { useTranslation } from "next-i18next";
 import { useTranslation } from "next-i18next";
-import { IoMdCheckmarkCircleOutline } from "react-icons/io";
 
 
-import { EventContext } from "../../utils/contexts/calendar";
+import Event from "./event";
 
 
 const cellStyle = "relative w-10 flex items-center justify-center flex-col";
 const cellStyle = "relative w-10 flex items-center justify-center flex-col";
 const monthButton = "pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer";
 const monthButton = "pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer";
@@ -32,11 +31,11 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
 
 
     // selected same day style
     // selected same day style
     style +=
     style +=
-      displayDate.toFormat("MM-dd-yyyy") === showDate.toFormat("MM-dd-yyyy")
+      displayDate.startOf("day").ts === showDate.startOf("day").ts
         ? "text-black-500 bg-theme-100/20 dark:bg-white/10 rounded-md "
         ? "text-black-500 bg-theme-100/20 dark:bg-white/10 rounded-md "
         : "";
         : "";
 
 
-    if (displayDate.toFormat("MM-dd-yyyy") === currentDate.toFormat("MM-dd-yyyy")) {
+    if (displayDate.startOf("day").ts === currentDate.startOf("day").ts) {
       // today style
       // today style
       style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md ";
       style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md ";
     } else {
     } else {
@@ -61,7 +60,7 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
             .slice(0, 4)
             .slice(0, 4)
             .map((event) => (
             .map((event) => (
               <span
               <span
-                key={event.date.toLocaleString() + event.color + event.title}
+                key={`${event.date.ts}+${event.color}-${event.title}-${event.additional}`}
                 className={classNames("inline-flex h-1 w-1 m-0.5 rounded", colorVariants[event.color] ?? "gray")}
                 className={classNames("inline-flex h-1 w-1 m-0.5 rounded", colorVariants[event.color] ?? "gray")}
               />
               />
             ))}
             ))}
@@ -70,25 +69,6 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
   );
   );
 }
 }
 
 
-export function Event({ event }) {
-  return (
-    <div
-      key={event.title}
-      className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
-    >
-      <span className="absolute left-2 text-left text-xs mt-[2px] truncate text-ellipsis" style={{ width: "96%" }}>
-        {event.title}
-        {event.additional ? ` - ${event.additional}` : ""}
-      </span>
-      {event.isCompleted && (
-        <span className="text-right text-xs flex justify-end mr-1 mt-1 z-10 ">
-          <IoMdCheckmarkCircleOutline />
-        </span>
-      )}
-    </div>
-  );
-}
-
 const dayInWeekId = {
 const dayInWeekId = {
   monday: 1,
   monday: 1,
   tuesday: 2,
   tuesday: 2,
@@ -99,10 +79,9 @@ const dayInWeekId = {
   sunday: 7,
   sunday: 7,
 };
 };
 
 
-export default function Monthly({ service, colorVariants, showDate, setShowDate }) {
+export default function Monthly({ service, colorVariants, events, showDate, setShowDate }) {
   const { widget } = service;
   const { widget } = service;
   const { i18n } = useTranslation();
   const { i18n } = useTranslation();
-  const { events } = useContext(EventContext);
   const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
   const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
 
 
   const dayNames = Info.weekdays("short", { locale: i18n.language });
   const dayNames = Info.weekdays("short", { locale: i18n.language });
@@ -161,7 +140,7 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
         </span>
         </span>
       </div>
       </div>
 
 
-      <div className="p-2 w-full">
+      <div className="pl-1 pr-1 pb-1 w-full">
         <div className="flex justify-between flex-wrap">
         <div className="flex justify-between flex-wrap">
           {dayNames.map((name) => (
           {dayNames.map((name) => (
             <span key={name} className={classNames(cellStyle)} style={{ width: "14%" }}>
             <span key={name} className={classNames(cellStyle)} style={{ width: "14%" }}>
@@ -172,7 +151,7 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
 
 
         <div
         <div
           className={classNames(
           className={classNames(
-            "flex justify-between flex-wrap",
+            "flex justify-between flex-wrap pb-1",
             !eventsArray.length && widget?.integrations?.length && "animate-pulse",
             !eventsArray.length && widget?.integrations?.length && "animate-pulse",
           )}
           )}
         >
         >
@@ -191,12 +170,18 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
           )}
           )}
         </div>
         </div>
 
 
-        <div className="flex flex-col pt-1 pb-1">
+        <div className="flex flex-col">
           {eventsArray
           {eventsArray
-            ?.filter((event) => showDate.startOf("day").toUnixInteger() === event.date?.startOf("day").toUnixInteger())
+            ?.filter((event) => showDate.startOf("day").ts === event.date?.startOf("day").ts)
             .slice(0, widget?.maxEvents ?? 10)
             .slice(0, widget?.maxEvents ?? 10)
             .map((event) => (
             .map((event) => (
-              <Event key={`event${event.title}-${event.additional}`} event={event} />
+              <Event
+                key={`event-monthly-${event.title}-${event.date}-${event.additional}`}
+                event={event}
+                colorVariants={colorVariants}
+                showDateColumn={widget?.showTime ?? false}
+                showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts}
+              />
             ))}
             ))}
         </div>
         </div>
       </div>
       </div>

+ 33 - 0
src/widgets/calendar/proxy.js

@@ -0,0 +1,33 @@
+import getServiceWidget from "utils/config/service-helpers";
+import { httpProxy } from "utils/proxy/http";
+import createLogger from "utils/logger";
+
+const logger = createLogger("calendarProxyHandler");
+
+export default async function calendarProxyHandler(req, res) {
+  const { group, service, endpoint } = req.query;
+
+  if (group && service) {
+    const widget = await getServiceWidget(group, service);
+    const integration = widget.integrations?.find((i) => i.name === endpoint);
+
+    if (integration) {
+      if (!integration.url) {
+        return res.status(403).json({ error: "No integration URL specified" });
+      }
+
+      const [status, contentType, data] = await httpProxy(integration.url);
+
+      if (contentType) res.setHeader("Content-Type", contentType);
+
+      if (status !== 200) {
+        logger.debug(`HTTTP ${status} retrieving data from integration URL ${integration.url} : ${data}`);
+        return res.status(status).send(data);
+      }
+
+      return res.status(status).json({ data: data.toString() });
+    }
+  }
+
+  return res.status(400).json({ error: "Invalid integration" });
+}

+ 8 - 0
src/widgets/calendar/widget.js

@@ -0,0 +1,8 @@
+import calendarProxyHandler from "./proxy";
+
+const widget = {
+  api: "{url}",
+  proxyHandler: calendarProxyHandler,
+};
+
+export default widget;

+ 2 - 0
src/widgets/widgets.js

@@ -6,6 +6,7 @@ import autobrr from "./autobrr/widget";
 import azuredevops from "./azuredevops/widget";
 import azuredevops from "./azuredevops/widget";
 import bazarr from "./bazarr/widget";
 import bazarr from "./bazarr/widget";
 import caddy from "./caddy/widget";
 import caddy from "./caddy/widget";
+import calendar from "./calendar/widget";
 import calibreweb from "./calibreweb/widget";
 import calibreweb from "./calibreweb/widget";
 import changedetectionio from "./changedetectionio/widget";
 import changedetectionio from "./changedetectionio/widget";
 import channelsdvrserver from "./channelsdvrserver/widget";
 import channelsdvrserver from "./channelsdvrserver/widget";
@@ -131,6 +132,7 @@ const widgets = {
   homeassistant,
   homeassistant,
   homebridge,
   homebridge,
   healthchecks,
   healthchecks,
+  ical: calendar,
   immich,
   immich,
   jackett,
   jackett,
   jdownloader,
   jdownloader,