sendou.ink/app/routes/calendar/index.tsx
Kalle ef78d3a2c2
Tournament full (#1373)
* Got something going

* Style overwrites

* width != height

* More playing with lines

* Migrations

* Start bracket initial

* Unhardcode stage generation params

* Link to match page

* Matches page initial

* Support directly adding seed to map list generator

* Add docs

* Maps in matches page

* Add invariant about tie breaker map pool

* Fix PICNIC lacking tie breaker maps

* Only link in bracket when tournament has started

* Styled tournament roster inputs

* Prefer IGN in tournament match page

* ModeProgressIndicator

* Some conditional rendering

* Match action initial + better error display

* Persist bestOf in DB

* Resolve best of ahead of time

* Move brackets-manager to core

* Score reporting works

* Clear winner on score report

* ModeProgressIndicator: highlight winners

* Fix inconsistent input

* Better text when submitting match

* mapCountPlayedInSetWithCertainty that works

* UNDO_REPORT_SCORE implemented

* Permission check when starting tournament

* Remove IGN from upsert

* View match results page

* Source in DB

* Match page waiting for teams

* Move tournament bracket to feature folder

* REOPEN_MATCH initial

* Handle proper resetting of match

* Inline bracket-manager

* Syncify

* Transactions

* Handle match is locked gracefully

* Match page auto refresh

* Fix match refresh called "globally"

* Bracket autoupdate

* Move fillWithNullTillPowerOfTwo to utils with testing

* Fix map lists not visible after tournament started

* Optimize match events

* Show UI while in progress to members

* Fix start tournament alert not being responsive

* Teams can check in

* Fix map list 400

* xxx -> TODO

* Seeds page

* Remove map icons for team page

* Don't display link to seeds after tournament has started

* Admin actions initial

* Change captain admin action

* Make all hooks ts

* Admin actions functioning

* Fix validate error not displaying in CatchBoundary

* Adjust validate args order

* Remove admin loader

* Make delete team button menancing

* Only include checked in teams to bracket

* Optimize to.id route loads

* Working show map list generator toggle

* Update full tournaments flow

* Make full tournaments work with many start times

* Handle undefined in crud

* Dynamic stage banner

* Handle default strat if map list generation fails

* Fix crash on brackets if less than 2 teams

* Add commented out test for reference

* Add TODO

* Add players from team during register

* TrustRelationship

* Prefers not to host feature

* Last before merge

* Rename some vars

* More renames
2023-05-15 22:37:43 +03:00

457 lines
14 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 {
LoaderArgs,
V2_MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { json, type LinksFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { addDays, addMonths, subDays, subMonths } from "date-fns";
import React from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import { z } from "zod";
import { Alert } from "~/components/Alert";
import { LinkButton } from "~/components/Button";
import { Main } from "~/components/Main";
import { db } from "~/db";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { getUserId } from "~/modules/auth/user.server";
import { i18next } from "~/modules/i18n";
import styles from "~/styles/calendar.css";
import { joinListToNaturalString } from "~/utils/arrays";
import {
databaseTimestampToDate,
dateToThisWeeksMonday,
dateToWeekNumber,
weekNumberToDate,
} from "~/utils/dates";
import { type SendouRouteHandle } from "~/utils/remix";
import { discordFullName, makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import {
calendarReportWinnersPage,
CALENDAR_PAGE,
navIconUrl,
resolveBaseUrl,
tournamentPage,
} from "~/utils/urls";
import { actualNumber } from "~/utils/zod";
import { Tags } from "./components/Tags";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: V2_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 loaderSearchParamsSchema = z.object({
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
year: z.preprocess(actualNumber, z.number().int()),
});
export const loader = async ({ request }: LoaderArgs) => {
const user = await getUserId(request);
const t = await i18next.getFixedT(request);
const url = new URL(request.url);
const parsedParams = loaderSearchParamsSchema.safeParse({
year: url.searchParams.get("year"),
week: url.searchParams.get("week"),
});
const mondayDate = dateToThisWeeksMonday(new Date());
const currentWeek = dateToWeekNumber(mondayDate);
const displayedWeek = parsedParams.success
? parsedParams.data.week
: currentWeek;
const displayedYear = parsedParams.success
? parsedParams.data.year
: mondayDate.getFullYear();
return json({
currentWeek,
displayedWeek,
nearbyStartTimes: db.calendarEvents.startTimesOfRange({
startTime: subMonths(
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
1
),
endTime: addMonths(
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
1
),
}),
weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }),
events: fetchEventsOfWeek({ week: displayedWeek, year: displayedYear }),
eventsToReport: db.calendarEvents.eventsToReport(user?.id),
title: makeTitle([`Week ${displayedWeek}`, t("pages.calendar")]),
});
};
function closeByWeeks(args: { week: number; year: number }) {
const dateFromWeekNumber = weekNumberToDate(args);
return [-4, -3, -2, -1, 0, 1, 2, 3, 4].map((week) => {
const date =
week < 0
? subDays(dateFromWeekNumber, Math.abs(week) * 7)
: addDays(dateFromWeekNumber, week * 7);
return {
number: dateToWeekNumber(date),
year: date.getFullYear(),
};
});
}
function fetchEventsOfWeek(args: { week: number; year: number }) {
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 db.calendarEvents.findAllBetweenTwoTimestamps({ startTime, endTime });
}
export default function CalendarPage() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
const user = useUser();
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(databaseTimestampToDate(event.startTime)) ===
data.displayedWeek
)
: data.events;
return (
<Main classNameOverwrite="stack lg layout__main">
<WeekLinks />
<EventsToReport />
<div className="stack md">
{user && (
<LinkButton to="new" className="calendar__add-new-button" size="tiny">
{t("addNew")}
</LinkButton>
)}
{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 eventCounts = isMounted
? getEventsCountPerWeek(data.nearbyStartTimes)
: null;
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={`?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 relativeWeekIdentifier =
week.number === data.currentWeek
? t("week.this")
: week.number - data.currentWeek === 1
? t("week.next")
: week.number - data.currentWeek === -1
? 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 EventsList({
events,
}: {
events: SerializeFrom<typeof loader>["events"];
}) {
const { t, i18n } = useTranslation("calendar");
return (
<div className="calendar__events-container">
{eventsGroupedByDay(events).map(([daysDate, events]) => {
return (
<React.Fragment key={daysDate.getTime()}>
<div className="calendar__event__date-container">
<div className="calendar__event__date main">
{daysDate.toLocaleDateString(i18n.language, {
weekday: "long",
day: "numeric",
month: "long",
})}
</div>
</div>
<div className="stack md">
{events.map((calendarEvent) => {
return (
<section
key={calendarEvent.eventDateId}
className="calendar__event main stack md"
>
<div className="stack sm">
<div className="calendar__event__top-info-container">
<time
dateTime={databaseTimestampToDate(
calendarEvent.startTime
).toISOString()}
className="calendar__event__time"
>
{databaseTimestampToDate(
calendarEvent.startTime
).toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "numeric",
})}
</time>
<div className="calendar__event__author">
{t("from", {
author: discordFullName(calendarEvent),
})}
</div>
</div>
<div className="stack xs">
<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>
<Tags
tags={calendarEvent.tags}
badges={calendarEvent.badgePrizes}
/>
</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>
);
}
function eventsGroupedByDay(events: SerializeFrom<typeof loader>["events"]) {
const result: Array<[Date, SerializeFrom<typeof loader>["events"]]> = [];
for (const calendarEvent of events) {
const previousIterationEvents = result[result.length - 1] ?? null;
const eventsDate = databaseTimestampToDate(calendarEvent.startTime);
if (
!previousIterationEvents ||
previousIterationEvents[0].getDay() !== eventsDate.getDay()
) {
result.push([eventsDate, [calendarEvent]]);
} else {
previousIterationEvents[1].push(calendarEvent);
}
}
return result;
}