mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 15:56:19 -05:00
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:
parent
4044795bd4
commit
f0728d9253
81
app/features/calendar/core/ical.ts
Normal file
81
app/features/calendar/core/ical.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
43
app/features/calendar/loaders/calendar[.]ics.server.ts
Normal file
43
app/features/calendar/loaders/calendar[.]ics.server.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
};
|
||||
2
app/features/calendar/routes/calendar.ics.tsx
Normal file
2
app/features/calendar/routes/calendar.ics.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { loader } from "../loaders/calendar[.]ics.server";
|
||||
export { loader };
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
78
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user