sendou.ink/app/features/calendar/routes/calendar.tsx
Kalle 8f156fb917
Team editors in addition to the owner (#2077)
* Initial

* Handle owner leaving

* Remove old team queries

* Progress

* Retire old toggle

* e2e tests

* Divide loaders/actions of team pages
2025-02-04 10:56:33 +02:00

746 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import clsx from "clsx";
import { addMonths, subMonths } from "date-fns";
import React from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { z } from "zod";
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 { UsersIcon } from "~/components/icons/Users";
import { getUserId } from "~/features/auth/core/user.server";
import { currentSeason } from "~/features/mmr/season";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { i18next } from "~/modules/i18n/i18next.server";
import { joinListToNaturalString } from "~/utils/arrays";
import {
databaseTimestampToDate,
dateToThisWeeksMonday,
dateToThisWeeksSunday,
dateToWeekNumber,
dayToWeekStartsAtMondayDay,
getWeekStartsAtMondayDay,
weekNumberToDate,
} from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import {
CALENDAR_PAGE,
calendarReportWinnersPage,
navIconUrl,
resolveBaseUrl,
tournamentOrganizationPage,
tournamentPage,
userSubmittedImage,
} from "~/utils/urls";
import { actualNumber, safeSplit } from "~/utils/zod";
import { Label } from "../../../components/Label";
import type {
CalendarEventTag,
PersistedCalendarEventTag,
} from "../../../db/types";
import * as CalendarRepository from "../CalendarRepository.server";
import { calendarEventTagSchema } from "../actions/calendar.new.server";
import { CALENDAR_EVENT } from "../calendar-constants";
import { closeByWeeks } from "../calendar-utils";
import { Tags } from "../components/Tags";
import "~/styles/calendar.css";
import { SendouSwitch } from "~/components/elements/Switch";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [
{ title: data.title },
{
name: "description",
content: `${data.events.length} events happening during week ${
data.displayedWeek
} including ${joinListToNaturalString(
data.events.slice(0, 3).map((e) => e.name),
)}`,
},
];
};
export const handle: SendouRouteHandle = {
i18n: "calendar",
breadcrumb: () => ({
imgPath: navIconUrl("calendar"),
href: CALENDAR_PAGE,
type: "IMAGE",
}),
};
const loaderWeekSearchParamsSchema = z.object({
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
year: z.preprocess(actualNumber, z.number().int()),
});
const loaderFilterSearchParamsSchema = z.object({
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
});
const loaderTournamentsOnlySearchParamsSchema = z.object({
tournaments: z.literal("true").nullish(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const t = await i18next.getFixedT(request);
const url = new URL(request.url);
// separate from tags parse so they can fail independently
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
year: url.searchParams.get("year"),
week: url.searchParams.get("week"),
});
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
tags: url.searchParams.get("tags"),
});
const parsedTournamentsOnlyParams =
loaderTournamentsOnlySearchParamsSchema.safeParse({
tournaments: url.searchParams.get("tournaments"),
});
const mondayDate = dateToThisWeeksMonday(new Date());
const sundayDate = dateToThisWeeksSunday(new Date());
const currentWeek = dateToWeekNumber(mondayDate);
const displayedWeek = parsedWeekParams.success
? parsedWeekParams.data.week
: currentWeek;
const displayedYear = parsedWeekParams.success
? parsedWeekParams.data.year
: currentWeek === 1 // handle first week of the year special case
? sundayDate.getFullYear()
: mondayDate.getFullYear();
const tagsToFilterBy = parsedFilterParams.success
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
: [];
const onlyTournaments = parsedTournamentsOnlyParams.success
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
: false;
return {
currentWeek,
displayedWeek,
currentDay: new Date().getDay(),
nearbyStartTimes: await CalendarRepository.startTimesOfRange({
startTime: subMonths(
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
1,
),
endTime: addMonths(
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
1,
),
tagsToFilterBy,
onlyTournaments,
}),
weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }),
events: await fetchEventsOfWeek({
week: displayedWeek,
year: displayedYear,
tagsToFilterBy,
onlyTournaments,
}),
eventsToReport: user
? await CalendarRepository.eventsToReport(user.id)
: [],
title: makeTitle([`Week ${displayedWeek}`, t("pages.calendar")]),
};
};
function fetchEventsOfWeek(args: {
week: number;
year: number;
tagsToFilterBy: PersistedCalendarEventTag[];
onlyTournaments: boolean;
}) {
const startTime = weekNumberToDate(args);
const endTime = new Date(startTime);
endTime.setDate(endTime.getDate() + 7);
// handle timezone mismatch between server and client
startTime.setHours(startTime.getHours() - 12);
endTime.setHours(endTime.getHours() + 12);
return CalendarRepository.findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy: args.tagsToFilterBy,
onlyTournaments: args.onlyTournaments,
});
}
export default function CalendarPage() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
// we don't know which events are starting in user's time zone on server
// so that's why this calculation is not in the loader
const thisWeeksEvents = isMounted
? data.events.filter(
(event) =>
dateToWeekNumber(
dateToSixHoursAgo(databaseTimestampToDate(event.startTime)),
) === data.displayedWeek,
)
: data.events;
return (
<Main classNameOverwrite="stack lg main layout__main">
<WeekLinks />
<EventsToReport />
<div>
<div className="stack horizontal justify-between">
<TagsFilter />
<OnSendouInkToggle />
</div>
{isMounted ? (
<>
{thisWeeksEvents.length > 0 ? (
<>
<EventsList events={thisWeeksEvents} />
<div className="calendar__time-zone-info">
{t("inYourTimeZone")}{" "}
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</div>
</>
) : (
<h2 className="calendar__no-events">{t("noEvents")}</h2>
)}
</>
) : (
<div className="calendar__placeholder" />
)}
</div>
</Main>
);
}
function WeekLinks() {
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [searchParams] = useSearchParams();
const eventCounts = isMounted
? getEventsCountPerWeek(data.nearbyStartTimes)
: null;
const linkTo = (args: { week: number; year: number }) => {
const params = new URLSearchParams(searchParams);
params.set("week", String(args.week));
params.set("year", String(args.year));
return `?${params.toString()}`;
};
return (
<Flipper flipKey={data.weeks.map(({ number }) => number).join("")}>
<div className="flex justify-center">
<div className="calendar__weeks">
{data.weeks.map((week, i) => {
const hidden = [
0,
1,
data.weeks.length - 2,
data.weeks.length - 1,
].includes(i);
const isCurrentWeek = i === 4;
return (
<Flipped key={week.number} flipId={week.number}>
<Link
to={linkTo({ week: week.number, year: week.year })}
className={clsx("calendar__week", { invisible: hidden })}
aria-hidden={hidden}
tabIndex={hidden || isCurrentWeek ? -1 : 0}
onClick={(e) => isCurrentWeek && e.preventDefault()}
>
<>
<WeekLinkTitle week={week} />
<div
className={clsx("calendar__event-count", {
invisible: !eventCounts,
})}
>
×{eventCounts?.get(week.number) ?? 0}
</div>
</>
</Link>
</Flipped>
);
})}
</div>
</div>
</Flipper>
);
}
function WeekLinkTitle({
week,
}: {
week: Unpacked<SerializeFrom<typeof loader>["weeks"]>;
}) {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
const { i18n } = useTranslation();
const isSameYear = week.year === new Date().getFullYear();
const relativeWeekIdentifier =
week.number === data.currentWeek && isSameYear
? t("week.this")
: week.number - data.currentWeek === 1 && isSameYear
? t("week.next")
: week.number - data.currentWeek === -1 && isSameYear
? t("week.last")
: null;
if (relativeWeekIdentifier) {
return (
<div className="calendar__week__relative">
<div>{relativeWeekIdentifier}</div>
</div>
);
}
return (
<div>
<div>
{weekNumberToDate({
week: week.number,
year: week.year,
}).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
})}
</div>
<div className="calendar__week__dash">-</div>
<div>
{weekNumberToDate({
week: week.number,
year: week.year,
position: "end",
}).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
})}
</div>
</div>
);
}
function getEventsCountPerWeek(
startTimes: SerializeFrom<typeof loader>["nearbyStartTimes"],
) {
const result = new Map<number, number>();
for (const startTime of startTimes) {
const week = dateToWeekNumber(databaseTimestampToDate(startTime));
const previousCount = result.get(week) ?? 0;
result.set(week, previousCount + 1);
}
return result;
}
function EventsToReport() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
if (data.eventsToReport.length === 0) return null;
return (
<Alert textClassName="calendar__events-to-report">
{t("reportResults")}{" "}
{data.eventsToReport.map((event, i) => (
<React.Fragment key={event.id}>
<Link to={calendarReportWinnersPage(event.id)}>{event.name}</Link>
{i === data.eventsToReport.length - 1 ? "" : ", "}
</React.Fragment>
))}
</Alert>
);
}
function TagsFilter() {
const { t } = useTranslation(["calendar", "common"]);
const id = React.useId();
const [searchParams, setSearchParams] = useSearchParams();
const tagsToFilterBy = (searchParams
.get("tags")
?.split(",")
.filter((tag) => CALENDAR_EVENT.TAGS.includes(tag as CalendarEventTag)) ??
[]) as CalendarEventTag[];
const setTagsToFilterBy = (tags: CalendarEventTag[]) => {
setSearchParams((params) => {
if (tags.length === 0) {
params.delete("tags");
return params;
}
params.set("tags", tags.join(","));
return params;
});
};
const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter(
(tag) => !tagsToFilterBy.includes(tag),
);
return (
<div className="stack sm">
<div>
<label htmlFor={id}>{t("calendar:tag.filter.label")}</label>
<select
id={id}
className="w-max"
onChange={(e) =>
setTagsToFilterBy([
...tagsToFilterBy,
e.target.value as CalendarEventTag,
])
}
>
<option value=""></option>
{tagsForSelect.map((tag) => (
<option key={tag} value={tag}>
{t(`common:tag.name.${tag}`)}
</option>
))}
</select>
</div>
<Tags
tags={tagsToFilterBy}
onDelete={(tagToDelete) =>
setTagsToFilterBy(tagsToFilterBy.filter((tag) => tag !== tagToDelete))
}
/>
</div>
);
}
function OnSendouInkToggle() {
const { t } = useTranslation(["calendar"]);
const [searchParams, setSearchParams] = useSearchParams();
const onlyTournaments = searchParams.get("tournaments") === "true";
const setOnlyTournaments = (value: boolean) => {
setSearchParams((params) => {
if (value) {
params.set("tournaments", "true");
} else {
params.delete("tournaments");
}
return params;
});
};
return (
<div className="stack horizontal justify-end">
<div className="stack items-end">
<Label htmlFor="onlyTournaments">
{t("calendar:tournament.filter.label")}
</Label>
<SendouSwitch
id="onlyTournaments"
isSelected={onlyTournaments}
onChange={setOnlyTournaments}
/>
</div>
</div>
);
}
function EventsList({
events,
}: {
events: SerializeFrom<typeof loader>["events"];
}) {
const data = useLoaderData<typeof loader>();
const { t, i18n } = useTranslation("calendar");
const sortPastEventsLast = data.currentWeek === data.displayedWeek;
const eventsGrouped = eventsGroupedByDay(events);
if (sortPastEventsLast) {
eventsGrouped.sort(
pastEventsLast(dayToWeekStartsAtMondayDay(data.currentDay)),
);
}
let dividerRendered = false;
return (
<div className="calendar__events-container">
{eventsGrouped.map(([daysDate, events]) => {
const renderDivider =
sortPastEventsLast &&
!dividerRendered &&
getWeekStartsAtMondayDay(daysDate) <
dayToWeekStartsAtMondayDay(data.currentDay);
if (renderDivider) {
dividerRendered = true;
}
const sectionWeekday = daysDate.toLocaleString(i18n.language, {
weekday: "short",
});
return (
<React.Fragment key={daysDate.getTime()}>
<div className="calendar__event__date-container">
{renderDivider ? (
<Divider className="calendar__event__divider">
{t("pastEvents.dividerText")}
</Divider>
) : null}
<div className="calendar__event__date">
{daysDate.toLocaleDateString(i18n.language, {
weekday: "long",
day: "numeric",
month: "long",
})}
</div>
</div>
<div className="stack md">
{events.map((calendarEvent) => {
const eventWeekday = databaseTimestampToDate(
calendarEvent.startTime,
).toLocaleString(i18n.language, {
weekday: "short",
});
const isOneVsOne =
calendarEvent.tournamentSettings?.minMembersPerTeam === 1;
const startTimeDate = databaseTimestampToDate(
calendarEvent.startTime,
);
const tournamentRankedStatus = () => {
if (!calendarEvent.tournamentSettings) return undefined;
if (!currentSeason(startTimeDate)) return undefined;
return calendarEvent.tournamentSettings.isRanked &&
(!calendarEvent.tournamentSettings.minMembersPerTeam ||
calendarEvent.tournamentSettings.minMembersPerTeam === 4)
? "RANKED"
: "UNRANKED";
};
return (
<section
key={calendarEvent.eventDateId}
className="calendar__event stack md"
>
<div className="stack sm">
<div className="calendar__event__top-info-container">
<time
dateTime={databaseTimestampToDate(
calendarEvent.startTime,
).toISOString()}
className="calendar__event__time"
>
{startTimeDate.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "numeric",
})}
</time>
{calendarEvent.organization ? (
<Link
to={tournamentOrganizationPage({
organizationSlug: calendarEvent.organization.slug,
})}
className="stack horizontal xs items-center text-xs text-main-forced"
>
<Avatar
url={
calendarEvent.organization.avatarUrl
? userSubmittedImage(
calendarEvent.organization.avatarUrl,
)
: undefined
}
size="xxs"
/>
{calendarEvent.organization.name}
</Link>
) : (
<div className="calendar__event__author">
{t("from", {
author: calendarEvent.username,
})}
</div>
)}
{sectionWeekday !== eventWeekday ? (
<div className="text-xxs font-bold text-theme-secondary ml-auto">
{eventWeekday}
</div>
) : null}
</div>
<div className="stack xs">
<div className="stack horizontal sm-plus items-center">
{calendarEvent.tournamentId ? (
<img
src={
calendarEvent.logoUrl
? userSubmittedImage(calendarEvent.logoUrl)
: HACKY_resolvePicture({
name: calendarEvent.name,
})
}
alt=""
width={40}
height={40}
className="calendar__event-logo"
/>
) : null}
<div>
<Link
to={
calendarEvent.tournamentId
? tournamentPage(calendarEvent.tournamentId)
: String(calendarEvent.eventId)
}
>
<h2 className="calendar__event__title">
{calendarEvent.name}{" "}
{calendarEvent.nthAppearance > 1 ? (
<span className="calendar__event__day">
{t("day", {
number: calendarEvent.nthAppearance,
})}
</span>
) : null}
</h2>
</Link>
{calendarEvent.participantCounts &&
calendarEvent.participantCounts.teams > 0 ? (
<div className="calendar__event__participant-counts">
<UsersIcon />{" "}
{!isOneVsOne ? (
<>
{t("count.teams", {
count:
calendarEvent.participantCounts.teams,
})}{" "}
/{" "}
</>
) : null}
{t("count.players", {
count:
calendarEvent.participantCounts.players,
})}
</div>
) : null}
</div>
</div>
<Tags
tags={calendarEvent.tags}
badges={calendarEvent.badgePrizes}
tournamentRankedStatus={tournamentRankedStatus()}
/>
</div>
</div>
<div className="calendar__event__bottom-info-container">
{calendarEvent.discordUrl ? (
<LinkButton
to={calendarEvent.discordUrl}
variant="outlined"
size="tiny"
isExternal
>
Discord
</LinkButton>
) : null}
{!calendarEvent.tournamentId ? (
<LinkButton
to={calendarEvent.bracketUrl}
variant="outlined"
size="tiny"
isExternal
>
{resolveBaseUrl(calendarEvent.bracketUrl)}
</LinkButton>
) : null}
</div>
</section>
);
})}
</div>
</React.Fragment>
);
})}
</div>
);
}
// Goal with this is to make events that start during the night for EU (NA events)
// grouped up with the previous day. Otherwise you have a past event showing at the
// top of the page for the whole following day for EU.
function dateToSixHoursAgo(date: Date) {
const sixHoursAgo = new Date(date);
sixHoursAgo.setHours(sixHoursAgo.getHours() - 6);
return sixHoursAgo;
}
type EventsGrouped = [Date, SerializeFrom<typeof loader>["events"]];
function eventsGroupedByDay(events: SerializeFrom<typeof loader>["events"]) {
const result: EventsGrouped[] = [];
for (const calendarEvent of events) {
const previousIterationEvents = result[result.length - 1] ?? null;
const eventsDate = dateToSixHoursAgo(
databaseTimestampToDate(calendarEvent.startTime),
);
if (
!previousIterationEvents ||
previousIterationEvents[0].getDay() !== eventsDate.getDay()
) {
result.push([eventsDate, [calendarEvent]]);
} else {
previousIterationEvents[1].push(calendarEvent);
}
}
return result;
}
function pastEventsLast(currentDay: number) {
return (a: EventsGrouped, b: EventsGrouped) => {
const aDay = getWeekStartsAtMondayDay(a[0]);
const bDay = getWeekStartsAtMondayDay(b[0]);
if (aDay < currentDay && bDay >= currentDay) {
return 1;
}
if (aDay >= currentDay && bDay < currentDay) {
return -1;
}
return 0;
};
}