New calendar UI, more filters & persisted filters (#2318)

* Add types

* Delete stuff

* wip

* findAllBetweenTwoTimestamps refactor

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Fixes

* wip

* wip

* Fix InfoPopover button styling

* wip

* wip

* wip

* Merge branch 'rewrite' into new-calendar

* wip

* wip

* wip

* wip

* Rename myform -> sendouform

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* rename

* fix test
This commit is contained in:
Kalle
2025-05-24 17:13:30 +03:00
committed by GitHub
parent f0728d9253
commit cb8669acc3
119 changed files with 3399 additions and 2030 deletions

View File

@@ -1,17 +1,14 @@
import { badgeUrl } from "~/utils/urls";
import { Image } from "./Image";
export function Badge({
badge,
onClick,
isAnimated,
size,
}: {
export interface BadgeProps {
badge: { displayName: string; hue?: number | null; code: string };
onClick?: () => void;
isAnimated: boolean;
size: number;
}) {
}
export function Badge({ badge, onClick, isAnimated, size }: BadgeProps) {
const commonProps = {
title: badge.displayName,
onClick,

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClipboardIcon } from "~/components/icons/Clipboard";
interface CopyToClipboardPopoverProps {
url: string;
trigger: React.ReactNode;
}
export function CopyToClipboardPopover({
trigger,
url,
}: CopyToClipboardPopoverProps) {
const { t } = useTranslation(["common"]);
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]);
return (
<SendouPopover trigger={trigger}>
<div className="stack sm">
<input defaultValue={url} readOnly />
<SendouButton
size="miniscule"
variant="minimal"
onPress={() => copyToClipboard(url)}
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
>
{t("common:actions.copyToClipboard")}
</SendouButton>
</div>
</SendouPopover>
);
}

View File

@@ -1,7 +1,8 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists";
import type { ModeShortWithSpecial } from "~/modules/in-game-lists/types";
import {
TIER_PLUS_URL,
mainWeaponImageUrl,
@@ -104,7 +105,7 @@ export function WeaponImage({
}
type ModeImageProps = {
mode: ModeShort;
mode: ModeShortWithSpecial;
} & Omit<ImageProps, "path" | "alt">;
export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) {

View File

@@ -12,7 +12,7 @@ import {
type StageId,
modesShort,
} from "~/modules/in-game-lists";
import { modes, stageIds } from "~/modules/in-game-lists";
import { stageIds } from "~/modules/in-game-lists";
import { split, startsWith } from "~/utils/strings";
import { assertType } from "~/utils/types";
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
@@ -263,25 +263,24 @@ export function MapPoolStages({
{t(`game-misc:STAGE_${stageId}`)}
</div>
<div className={styles.modeButtonsContainer}>
{modes
{modesShort
.filter(
(mode) =>
!modesToInclude || modesToInclude.includes(mode.short),
(mode) => !modesToInclude || modesToInclude.includes(mode),
)
.map((mode) => {
const selected = mapPool.has({ stageId, mode: mode.short });
const selected = mapPool.has({ stageId, mode });
if (isPresentational && !selected) return null;
if (isPresentational && selected) {
return (
<Image
key={mode.short}
key={mode}
className={clsx(styles.mode, {
[styles.selected]: selected,
})}
title={t(`game-misc:MODE_LONG_${mode.short}`)}
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
path={modeImageUrl(mode.short)}
title={t(`game-misc:MODE_LONG_${mode}`)}
alt={t(`game-misc:MODE_LONG_${mode}`)}
path={modeImageUrl(mode)}
width={33}
height={33}
/>
@@ -290,24 +289,21 @@ export function MapPoolStages({
const preselected = preselectedMapPool?.has({
stageId,
mode: mode.short,
mode,
});
return (
<button
key={mode.short}
key={mode}
className={clsx(styles.modeButton, "outline-theme", {
[styles.selected]: selected,
[styles.preselected]: preselected,
invisible:
hideBanned &&
BANNED_MAPS[mode.short].includes(stageId),
hideBanned && BANNED_MAPS[mode].includes(stageId),
})}
onClick={() =>
handleModeChange?.({ mode: mode.short, stageId })
}
onClick={() => handleModeChange?.({ mode, stageId })}
type="button"
title={t(`game-misc:MODE_LONG_${mode.short}`)}
title={t(`game-misc:MODE_LONG_${mode}`)}
aria-describedby={`${id}-stage-name-${stageId}`}
aria-pressed={selected}
disabled={preselected}
@@ -317,8 +313,8 @@ export function MapPoolStages({
[styles.selected]: selected,
[styles.preselected]: preselected,
})}
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
path={modeImageUrl(mode.short)}
alt={t(`game-misc:MODE_LONG_${mode}`)}
path={modeImageUrl(mode)}
width={20}
height={20}
/>
@@ -405,10 +401,10 @@ function MapPoolTemplateSelect({
{t(`common:maps.template.preset.${presetId}`)}
</option>
))}
{modes.map((mode) => (
<option key={mode.short} value={`preset:${mode.short}`}>
{modesShort.map((mode) => (
<option key={mode} value={`preset:${mode}`}>
{t("common:maps.template.preset.onlyMode", {
modeName: t(`game-misc:MODE_LONG_${mode.short}`),
modeName: t(`game-misc:MODE_LONG_${mode}`),
})}
</option>
))}

View File

@@ -17,7 +17,7 @@ type ButtonVariant =
| "minimal-success"
| "minimal-destructive";
interface SendouButtonProps extends ReactAriaButtonProps {
export interface SendouButtonProps extends ReactAriaButtonProps {
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
icon?: JSX.Element;

View File

@@ -0,0 +1,41 @@
import clsx from "clsx";
import {
Button,
Calendar,
CalendarCell,
CalendarGrid,
type CalendarProps,
type DateValue,
Heading,
} from "react-aria-components";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
export interface SendouCalendarProps<T extends DateValue>
extends CalendarProps<T> {
className?: string;
}
export function SendouCalendar<T extends DateValue>({
className,
...rest
}: SendouCalendarProps<T>) {
return (
<Calendar className={clsx(className, "react-aria-Calendar")} {...rest}>
<header>
<Button slot="previous">
<ArrowLeftIcon />
</Button>
<Heading />
<Button slot="next">
<ArrowRightIcon />
</Button>
</header>
<CalendarGrid>
{(date) => {
return <CalendarCell date={date} data-testid="choose-date-button" />;
}}
</CalendarGrid>
</Calendar>
);
}

View File

@@ -1,26 +1,21 @@
import clsx from "clsx";
import {
Button,
Calendar,
CalendarCell,
CalendarGrid,
DateInput,
type DatePickerProps,
DateSegment,
type DateValue,
Dialog,
Group,
Heading,
Popover,
DatePicker as ReactAriaDatePicker,
} from "react-aria-components";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouCalendar } from "~/components/elements/Calendar";
import {
type FormFieldSize,
formFieldSizeToClassName,
} from "../form/form-utils";
import { ArrowLeftIcon } from "../icons/ArrowLeft";
import { ArrowRightIcon } from "../icons/ArrowRight";
import { CalendarIcon } from "../icons/Calendar";
import { SendouLabel } from "./Label";
@@ -54,24 +49,7 @@ export function SendouDatePicker<T extends DateValue>({
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover>
<Dialog>
<Calendar>
<header>
<Button slot="previous">
<ArrowLeftIcon />
</Button>
<Heading />
<Button slot="next">
<ArrowRightIcon />
</Button>
</header>
<CalendarGrid>
{(date) => {
return (
<CalendarCell date={date} data-testid="choose-date-button" />
);
}}
</CalendarGrid>
</Calendar>
<SendouCalendar />
</Dialog>
</Popover>
</ReactAriaDatePicker>

View File

@@ -6,7 +6,7 @@
background-color: rgba(0, 0, 0, 0.25);
display: flex;
min-height: 100%;
align-items: center;
align-items: flex-start;
justify-content: center;
padding: 1rem;
text-align: center;
@@ -43,6 +43,7 @@
vertical-align: middle;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px
rgba(0, 0, 0, 0.05);
margin: auto;
}
.fullScreenModal {

View File

@@ -6,6 +6,19 @@ import {
type PopoverProps,
} from "react-aria-components";
/**
* A reusable popover component that wraps around a trigger element (SendouButton or Button from React Aria Components library).
* Supports controlled and uncontrolled open states.
*
* @example
* ```tsx
* <SendouPopover
* trigger={<SendouButton>Click me</SendouButton>}
* >
* Popover content goes here!
* </SendouPopover>
* ```
*/
export function SendouPopover({
children,
trigger,

View File

@@ -1,11 +1,10 @@
import { parseDate } from "@internationalized/date";
import {
Controller,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { dateToYYYYMMDD } from "../../utils/dates";
import { dayMonthYearToDateValue } from "../../utils/dates";
import type { DayMonthYear } from "../../utils/zod";
import { SendouDatePicker } from "../elements/DatePicker";
import type { FormFieldSize } from "./form-utils";
@@ -38,18 +37,7 @@ export function DateFormField<T extends FieldValues>({
if (!originalValue) return null;
const isoString = dateToYYYYMMDD(
new Date(
Date.UTC(
originalValue.year,
originalValue.month,
originalValue.day,
12,
),
),
);
return parseDate(isoString);
return dayMonthYearToDateValue(originalValue);
};
return (

View File

@@ -9,13 +9,14 @@ import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils";
export function TextFormField<T extends FieldValues>({
export function InputFormField<T extends FieldValues>({
label,
name,
bottomText,
placeholder,
required,
size = "small",
type,
}: {
label: string;
name: FieldPath<T>;
@@ -23,6 +24,7 @@ export function TextFormField<T extends FieldValues>({
placeholder?: string;
required?: boolean;
size?: FormFieldSize;
type?: React.HTMLInputTypeAttribute;
}) {
const methods = useFormContext();
const id = React.useId();
@@ -37,6 +39,7 @@ export function TextFormField<T extends FieldValues>({
<input
id={id}
placeholder={placeholder}
type={type}
{...methods.register(name)}
className={formFieldSizeToClassName(size)}
/>

View File

@@ -0,0 +1,118 @@
import * as React from "react";
import {
Controller,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
interface InputGroupFormFieldProps<T extends FieldValues> {
label: string;
name: FieldPath<T>;
bottomText?: string;
type: "checkbox" | "radio";
values: Array<{
label: string;
value: string;
}>;
}
export function InputGroupFormField<T extends FieldValues>({
label,
name,
bottomText,
values,
type,
}: InputGroupFormFieldProps<T>) {
const methods = useFormContext();
return (
<Controller
name={name}
control={methods.control}
render={({
field: { name, value, onChange, ref },
fieldState: { error },
}) => {
const handleCheckboxChange =
(name: string) => (newChecked: boolean) => {
const newValue = newChecked
? [...(value || []), name]
: value?.filter((v: string) => v !== name);
onChange(newValue);
};
const handleRadioChange = (name: string) => () => {
onChange(name);
};
return (
<div>
<fieldset className="stack sm" ref={ref}>
<legend>{label}</legend>
{values.map((checkbox) => {
const isChecked = value?.includes(checkbox.value);
return (
<GroupInput
key={checkbox.value}
type={type}
name={name}
checked={isChecked}
onChange={
type === "checkbox"
? handleCheckboxChange(checkbox.value)
: handleRadioChange(checkbox.value)
}
>
{checkbox.label}
</GroupInput>
);
})}
</fieldset>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}}
/>
);
}
function GroupInput({
children,
name,
checked,
onChange,
type,
}: {
children: React.ReactNode;
name: string;
checked: boolean;
onChange: (newChecked: boolean) => void;
type: "checkbox" | "radio";
}) {
const id = React.useId();
return (
<div className="stack horizontal sm items-center">
<input
type={type}
id={id}
name={name}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<label htmlFor={id} className="mb-0">
{children}
</label>
</div>
);
}

View File

@@ -9,7 +9,7 @@ import type { ActionError } from "~/utils/remix.server";
import { LinkButton } from "../Button";
import { SubmitButton } from "../SubmitButton";
export function MyForm<T extends z.ZodTypeAny>({
export function SendouForm<T extends z.ZodTypeAny>({
schema,
defaultValues,
heading,

View File

@@ -1,20 +1,25 @@
import { useFieldArray, useFormContext } from "react-hook-form";
import type { z } from "zod";
import {
type FieldPath,
type FieldValues,
useFieldArray,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { AddFieldButton } from "./AddFieldButton";
import { RemoveFieldButton } from "./RemoveFieldButton";
export function TextArrayFormField<T extends z.ZodTypeAny>({
export function TextArrayFormField<T extends FieldValues>({
label,
name,
defaultFieldValue,
bottomText,
/** If "plain", value in the text array is a plain string. If "object" then an object containing the text under "value" key */
format = "plain",
}: {
label: string;
name: keyof z.infer<T> & string;
defaultFieldValue: string;
name: FieldPath<T>;
bottomText?: string;
format?: "plain" | "object";
}) {
const {
register,
@@ -38,15 +43,19 @@ export function TextArrayFormField<T extends z.ZodTypeAny>({
return (
<div key={field.id}>
<div className="stack horizontal md">
<input {...register(`${name}.${index}.value`)} />
{fields.length > 1 ? (
<RemoveFieldButton
onClick={() => {
remove(index);
clearErrors(`${name}.root`);
}}
/>
) : null}
<input
{...register(
format === "plain"
? `${name}.${index}`
: `${name}.${index}.value`,
)}
/>
<RemoveFieldButton
onClick={() => {
remove(index);
clearErrors(`${name}.root`);
}}
/>
</div>
{error && (
<FormMessage type="error">
@@ -58,7 +67,7 @@ export function TextArrayFormField<T extends z.ZodTypeAny>({
})}
<AddFieldButton
// @ts-expect-error
onClick={() => append({ value: defaultFieldValue })}
onClick={() => append(format === "plain" ? "" : { value: "" })}
/>
{rootError && (
<FormMessage type="error">{rootError.message as string}</FormMessage>

View File

@@ -27,7 +27,11 @@ export function ToggleFormField<T extends FieldValues>({
control={methods.control}
name={name}
render={({ field: { value, onChange } }) => (
<SendouSwitch id={id} isSelected={value} onChange={onChange} />
<SendouSwitch
id={id}
isSelected={value ?? false}
onChange={onChange}
/>
)}
/>
{error && (

View File

@@ -0,0 +1,17 @@
export function FilterFilledIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Filter Icon</title>
<path
fillRule="evenodd"
d="M3.792 2.938A49.069 49.069 0 0 1 12 2.25c2.797 0 5.54.236 8.209.688a1.857 1.857 0 0 1 1.541 1.836v1.044a3 3 0 0 1-.879 2.121l-6.182 6.182a1.5 1.5 0 0 0-.439 1.061v2.927a3 3 0 0 1-1.658 2.684l-1.757.878A.75.75 0 0 1 9.75 21v-5.818a1.5 1.5 0 0 0-.44-1.06L3.13 7.938a3 3 0 0 1-.879-2.121V4.774c0-.897.64-1.683 1.542-1.836Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,20 @@
export function TrophyIcon({
className,
title,
}: { className?: string; title?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>{title ?? "Trophy Icon"}</title>
<path
fillRule="evenodd"
d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 0 0-.584.859 6.753 6.753 0 0 0 6.138 5.6 6.73 6.73 0 0 0 2.743 1.346A6.707 6.707 0 0 1 9.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 0 0-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 0 1-1.112-3.173 6.73 6.73 0 0 0 2.743-1.347 6.753 6.753 0 0 0 6.139-5.6.75.75 0 0 0-.585-.858 47.077 47.077 0 0 0-3.07-.543V2.62a.75.75 0 0 0-.658-.744 49.22 49.22 0 0 0-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 0 0-.657.744Zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 0 1 3.16 5.337a45.6 45.6 0 0 1 2.006-.343v.256Zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 0 1-2.863 3.207 6.72 6.72 0 0 0 .857-3.294Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -79,6 +79,7 @@ function NotificationContent({
const { t } = useTranslation(["common"]);
const { revalidate, state } = useRevalidator();
// TODO: for some reason this makes "adds a badge owner sending a notification" E2E test flaky, figure out why and fix
useMarkNotificationsAsSeen(unseenIds);
return (
@@ -131,6 +132,7 @@ function NotificationsFooter() {
size="tiny"
to={NOTIFICATIONS_URL}
className="mt-1-5"
testId="notifications-see-all-button"
>
{t("common:notifications.seeAll")}
</LinkButton>

View File

@@ -8,7 +8,7 @@ import type { SeedVariation } from "~/features/api-private/routes/seed";
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { persistedTags } from "~/features/calendar/calendar-constants";
import { tags } from "~/features/calendar/calendar-constants";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
@@ -718,7 +718,7 @@ function calendarEvents() {
const userIds = userIdsInRandomOrder();
for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) {
const shuffledTags = faker.helpers.shuffle(Object.keys(persistedTags));
const shuffledTags = faker.helpers.shuffle(Object.keys(tags));
sql
.prepare(

View File

@@ -8,10 +8,8 @@ import type {
Updateable,
} from "kysely";
import type { AssociationVisibility } from "~/features/associations/associations-types";
import type {
persistedTags,
tags,
} from "~/features/calendar/calendar-constants";
import type { tags } from "~/features/calendar/calendar-constants";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
@@ -130,7 +128,6 @@ export type CalendarEventAvatarMetadata = {
textColor: string;
};
export type PersistedCalendarEventTag = keyof typeof persistedTags;
export type CalendarEventTag = keyof typeof tags;
export interface CalendarEvent {
@@ -802,6 +799,7 @@ export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number];
export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
disallowScrimPickupsFromUntrusted?: boolean;
defaultCalendarFilters?: CalendarFilters;
}
export interface User {

View File

@@ -1,8 +1,12 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { databaseTimestampToDate, weekNumberToDate } from "~/utils/dates";
import { db } from "~/db/sql";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
weekNumberToDate,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import {
handleOptionsRequest,
@@ -44,10 +48,30 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
const endTime = new Date(startTime);
endTime.setDate(endTime.getDate() + 7);
return CalendarRepository.findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy: [],
onlyTournaments: false,
});
return db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
.select([
"Tournament.id as tournamentId",
"CalendarEvent.name",
"CalendarEventDate.startTime",
])
.where(
"CalendarEventDate.startTime",
">=",
dateToDatabaseTimestamp(startTime),
)
.where(
"CalendarEventDate.startTime",
"<=",
dateToDatabaseTimestamp(endTime),
)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startTime", "asc")
.execute();
}

View File

@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { SendouDialog } from "~/components/elements/Dialog";
import { MyForm } from "~/components/form/MyForm";
import { TextFormField } from "~/components/form/TextFormField";
import { InputFormField } from "~/components/form/InputFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage } from "~/utils/urls";
@@ -24,17 +24,17 @@ export default function AssociationsNewPage() {
heading={t("scrims:associations.forms.title")}
onCloseTo={associationsPage()}
>
<MyForm
<SendouForm
schema={createNewAssociationSchema}
defaultValues={{
name: "",
}}
>
<TextFormField<FormFields>
<InputFormField<FormFields>
label={t("scrims:associations.forms.name.title")}
name="name"
/>
</MyForm>
</SendouForm>
</SendouDialog>
);
}

View File

@@ -16,12 +16,16 @@ export interface BadgeDisplayProps {
badges: Array<Omit<Tables["Badge"], "authorId"> & { count?: number }>;
onChange?: (badgeIds: number[]) => void;
children?: React.ReactNode;
showText?: boolean;
className?: string;
}
export function BadgeDisplay({
badges: _badges,
onChange,
children,
showText = true,
className,
}: BadgeDisplayProps) {
const { t } = useTranslation("badges");
const [badges, setBadges] = React.useState(_badges);
@@ -58,13 +62,13 @@ export function BadgeDisplay({
return (
<div data-testid="badge-display">
{isPaginated ? (
{isPaginated && showText ? (
<div className={styles.badgeExplanation}>
{badgeExplanationText(t, bigBadge)}
</div>
) : null}
<div
className={clsx(styles.badges, {
className={clsx(className, styles.badges, {
"justify-center": smallBadges.length === 0,
})}
>

View File

@@ -1,4 +1,9 @@
import type { Expression, ExpressionBuilder, Transaction } from "kysely";
import type {
Expression,
ExpressionBuilder,
NotNull,
Transaction,
} from "kysely";
import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import * as R from "remeda";
@@ -6,15 +11,29 @@ import { db } from "~/db/sql";
import type {
CalendarEventTag,
DB,
PersistedCalendarEventTag,
Tables,
TournamentSettings,
} from "~/db/tables";
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampNow,
databaseTimestampToDate,
databaseTimestampToJavascriptTimestamp,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import invariant from "~/utils/invariant";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
import { calendarEventPage, tournamentPage } from "~/utils/urls";
import {
modesIncluded,
normalizedTeamCount,
tournamentIsRanked,
} from "../tournament/tournament-utils";
import type { CalendarEvent } from "./calendar-types";
import { calendarEventSorter } from "./calendar-utils";
// TODO: convert from raw to using the "exists" function
const hasBadge = sql<number> /* sql */`exists (
@@ -77,6 +96,248 @@ function tournamentOrganization(organizationId: Expression<number | null>) {
);
}
interface FindAllBetweenTwoTimestampsArgs {
startTime: Date;
endTime: Date;
}
export async function findAllBetweenTwoTimestamps(
args: FindAllBetweenTwoTimestampsArgs,
) {
const rows = await findAllBetweenTwoTimestampsQuery(args);
return findAllBetweenTwoTimestampsMapped(rows);
}
const withOrganization = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
jsonObjectFrom(
eb
.selectFrom("TournamentOrganization")
.select(["TournamentOrganization.name", "TournamentOrganization.slug"])
.whereRef(
"TournamentOrganization.id",
"=",
"CalendarEvent.organizationId",
),
);
const withTeamsCount = (
eb: ExpressionBuilder<DB, "CalendarEventDate" | "Tournament">,
) =>
eb
.selectFrom("TournamentTeam")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.on("TournamentTeamCheckIn.bracketIdx", "is", null)
.onRef(
"TournamentTeamCheckIn.tournamentTeamId",
"=",
"TournamentTeam.id",
),
)
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.where((eb) =>
eb.or([
eb("TournamentTeamCheckIn.checkedInAt", "is not", null),
eb("CalendarEventDate.startTime", ">", databaseTimestampNow()),
]),
)
.select(({ fn }) => [fn.countAll<number>().as("teamsCount")]);
const withLogoUrl = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id");
function findAllBetweenTwoTimestampsQuery({
startTime,
endTime,
}: FindAllBetweenTwoTimestampsArgs) {
return db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
.select((eb) => [
"CalendarEvent.id as eventId",
"CalendarEvent.authorId",
"Tournament.id as tournamentId",
"Tournament.settings as tournamentSettings",
"Tournament.mapPickingStyle",
"CalendarEvent.name",
"CalendarEvent.tags",
"CalendarEventDate.startTime",
// events get grouped to their closest :00 or :30 so for example users can't make their event start at :59 to make it show at the top
sql<number>`(("CalendarEventDate"."startTime" + 900) / 1800) * 1800`.as(
"normalizedStartTime",
),
withOrganization(eb).as("organization"),
withTeamsCount(eb).as("teamsCount"),
withLogoUrl(eb).as("logoUrl"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.select(["MapPoolMap.mode"])
.whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"),
).as("toSetMapPool"),
jsonArrayFrom(
eb
.selectFrom("CalendarEventBadge")
.innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id")
.select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"])
.whereRef(
"CalendarEventBadge.eventId",
"=",
"CalendarEventDate.eventId",
)
.orderBy("Badge.id", "asc"),
).as("badges"),
])
.where("CalendarEvent.hidden", "=", 0)
.where(
"CalendarEventDate.startTime",
">=",
dateToDatabaseTimestamp(startTime),
)
.where(
"CalendarEventDate.startTime",
"<=",
dateToDatabaseTimestamp(endTime),
)
.$narrowType<{ teamsCount: NotNull }>()
.execute();
}
function findAllBetweenTwoTimestampsMapped(
rows: Awaited<ReturnType<typeof findAllBetweenTwoTimestampsQuery>>,
): Array<{
at: number;
events: Array<CalendarEvent>;
}> {
const mapped: Array<CalendarEvent & { startTime: number }> = rows.map(
(row) => {
const tags = row.tags
? (row.tags.split(",") as CalendarEvent["tags"])
: [];
return {
at: databaseTimestampToJavascriptTimestamp(row.startTime),
type: "calendar",
id: row.eventId,
url: row.tournamentId
? tournamentPage(row.tournamentId)
: calendarEventPage(row.eventId),
name: row.name,
organization: row.organization,
authorId: row.authorId,
tags: tags.filter((tag) => !EXCLUDED_TAGS.includes(tag)),
teamsCount: row.teamsCount,
normalizedTeamCount: normalizedTeamCount({
teamsCount: row.teamsCount,
minMembersPerTeam: row.tournamentSettings?.minMembersPerTeam ?? 4,
}),
modes: tags.includes("CARDS")
? ["TB"]
: tags.includes("SR")
? ["SR"]
: row.mapPickingStyle
? modesIncluded(row.mapPickingStyle, row.toSetMapPool)
: null,
badges: row.badges,
logoUrl: row.logoUrl,
startTime: row.normalizedStartTime,
isRanked: row.tournamentSettings
? tournamentIsRanked({
isSetAsRanked: row.tournamentSettings.isRanked,
startTime: databaseTimestampToDate(row.startTime),
minMembersPerTeam: row.tournamentSettings.minMembersPerTeam ?? 4,
isTest: row.tournamentSettings.isTest ?? false,
})
: null,
};
},
);
const grouped = R.groupBy(mapped, (row) => row.startTime);
const dates = Object.keys(grouped)
.map((dbTimestamp) => ({
at: databaseTimestampToDate(Number(dbTimestamp)).getTime(),
events: grouped[Number(dbTimestamp)].sort(calendarEventSorter),
}))
.sort((a, b) => a.at - b.at);
return dates;
}
export type ForShowcase = Unwrapped<typeof forShowcase>;
export function forShowcase() {
return db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select((eb) => [
"Tournament.id",
"Tournament.settings",
"CalendarEvent.authorId",
"CalendarEvent.name",
"CalendarEventDate.startTime",
withTeamsCount(eb).as("teamsCount"),
withLogoUrl(eb).as("logoUrl"),
withOrganization(eb).as("organization"),
jsonArrayFrom(
eb
.selectFrom("TournamentResult")
.innerJoin("User", "TournamentResult.userId", "User.id")
.innerJoin(
"TournamentTeam",
"TournamentResult.tournamentTeamId",
"TournamentTeam.id",
)
.leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id")
.leftJoin(
"UserSubmittedImage as TeamAvatar",
"AllTeam.avatarImgId",
"TeamAvatar.id",
)
.leftJoin(
"UserSubmittedImage as TournamentTeamAvatar",
"TournamentTeam.avatarImgId",
"TournamentTeamAvatar.id",
)
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
.where("TournamentResult.placement", "=", 1)
.select([
...COMMON_USER_FIELDS,
"User.country",
"TournamentTeam.name as teamName",
"TeamAvatar.url as teamLogoUrl",
"TournamentTeamAvatar.url as pickupAvatarUrl",
]),
).as("firstPlacers"),
])
.where("CalendarEvent.hidden", "=", 0)
.where("CalendarEventDate.startTime", ">", databaseTimestampWeekAgo())
.orderBy("CalendarEventDate.startTime", "asc")
.$narrowType<{ teamsCount: NotNull }>()
.execute();
}
function databaseTimestampWeekAgo() {
const now = new Date();
now.setDate(now.getDate() - 7);
return dateToDatabaseTimestamp(now);
}
export async function findById({
id,
includeMapPool = false,
@@ -136,121 +397,6 @@ export async function findById({
};
}
const nthAppearance = sql<number> /* sql */`
rank() over (
partition by "eventId"
order by
"startTime" asc
)`.as("nthAppearance");
export type FindAllBetweenTwoTimestampsItem = Unwrapped<
typeof findAllBetweenTwoTimestamps
>;
export async function findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy = [],
onlyTournaments,
}: {
startTime: Date;
endTime: Date;
tagsToFilterBy?: Array<PersistedCalendarEventTag>;
onlyTournaments: boolean;
}) {
let query = db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.innerJoin("User", "CalendarEvent.authorId", "User.id")
.innerJoin(
(eb) =>
eb
.selectFrom("CalendarEventDate")
.select(["id", "eventId", "startTime", nthAppearance])
.as("CalendarEventRanks"),
(join) =>
join.onRef("CalendarEventRanks.id", "=", "CalendarEventDate.id"),
)
.select(({ eb, ref }) => [
"CalendarEvent.name",
"CalendarEvent.discordUrl",
"CalendarEvent.bracketUrl",
"CalendarEvent.tags",
"CalendarEvent.tournamentId",
"CalendarEventDate.id as eventDateId",
"CalendarEventDate.eventId",
"CalendarEventDate.startTime",
"User.username",
"CalendarEventRanks.nthAppearance",
tournamentOrganization(ref("CalendarEvent.organizationId")).as(
"organization",
),
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
eb
.selectFrom("Tournament")
.select("Tournament.settings")
.whereRef("Tournament.id", "=", "CalendarEvent.tournamentId")
.as("tournamentSettings"),
hasBadge,
jsonArrayFrom(
eb
.selectFrom("CalendarEventBadge")
.innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id")
.select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"])
.whereRef(
"CalendarEventBadge.eventId",
"=",
"CalendarEventDate.eventId",
),
).as("badgePrizes"),
])
.where(
"CalendarEventDate.startTime",
">=",
dateToDatabaseTimestamp(startTime),
)
.where(
"CalendarEventDate.startTime",
"<=",
dateToDatabaseTimestamp(endTime),
)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startTime", "asc");
for (const tag of tagsToFilterBy) {
query = query.where("CalendarEvent.tags", "like", `%${tag}%`);
}
if (onlyTournaments) {
query = query.where("CalendarEvent.tournamentId", "is not", null);
}
const rows = await query.execute();
return Promise.all(
rows
.map((row) => ({ ...row, tags: tagsArray(row) }))
.map(async (row) => {
return {
...row,
participantCounts: row.tournamentId
? await tournamentParticipantCount({
tournamentId: row.tournamentId,
checkedInOnly:
row.startTime < dateToDatabaseTimestamp(new Date()),
})
: undefined,
};
}),
);
}
export async function findRecentTournamentsByAuthorId(authorId: number) {
return db
.selectFrom("CalendarEvent")
@@ -280,105 +426,9 @@ function tagsArray(args: {
args.tags ? args.tags.split(",") : []
) as Array<CalendarEventTag>;
if (args.hasBadge) tags.unshift("BADGE");
return tags;
}
async function tournamentParticipantCount({
tournamentId,
checkedInOnly,
}: {
tournamentId: number;
checkedInOnly: boolean;
}) {
const rows = await db
.selectFrom("TournamentTeam")
.leftJoin(
"TournamentTeamMember",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.$if(checkedInOnly, (qb) =>
qb.innerJoin("TournamentTeamCheckIn", (join) =>
join
.onRef(
"TournamentTeamCheckIn.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.on("TournamentTeamCheckIn.bracketIdx", "is", null),
),
)
.select(({ fn }) => fn.countAll<number>().as("memberCount"))
.where("TournamentTeam.tournamentId", "=", tournamentId)
.groupBy("TournamentTeam.id")
.execute();
return {
teams: rows.length,
players: R.sum(rows.map((row) => row.memberCount)),
};
}
export async function startTimesOfRange({
startTime,
endTime,
tagsToFilterBy,
onlyTournaments,
}: {
startTime: Date;
endTime: Date;
tagsToFilterBy: Array<PersistedCalendarEventTag>;
onlyTournaments: boolean;
}) {
let query = db
.selectFrom("CalendarEventDate")
.innerJoin("CalendarEvent", "CalendarEvent.id", "CalendarEventDate.eventId")
.select(["startTime"])
.where("startTime", ">=", dateToDatabaseTimestamp(startTime))
.where("startTime", "<=", dateToDatabaseTimestamp(endTime));
for (const tag of tagsToFilterBy) {
query = query.where("CalendarEvent.tags", "like", `%${tag}%`);
}
if (onlyTournaments) {
query = query.where("CalendarEvent.tournamentId", "is not", null);
}
const rows = await query.execute();
return rows.map((row) => row.startTime);
}
export async function eventsToReport(authorId: number) {
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
const rows = await db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(({ fn }) => [
"CalendarEvent.id",
"CalendarEvent.name",
fn.max("CalendarEventDate.startTime").as("startTime"),
])
.where("CalendarEvent.authorId", "=", authorId)
.where("CalendarEvent.hidden", "=", 0)
.where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo))
.where("startTime", "<=", dateToDatabaseTimestamp(new Date()))
.where("CalendarEvent.participantCount", "is", null)
.where("CalendarEvent.tournamentId", "is", null)
.groupBy("CalendarEvent.id")
.execute();
return rows.map((row) => ({ id: row.id, name: row.name }));
}
export async function findRecentMapPoolsByAuthorId(authorId: number) {
const rows = await db
.selectFrom("CalendarEvent")

View File

@@ -5,20 +5,22 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseParams,
safeParseRequestFormData,
} from "~/utils/remix.server";
import { calendarEventPage } from "~/utils/urls";
import {
reportWinnersActionSchema,
reportWinnersParamsSchema,
} from "../calendar-schemas";
import { idObject } from "~/utils/zod";
import { reportWinnersActionSchema } from "../calendar-schemas";
import { canReportCalendarEventWinners } from "../calendar-utils";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const parsedParams = reportWinnersParamsSchema.parse(params);
export const action: ActionFunction = async (args) => {
const user = await requireUserId(args.request);
const params = parseParams({
params: args.params,
schema: idObject,
});
const parsedInput = await safeParseRequestFormData({
request,
request: args.request,
schema: reportWinnersActionSchema,
});
@@ -29,7 +31,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}
const event = notFoundIfFalsy(
await CalendarRepository.findById({ id: parsedParams.id }),
await CalendarRepository.findById({ id: params.id }),
);
errorToastIfFalsy(
canReportCalendarEventWinners({
@@ -41,7 +43,7 @@ export const action: ActionFunction = async ({ request, params }) => {
);
await CalendarRepository.upsertReportedScores({
eventId: parsedParams.id,
eventId: params.id,
participantCount: parsedInput.data.participantCount,
results: parsedInput.data.team.map((t) => ({
teamName: t.teamName,
@@ -53,5 +55,5 @@ export const action: ActionFunction = async ({ request, params }) => {
})),
});
throw redirect(calendarEventPage(parsedParams.id));
throw redirect(calendarEventPage(params.id));
};

View File

@@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node";
import type { CalendarEventTag } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas";
import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { notify } from "~/features/notifications/core/notify.server";

View File

@@ -0,0 +1,35 @@
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import {
parseRequestPayload,
parseSafeSearchParams,
} from "~/utils/remix.server";
import { calendarPage } from "~/utils/urls";
import { dayMonthYear } from "~/utils/zod";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({
request,
schema: calendarFiltersSearchParamsSchema,
});
await UserRepository.updatePreferences(user.id, {
defaultCalendarFilters: data,
});
const parsedSearchParams = parseSafeSearchParams({
request,
schema: dayMonthYear,
});
return redirect(
calendarPage({
dayMonthYear: parsedSearchParams.success
? parsedSearchParams.data
: undefined,
}),
);
};

View File

@@ -1,6 +1,6 @@
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/tables";
import type { CalendarEventTag } from "~/db/tables";
export const persistedTags = {
export const tags = {
SPECIAL: {
color: "#CE93D8",
},
@@ -41,7 +41,7 @@ export const persistedTags = {
color: "#1ADB1E",
},
TRIOS: {
color: "#571ADB",
color: "#B694FF",
},
S1: {
color: "#E5E4E2",
@@ -60,13 +60,6 @@ export const persistedTags = {
},
};
export const tags = {
...persistedTags,
BADGE: {
color: "#000",
},
};
export const CALENDAR_EVENT = {
NAME_MIN_LENGTH: 2,
NAME_MAX_LENGTH: 100,
@@ -76,10 +69,6 @@ export const CALENDAR_EVENT = {
BRACKET_URL_MAX_LENGTH: 200,
MAX_AMOUNT_OF_DATES: 5,
/** Calendar event tag that is persisted in the database */
PERSISTED_TAGS: Object.keys(
persistedTags,
) as Array<PersistedCalendarEventTag>,
/** Calendar event tag, both those persisted in the database and those that are computed */
TAGS: Object.keys(tags) as Array<CalendarEventTag>,
AVATAR_SIZE: 512,
};
@@ -103,3 +92,14 @@ export const REG_CLOSES_AT_OPTIONS = [
] as const;
export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number];
/** How many days are shown at the /calendar page at a time */
export const DAYS_SHOWN_AT_A_TIME = 4;
/** Tags not shown on the tournament cards */
export const EXCLUDED_TAGS: Array<CalendarEventTag> = [
"CARDS",
"SR",
"SZ",
"TW",
];

View File

@@ -0,0 +1,142 @@
import * as React from "react";
import { calendarEventSorter } from "~/features/calendar/calendar-utils";
import type { CalendarLoaderData } from "~/features/calendar/loaders/calendar.server";
interface CollapsedEvents {
eventsShown: CalendarLoaderData["eventTimes"][number]["events"]["shown"];
date: {
from: Date;
to?: Date;
};
hiddenShown: boolean;
hiddenCount: number;
onToggleHidden: () => void;
}
/**
* Custom hook to manage hiding/showing calendar events. Handles collapsing adjacent times given both of them have only hidden events.
* E.g. 10pm with all hidden and 11pm with all hidden should be shown in one section as 10pm - 11pm to the user.
*
* @param eventTimes - Array of event times as returned by the calendar data loader.
*/
export function useCollapsableEvents(
eventTimes: CalendarLoaderData["eventTimes"],
) {
const [collapsingDisabled, setCollapsingDisabled] = React.useState(false);
const [shownEventTimes, setShownEventTimes] = React.useState(
new Set<number>(),
);
const eventsResult: Array<CollapsedEvents> = [];
const hiddenTimes: Set<number> = new Set();
for (const [i, eventTime] of eventTimes.entries()) {
if (hiddenTimes.has(eventTime.at)) {
continue;
}
const { containedTimes, date } = !collapsingDisabled
? resolveCollapsedDateRange(eventTimes, i)
: {
containedTimes: new Set<number>().add(eventTime.at),
date: {
from: new Date(eventTime.at),
to: undefined,
},
};
for (const time of containedTimes) {
hiddenTimes.add(time);
}
const hiddenShown = shownEventTimes.has(eventTime.at);
eventsResult.push({
eventsShown: hiddenShown
? [...eventTime.events.shown, ...eventTime.events.hidden].sort(
calendarEventSorter,
)
: eventTime.events.shown,
date,
hiddenShown,
onToggleHidden: () => {
// if we clicked a range section, uncollapse it
if (containedTimes.size > 1) {
setCollapsingDisabled(true);
}
setShownEventTimes((prev) => {
const newSet = new Set(prev);
if (newSet.has(eventTime.at)) {
for (const time of containedTimes) {
newSet.delete(time);
}
} else {
for (const time of containedTimes) {
newSet.add(time);
}
}
return newSet;
});
},
hiddenCount: !date.to
? eventTime.events.hidden.length
: eventTimes.reduce((acc, cur) => {
if (cur.at < eventTime.at || cur.at > date.to.getTime()) {
return acc;
}
return acc + cur.events.hidden.length;
}, 0),
});
}
return eventsResult;
}
function resolveCollapsedDateRange(
eventTimes: CalendarLoaderData["eventTimes"],
forIndex: number,
) {
if (eventTimes[forIndex].events.shown.length > 0) {
return {
containedTimes: new Set<number>().add(eventTimes[forIndex].at),
date: {
from: new Date(eventTimes[forIndex].at),
to: undefined,
},
};
}
const containedTimes: Set<number> = new Set();
let to: Date | undefined;
for (let j = forIndex + 1; j < eventTimes.length; j++) {
const eventTime = eventTimes[j];
if (eventTime.events.shown.length > 0) {
break;
}
to = new Date(eventTime.at);
containedTimes.add(eventTime.at);
}
containedTimes.add(eventTimes[forIndex].at);
if (!to) {
return {
containedTimes,
date: {
from: new Date(eventTimes[forIndex].at),
to: undefined,
},
};
}
return {
containedTimes,
date: {
from: new Date(eventTimes[forIndex].at),
to,
},
};
}

View File

@@ -0,0 +1,123 @@
import { z } from "zod";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import "~/styles/calendar-new.css";
import {
bracketProgressionSchema,
calendarEventTagSchema,
} from "~/features/calendar/calendar-schemas";
import {
actualNumber,
checkboxValueToBoolean,
date,
falsyToNull,
id,
processMany,
removeDuplicates,
safeJSONParse,
toArray,
} from "~/utils/zod";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
organizationId: z.preprocess(actualNumber, id.nullish()),
name: z
.string()
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
description: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
),
rules: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
),
date: z.preprocess(
toArray,
z
.array(
z.preprocess(
date,
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
),
)
.min(1)
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
),
bracketUrl: z
.string()
.url()
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
.default("https://sendou.ink"),
discordInviteCode: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
),
tags: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(calendarEventTagSchema).nullable(),
),
badges: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(id).nullable(),
),
avatarImgId: id.nullish(),
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
enableNoScreenToggle: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
requireInGameNames: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(),
bracketProgression: bracketProgressionSchema.nullish(),
})
.refine(
async (schema) => {
if (schema.eventToEditId) {
const eventToEdit = await CalendarRepository.findById({
id: schema.eventToEditId,
});
return schema.date.length === 1 || !eventToEdit?.tournamentId;
}
return schema.date.length === 1 || !schema.toToolsEnabled;
},
{
message: "Tournament must have exactly one date",
},
)
.refine(
(schema) => {
if (schema.toToolsMode !== "ALL") {
return true;
}
const maps = schema.pool ? MapPool.toDbList(schema.pool) : [];
return (
maps.length === 4 &&
rankedModesShort.every((mode) => maps.some((map) => map.mode === mode))
);
},
{
message:
'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"',
},
);

View File

@@ -1,29 +1,95 @@
import { z } from "zod";
import { CALENDAR_EVENT_RESULT } from "~/constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import "~/styles/calendar-new.css";
import {
type PersistedCalendarEventTag,
TOURNAMENT_STAGE_TYPES,
} from "~/db/tables";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import "~/styles/calendar-new.css";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import {
actualNumber,
checkboxValueToBoolean,
date,
falsyToNull,
gamesShortSchema,
id,
processMany,
removeDuplicates,
modeShortWithSpecial,
safeJSONParse,
safeSplit,
toArray,
} from "~/utils/zod";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
import { CALENDAR_EVENT } from "./calendar-constants";
import * as CalendarEvent from "./core/CalendarEvent";
export const calendarEventTagSchema = z
.string()
.refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag));
const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10);
const calendarFiltersIdsArr = z.array(id).max(10);
const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3);
const preferredStartTime = z.enum(["ANY", "EU", "NA", "AU"]);
const preferredVersus = z
.array(z.enum(versusShort))
.min(1)
.max(versusShort.length);
const modeArr = z
.array(modeShortWithSpecial)
.min(1)
.max(modesShortWithSpecial.length);
export const calendarFiltersSearchParamsSchema = z.object({
preferredStartTime: preferredStartTime.catch("ANY"),
tagsIncluded: z.array(calendarEventTagSchema).catch([]),
tagsExcluded: z.array(calendarEventTagSchema).catch([]),
isSendou: z.boolean().catch(false),
isRanked: z.boolean().catch(false),
orgsIncluded: calendarFiltersPlainStringArr.catch([]),
orgsExcluded: calendarFiltersPlainStringArr.catch([]),
authorIdsExcluded: calendarFiltersIdsArr.catch([]),
games: calendarFilterGamesArr.catch([...gamesShort]),
preferredVersus: preferredVersus.catch([...versusShort]),
modes: modeArr.catch([...modesShortWithSpecial]),
modesExact: z.boolean().catch(false),
minTeamCount: z.coerce.number().int().nonnegative().catch(0),
});
export const calendarFiltersFormSchema = z
.object({
preferredStartTime: preferredStartTime,
tagsIncluded: z.array(calendarEventTagSchema),
tagsExcluded: z.array(calendarEventTagSchema),
isSendou: z.boolean(),
isRanked: z.boolean(),
orgsIncluded: calendarFiltersPlainStringArr,
orgsExcluded: calendarFiltersPlainStringArr,
authorIdsExcluded: calendarFiltersIdsArr,
games: calendarFilterGamesArr,
preferredVersus: preferredVersus,
modes: modeArr,
modesExact: z.boolean(),
minTeamCount: z.coerce.number().int().nonnegative(),
})
.superRefine((filters, ctx) => {
if (
filters.tagsIncluded.some((tag) => filters.tagsExcluded.includes(tag))
) {
ctx.addIssue({
path: ["tagsExcluded"],
message: "Can't include and exclude the same tag",
code: z.ZodIssueCode.custom,
});
}
if (filters.orgsIncluded.length > 0 && filters.orgsExcluded.length > 0) {
ctx.addIssue({
path: ["orgsExcluded"],
message: "Can't both include and exclude organizations",
code: z.ZodIssueCode.custom,
});
}
});
export const calendarFiltersSearchParamsObject = z.object({
filters: z
.preprocess(safeJSONParse, calendarFiltersSearchParamsSchema)
.catch(CalendarEvent.defaultFilters()),
});
const playersSchema = z
.array(
@@ -86,29 +152,6 @@ export const reportWinnersActionSchema = z.object({
),
});
export const reportWinnersParamsSchema = z.object({
id: z.preprocess(actualNumber, id),
});
export const loaderWeekSearchParamsSchema = z.object({
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
year: z.preprocess(actualNumber, z.number().int()),
});
export const calendarEventTagSchema = z
.string()
.refine((val) =>
CALENDAR_EVENT.PERSISTED_TAGS.includes(val as PersistedCalendarEventTag),
);
export const loaderFilterSearchParamsSchema = z.object({
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
});
export const loaderTournamentsOnlySearchParamsSchema = z.object({
tournaments: z.literal("true").nullish(),
});
export const bracketProgressionSchema = z.preprocess(
safeJSONParse,
z
@@ -140,104 +183,3 @@ export const bracketProgressionSchema = z.preprocess(
"Invalid bracket progression",
),
);
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
organizationId: z.preprocess(actualNumber, id.nullish()),
name: z
.string()
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
description: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
),
rules: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
),
date: z.preprocess(
toArray,
z
.array(
z.preprocess(
date,
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
),
)
.min(1)
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
),
bracketUrl: z
.string()
.url()
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
.default("https://sendou.ink"),
discordInviteCode: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
),
tags: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(calendarEventTagSchema).nullable(),
),
badges: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(id).nullable(),
),
avatarImgId: id.nullish(),
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
enableNoScreenToggle: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
requireInGameNames: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(),
bracketProgression: bracketProgressionSchema.nullish(),
})
.refine(
async (schema) => {
if (schema.eventToEditId) {
const eventToEdit = await CalendarRepository.findById({
id: schema.eventToEditId,
});
return schema.date.length === 1 || !eventToEdit?.tournamentId;
}
return schema.date.length === 1 || !schema.toToolsEnabled;
},
{
message: "Tournament must have exactly one date",
},
)
.refine(
(schema) => {
if (schema.toToolsMode !== "ALL") {
return true;
}
const maps = schema.pool ? MapPool.toDbList(schema.pool) : [];
return (
maps.length === 4 &&
rankedModesShort.every((mode) => maps.some((map) => map.mode === mode))
);
},
{
message:
'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"',
},
);

View File

@@ -1,4 +1,58 @@
export interface FollowUpBracket {
import type { z } from "zod";
import type { CalendarEventTag, Tables } from "~/db/tables";
import type { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
import type { ModeShortWithSpecial } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
interface CommonEvent {
id: number;
name: string;
placements: Array<number>;
teamsCount: number;
logoUrl: string | null;
url: string;
/** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */
isRanked: boolean | null;
modes: Array<ModeShortWithSpecial> | null;
organization: {
name: string;
slug: string;
} | null;
/** User id of the author of the event */
authorId: number;
}
export interface CalendarEvent extends CommonEvent {
/** The date of the event in UNIX timestamp (JS format) */
at: number;
type: "calendar";
tags: Array<CalendarEventTag>;
/** Used for comparison, teams count where it is taken in account whether the tournament is 4v4, 3v3, 2v2 or 1v1 */
normalizedTeamCount: number;
/** For multi-day tournaments, which day of the event is this */
day?: number;
badges: Array<
Pick<Tables["Badge"], "id" | "code" | "displayName" | "hue">
> | null;
}
export interface ShowcaseCalendarEvent extends CommonEvent {
type: "showcase";
startTime: number;
firstPlacer: {
teamName: string;
logoUrl: string | null;
members: (CommonUser & { country: Tables["User"]["country"] })[];
notShownMembersCount: number;
} | null;
}
export interface GroupedCalendarEvents {
/** The date of the event in UNIX timestamp (JS format) */
at: number;
events: {
shown: CalendarEvent[];
hidden: CalendarEvent[];
};
}
export type CalendarFilters = z.infer<typeof calendarFiltersSearchParamsSchema>;

View File

@@ -4,7 +4,12 @@ import { allTruthy } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import type { RegClosesAtOption } from "./calendar-constants";
import type { DayMonthYear } from "~/utils/zod";
import {
DAYS_SHOWN_AT_A_TIME,
type RegClosesAtOption,
} from "./calendar-constants";
import type { CalendarEvent } from "./calendar-types";
export const calendarEventMinDate = () => new Date(Date.UTC(2015, 4, 28));
export const calendarEventMaxDate = () => {
@@ -192,3 +197,72 @@ function eventStartedInThePast(
databaseTimestampToDate(startTime).getTime() < new Date().getTime(),
);
}
export function daysForCalendar(currentDate?: DayMonthYear) {
type DaysArray = Array<DayMonthYear>;
const previous: DaysArray = [];
const shown: DaysArray = [];
const next: DaysArray = [];
const startDate = () =>
currentDate
? new Date(currentDate.year, currentDate.month, currentDate.day)
: new Date();
const currentDayMonthYear = () => {
const now = startDate();
return {
day: now.getDate(),
month: now.getMonth(),
year: now.getFullYear(),
};
};
let now = startDate();
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
shown.push({
day: now.getDate(),
month: now.getMonth(),
year: now.getFullYear(),
});
now.setDate(now.getDate() + 1);
}
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
next.push({
day: now.getDate(),
month: now.getMonth(),
year: now.getFullYear(),
});
now.setDate(now.getDate() + 1);
}
now = startDate();
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
now.setDate(now.getDate() - 1);
previous.push({
day: now.getDate(),
month: now.getMonth(),
year: now.getFullYear(),
});
}
previous.reverse();
return {
previous,
shown,
next,
current: currentDayMonthYear(),
};
}
export function calendarEventSorter(a: CalendarEvent, b: CalendarEvent) {
return b.normalizedTeamCount - a.normalizedTeamCount;
}

View File

@@ -0,0 +1,216 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useFetcher, useSearchParams } from "@remix-run/react";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { SubmitButton } from "~/components/SubmitButton";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { FilterFilledIcon } from "~/components/icons/FilterFilled";
import type { CalendarEventTag } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import { TagsFormField } from "~/features/calendar/components/TagsFormField";
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
const { t } = useTranslation(["calendar"]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<SendouButton
size="small"
icon={<FilterFilledIcon />}
onClick={() => setIsOpen(true)}
data-testid="filter-events-button"
>
{t("calendar:filter.button")}
</SendouButton>
<SendouDialog
heading={t("calendar:filter.heading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<FiltersForm
filters={filters}
closeDialog={() => {
setIsOpen(false);
}}
/>
</SendouDialog>
</>
);
}
const TAGS_TO_OMIT: Array<CalendarEventTag> = [
"CARDS",
"SR",
"S1",
"S2",
"SZ",
"TW",
"ONES",
"DUOS",
"TRIOS",
] as const;
function FiltersForm({
filters,
closeDialog,
}: { filters: CalendarFilters; closeDialog: () => void }) {
const user = useUser();
const { t } = useTranslation(["game-misc", "calendar"]);
const methods = useForm({
resolver: zodResolver(calendarFiltersFormSchema),
defaultValues: filters,
});
const fetcher = useFetcher<any>();
const [, setSearchParams] = useSearchParams();
const filtersToSearchParams = (newFilters: CalendarFilters) => {
setSearchParams((prev) => {
prev.set("filters", JSON.stringify(newFilters));
return prev;
});
};
const onApply = React.useCallback(
methods.handleSubmit((values) => {
filtersToSearchParams(values);
closeDialog();
}),
[],
);
const onApplyAndPersist = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(values, { method: "post", encType: "application/json" }),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form
className="stack md-plus items-start"
onSubmit={onApplyAndPersist}
>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.modes")}
name={"modes" as const}
values={[
{ label: t("game-misc:MODE_LONG_TW"), value: "TW" },
{ label: t("game-misc:MODE_LONG_SZ"), value: "SZ" },
{ label: t("game-misc:MODE_LONG_TC"), value: "TC" },
{ label: t("game-misc:MODE_LONG_RM"), value: "RM" },
{ label: t("game-misc:MODE_LONG_CB"), value: "CB" },
{ label: t("game-misc:MODE_LONG_SR"), value: "SR" },
{ label: t("game-misc:MODE_LONG_TB"), value: "TB" },
]}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.exactModes")}
name={"modesExact" as const}
bottomText={t("calendar:filter.exactModesBottom")}
/>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.games")}
name={"games" as const}
values={[
{ label: t("game-misc:GAME_S1"), value: "S1" },
{ label: t("game-misc:GAME_S2"), value: "S2" },
{ label: t("game-misc:GAME_S3"), value: "S3" },
]}
/>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.vs")}
name={"preferredVersus" as const}
values={[
{ label: "4v4", value: "4v4" },
{ label: "3v3", value: "3v3" },
{ label: "2v2", value: "2v2" },
{ label: "1v1", value: "1v1" },
]}
/>
<InputGroupFormField<CalendarFilters>
type="radio"
label={t("calendar:filter.startTime")}
name={"preferredStartTime" as const}
values={[
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
{ label: t("calendar:filter.startTime.na"), value: "NA" },
{ label: t("calendar:filter.startTime.au"), value: "AU" },
]}
/>
<TagsFormField<CalendarFilters>
label={t("calendar:filter.tagsIncluded")}
name={"tagsIncluded" as const}
tagsToOmit={TAGS_TO_OMIT}
/>
<TagsFormField<CalendarFilters>
label={t("calendar:filter.tagsExcluded")}
name={"tagsExcluded" as const}
tagsToOmit={TAGS_TO_OMIT}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.isSendou")}
name={"isSendou" as const}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.isRanked")}
name={"isRanked" as const}
/>
<InputFormField<CalendarFilters>
label={t("calendar:filter.minTeamCount")}
type="number"
name={"minTeamCount" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.orgsIncluded")}
name={"orgsIncluded" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.orgsExcluded")}
name={"orgsExcluded" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.authorIdsExcluded")}
name={"authorIdsExcluded" as const}
bottomText={t("calendar:filter.authorIdsExcludedBottom")}
/>
<div className="stack horizontal md justify-center mt-6 w-full">
<SendouButton onPress={() => onApply()}>
{t("calendar:filter.apply")}
</SendouButton>
{user ? (
<SubmitButton variant="outlined" state={fetcher.state}>
{t("calendar:filter.applyAndDefault")}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
);
}

View File

@@ -1,45 +1,35 @@
import clsx from "clsx";
import React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "~/components/Badge";
import { Button } from "~/components/Button";
import { CrossIcon } from "~/components/icons/Cross";
import type { CalendarEventTag } from "~/db/tables";
import type * as CalendarRepository from "../CalendarRepository.server";
import { tags as allTags } from "../calendar-constants";
export function Tags({
tags,
badges,
onDelete,
tournamentRankedStatus,
small = false,
centered = false,
}: {
tags: Array<CalendarEventTag>;
badges?: CalendarRepository.FindAllBetweenTwoTimestampsItem["badgePrizes"];
tournamentRankedStatus?: "RANKED" | "UNRANKED";
small?: boolean;
centered?: boolean;
/** Called when tag delete button clicked. If undefined delete buttons won't be shown. */
onDelete?: (tag: CalendarEventTag) => void;
}) {
const { t } = useTranslation();
if (tags.length === 0 && !tournamentRankedStatus) return null;
if (tags.length === 0) return null;
return (
<ul className="calendar__event__tags">
{tournamentRankedStatus === "RANKED" ? (
<li className="calendar__event__ranked-tag">Ranked</li>
) : null}
{tournamentRankedStatus === "UNRANKED" ? (
<li className="calendar__event__unranked-tag">Unranked</li>
) : null}
<ul className={clsx("calendar__event__tags", { small, centered })}>
{tags.map((tag) => (
<React.Fragment key={tag}>
<li
style={{ backgroundColor: allTags[tag].color }}
className={clsx("calendar__event__tag", {
"calendar__event__badge-tag": tag === "BADGE",
})}
className="calendar__event__tag"
>
{t(`tag.name.${tag}`)}
{onDelete && (
@@ -52,13 +42,6 @@ export function Tags({
size="tiny"
/>
)}
{tag === "BADGE" && badges && (
<div className="calendar__event__tag-badges">
{badges.map((badge) => (
<Badge key={badge.id} badge={badge} size={20} isAnimated />
))}
</div>
)}
</li>
</React.Fragment>
))}

View File

@@ -0,0 +1,34 @@
.tagGroup {
width: 100%;
}
.tagList {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: var(--s-2);
}
.tag {
border: 2px solid var(--tag-color);
border-radius: var(--rounded-sm);
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
display: grid;
place-items: center;
min-width: max-content;
}
.tag[data-selected] {
background-color: var(--tag-color) !important;
color: var(--black-text);
}
.tag[data-focus-visible] {
outline: 2px solid var(--tag-color);
outline-offset: 1px;
background-color: var(--bg-lighter);
}
.tag[data-hovered] {
background-color: var(--bg-lighter);
}

View File

@@ -0,0 +1,101 @@
import * as React from "react";
import { Tag, TagGroup, TagList } from "react-aria-components";
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import type { CalendarEventTag } from "~/db/tables";
import { CALENDAR_EVENT } from "~/features/calendar/calendar-constants";
import { tags as allTags } from "../calendar-constants";
import styles from "./TagsFormField.module.css";
export function TagsFormField<T extends FieldValues>({
label,
name,
bottomText,
tagsToOmit,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
tagsToOmit?: Array<CalendarEventTag>;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div className="w-full">
<Label htmlFor={id}>{label}</Label>
<Controller
control={methods.control}
name={name}
render={({ field: { onChange, value, ref } }) => (
<SelectableTags
selectedTags={value}
onSelectionChange={onChange}
tagsToOmit={tagsToOmit}
ref={ref}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}
export const SelectableTags = React.forwardRef<
HTMLDivElement,
{
selectedTags: Array<CalendarEventTag>;
tagsToOmit?: Array<CalendarEventTag>;
onSelectionChange: (selectedTags: Array<CalendarEventTag>) => void;
}
>(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => {
const { t } = useTranslation();
const availableTags = tagsToOmit
? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag))
: CALENDAR_EVENT.TAGS;
return (
<TagGroup
className={styles.tagGroup}
selectionMode="multiple"
selectedKeys={selectedTags}
onSelectionChange={(newSelection) =>
onSelectionChange(Array.from(newSelection) as CalendarEventTag[])
}
aria-label="Select tags"
ref={ref}
>
<TagList className={styles.tagList}>
{availableTags.map((tag) => {
return (
<Tag
key={tag}
id={tag}
className={styles.tag}
style={{ "--tag-color": allTags[tag].color }}
>
{t(`tag.name.${tag}`)}
</Tag>
);
})}
</TagList>
</TagGroup>
);
});

View File

@@ -0,0 +1,153 @@
.container {
min-width: var(--card-width);
max-width: var(--card-width);
height: calc(var(--card-height) + 26px);
display: flex;
flex-direction: column;
gap: var(--s-1);
--tournament-card-icon-size: 13px;
}
.containerTall {
height: 100%;
}
.card {
background-color: var(--bg-lightest);
border-radius: var(--rounded);
color: var(--text);
padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2);
height: 100%;
min-width: var(--card-width);
max-width: var(--card-width);
}
.card:hover .avatarImg {
outline: 6px solid var(--theme-transparent);
}
.imgContainer {
background-color: var(--bg);
padding: var(--s-1-5);
border-radius: 100%;
margin-left: -10px;
margin-top: -10px;
}
.avatarImg {
border-radius: 100%;
}
.org {
display: flex;
gap: var(--s-1);
font-size: var(--fonts-xxs);
color: var(--text-lighter);
}
.time {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text-lighter);
display: block;
}
.name {
text-align: center;
font-weight: var(--semi-bold);
font-size: var(--fonts-sm);
white-space: nowrap;
text-overflow: ellipsis;
max-width: 225px;
overflow: hidden;
}
.teamCount {
font-size: var(--fonts-xxs);
font-weight: var(--bold);
background-color: var(--bg-lightest);
border-radius: var(--rounded-sm);
width: max-content;
padding: var(--s-0-5) var(--s-1-5);
display: flex;
gap: var(--s-1);
}
.teamCount svg {
width: var(--tournament-card-icon-size);
}
.modesPillContainer {
padding-inline-start: 12px;
}
.modesPill {
background-color: var(--bg-lightest);
border-radius: var(--rounded-sm);
width: max-content;
padding: var(--s-0-5) var(--s-1-5);
display: flex;
gap: var(--s-1);
}
.pillsContainer {
display: flex;
gap: var(--s-1);
justify-content: end;
padding-inline-end: 12px;
}
.pill {
font-size: var(--fonts-xxs);
font-weight: var(--bold);
border-radius: var(--rounded-sm);
padding: var(--s-0-5) var(--s-1-5);
display: grid;
place-items: center;
}
.pillRanked {
background-color: var(--theme-info-transparent);
color: var(--theme-info);
}
.pillRanked svg {
width: var(--tournament-card-icon-size);
}
.firstPlacers {
margin-inline: auto;
margin-top: var(--s-5);
width: max-content;
}
.firstPlacersTeamName {
max-width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badgesContainer {
display: flex;
gap: var(--s-1);
justify-content: center;
background-color: black;
border-radius: var(--rounded-sm);
padding: var(--s-1);
}
.badgeNavIcon {
min-width: 16px;
}
.badgeDisplay {
min-width: initial;
min-height: initial;
}
.badgePill {
background-color: var(--bg-lightest);
border-radius: var(--rounded-sm);
padding: var(--s-0-5) var(--s-1-5);
}

View File

@@ -0,0 +1,207 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Flag } from "~/components/Flag";
import { Image, ModeImage } from "~/components/Image";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { TrophyIcon } from "~/components/icons/Trophy";
import { UsersIcon } from "~/components/icons/Users";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl, userSubmittedImage } from "~/utils/urls";
import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types";
import { Tags } from "./Tags";
import styles from "./TournamentCard.module.css";
export function TournamentCard({
tournament,
className,
}: {
tournament: CalendarEvent | ShowcaseCalendarEvent;
className?: string;
}) {
const isMounted = useIsMounted();
const { i18n } = useTranslation(["front", "common"]);
const isShowcase = tournament.type === "showcase";
const isCalendar = tournament.type === "calendar";
const isHostedOnSendouInk = typeof tournament.isRanked === "boolean";
const time = () => {
if (!isShowcase) return null;
if (!isMounted) return "Placeholder";
const date = databaseTimestampToDate(tournament.startTime);
return date.toLocaleString(i18n.language, {
month: "short",
day: "numeric",
hour: "numeric",
weekday: "short",
minute: date.getMinutes() !== 0 ? "numeric" : undefined,
});
};
return (
<div
className={clsx(className, styles.container, {
[styles.containerTall]: isShowcase && tournament.firstPlacer,
})}
data-testid="tournament-card"
>
<Link to={tournament.url} className={styles.card}>
<div className="stack horizontal justify-between">
{isHostedOnSendouInk ? (
<div className={styles.imgContainer}>
<img
src={
tournament.logoUrl
? userSubmittedImage(tournament.logoUrl)
: HACKY_resolvePicture(tournament)
}
width={32}
height={32}
className={styles.avatarImg}
alt=""
/>
</div>
) : null}
{tournament.organization ? (
<div className={styles.org}>{tournament.organization.name}</div>
) : null}
</div>
<div
className={clsx(styles.name, {
"mt-3": !isHostedOnSendouInk,
"mt-1": isHostedOnSendouInk,
})}
>
{tournament.name}{" "}
{isShowcase ? (
<time
className={clsx(styles.time, {
invisible: !isMounted,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,
).toISOString()}
>
{time()}
</time>
) : null}
</div>
{isCalendar ? (
<div className="stack sm items-center my-2">
<Tags tags={tournament.tags} small centered />
</div>
) : null}
{isShowcase && tournament.firstPlacer ? (
<TournamentFirstPlacers firstPlacer={tournament.firstPlacer} />
) : null}
</Link>
<div className="stack horizontal justify-between items-center">
{tournament.modes ? <ModesPill modes={tournament.modes} /> : null}
{isHostedOnSendouInk ? (
<div className={styles.pillsContainer}>
{tournament.isRanked ? (
<div className={clsx(styles.pill, styles.pillRanked)}>
<TrophyIcon title="Ranked (impacts this seasons SP)" />
</div>
) : null}
{isCalendar && tournament.badges && tournament.badges.length > 0 ? (
<BadgePrizesPill badges={tournament.badges} />
) : null}
<div className={styles.teamCount}>
<UsersIcon /> {tournament.teamsCount}
</div>
</div>
) : null}
</div>
</div>
);
}
function TournamentFirstPlacers({
firstPlacer,
}: {
firstPlacer: NonNullable<ShowcaseCalendarEvent["firstPlacer"]>;
}) {
const { t } = useTranslation(["front"]);
return (
<div className={styles.firstPlacers}>
<div className="stack xs horizontal items-center text-xs">
{firstPlacer.logoUrl ? (
<img
src={userSubmittedImage(firstPlacer.logoUrl)}
alt=""
width={24}
className="rounded-full"
/>
) : null}{" "}
<div className="stack items-start">
<span className={styles.firstPlacersTeamName}>
{firstPlacer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
</div>
</div>
</div>
<div className="text-xxs stack items-start mt-1">
{firstPlacer.members.map((member) => (
<div key={member.id} className="stack horizontal xs items-center">
{member.country ? <Flag tiny countryCode={member.country} /> : null}
{member.username}{" "}
</div>
))}
{firstPlacer.notShownMembersCount > 0 ? (
<div className="font-bold text-lighter">
+{firstPlacer.notShownMembersCount}
</div>
) : null}
</div>
</div>
);
}
function ModesPill({ modes }: { modes: NonNullable<CalendarEvent["modes"]> }) {
const size = 16;
return (
<div className={styles.modesPillContainer}>
<div className={styles.modesPill}>
{modes.map((mode) => (
<ModeImage key={mode} mode={mode} size={size} />
))}
</div>
</div>
);
}
function BadgePrizesPill({
badges,
}: { badges: NonNullable<CalendarEvent["badges"]> }) {
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={styles.badgePill}>
<Image
size={16}
path={navIconUrl("badges")}
alt="Badge prizes"
className={styles.badgeNavIcon}
/>
</SendouButton>
}
>
<BadgeDisplay
badges={badges}
showText={false}
className={styles.badgeDisplay}
/>
</SendouPopover>
);
}

View File

@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import type {
CalendarEvent as CalendarEventType,
CalendarFilters,
} from "../calendar-types";
import * as CalendarEvent from "./CalendarEvent";
function makeEvent(
overrides: Partial<CalendarEventType> = {},
): CalendarEventType {
return {
at: new Date().getTime(),
id: 1,
isRanked: null,
tags: [],
modes: ["SZ"],
teamsCount: 2,
organization: null,
authorId: 1,
type: "calendar",
normalizedTeamCount: 0,
badges: [],
logoUrl: null,
name: "",
url: "",
...overrides,
};
}
describe("CalendarEvent.applyFilters", () => {
it("returns all events as shown with default filters", () => {
const events = [
{
at: 123,
events: [makeEvent({ id: 1 }), makeEvent({ id: 2 })],
},
];
const result = CalendarEvent.applyFilters(
events,
CalendarEvent.defaultFilters(),
);
expect(result[0].events.shown).toHaveLength(2);
expect(result[0].events.hidden).toHaveLength(0);
});
it("filters by isRanked", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, isRanked: true }),
makeEvent({ id: 2, isRanked: false }),
makeEvent({ id: 3, isRanked: null }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
isRanked: true,
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown).toHaveLength(1);
expect(result[0].events.shown[0].id).toBe(1);
expect(result[0].events.hidden).toHaveLength(2);
});
it("filters by tagsIncluded", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tags: ["MONEY", "ART"] }),
makeEvent({ id: 2, tags: ["LOW"] }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
tagsIncluded: ["MONEY"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown).toHaveLength(1);
expect(result[0].events.shown[0].id).toBe(1);
});
it("filters by tagsExcluded", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tags: ["MONEY"] }),
makeEvent({ id: 2, tags: ["ART"] }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
tagsExcluded: ["MONEY"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown).toHaveLength(1);
expect(result[0].events.shown[0].id).toBe(2);
});
it("filters by games", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tags: ["S1"] }),
makeEvent({ id: 2, tags: ["S2"] }),
makeEvent({ id: 3, tags: [] }), // S3
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
games: ["S1"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown).toHaveLength(1);
expect(result[0].events.shown[0].id).toBe(1);
});
it("filters by preferredVersus", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tags: ["ONES"] }),
makeEvent({ id: 2, tags: ["DUOS"] }),
makeEvent({ id: 3, tags: ["TRIOS"] }),
makeEvent({ id: 4, tags: [] }), // 4v4
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
preferredVersus: ["1v1", "2v2"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]);
});
it("filters by modes (not exact)", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, modes: ["SZ"] }),
makeEvent({ id: 2, modes: ["TC"] }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
modes: ["SZ"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
});
it("filters by modes (exact)", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, modes: ["SZ", "TC"] }),
makeEvent({ id: 2, modes: ["SZ"] }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
modes: ["SZ"],
modesExact: true,
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
});
it("filters by minTeamCount", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, teamsCount: 2 }),
makeEvent({ id: 2, teamsCount: 4 }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
minTeamCount: 3,
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
});
it("filters by orgsIncluded", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, organization: { name: "OrgA" } as any }),
makeEvent({ id: 2, organization: { name: "OrgB" } as any }),
makeEvent({ id: 3, organization: undefined }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
orgsIncluded: ["OrgA"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
});
it("filters by orgsExcluded", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, organization: { name: "OrgA" } as any }),
makeEvent({ id: 2, organization: { name: "OrgB" } as any }),
makeEvent({ id: 3, organization: undefined }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
orgsExcluded: ["OrgA"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([2, 3]);
});
it("filters by authorIdsExcluded", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, authorId: 1 }),
makeEvent({ id: 2, authorId: 2 }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
authorIdsExcluded: [1],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
});
it("filters by combining two different filters", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tags: ["MONEY"], isRanked: true }),
makeEvent({ id: 2, tags: ["LOW"], isRanked: false }),
makeEvent({ id: 3, tags: ["MONEY"], isRanked: false }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
tagsIncluded: ["MONEY"],
isRanked: true,
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
});
});

View File

@@ -0,0 +1,303 @@
import { TZDate } from "@date-fns/tz";
import { isWeekend } from "date-fns";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import { assertType } from "~/utils/types";
import type {
CalendarEvent,
CalendarFilters,
GroupedCalendarEvents,
} from "../calendar-types";
const FILTERS_KEYS = [
"preferredStartTime",
"tagsIncluded",
"tagsExcluded",
"isSendou",
"isRanked",
"games",
"orgsIncluded",
"orgsExcluded",
"authorIdsExcluded",
"modes",
"modesExact",
"minTeamCount",
"preferredVersus",
] as const;
assertType<(typeof FILTERS_KEYS)[number], keyof CalendarFilters>();
assertType<keyof CalendarFilters, (typeof FILTERS_KEYS)[number]>();
/**
* Returns the default empty state of filter settings for calendar events.
*/
export function defaultFilters(): CalendarFilters {
return {
preferredStartTime: "ANY",
tagsIncluded: [],
tagsExcluded: [],
isSendou: false,
isRanked: false,
games: [...gamesShort],
modes: [...modesShortWithSpecial],
preferredVersus: [...versusShort],
modesExact: false,
orgsIncluded: [],
orgsExcluded: [],
authorIdsExcluded: [],
minTeamCount: 0,
};
}
const defaultFiltersString = filtersToString(defaultFilters());
/**
* Determines whether the provided calendar filters match the default filter settings.
*/
export function isDefaultFilters(filters: CalendarFilters): boolean {
return filtersToString(filters) === defaultFiltersString;
}
/**
* Serializes the given calendar filters object into a string representation to be used as e.g. React key.
*/
export function filtersToString(filters: CalendarFilters): string {
let result = "";
for (const key of FILTERS_KEYS) {
result += `${key}-${filters[key]};`;
}
return result;
}
/**
* Applies the provided calendar filters to a list of grouped calendar events.
*
* Returns a new array where each group contains separated lists of shown and hidden events. Event is shown if it matches all set filters.
*/
export function applyFilters(
events: {
at: number;
events: Array<CalendarEvent>;
}[],
filters: CalendarFilters,
): Array<GroupedCalendarEvents> {
return events.map((eventTime) => {
const shown: CalendarEvent[] = [];
const hidden: CalendarEvent[] = [];
for (const calendarEvent of eventTime.events) {
let isHidden = false;
for (const key of FILTERS_KEYS) {
if (!matchesFilter(calendarEvent, eventTime.at, key, filters)) {
isHidden = true;
break;
}
}
if (isHidden) {
hidden.push(calendarEvent);
continue;
}
shown.push(calendarEvent);
}
return {
at: eventTime.at,
events: {
shown,
hidden,
},
};
});
}
function matchesFilter(
event: CalendarEvent,
startTime: number,
key: keyof CalendarFilters,
filters: CalendarFilters,
): boolean {
switch (key) {
case "preferredStartTime": {
const preferredStartTime = filters[key];
if (preferredStartTime === "ANY") {
return true;
}
const timeZone =
preferredStartTime === "EU"
? "Europe/Paris"
: preferredStartTime === "NA"
? "America/Winnipeg"
: "Australia/Perth";
const tzDate = new TZDate(startTime, timeZone);
if (isWeekend(tzDate)) {
return tzDate.getHours() >= 10 && tzDate.getHours() <= 24;
}
return tzDate.getHours() >= 16 && tzDate.getHours() <= 23;
}
case "isSendou": {
if (filters[key] !== true) {
return true;
}
return event.isRanked !== null;
}
case "isRanked": {
if (filters[key] !== true) {
return true;
}
return event.isRanked === true;
}
case "tagsIncluded": {
const tags = filters[key];
if (tags.length === 0) {
return true;
}
return event.tags.some((tag) => tags.includes(tag));
}
case "tagsExcluded": {
const tags = filters[key];
if (tags.length === 0) {
return true;
}
return !event.tags.some((tag) => tags.includes(tag));
}
case "games": {
const games = filters[key];
if (!games.length || games.length === gamesShort.length) {
return true;
}
const isSplatoonOne = event.tags.includes("S1");
const isSplatoonTwo = event.tags.includes("S2");
const isSplatoonThree = !isSplatoonOne && !isSplatoonTwo;
for (const game of games) {
if (game === "S1" && isSplatoonOne) {
return true;
}
if (game === "S2" && isSplatoonTwo) {
return true;
}
if (game === "S3" && isSplatoonThree) {
return true;
}
}
return false;
}
case "preferredVersus": {
const preferredVersus = filters[key];
if (
!preferredVersus.length ||
preferredVersus.length === versusShort.length
) {
return true;
}
const eventType = event.tags.find(
(tag) => tag === "ONES" || tag === "DUOS" || tag === "TRIOS",
);
if (eventType === "ONES") {
return preferredVersus.includes("1v1");
}
if (eventType === "DUOS") {
return preferredVersus.includes("2v2");
}
if (eventType === "TRIOS") {
return preferredVersus.includes("3v3");
}
return preferredVersus.includes("4v4");
}
case "modes": {
const modes = filters[key];
if (!modes.length || modes.length === modesShortWithSpecial.length) {
return true;
}
if (!event.modes) {
return false;
}
if (filters.modesExact) {
return (
event.modes.length === modes.length &&
event.modes.every((mode) => modes.includes(mode))
);
}
return event.modes.some((mode) => modes.includes(mode));
}
case "modesExact": {
// handled in the modes filter
return true;
}
case "minTeamCount": {
const minTeamCount = filters[key];
if (minTeamCount === 0) {
return true;
}
return event.teamsCount >= minTeamCount;
}
case "orgsIncluded": {
const orgsIncluded = filters[key];
if (orgsIncluded.length === 0) {
return true;
}
const org = event.organization;
if (!org) {
return false;
}
return orgsIncluded.some((orgName) =>
orgNameMatches({ orgName: org.name, value: orgName }),
);
}
case "orgsExcluded": {
const orgsExcluded = filters[key];
if (orgsExcluded.length === 0) {
return true;
}
const org = event.organization;
if (!org) {
return true;
}
return !orgsExcluded.some((orgName) =>
orgNameMatches({ orgName: org.name, value: orgName }),
);
}
case "authorIdsExcluded": {
const authorIds = filters[key];
if (authorIds.length === 0) {
return true;
}
return !authorIds.some((id) => event.authorId === id);
}
}
}
function orgNameMatches({
orgName,
value,
}: { orgName: string; value: string }) {
return orgName.trim().toLowerCase() === value.trim().toLowerCase();
}

View File

@@ -0,0 +1,51 @@
import * as ics from "ics";
import type { CalendarEvent } from "~/features/calendar/calendar-types";
import { logger } from "~/utils/logger";
import { SENDOU_INK_BASE_URL } from "~/utils/urls";
export async function getICalendar(events: Array<CalendarEvent>) {
// 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;
}
if (!value) {
logger.error("Error constructing ical feed: no value returned");
return null;
}
return value;
}
function eventsAsICal(events: Array<CalendarEvent>): ics.ReturnObject {
return ics.createEvents(events.map(eventInfoAsICalEvent));
}
function eventInfoAsICalEvent(event: CalendarEvent): ics.EventAttributes {
const startDate = new Date(event.at);
const eventLink = `${SENDOU_INK_BASE_URL}/${event.url}`;
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: event.tags,
productId: "sendou.ink/calendar",
};
}

View File

@@ -1,81 +0,0 @@
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

@@ -1,15 +1,22 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { notFoundIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server";
import { reportWinnersParamsSchema } from "../calendar-schemas";
import {
notFoundIfFalsy,
parseParams,
unauthorizedIfFalsy,
} from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { canReportCalendarEventWinners } from "../calendar-utils";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const parsedParams = reportWinnersParamsSchema.parse(params);
const user = await requireUserId(request);
export const loader = async (args: LoaderFunctionArgs) => {
const params = parseParams({
params: args.params,
schema: idObject,
});
const user = await requireUserId(args.request);
const event = notFoundIfFalsy(
await CalendarRepository.findById({ id: parsedParams.id }),
await CalendarRepository.findById({ id: params.id }),
);
unauthorizedIfFalsy(
@@ -23,6 +30,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return {
name: event.name,
participantCount: event.participantCount,
winners: await CalendarRepository.findResultsByEventId(parsedParams.id),
winners: await CalendarRepository.findResultsByEventId(params.id),
};
};

View File

@@ -28,14 +28,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
if (!event) return;
// special tags that are added automatically
const tags = event?.tags?.filter((tag) => tag !== "BADGE");
if (!event?.tournamentId) return { ...event, tags, tournament: null };
if (!event?.tournamentId) return { ...event, tournament: null };
return {
...event,
tags,
tournament: await tournamentData({
tournamentId: event.tournamentId,
user,

View File

@@ -1,105 +1,71 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { addMonths, subMonths } from "date-fns";
import type { PersistedCalendarEventTag } from "~/db/tables";
import { getUserId } from "~/features/auth/core/user.server";
import type { UserPreferences } from "~/db/tables";
import { getUser } from "~/features/auth/core/user.server";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
import {
dateToThisWeeksMonday,
dateToThisWeeksSunday,
dateToWeekNumber,
weekNumberToDate,
} from "~/utils/dates";
calendarFiltersSearchParamsObject,
calendarFiltersSearchParamsSchema,
} from "~/features/calendar/calendar-schemas";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams, parseSearchParams } from "~/utils/remix.server";
import { dayMonthYear } from "~/utils/zod";
import * as CalendarRepository from "../CalendarRepository.server";
import {
loaderFilterSearchParamsSchema,
loaderTournamentsOnlySearchParamsSchema,
loaderWeekSearchParamsSchema,
} from "../calendar-schemas";
import { closeByWeeks } from "../calendar-utils";
import * as CalendarEvent from "../core/CalendarEvent";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const url = new URL(request.url);
export type CalendarLoaderData = SerializeFrom<typeof loader>;
// separate from tags parse so they can fail independently
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
year: url.searchParams.get("year"),
week: url.searchParams.get("week"),
export const loader = async (args: LoaderFunctionArgs) => {
const user = await getUser(args.request);
const parsed = parseSafeSearchParams({
request: args.request,
schema: dayMonthYear,
});
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
tags: url.searchParams.get("tags"),
const date = parsed.success
? new Date(
Date.UTC(parsed.data.year, parsed.data.month, parsed.data.day),
).getTime()
: Date.now();
const twentyFourHoursAgo = date - 24 * 60 * 60 * 1000;
const fiveDaysFromNow = date + DAYS_SHOWN_AT_A_TIME * 24 * 60 * 60 * 1000;
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
startTime: new Date(twentyFourHoursAgo),
endTime: new Date(fiveDaysFromNow),
});
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;
const filters = resolveFilters(args.request, user?.preferences);
const filtered = CalendarEvent.applyFilters(events, filters);
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)
: [],
eventTimes: filtered,
dateViewed: parsed.success ? parsed.data : undefined,
filters,
};
};
function fetchEventsOfWeek(args: {
week: number;
year: number;
tagsToFilterBy: PersistedCalendarEventTag[];
onlyTournaments: boolean;
}) {
const startTime = weekNumberToDate(args);
function resolveFilters(
request: Request,
preferences?: UserPreferences | null,
) {
const parsed = parseSearchParams({
request,
schema: calendarFiltersSearchParamsObject,
}).filters;
const endTime = new Date(startTime);
endTime.setDate(endTime.getDate() + 7);
if (!CalendarEvent.isDefaultFilters(parsed)) {
return parsed;
}
// handle timezone mismatch between server and client
startTime.setHours(startTime.getHours() - 12);
endTime.setHours(endTime.getHours() + 12);
if (preferences?.defaultCalendarFilters) {
// make sure the saved values still match current reality
const parsedDefault = calendarFiltersSearchParamsSchema.parse(
preferences.defaultCalendarFilters,
);
return CalendarRepository.findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy: args.tagsToFilterBy,
onlyTournaments: args.onlyTournaments,
});
return parsedDefault;
}
return CalendarEvent.defaultFilters();
}

View File

@@ -1,34 +1,36 @@
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";
import { parseSearchParams } from "~/utils/remix.server";
import * as CalendarRepository from "../CalendarRepository.server";
import { calendarFiltersSearchParamsObject } from "../calendar-schemas";
import * as CalendarEvent from "../core/CalendarEvent";
import * as ICal from "../core/ICal.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const filters = parseSearchParams({
request,
schema: calendarFiltersSearchParamsObject,
}).filters;
// allows limiting calendar events to specific tags
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
tags: url.searchParams.get("tags"),
const startTime = new Date();
const endTime = new Date(startTime);
// get all events over the two weeks, might be good to make this an parameter in the future
endTime.setDate(startTime.getDate() + 14);
// handle timezone mismatch between server and client
startTime.setHours(startTime.getHours() - 12);
endTime.setHours(endTime.getHours() + 12);
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
startTime,
endTime,
});
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 filtered = CalendarEvent.applyFilters(events, filters);
const iCalData = await ical.getICalendar({
tagsFilter: tagsToFilterBy,
tournamentsFilter: onlyTournaments,
});
const iCalData = await ICal.getICalendar(
filtered.flatMap((eventTime) => eventTime.events.shown),
);
if (iCalData === null) {
return new Response(null, { status: 204 });

View File

@@ -120,7 +120,7 @@ export default function CalendarEventPage() {
<div className="stack md">
<div className="stack xs">
<h2>{data.event.name}</h2>
<Tags tags={data.event.tags} badges={data.event.badgePrizes} />
<Tags tags={data.event.tags} />
</div>
<div className="stack horizontal sm flex-wrap">
{data.event.discordUrl ? (

View File

@@ -0,0 +1,127 @@
.buttonsContainer {
display: flex;
justify-content: space-between;
gap: var(--s-6);
align-items: start;
flex-wrap: wrap-reverse;
}
.navigateButtonsContainer {
display: flex;
gap: var(--s-4);
width: 100%;
}
.navigateButton {
all: unset;
background-color: var(--bg-lightest);
border: 2px solid var(--border);
display: flex;
align-items: center;
font-weight: var(--bold);
border-radius: var(--rounded-sm);
padding: var(--s-1) var(--s-2);
font-size: var(--fonts-xs);
flex: 1 1 0;
}
.navigateButton:focus-visible {
outline: 2px solid var(--theme);
}
@media screen and (min-width: 600px) {
.navigateButton {
flex: initial;
}
.navigateButtonsContainer {
width: initial;
}
}
.navigateButton:not(.navigateArrowButton) {
justify-content: center;
}
.navigateButton svg {
min-width: 1.75rem;
max-width: 1.75rem;
min-width: 1.75rem;
max-height: 1.75rem;
stroke-width: 2px;
}
.navigateArrowButton svg {
margin-inline-start: -5px;
}
.majorLink {
all: unset;
background-color: orange;
border: 2px solid darkorange;
color: rgb(49, 49, 49);
font-weight: var(--bold);
border-radius: var(--rounded-sm);
padding: var(--s-1) var(--s-2);
font-size: var(--fonts-xs);
position: relative;
}
.columnsContainer {
display: grid;
grid-template-columns: repeat(var(--columns-count), 225px);
gap: var(--s-14);
overflow-x: auto;
}
.dayHeader {
border: 2px solid var(--border);
border-radius: var(--rounded-sm);
background-color: var(--bg-lighter);
padding: var(--s-2);
text-align: center;
font-size: var(--fonts-md);
font-weight: var(--bold);
}
.dayHeaderToday {
background-color: var(--bg-lightest);
border: 2px solid var(--theme-transparent);
}
.dayHeaderWeekday {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text-lighter);
}
.dayEvents {
margin-block-start: var(--s-4);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.clockHeader {
font-weight: var(--semi-bold);
font-size: var(--fonts-sm);
}
.clockHeaderDivider {
width: 100%;
height: 3px;
background-color: var(--theme-transparent);
border-radius: var(--rounded);
}
.hiddenEventsButton svg {
width: 1rem;
height: 1rem;
stroke-width: 2px;
}
.calendar {
background-color: initial;
padding: var(--s-2);
border: none;
}

View File

@@ -584,9 +584,9 @@ function TagsAdder() {
const [tags, setTags] = React.useState(baseEvent?.tags ?? []);
const id = React.useId();
const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter(
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
(tag) => !tags.includes(tag),
);
).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration?
return (
<div className="stack sm">
@@ -601,7 +601,6 @@ function TagsAdder() {
id={id}
className="calendar-new__select"
onChange={(e) =>
// @ts-expect-error TODO: fix this (5.5 version)
setTags([...tags, e.target.value as CalendarEventTag])
}
>
@@ -612,7 +611,6 @@ function TagsAdder() {
</option>
))}
</select>
<FormMessage type="info">{t("calendar:forms.tags.info")}</FormMessage>
</div>
<Tags
tags={tags}

View File

@@ -1,87 +1,58 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import {
Link,
createSearchParams,
useLoaderData,
useSearchParams,
} from "@remix-run/react";
import type { MetaFunction } from "@remix-run/node";
import { Link, useLoaderData, useNavigate } from "@remix-run/react";
import clsx from "clsx";
import React from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import type * as React from "react";
import type { DateValue } from "react-aria-components";
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 { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
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";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { joinListToNaturalString } from "~/utils/arrays";
import {
databaseTimestampToDate,
dateToWeekNumber,
dayToWeekStartsAtMondayDay,
getWeekStartsAtMondayDay,
weekNumberToDate,
} from "~/utils/dates";
SendouButton,
type SendouButtonProps,
} from "~/components/elements/Button";
import { SendouCalendar } from "~/components/elements/Calendar";
import { SendouPopover } from "~/components/elements/Popover";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
import { CalendarIcon } from "~/components/icons/Calendar";
import { EyeIcon } from "~/components/icons/Eye";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { LinkIcon } from "~/components/icons/Link";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
import { dayMonthYearToDateValue } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import {
CALENDAR_PAGE,
calendarReportWinnersPage,
calendarIcalFeed,
calendarPage,
navIconUrl,
resolveBaseUrl,
tournamentOrganizationPage,
tournamentPage,
userSubmittedImage,
} from "~/utils/urls";
import { Label } from "../../../components/Label";
import { metaTags } from "../../../utils/remix";
import { CALENDAR_EVENT } from "../calendar-constants";
import { Tags } from "../components/Tags";
import type { DayMonthYear } from "~/utils/zod";
import { daysForCalendar } from "../calendar-utils";
import { FiltersDialog } from "../components/FiltersDialog";
import { TournamentCard } from "../components/TournamentCard";
import * as CalendarEvent from "../core/CalendarEvent";
import { loader } from "../loaders/calendar.server";
export { loader };
import { action } from "../actions/calendar";
import { type CalendarLoaderData, loader } from "../loaders/calendar.server";
export { action, loader };
import "~/styles/calendar.css";
import styles from "./calendar.module.css";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
const events = data.events.slice().sort((a, b) => {
const aParticipants = a.participantCounts?.teams ?? 0;
const bParticipants = b.participantCounts?.teams ?? 0;
if (aParticipants > bParticipants) return -1;
if (aParticipants < bParticipants) return 1;
return 0;
});
return metaTags({
title: "Calendar",
ogTitle: "Splatoon competitive event calendar",
location: args.location,
description: `${data.events.length} events on sendou.ink happening during week ${
data.displayedWeek
} including ${joinListToNaturalString(
events.slice(0, 3).map((e) => e.name),
)}`,
description:
"Browser Splatoon competitive tournaments and events both local and online. Events for players of all skill levels from newcomer to pro.",
});
};
export const handle: SendouRouteHandle = {
i18n: "calendar",
i18n: ["calendar", "front"],
breadcrumb: () => ({
imgPath: navIconUrl("calendar"),
href: CALENDAR_PAGE,
@@ -90,608 +61,268 @@ export const handle: SendouRouteHandle = {
};
export default function CalendarPage() {
const { t } = useTranslation("calendar");
const { t } = useTranslation(["calendar", "common"]);
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;
const { previous, shown, next, current } = daysForCalendar(data.dateViewed);
return (
<Main classNameOverwrite="stack lg main layout__main">
<WeekLinks />
<EventsToReport />
<div>
<div className="stack horizontal justify-between">
<div className="stack sm">
<TagsFilter />
<ICalLink />
</div>
<OnSendouInkToggle />
<Main bigger className="stack lg">
<div className={styles.buttonsContainer}>
<div className={styles.navigateButtonsContainer}>
<NavigateButton
icon={<ArrowLeftIcon />}
daysInterval={previous}
filters={data.filters}
>
{t("common:actions.previous")}
</NavigateButton>
<NavigateButton
icon={<ArrowRightIcon />}
daysInterval={next}
filters={data.filters}
>
{t("common:actions.next")}
</NavigateButton>
<CalendarDatePicker
dayMonthYear={current}
filters={data.filters}
key={JSON.stringify(current)}
/>
</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 className="stack sm horizontal ml-auto">
<CopyToClipboardPopover
trigger={
<SendouButton icon={<LinkIcon />} size="small" variant="outlined">
{t("calendar:icalFeed")}
</SendouButton>
}
url={calendarIcalFeed(data.filters)}
/>
<FiltersDialog
key={CalendarEvent.filtersToString(data.filters)}
filters={data.filters}
/>
</div>
</div>
<div
className={styles.columnsContainer}
style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME }}
>
{shown.map((date) => (
<DayEventsColumn
key={`${date.month}-${date.day}`}
date={date.day}
month={date.month}
eventTimes={data.eventTimes.filter((event) => {
const eventDate = new Date(event.at);
return (
eventDate.getDate() === date.day &&
eventDate.getMonth() === date.month
);
})}
/>
))}
</div>
</Main>
);
}
function WeekLinks() {
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [searchParams] = useSearchParams();
function NavigateButton({
icon,
children,
daysInterval,
filters,
}: {
icon: SendouButtonProps["icon"];
children: React.ReactNode;
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
filters?: CalendarLoaderData["filters"];
}) {
const { i18n } = useTranslation();
const lowestDate = daysInterval[0];
const highestDate = daysInterval[daysInterval.length - 1];
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()}`;
};
const dateToString = (
day: ReturnType<typeof daysForCalendar>["shown"][number],
) =>
new Date(new Date().getFullYear(), day.month, day.day).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "short",
},
);
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>
);
})}
<Link
to={calendarPage({ filters, dayMonthYear: lowestDate })}
className={clsx(styles.navigateButton, styles.navigateArrowButton)}
data-testid="calendar-navigate-button"
>
{icon}
<div>
<div>{children}</div>
<div className="text-xxs text-lighter">
{dateToString(lowestDate)} - {dateToString(highestDate)}
</div>
</div>
</Flipper>
</Link>
);
}
function WeekLinkTitle({
week,
function CalendarDatePicker({
dayMonthYear,
filters,
}: { dayMonthYear: DayMonthYear; filters?: CalendarLoaderData["filters"] }) {
const navigate = useNavigate();
const onChange = (date: DateValue) => {
navigate(
calendarPage({
filters,
dayMonthYear: {
day: date.day,
month: date.month - 1,
year: date.year,
},
}),
);
};
return (
<SendouPopover
trigger={
<SendouButton
className={styles.navigateButton}
icon={<CalendarIcon />}
/>
}
>
<SendouCalendar
className={styles.calendar}
value={dayMonthYearToDateValue(dayMonthYear)}
onChange={onChange}
/>
</SendouPopover>
);
}
function DayEventsColumn({
date,
month,
eventTimes,
}: {
week: Unpacked<SerializeFrom<typeof loader>["weeks"]>;
date: number;
month: number;
eventTimes: CalendarLoaderData["eventTimes"];
}) {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
const eventTimesCollapsed = useCollapsableEvents(eventTimes);
return (
<div>
<DayHeader date={date} month={month} />
<div className={styles.dayEvents}>
{eventTimesCollapsed.map((eventTime, i) => {
return (
<div key={eventTime.date.from.getTime()} className="stack md">
<ClockHeader
date={eventTime.date.from}
toDate={eventTime.date.to}
hiddenEventsCount={eventTime.hiddenCount}
hiddenShown={eventTime.hiddenShown}
onToggleHidden={eventTime.onToggleHidden}
className={i !== 0 ? "mt-4" : undefined}
/>
{eventTime.eventsShown.map((event) => (
<TournamentCard key={event.id} tournament={event} />
))}
</div>
);
})}
</div>
</div>
);
}
function DayHeader(props: { date: number; month: number }) {
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>
);
}
const date = new Date(new Date().getFullYear(), props.month, props.date);
const isToday = date.toDateString() === new Date().toDateString();
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 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();
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 (!Seasons.current(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
className={clsx(styles.dayHeader, {
[styles.dayHeaderToday]: isToday,
})}
data-testid={isToday ? "today-header" : undefined}
>
{date.toLocaleDateString(i18n.language, {
day: "numeric",
month: "long",
})}
<div className={styles.dayHeaderWeekday}>
{date.toLocaleDateString(i18n.language, {
weekday: "long",
})}
</div>
</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;
};
function ClockHeader({
date,
toDate,
hiddenEventsCount = 0,
onToggleHidden,
hiddenShown,
className,
}: {
date: Date;
toDate?: Date;
hiddenEventsCount?: number;
onToggleHidden: () => void;
hiddenShown: boolean;
className?: string;
}) {
const { i18n } = useTranslation();
const isInThePast = (toDate ?? date).getTime() < Date.now();
return (
<div className={clsx(className, styles.clockHeader)}>
<div className="stack horizontal justify-between">
<span
className={clsx({
"text-lighter italic": isInThePast,
})}
>
{date.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
})}
{toDate
? ` - ${toDate.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
})}`
: ""}
</span>
{hiddenEventsCount > 0 ? (
<SendouButton
icon={hiddenShown ? <EyeIcon /> : <EyeSlashIcon />}
onClick={onToggleHidden}
variant="minimal"
className={styles.hiddenEventsButton}
data-testid="hidden-events-button"
>
{hiddenEventsCount}
</SendouButton>
) : null}
</div>
<div className={styles.clockHeaderDivider} />
</div>
);
}

View File

@@ -1,6 +1,6 @@
import cachified from "@epic-web/cachified";
import { TWO_HOURS_IN_MS } from "~/constants";
import type { Tables } from "~/db/tables";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
import { cache, ttl } from "~/utils/cache.server";
@@ -9,36 +9,18 @@ import {
dateToDatabaseTimestamp,
} from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
import { tournamentPage } from "~/utils/urls";
interface ShowcaseTournamentCollection {
participatingFor: ShowcaseTournament[];
organizingFor: ShowcaseTournament[];
showcase: ShowcaseTournament[];
results: ShowcaseTournament[];
}
export interface ShowcaseTournament {
id: number;
name: string;
startTime: number;
teamsCount: number;
isRanked: boolean;
logoUrl: string | null;
organization: {
name: string;
slug: string;
} | null;
firstPlacer: {
teamName: string;
logoUrl: string | null;
members: (CommonUser & { country: Tables["User"]["country"] })[];
notShownMembersCount: number;
} | null;
participatingFor: ShowcaseCalendarEvent[];
organizingFor: ShowcaseCalendarEvent[];
showcase: ShowcaseCalendarEvent[];
results: ShowcaseCalendarEvent[];
}
interface ParticipationInfo {
participants: Set<ShowcaseTournament["id"]>;
organizers: Set<ShowcaseTournament["id"]>;
participants: Set<ShowcaseCalendarEvent["id"]>;
organizers: Set<ShowcaseCalendarEvent["id"]>;
}
export async function frontPageTournamentsByUserId(
@@ -152,7 +134,7 @@ export function updateCachedTournamentTeamCount({
async function cachedParticipationInfo(
userId: number | null,
tournaments: ShowcaseTournament[],
tournaments: ShowcaseCalendarEvent[],
): Promise<ParticipationInfo> {
if (!userId) {
return emptyParticipationInfo();
@@ -188,7 +170,7 @@ async function cachedTournaments() {
});
}
function deleteExtraResults(tournaments: ShowcaseTournament[]) {
function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
const nonResults = tournaments.filter(
(tournament) => !tournament.firstPlacer,
);
@@ -216,8 +198,8 @@ function deleteExtraResults(tournaments: ShowcaseTournament[]) {
}
function resolveShowcaseTournaments(
tournaments: ShowcaseTournament[],
): ShowcaseTournament[] {
tournaments: ShowcaseCalendarEvent[],
): ShowcaseCalendarEvent[] {
const happeningDuringNextWeek = tournaments.filter(
(tournament) =>
tournament.startTime > databaseTimestampSixHoursAgo() &&
@@ -237,7 +219,7 @@ function resolveShowcaseTournaments(
}
async function tournamentsToParticipationInfoMap(
tournaments: ShowcaseTournament[],
tournaments: ShowcaseCalendarEvent[],
): Promise<Map<CommonUser["id"], ParticipationInfo>> {
const tournamentIds = tournaments.map((tournament) => tournament.id);
const tournamentsWithUsers =
@@ -284,9 +266,12 @@ const MEMBERS_TO_SHOW = 5;
function mapTournamentFromDB(
tournament: TournamentRepository.ForShowcase,
): ShowcaseTournament {
): ShowcaseCalendarEvent {
return {
type: "showcase",
url: tournamentPage(tournament.id),
id: tournament.id,
authorId: tournament.authorId,
name: tournament.name,
startTime: tournament.startTime,
teamsCount: tournament.teamsCount,
@@ -303,6 +288,7 @@ function mapTournamentFromDB(
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
isTest: tournament.settings.isTest ?? false,
}),
modes: null, // no need to show modes for front page, maybe could in the future?
firstPlacer:
tournament.firstPlacers.length > 0
? {

View File

@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { Flag } from "~/components/Flag";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { NewTabs } from "~/components/NewTabs";
@@ -20,11 +19,11 @@ import { SearchIcon } from "~/components/icons/Search";
import { UsersIcon } from "~/components/icons/Users";
import { navItems } from "~/components/layout/nav-items";
import { useUser } from "~/features/auth/core/user";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
BLANK_IMAGE_URL,
@@ -35,10 +34,7 @@ import {
leaderboardsPage,
navIconUrl,
sqHeaderGuyImageUrl,
tournamentPage,
userSubmittedImage,
} from "~/utils/urls";
import type * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
import { type LeaderboardEntry, loader } from "../loaders/index.server";
export { loader };
@@ -239,7 +235,7 @@ function TournamentCards() {
function ShowcaseTournamentScroller({
tournaments,
}: { tournaments: ShowcaseTournaments.ShowcaseTournament[] }) {
}: { tournaments: ShowcaseCalendarEvent[] }) {
return (
<div className="front__tournament-cards">
<div className="front__tournament-cards__spacer overflow-x-scroll">
@@ -247,7 +243,7 @@ function ShowcaseTournamentScroller({
<TournamentCard
key={tournament.id}
tournament={tournament}
topSpaced
className="mt-4"
/>
))}
</div>
@@ -270,141 +266,6 @@ function AllTournamentsLinkCard() {
);
}
function TournamentCard({
tournament,
topSpaced,
}: {
tournament: ShowcaseTournaments.ShowcaseTournament;
topSpaced?: boolean;
}) {
const isMounted = useIsMounted();
const { t, i18n } = useTranslation(["front", "common"]);
const time = () => {
if (!isMounted) return "Placeholder";
const date = databaseTimestampToDate(tournament.startTime);
return date.toLocaleString(i18n.language, {
month: "short",
day: "numeric",
hour: "numeric",
weekday: "short",
minute: date.getMinutes() !== 0 ? "numeric" : undefined,
});
};
return (
<div
className={clsx("front__tournament-card__container", {
"front__tournament-card__container__tall": tournament.firstPlacer,
"mt-4": topSpaced,
})}
>
<Link
to={tournamentPage(tournament.id)}
className="front__tournament-card"
>
<div className="stack horizontal justify-between">
<div className="front__tournament-card__img-container">
<img
src={
tournament.logoUrl
? userSubmittedImage(tournament.logoUrl)
: HACKY_resolvePicture(tournament)
}
width={32}
height={32}
className="front__tournament-card__tournament-avatar-img"
alt=""
/>
</div>
{tournament.organization ? (
<div className="front__tournament-card__org">
{tournament.organization.name}
</div>
) : null}
</div>
<div className="front__tournament-card__name">
{tournament.name}{" "}
<time
className={clsx("front__tournament-card__time", {
invisible: !isMounted,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,
).toISOString()}
>
{time()}
</time>
</div>
{tournament.firstPlacer ? (
<TournamentFirstPlacers firstPlacer={tournament.firstPlacer} />
) : null}
</Link>
<div className="stack horizontal xxs justify-end">
<div className="front__tournament-card__team-count">
<UsersIcon /> {tournament.teamsCount}
</div>
{tournament.isRanked ? (
<div className="front__tournament-card__tag front__tournament-card__ranked">
{t("front:showcase.card.ranked")}
</div>
) : (
<div className="front__tournament-card__tag front__tournament-card__unranked">
{t("front:showcase.card.unranked")}
</div>
)}
</div>
</div>
);
}
function TournamentFirstPlacers({
firstPlacer,
}: {
firstPlacer: NonNullable<
ShowcaseTournaments.ShowcaseTournament["firstPlacer"]
>;
}) {
const { t } = useTranslation(["front"]);
return (
<div className="front__tournament-card__first-placers">
<div className="stack xs horizontal items-center text-xs">
{firstPlacer.logoUrl ? (
<img
src={userSubmittedImage(firstPlacer.logoUrl)}
alt=""
width={24}
className="rounded-full"
/>
) : null}{" "}
<div className="stack items-start">
<span className="front__tournament-card__first-placers__team-name">
{firstPlacer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
</div>
</div>
</div>
<div className="text-xxs stack items-start mt-1">
{firstPlacer.members.map((member) => (
<div key={member.id} className="stack horizontal xs items-center">
{member.country ? <Flag tiny countryCode={member.country} /> : null}
{member.username}{" "}
</div>
))}
{firstPlacer.notShownMembersCount > 0 ? (
<div className="font-bold text-lighter">
+{firstPlacer.notShownMembersCount}
</div>
) : null}
</div>
</div>
);
}
function ResultHighlights() {
const { t } = useTranslation(["front"]);
const data = useLoaderData<typeof loader>();

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { Label } from "~/components/Label";
import { DateTimeFormField } from "~/components/form/DateTimeFormField";
import { MyForm } from "~/components/form/MyForm";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { nullFilledArray } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -35,7 +35,7 @@ export default function NewScrimPage() {
return (
<Main>
<MyForm
<SendouForm
schema={scrimsNewActionSchema}
heading={t("scrims:forms.title")}
defaultValues={{
@@ -77,7 +77,7 @@ export default function NewScrimPage() {
name="postText"
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
/>
</MyForm>
</SendouForm>
</Main>
);
}

View File

@@ -13,7 +13,7 @@ import { Table } from "~/components/Table";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { MyForm } from "~/components/form/MyForm";
import { SendouForm } from "~/components/form/SendouForm";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { SpeechBubbleIcon } from "~/components/icons/SpeechBubble";
import { UsersIcon } from "~/components/icons/Users";
@@ -186,7 +186,7 @@ function RequestScrimModal({
return (
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
<MyForm
<SendouForm
schema={newRequestSchema}
defaultValues={{
_action: "NEW_REQUEST",
@@ -211,7 +211,7 @@ function RequestScrimModal({
) : null}
<Divider />
<WithFormField usersTeams={data.teams} />
</MyForm>
</SendouForm>
</SendouDialog>
);
}

View File

@@ -1,4 +1,3 @@
import * as R from "remeda";
import type {
Tables,
TournamentStage,
@@ -10,11 +9,13 @@ import {
LEAGUES,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
import {
modesIncluded,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Match, Stage } from "~/modules/brackets-model";
import type { ModeShort } from "~/modules/in-game-lists";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { isAdmin } from "~/modules/permissions/utils";
import {
databaseTimestampNow,
@@ -728,32 +729,7 @@ export class Tournament {
}
get modesIncluded(): ModeShort[] {
switch (this.ctx.mapPickingStyle) {
case "AUTO_SZ": {
return ["SZ"];
}
case "AUTO_TC": {
return ["TC"];
}
case "AUTO_RM": {
return ["RM"];
}
case "AUTO_CB": {
return ["CB"];
}
default: {
const pickedModes = R.unique(
this.ctx.toSetMapPool.map((map) => map.mode),
);
if (pickedModes.length === 0) {
return [...rankedModesShort];
}
return pickedModes.sort(
(a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
);
}
}
return modesIncluded(this.ctx.mapPickingStyle, this.ctx.toSetMapPool);
}
tournamentTeamLogoSrc(team: TournamentDataTeam) {

View File

@@ -1,7 +1,7 @@
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { modesIncluded } from "~/features/tournament/tournament-utils";
import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils";
import type { Round } from "~/modules/brackets-model";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
@@ -132,7 +132,7 @@ export function resolveFreshTeamPickedMapList(
if (count() === 1) {
return starterMap({
seed: String(args.matchId),
modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
tiebreakerMaps: new MapPool(tieBreakerMapPool),
teams: [
{
@@ -151,7 +151,7 @@ export function resolveFreshTeamPickedMapList(
return createTournamentMapList({
count: count(),
seed: String(args.matchId),
modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
tiebreakerMaps: new MapPool(tieBreakerMapPool),
teams: [
{
@@ -173,7 +173,7 @@ export function resolveFreshTeamPickedMapList(
return createTournamentMapList({
count: count(),
seed: String(args.matchId),
modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
tiebreakerMaps: new MapPool(tieBreakerMapPool),
teams: [
{

View File

@@ -7,11 +7,11 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { AddFieldButton } from "~/components/form/AddFieldButton";
import { FormFieldset } from "~/components/form/FormFieldset";
import { MyForm } from "~/components/form/MyForm";
import { InputFormField } from "~/components/form/InputFormField";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
import { TextFormField } from "~/components/form/TextFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
@@ -44,7 +44,7 @@ export default function TournamentOrganizationEditPage() {
return (
<Main>
<MyForm
<SendouForm
heading={t("org:edit.form.title")}
schema={organizationEditSchema}
defaultValues={{
@@ -74,9 +74,12 @@ export default function TournamentOrganizationEditPage() {
{t("org:edit.form.uploadLogo")}
</Link>
<TextFormField<FormFields> label={t("common:forms.name")} name="name" />
<InputFormField<FormFields>
label={t("common:forms.name")}
name="name"
/>
<TextAreaFormField<typeof organizationEditSchema>
<TextAreaFormField<FormFields>
label={t("common:forms.description")}
name="description"
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
@@ -84,16 +87,16 @@ export default function TournamentOrganizationEditPage() {
<MembersFormField />
<TextArrayFormField<typeof organizationEditSchema>
<TextArrayFormField<FormFields>
label={t("org:edit.form.socialLinks.title")}
name="socials"
defaultFieldValue=""
format="object"
/>
<SeriesFormField />
<BadgesFormField />
</MyForm>
</SendouForm>
</Main>
);
}
@@ -159,7 +162,7 @@ function MemberFieldset({
}))}
/>
<TextFormField<FormFields>
<InputFormField<FormFields>
label={t("org:edit.form.members.roleDisplayName.title")}
name={`members.${idx}.roleDisplayName` as const}
/>
@@ -213,7 +216,7 @@ function SeriesFieldset({
clearErrors("series");
}}
>
<TextFormField<FormFields>
<InputFormField<FormFields>
label={t("org:edit.form.series.seriesName.title")}
name={`series.${idx}.name` as const}
/>

View File

@@ -452,6 +452,7 @@ export function forShowcase() {
.select((eb) => [
"Tournament.id",
"Tournament.settings",
"CalendarEvent.authorId",
"CalendarEvent.name",
"CalendarEventDate.startTime",
eb
@@ -538,6 +539,36 @@ function databaseTimestampWeekAgo() {
return dateToDatabaseTimestamp(now);
}
export function findAllBetweenTwoTimestamps({
startTime,
endTime,
}: {
startTime: Date;
endTime: Date;
}) {
return db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.innerJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
.select(["Tournament.id as tournamentId"])
.where(
"CalendarEventDate.startTime",
">=",
dateToDatabaseTimestamp(startTime),
)
.where(
"CalendarEventDate.startTime",
"<=",
dateToDatabaseTimestamp(endTime),
)
.where("CalendarEvent.hidden", "=", 0)
.execute();
}
export function topThreeResultsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentResult")

View File

@@ -14,7 +14,6 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseFormData,
parseParams,
uploadImageIfSubmitted,
@@ -25,7 +24,6 @@ import { idObject } from "~/utils/zod";
import { checkIn } from "../queries/checkIn.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import deleteTeamMember from "../queries/deleteTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
@@ -52,7 +50,6 @@ export const action: ActionFunction = async ({ request, params }) => {
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
errorToastIfFalsy(
!tournament.hasStarted,
@@ -198,7 +195,10 @@ export const action: ActionFunction = async ({ request, params }) => {
errorToastIfFalsy(
validateCounterPickMapPool(
mapPool,
isOneModeTournamentOf(event),
isOneModeTournamentOf(
tournament.ctx.mapPickingStyle,
tournament.ctx.toSetMapPool,
),
tournament.ctx.tieBreakerMapPool,
) === "VALID",
"Invalid map pool",

View File

@@ -1,6 +1,7 @@
import * as R from "remeda";
import { INVITE_CODE_LENGTH } from "~/constants";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { weekNumberToDate } from "~/utils/dates";
import { tournamentLogoUrl } from "~/utils/urls";
import type { Tables, TournamentStageSettings } from "../../db/tables";
@@ -13,33 +14,46 @@ import type { TournamentData } from "../tournament-bracket/core/Tournament.serve
import type { PlayedSet } from "./core/sets.server";
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
const mapPickingStyleToModeRecord = {
AUTO_SZ: ["SZ"],
AUTO_TC: ["TC"],
AUTO_RM: ["RM"],
AUTO_CB: ["CB"],
AUTO_ALL: rankedModesShort,
} as const;
export const mapPickingStyleToModes = (
mapPickingStyle: Exclude<Tables["Tournament"]["mapPickingStyle"], "TO">,
) => {
return mapPickingStyleToModeRecord[mapPickingStyle].slice();
};
export function modesIncluded(
tournament: Pick<Tables["Tournament"], "mapPickingStyle">,
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"],
toSetMapPool: Array<{ mode: ModeShort }>,
): ModeShort[] {
switch (tournament.mapPickingStyle) {
case "AUTO_SZ": {
return ["SZ"];
}
case "AUTO_TC": {
return ["TC"];
}
case "AUTO_RM": {
return ["RM"];
}
case "AUTO_CB": {
return ["CB"];
}
default: {
return [...rankedModesShort];
}
if (mapPickingStyle !== "TO") {
return mapPickingStyleToModes(mapPickingStyle);
}
const pickedModes = R.unique(toSetMapPool.map((map) => map.mode));
// fallback
if (pickedModes.length === 0) {
return [...rankedModesShort];
}
return pickedModes.sort(
(a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
);
}
export function isOneModeTournamentOf(
tournament: Pick<Tables["Tournament"], "mapPickingStyle">,
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"],
toSetMapPool: Array<{ mode: ModeShort }>,
) {
return modesIncluded(tournament).length === 1
? modesIncluded(tournament)[0]
return modesIncluded(mapPickingStyle, toSetMapPool).length === 1
? modesIncluded(mapPickingStyle, toSetMapPool)[0]
: null;
}
@@ -370,3 +384,10 @@ export function validateCanJoinTeam({
return "VALID";
}
export function normalizedTeamCount({
teamsCount,
minMembersPerTeam,
}: { teamsCount: number; minMembersPerTeam: number }) {
return teamsCount * minMembersPerTeam;
}

View File

@@ -256,6 +256,7 @@ function ExtraInfos() {
return (
<div className="u__extra-infos">
<div className="u__extra-info">#{data.user.id}</div>
{data.user.discordUniqueName && (
<div className="u__extra-info">
<span className="u__extra-info__heading">

View File

@@ -27,9 +27,9 @@ import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Alert } from "../../../components/Alert";
import { DateFormField } from "../../../components/form/DateFormField";
import { MyForm } from "../../../components/form/MyForm";
import { InputFormField } from "../../../components/form/InputFormField";
import { SelectFormField } from "../../../components/form/SelectFormField";
import { TextFormField } from "../../../components/form/TextFormField";
import { SendouForm } from "../../../components/form/SendouForm";
import { videoMatchTypes } from "../vods-constants";
import { videoInputSchema } from "../vods-schemas";
@@ -58,7 +58,7 @@ export default function NewVodPage() {
return (
<Main halfWidth>
<MyForm
<SendouForm
heading={
data.vodToEdit
? t("vods:forms.title.edit")
@@ -83,7 +83,7 @@ export default function NewVodPage() {
}
>
<FormFields />
</MyForm>
</SendouForm>
</Main>
);
}
@@ -96,7 +96,7 @@ function FormFields() {
return (
<>
<TextFormField<VodFormFields>
<InputFormField<VodFormFields>
label={t("vods:forms.title.youtubeUrl")}
name="video.youtubeUrl"
placeholder="https://www.youtube.com/watch?v=-dQ6JsVIKdY"
@@ -104,7 +104,7 @@ function FormFields() {
size="medium"
/>
<TextFormField<VodFormFields>
<InputFormField<VodFormFields>
label={t("vods:forms.title.videoTitle")}
name="video.title"
placeholder="[SCL 47] (Grand Finals) Team Olive vs. Kraken Paradise"
@@ -278,7 +278,7 @@ function MatchesFieldset({
{canRemove ? <RemoveFieldButton onClick={() => remove(idx)} /> : null}
</div>
<TextFormField<VodFormFields>
<InputFormField<VodFormFields>
required
label={t("vods:forms.title.startTimestamp")}
name={`video.matches.${idx}.startsAt`}

View File

@@ -0,0 +1,2 @@
export const gamesShort = ["S1", "S2", "S3"] as const;
export const versusShort = ["1v1", "2v2", "3v3", "4v4"] as const;

View File

@@ -1,4 +1,4 @@
export { modes, modesShort } from "./modes";
export { modesShort } from "./modes";
export {
weaponCategories,
mainWeaponIds,

View File

@@ -1,12 +1,6 @@
import type { RankedModeShort } from "./types";
export const modes = [
{ short: "TW" },
{ short: "SZ" },
{ short: "TC" },
{ short: "RM" },
{ short: "CB" },
] as const;
export const modesShort = modes.map((mode) => mode.short);
export const modesShort = ["TW", "SZ", "TC", "RM", "CB"] as const;
export const rankedModesShort = modesShort.slice(1) as RankedModeShort[];
export const modesShortWithSpecial = [...modesShort, "TB", "SR"] as const;

View File

@@ -1,5 +1,5 @@
import type { abilities } from "./abilities";
import type { modes } from "./modes";
import type { modesShort, modesShortWithSpecial } from "./modes";
import type { stageIds } from "./stage-ids";
import type {
mainWeaponIds,
@@ -7,8 +7,9 @@ import type {
subWeaponIds,
} from "./weapon-ids";
export type ModeShort = (typeof modes)[number]["short"];
export type RankedModeShort = "SZ" | "TC" | "RM" | "CB";
export type ModeShort = (typeof modesShort)[number];
export type ModeShortWithSpecial = (typeof modesShortWithSpecial)[number];
export type RankedModeShort = Exclude<ModeShort, "TW">;
export type StageId = (typeof stageIds)[number];

View File

@@ -1,6 +1,6 @@
import * as CalendarRepository from "../features/calendar/CalendarRepository.server";
import { notify } from "../features/notifications/core/notify.server";
import { tournamentDataCached } from "../features/tournament-bracket/core/Tournament.server";
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
@@ -9,10 +9,9 @@ export const NotifyCheckInStartRoutine = new Routine({
func: async () => {
const now = new Date();
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
const tournaments = await CalendarRepository.findAllBetweenTwoTimestamps({
const tournaments = await TournamentRepository.findAllBetweenTwoTimestamps({
startTime: now,
endTime: oneHourFromNow,
onlyTournaments: true,
});
for (const { tournamentId } of tournaments) {

View File

@@ -1,191 +0,0 @@
.calendar__placeholder {
height: 48rem;
}
.calendar__weeks {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
overflow-x: hidden;
padding-inline: var(--s-2-5);
--full-size-week-height: 6.75rem;
--full-size-week-width: 5rem;
}
.calendar__week {
display: flex;
min-width: calc(var(--full-size-week-width) - 2rem);
height: auto;
min-height: calc(var(--full-size-week-height) - 2rem);
flex-direction: column;
justify-content: space-between;
padding: var(--s-1-5);
border-radius: var(--rounded);
background-color: var(--theme-very-transparent);
cursor: pointer;
font-size: var(--fonts-xxxs);
font-weight: var(--bold);
text-align: center;
}
.calendar__week:hover {
background-color: var(--theme-transparent);
}
.calendar__week:focus {
outline: 2px solid var(--theme);
}
.calendar__event-count {
font-size: var(--fonts-xxxs);
font-weight: var(--body);
}
.calendar__week:nth-child(1),
.calendar__week:nth-child(2),
.calendar__week:nth-child(8),
.calendar__week:nth-child(9) {
min-width: calc(var(--full-size-week-width) - 2.5rem);
height: calc(var(--full-size-week-height) - 2.5rem);
cursor: initial;
font-size: var(--fonts-xxxxs);
opacity: 0;
}
.calendar__week:nth-child(5) {
min-width: var(--full-size-week-width);
min-height: var(--full-size-week-height);
color: var(--text);
font-size: var(--fonts-sm);
}
.calendar__week:nth-child(5) > .calendar__event-count {
font-size: var(--fonts-sm);
}
.calendar__week:nth-child(4),
.calendar__week:nth-child(6) {
min-width: calc(var(--full-size-week-width) - 1rem);
min-height: calc(var(--full-size-week-height) - 1rem);
font-size: var(--fonts-xs);
}
.calendar__week:nth-child(4) > .calendar__event-count,
.calendar__week:nth-child(6) > .calendar__event-count {
font-size: var(--fonts-xxs);
}
.calendar__week__relative {
max-width: 3rem;
margin: 0 auto;
text-align: center;
white-space: pre-line;
}
.calendar__week__dash {
line-height: 0.8;
}
.calendar__events-to-report {
line-height: 1.25rem;
}
.calendar__events-container {
background-color: var(--bg-lighter);
}
.calendar__event {
display: flex;
flex-direction: column;
padding-top: var(--s-3);
font-size: var(--fonts-lg);
padding-inline: var(--s-4);
}
.calendar__event + .calendar__event {
border-top: 2px solid var(--divider);
}
.calendar__event:last-child {
padding-block-end: var(--s-4);
}
.calendar__event__date-container {
background-color: var(--bg);
}
.calendar__event__divider {
padding-block-start: 40px;
}
.calendar__event__date {
font-weight: var(--semi-bold);
padding-block-start: var(--s-6);
}
.calendar__event__time {
font-weight: var(--semi-bold);
}
.calendar__event__top-info-container {
display: flex;
height: 1.25rem;
align-items: center;
font-size: var(--fonts-sm);
gap: var(--s-2);
}
.calendar__event__author {
color: var(--text-lighter);
}
.calendar__event__title {
color: var(--text);
font-size: var(--fonts-xl);
line-height: 1.35;
}
.calendar__event__day {
color: var(--text-lighter);
}
.calendar__event__participant-counts {
display: flex;
align-items: center;
color: var(--text-lighter);
font-size: var(--fonts-xs);
gap: var(--s-2);
margin-block-end: var(--s-1);
}
.calendar__event__participant-counts > svg {
width: 1rem;
margin-block-end: 1px;
}
.calendar__event__bottom-info-container {
display: flex;
align-items: center;
font-size: var(--fonts-sm);
gap: var(--s-2);
}
.calendar__no-events {
color: var(--text-lighter);
padding-block: var(--s-16);
text-align: center;
}
.calendar__time-zone-info {
color: var(--text-lighter);
font-size: var(--fonts-xs);
padding-block-end: var(--s-2);
text-align: center;
}
.calendar__event-logo {
border-radius: 100%;
min-width: 40px;
}

View File

@@ -712,6 +712,18 @@ abbr[title] {
list-style: none;
}
.calendar__event__tags.small {
font-size: var(--fonts-xxxs);
}
.calendar__event__tags.centered {
justify-content: center;
}
.calendar__event__tags.small > li {
padding: 0 var(--s-1);
}
.calendar__event__tags > li {
display: flex;
align-items: center;
@@ -724,20 +736,6 @@ abbr[title] {
color: var(--black-text);
}
.calendar__event__badge-tag {
color: var(--badge-text);
}
.calendar__event__ranked-tag {
background-color: var(--theme-info-transparent);
color: var(--theme-info);
}
.calendar__event__unranked-tag {
background-color: var(--theme-success-transparent);
color: var(--theme-success);
}
.calendar__event__tag-delete-button {
margin-left: auto;
}

View File

@@ -195,114 +195,6 @@
background-color: var(--bg-lightest);
}
.front__tournament-card__container {
min-width: var(--card-width);
max-width: var(--card-width);
height: calc(var(--card-height) + 22px);
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.front__tournament-card__container__tall {
height: 100%;
}
.front__tournament-card {
background-color: var(--bg-lightest);
border-radius: var(--rounded);
color: var(--text);
padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2);
height: 100%;
min-width: var(--card-width);
max-width: var(--card-width);
}
.front__tournament-card:hover .front__tournament-card__tournament-avatar-img {
outline: 6px solid var(--theme-transparent);
}
.front__tournament-card__img-container {
background-color: var(--bg);
padding: var(--s-1-5);
border-radius: 100%;
margin-left: -10px;
margin-top: -10px;
}
.front__tournament-card__tournament-avatar-img {
border-radius: 100%;
}
.front__tournament-card__org {
display: flex;
gap: var(--s-1);
font-size: var(--fonts-xxs);
color: var(--text-lighter);
}
.front__tournament-card__time {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text-lighter);
display: block;
}
.front__tournament-card__name {
text-align: center;
font-weight: var(--semi-bold);
font-size: var(--fonts-sm);
white-space: nowrap;
text-overflow: ellipsis;
max-width: 225px;
overflow: hidden;
}
.front__tournament-card__team-count {
font-size: var(--fonts-xxs);
font-weight: var(--bold);
background-color: var(--bg-lightest);
border-radius: var(--rounded-sm);
width: max-content;
padding: var(--s-0-5) var(--s-1-5);
display: flex;
gap: var(--s-1);
}
.front__tournament-card__team-count svg {
width: 12px;
}
.front__tournament-card__tag {
font-size: var(--fonts-xxs);
font-weight: var(--bold);
border-radius: var(--rounded-sm);
padding: var(--s-0-5) var(--s-1-5);
}
.front__tournament-card__ranked {
background-color: var(--theme-info-transparent);
color: var(--theme-info);
}
.front__tournament-card__unranked {
background-color: var(--theme-success-transparent);
color: var(--theme-success);
}
.front__tournament-card__first-placers {
margin-inline: auto;
margin-top: var(--s-5);
width: max-content;
}
.front__tournament-card__first-placers__team-name {
max-width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.front__result-highlights {
display: flex;
gap: var(--s-2);

View File

@@ -402,7 +402,6 @@
.layout__overlay-nav__dialog {
padding-block: var(--s-12) !important;
padding-block-end: var(--s-32) !important;
padding-inline: 0 !important;
}

View File

@@ -1,9 +1,14 @@
import { parseDate } from "@internationalized/date";
import { getWeek } from "date-fns";
import type { MonthYear } from "~/features/plus-voting/core";
import type { DayMonthYear } from "./zod";
export function databaseTimestampToDate(timestamp: number) {
return new Date(timestamp * 1000);
return new Date(databaseTimestampToJavascriptTimestamp(timestamp));
}
export function databaseTimestampToJavascriptTimestamp(timestamp: number) {
return timestamp * 1000;
}
export function dateToDatabaseTimestamp(date: Date) {
@@ -21,6 +26,15 @@ export function dayMonthYearToDate({ day, month, year }: DayMonthYear) {
return new Date(Date.UTC(year, month, day, 12));
}
/**
* Converts a date represented by day, month, and year into a DateValue object (used by react-aria-components), noon UTC.
*/
export function dayMonthYearToDateValue({ day, month, year }: DayMonthYear) {
const isoString = dateToYYYYMMDD(new Date(Date.UTC(year, month, day, 12)));
return parseDate(isoString);
}
/**
* Converts a date represented by day, month, and year into a database timestamp, noon UTC.
*/

View File

@@ -59,6 +59,11 @@ export async function selectComboboxValue({
/** page.goto that waits for the page to be hydrated before proceeding */
export async function navigate({ page, url }: { page: Page; url: string }) {
await page.goto(url);
await expectIsHydrated(page);
}
/** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */
export async function expectIsHydrated(page: Page) {
await expect(page.getByTestId("hydrated")).toHaveCount(1);
}

View File

@@ -1,3 +1,11 @@
/**
* Asserts that a code path is unreachable by accepting a value of type `never`.
* This function is useful for exhaustive checks in switch statements or discriminated unions.
* If called, it throws an error with a message containing the unexpected value.
*
* @param x - The value that should never occur (of type `never`).
* @throws {Error} Throws an error indicating an unexpected value was encountered.
*/
export function assertUnreachable(x: never): never {
throw new Error(
`Didn't expect to get here. Unexpected value: ${JSON.stringify(x)}`,

View File

@@ -3,6 +3,7 @@ import type { GearType, Preference, Tables } from "~/db/tables";
import type { ArtSource } from "~/features/art/art-types";
import type { AuthErrorCode } from "~/features/auth/core/errors";
import { serializeBuild } from "~/features/build-analyzer";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { StageBackgroundStyle } from "~/features/map-planner";
import type { TierName } from "~/features/mmr/mmr-constants";
import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants";
@@ -12,10 +13,12 @@ import type {
AbilityWithUnknown,
BuildAbilitiesTupleWithUnknown,
MainWeaponId,
ModeShortWithSpecial,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import type { DayMonthYear } from "~/utils/zod";
const staticAssetsUrl = ({
folder,
@@ -262,6 +265,31 @@ export const weaponBuildStatsPage = (weaponSlug: string) =>
export const weaponBuildPopularPage = (weaponSlug: string) =>
`${weaponBuildPage(weaponSlug)}/popular`;
export const calendarPage = (args?: {
filters?: CalendarFilters;
dayMonthYear?: DayMonthYear;
}) => {
const params = new URLSearchParams();
if (args?.filters) {
params.set("filters", JSON.stringify(args.filters));
}
if (args?.dayMonthYear) {
params.set("day", String(args.dayMonthYear.day));
params.set("month", String(args.dayMonthYear.month));
params.set("year", String(args.dayMonthYear.year));
}
return `${CALENDAR_PAGE}${params.toString() ? `?${params.toString()}` : ""}`;
};
export const calendarIcalFeed = (filters?: CalendarFilters) => {
const params = new URLSearchParams();
if (filters) {
params.set("filters", JSON.stringify(filters));
}
return `${SENDOU_INK_BASE_URL}/calendar.ics${params.toString() ? `?${params.toString()}` : ""}`;
};
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
export const calendarEditPage = (eventId?: number) =>
`/calendar/new${eventId ? `?eventId=${eventId}` : ""}`;
@@ -452,7 +480,7 @@ export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) =>
`/static-assets/img/special-weapons/${specialWeaponSplId}`;
export const abilityImageUrl = (ability: AbilityWithUnknown) =>
`/static-assets/img/abilities/${ability}`;
export const modeImageUrl = (mode: ModeShort) =>
export const modeImageUrl = (mode: ModeShortWithSpecial) =>
`/static-assets/img/modes/${mode}`;
export const stageImageUrl = (stageId: StageId) =>
`/static-assets/img/stages/${stageId}`;

View File

@@ -106,6 +106,17 @@ export const qWeapon = z.object({
});
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
export const modeShortWithSpecial = z.enum([
"TW",
"SZ",
"TC",
"RM",
"CB",
"SR",
"TB",
]);
export const gamesShortSchema = z.enum(["S1", "S2", "S3"]);
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
@@ -334,9 +345,9 @@ export function numericEnum<TValues extends readonly number[]>(
}
export const dayMonthYear = z.object({
day: z.number().int().min(1).max(31),
month: z.number().int().min(0).max(11),
year: z.number().int().min(2015).max(2100),
day: z.coerce.number().int().min(1).max(31),
month: z.coerce.number().int().min(0).max(11),
year: z.coerce.number().int().min(2015).max(2100),
});
export type DayMonthYear = z.infer<typeof dayMonthYear>;

View File

@@ -34,7 +34,7 @@ test.describe("Badges", () => {
await expect(page).toHaveURL(badgePage(1));
await page.getByTestId("notifications-button").click();
await page.getByText("See all").click();
await page.getByTestId("notifications-see-all-button").click();
await expect(
page.getByRole("heading", { name: "Notifications" }),

104
e2e/calendar.spec.ts Normal file
View File

@@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import {
expectIsHydrated,
impersonate,
isNotVisible,
navigate,
seed,
} from "~/utils/playwright";
import { calendarPage } from "~/utils/urls";
const SENDOU_INK_TOURNAMENTS_COUNT = 6;
test.describe("Calendar", () => {
test("applies filters and operates hidden events toggle", async ({
page,
}) => {
await seed(page);
await navigate({
page,
url: calendarPage(),
});
await page.getByTestId("filter-events-button").click();
await page.getByText("Only events hosted on sendou.ink").click();
await page.getByText("Apply", { exact: true }).click();
const tournamentCardLocator = page.getByTestId("tournament-card");
const hiddenEventsToggleButtonLocator = page.getByTestId(
"hidden-events-button",
);
await expect(tournamentCardLocator).toHaveCount(
SENDOU_INK_TOURNAMENTS_COUNT,
);
await page.reload();
await expectIsHydrated(page);
// remembers selection via search params
await expect(tournamentCardLocator).toHaveCount(
SENDOU_INK_TOURNAMENTS_COUNT,
);
await hiddenEventsToggleButtonLocator.first().click();
await expect
.poll(() => tournamentCardLocator.count())
.toBeGreaterThan(SENDOU_INK_TOURNAMENTS_COUNT);
const countAfterToggle = await tournamentCardLocator.count();
await hiddenEventsToggleButtonLocator.first().click();
await expect
.poll(() => tournamentCardLocator.count())
.toBeLessThan(countAfterToggle); // not SENDOU_INK_TOURNAMENTS_COUNT as it's possible we untoggle more than one tournament
});
test("sets default filters", async ({ page }) => {
await seed(page);
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: calendarPage(),
});
const hiddenEventsToggleButtonLocator = page.getByTestId(
"hidden-events-button",
);
await isNotVisible(hiddenEventsToggleButtonLocator);
await page.getByTestId("filter-events-button").click();
await page.getByText("Only ranked events").click();
await page.getByText("Apply & make default", { exact: true }).click();
await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible();
await navigate({
page,
url: calendarPage(),
});
// remembers selection via user preferences
await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible();
});
test("navigates view more buttons", async ({ page }) => {
await seed(page);
await navigate({
page,
url: calendarPage(),
});
await page.getByTestId("calendar-navigate-button").first().click();
await isNotVisible(page.getByTestId("today-header"));
await page.getByTestId("calendar-navigate-button").nth(1).click();
await expect(page.getByTestId("today-header")).toBeVisible();
});
});

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "Discordserver-invitations-URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Vælg et tag",
"forms.tags.info": "\"Præmiemærker\" tag tilføjes automatisk, hvis den er anvendelig",
"forms.badges": "Præmiemærker",
"forms.badges.placeholder": "Vælg et premiemærke",
"forms.mapPool": "Banepulje",

View File

@@ -92,7 +92,6 @@
"errors.genericReload": "Noget gik galt. Prøv at genindlæse siden.",
"tag.name.BADGE": "Premiemærker",
"tag.name.SPECIAL": "Særregler",
"tag.name.ART": "Kunstpræmier",
"tag.name.MONEY": "Pengepræmier",

View File

@@ -17,7 +17,6 @@
"forms.discordInvite": "Discord-Server Einladungs-URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Wähle einen Tag",
"forms.tags.info": "\"Abzeichen-Preis\"-Tag wird automatisch hinzugefügt (falls anwendbar)",
"forms.badges": "Abzeichen-Preis",
"forms.badges.placeholder": "Wähle ein Abzeichen für das Event",
"forms.mapPool": "Arenen-Pool",

View File

@@ -89,7 +89,6 @@
"errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.",
"tag.name.BADGE": "Abzeichen-Preise",
"tag.name.SPECIAL": "Spezielle Regeln",
"tag.name.ART": "Kunst-Preise",
"tag.name.MONEY": "Geld-Preise",

View File

@@ -1,8 +1,5 @@
{
"inYourTimeZone": "Times in your local time zone:",
"addNew": "Add new",
"noEvents": "No events for the selected week",
"reportResults": "You can report results:",
"day": "Day {{number}}",
"actions.reportWinners": "Report winners",
"actions.delete": "Delete event",
@@ -12,8 +9,6 @@
"members": "Members",
"results": "Results",
"createMapList": "Create map list",
"from": "From {{author}}",
"pastEvents.dividerText": "Past events",
"count.teams_one": "{{count}} team",
"count.players_one": "{{count}} player",
"count.teams_other": "{{count}} teams",
@@ -24,7 +19,6 @@
"forms.discordInvite": "Discord server invite URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Choose a tag",
"forms.tags.info": "\"Badge prizes\" tag is added automatically if applicable",
"forms.badges": "Badge prizes",
"forms.badges.placeholder": "Choose a badge prize",
"forms.mapPool": "Map pool",
@@ -47,11 +41,6 @@
"forms.errors.duplicatePlayer": "Can't have the same player twice in the same team.",
"forms.errors.emptyTeam": "Each team must have at least one player.",
"week.this": "This Week",
"week.next": "Next Week",
"week.last": "Last Week",
"tag.desc.BADGE": "Winner of this event gets a sendou.ink badge.",
"tag.desc.SPECIAL": "Ruleset that differs from the standard e.g. limited what weapons can be used.",
"tag.desc.ART": "You can win art by playing in this tournament.",
"tag.desc.MONEY": "You can win money by playing in this tournament.",
@@ -66,7 +55,34 @@
"tag.desc.S2": "The game played is Splatoon 2.",
"tag.desc.SR": "Salmon Run event.",
"tag.desc.CARDS": "Tableturf Battle event.",
"tag.filter.label": "Filter by tags",
"tournament.filter.label": "Hosted on sendou.ink"
"icalFeed": "iCal feed",
"filter.button": "Filter",
"filter.heading": "Filter calendar events",
"filter.modes": "Modes",
"filter.exactModes": "Exact modes",
"filter.exactModesBottom": "Only show events that match all selected modes",
"filter.games": "Games",
"filter.vs": "Vs.",
"filter.vs.4v4": "4v4",
"filter.vs.3v3": "3v3",
"filter.vs.2v2": "2v2",
"filter.vs.1v1": "1v1",
"filter.startTime": "Start time",
"filter.startTime.any": "Any",
"filter.startTime.eu": "Europe friendly",
"filter.startTime.na": "Americas friendly",
"filter.startTime.au": "AU/NZ friendly",
"filter.tagsIncluded": "Tags included",
"filter.tagsExcluded": "Tags excluded",
"filter.isSendou": "Only events hosted on sendou.ink",
"filter.isRanked": "Only ranked events",
"filter.minTeamCount": "Minimum team count",
"filter.orgsIncluded": "Visible organizations",
"filter.orgsExcluded": "Hidden organizations",
"filter.authorIdsExcluded": "Authors excluded",
"filter.authorIdsExcludedBottom": "You can find a user's id on their profile page",
"filter.apply": "Apply",
"filter.applyAndDefault": "Apply & make default"
}

View File

@@ -123,6 +123,8 @@
"actions.enable": "Enable",
"actions.disable": "Disable",
"actions.accept": "Accept",
"actions.next": "Next",
"actions.previous": "Previous",
"noResults": "No results",
@@ -165,7 +167,6 @@
"errors.genericReload": "Something went wrong. Try reloading the page.",
"tag.name.BADGE": "Badge prizes",
"tag.name.SPECIAL": "Special rules",
"tag.name.ART": "Art prizes",
"tag.name.MONEY": "Money prizes",

View File

@@ -58,5 +58,10 @@
"MODE_LONG_SZ": "Splat Zones",
"MODE_LONG_TC": "Tower Control",
"MODE_LONG_RM": "Rainmaker",
"MODE_LONG_CB": "Clam Blitz"
"MODE_LONG_CB": "Clam Blitz",
"MODE_LONG_SR": "Salmon Run",
"MODE_LONG_TB": "Tableturf Battle",
"GAME_S1": "Splatoon 1",
"GAME_S2": "Splatoon 2",
"GAME_S3": "Splatoon 3"
}

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "Enlace de invitación a Discord",
"forms.tags": "Etiquetas",
"forms.tags.placeholder": "Elegir etiquetas",
"forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.",
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
"forms.mapPool": "Grupo de mapas",

View File

@@ -98,7 +98,6 @@
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
"tag.name.BADGE": "Premios de insignia",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "Enlace de invitación a Discord",
"forms.tags": "Etiquetas",
"forms.tags.placeholder": "Elegir etiquetas",
"forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.",
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
"forms.mapPool": "Grupo de mapas",

View File

@@ -104,7 +104,6 @@
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
"tag.name.BADGE": "Premios de insignia",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "Lien d'invitation du Discord",
"forms.tags": "Tags",
"forms.tags.placeholder": "Choisir un tag",
"forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible",
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
"forms.mapPool": "Stages disponibles",

View File

@@ -95,7 +95,6 @@
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
"tag.name.BADGE": "Badge à gagner",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "Lien d'invitation du Discord",
"forms.tags": "Tags",
"forms.tags.placeholder": "Choisir un tag",
"forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible",
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
"forms.mapPool": "Stages disponibles",

View File

@@ -165,7 +165,6 @@
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
"tag.name.BADGE": "Badge à gagner",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "כתובת URL הזמנה לשרת Discord",
"forms.tags": "תגים",
"forms.tags.placeholder": "בחירת תג",
"forms.tags.info": "\"Badge prizes\" תג נוסף אוטומטית אם רלוונטי",
"forms.badges": "פרסי תגים",
"forms.badges.placeholder": "בחירת פרס תג",
"forms.mapPool": "מאגר מפות",

View File

@@ -95,7 +95,6 @@
"errors.genericReload": "משהו השתבש. נסו לטעון מחדש את העמוד.",
"tag.name.BADGE": "פרסי תג",
"tag.name.SPECIAL": "חוקים מיוחדים",
"tag.name.ART": "פרסי ציור",
"tag.name.MONEY": "פרסים בכסף",

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "URL invito server Discord",
"forms.tags": "Tag",
"forms.tags.placeholder": "Scegli un tag",
"forms.tags.info": "Il tag \"Badge prizes\" è aggiunto automaticamente se necessario",
"forms.badges": "Medaglie in palio",
"forms.badges.placeholder": "Scegli una medaglia come premio",
"forms.mapPool": "Pool di scenari",

View File

@@ -151,7 +151,6 @@
"errors.genericReload": "Qualcosa è andato storto. Prova a ricaricare la pagina.",
"tag.name.BADGE": "Premi medaglie",
"tag.name.SPECIAL": "Regole speciali",
"tag.name.ART": "Premi artistici",
"tag.name.MONEY": "Premi monetari",

View File

@@ -24,7 +24,6 @@
"forms.discordInvite": "Discord サーバー招待 URL",
"forms.tags": "タグ",
"forms.tags.placeholder": "タグを選択",
"forms.tags.info": "\"バッジプライズ\" タグは可能な場合に自動的に適用されます",
"forms.badges": "バッジプライズ",
"forms.badges.placeholder": "バッジプライズを選択する",
"forms.mapPool": "ステージプール",

View File

@@ -115,7 +115,6 @@
"errors.genericReload": "エラーが発生しました。ページを再読込してください。",
"tag.name.BADGE": "バッジプライズ",
"tag.name.SPECIAL": "特別ルール",
"tag.name.ART": "イラスト賞品",
"tag.name.MONEY": "賞金",

Some files were not shown because too many files have changed in this diff Show More