Added Ical feed to calendar (#2258)

* working routing, loader function gathers all events over the next 3 weeks

* working ical file generation, needs more checks like 75 octet line limit

* line length check

* removed adding empty lines and stopped returning an ical file with no events

* fixed spelling mistake and added basic ui

* finished basic ui, added some tests and ran npm run checks

* switched to using ics library

* added error logging
This commit is contained in:
kytoaa 2025-05-24 10:41:46 +01:00 committed by GitHub
parent 4044795bd4
commit f0728d9253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 267 additions and 2 deletions

View File

@ -0,0 +1,81 @@
import * as ics from "ics";
import type { PersistedCalendarEventTag } from "~/db/tables";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { CALENDAR_PAGE, SENDOU_INK_BASE_URL } from "~/utils/urls";
import {
type FindAllBetweenTwoTimestampsItem,
findAllBetweenTwoTimestamps,
} from "../CalendarRepository.server";
export async function getICalendar(
{
tagsFilter,
tournamentsFilter,
}: {
tagsFilter: Array<PersistedCalendarEventTag>;
tournamentsFilter: boolean;
} = { tagsFilter: [], tournamentsFilter: false },
): Promise<string | null> {
const startTime = new Date();
const endTime = new Date(startTime);
// get all events over the next month, might be good to make this an parameter in the future
endTime.setDate(startTime.getDate() + 30);
// handle timezone mismatch between server and client
startTime.setHours(startTime.getHours() - 12);
endTime.setHours(endTime.getHours() + 12);
const events = await findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy: tagsFilter,
onlyTournaments: tournamentsFilter,
});
// ical doesnt allow calendars with no events
if (events.length === 0) {
logger.warn("Could not construct ical feed, no events within time period");
return null;
}
const { error, value } = eventsAsICal(events);
if (error) {
logger.error(`Error constructing ical feed: ${error}`);
return null;
}
return value as string;
}
export function eventsAsICal(
events: Array<FindAllBetweenTwoTimestampsItem>,
): ics.ReturnObject {
return ics.createEvents(events.map(eventInfoAsICalEvent));
}
export function eventInfoAsICalEvent(
event: FindAllBetweenTwoTimestampsItem,
): ics.EventAttributes {
const startDate = databaseTimestampToDate(event.startTime);
const eventLink = `${SENDOU_INK_BASE_URL}${CALENDAR_PAGE}/${event.eventId}`;
const tags = event.tags;
return {
title: event.name,
start: [
startDate.getUTCFullYear(),
startDate.getUTCMonth() + 1,
startDate.getUTCDate(),
startDate.getUTCHours(),
startDate.getUTCMinutes(),
],
startInputType: "utc",
duration: { hours: 3 }, // arbitrary length
url: eventLink,
categories: tags,
productId: "sendou.ink/calendar",
};
}

View File

@ -0,0 +1,43 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { PersistedCalendarEventTag } from "~/db/tables";
import {
loaderFilterSearchParamsSchema,
loaderTournamentsOnlySearchParamsSchema,
} from "../calendar-schemas";
import * as ical from "../core/ical";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
// allows limiting calendar events to specific tags
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
tags: url.searchParams.get("tags"),
});
const parsedTournamentsOnlyParams =
loaderTournamentsOnlySearchParamsSchema.safeParse({
tournaments: url.searchParams.get("tournaments"),
});
const tagsToFilterBy = parsedFilterParams.success
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
: [];
const onlyTournaments = parsedTournamentsOnlyParams.success
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
: false;
const iCalData = await ical.getICalendar({
tagsFilter: tagsToFilterBy,
tournamentsFilter: onlyTournaments,
});
if (iCalData === null) {
return new Response(null, { status: 204 });
}
return new Response(iCalData, {
status: 200,
headers: {
"Content-Type": "text/calendar",
},
});
};

View File

@ -0,0 +1,2 @@
import { loader } from "../loaders/calendar[.]ics.server";
export { loader };

View File

@ -1,15 +1,24 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import {
Link,
createSearchParams,
useLoaderData,
useSearchParams,
} from "@remix-run/react";
import clsx from "clsx";
import React from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { LinkButton } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { Main } from "~/components/Main";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClipboardIcon } from "~/components/icons/Clipboard";
import { UsersIcon } from "~/components/icons/Users";
import type { CalendarEventTag } from "~/db/tables";
import * as Seasons from "~/features/mmr/core/Seasons";
@ -102,7 +111,10 @@ export default function CalendarPage() {
<EventsToReport />
<div>
<div className="stack horizontal justify-between">
<TagsFilter />
<div className="stack sm">
<TagsFilter />
<ICalLink />
</div>
<OnSendouInkToggle />
</div>
{isMounted ? (
@ -331,6 +343,53 @@ function TagsFilter() {
);
}
function ICalLink() {
const [searchParams, _] = useSearchParams();
const [state, copyToClipboard] = useCopyToClipboard();
const [copySuccess, setCopySuccess] = React.useState(false);
React.useEffect(() => {
if (!state.value) return;
setCopySuccess(true);
const timeout = setTimeout(() => setCopySuccess(false), 2000);
return () => clearTimeout(timeout);
}, [state]);
const filteredTags = (
searchParams
.get("tags")
?.split(",")
.filter((tag) => CALENDAR_EVENT.TAGS.includes(tag as CalendarEventTag)) ??
[]
).join();
const onlyTournaments = searchParams.get("tournaments") === "true";
const params = createSearchParams();
if (filteredTags.length > 0) params.append("tags", filteredTags);
if (onlyTournaments) params.append("tournaments", "true");
const icalURL = `https://sendou.ink/calendar.ics${params.size > 0 ? `?${params.toString()}` : ""}`;
return (
<div>
<label htmlFor="icalAddress">iCalendar</label>
<div className="stack horizontal sm items-center">
<input type="text" readOnly value={icalURL} id="icalAddress" />
<SendouButton
variant={copySuccess ? "outlined-success" : "outlined"}
onPress={() => copyToClipboard(icalURL)}
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
aria-label="Copy to clipboard"
/>
</div>
</div>
);
}
function OnSendouInkToggle() {
const { t } = useTranslation(["calendar"]);
const [searchParams, setSearchParams] = useSearchParams();

View File

@ -59,6 +59,7 @@ export default [
),
route("map-pool-events", "features/calendar/routes/map-pool-events.ts"),
]),
route("/calendar.ics", "features/calendar/routes/calendar.ics.tsx"),
route("/maps", "features/map-list-generator/routes/maps.tsx"),

78
package-lock.json generated
View File

@ -32,6 +32,7 @@
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^2.6.2",
"ics": "^3.8.1",
"isbot": "^5.1.27",
"kysely": "^0.28.2",
"lru-cache": "^11.1.0",
@ -10790,6 +10791,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ics": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/ics/-/ics-3.8.1.tgz",
"integrity": "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==",
"license": "ISC",
"dependencies": {
"nanoid": "^3.1.23",
"runes2": "^1.1.2",
"yup": "^1.2.0"
}
},
"node_modules/ics/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/icss-utils": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
@ -13919,6 +13949,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
@ -15140,6 +15176,12 @@
"@babel/runtime": "^7.1.2"
}
},
"node_modules/runes2": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
"license": "MIT"
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -16105,6 +16147,12 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -16270,6 +16318,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/totalist": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-2.0.0.tgz",
@ -17872,6 +17926,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/yup/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.24.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",

View File

@ -50,6 +50,7 @@
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^2.6.2",
"isbot": "^5.1.27",
"ics": "^3.8.1",
"kysely": "^0.28.2",
"lru-cache": "^11.1.0",
"markdown-to-jsx": "^7.7.6",