Scrimprovements (#2603)

This commit is contained in:
Kalle 2025-10-25 17:46:17 +03:00 committed by GitHub
parent 47c011dc2f
commit 510491c039
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
118 changed files with 4171 additions and 1295 deletions

View File

@ -5,6 +5,7 @@ import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
const dimensions = {
xxxs: 16,
xxxsm: 20,
xxs: 24,
xs: 36,
sm: 44,

View File

@ -0,0 +1,71 @@
.item {
font-size: var(--fonts-xsm);
font-weight: var(--semi-bold);
padding: var(--s-1-5);
border-radius: var(--rounded-sm);
height: 33px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
align-items: center;
gap: var(--s-2);
}
.popover {
min-height: 250px;
}
.itemTextsContainer {
line-height: 1.1;
}
.itemTextsContainer span {
max-width: 175px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
}
.selectValue {
max-width: calc(var(--select-width) - 55px);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
gap: var(--s-2);
}
button:disabled .selectValue {
color: var(--text-lighter);
font-style: italic;
}
.placeholder {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text-lighter);
text-align: center;
display: grid;
place-items: center;
height: 162px;
margin-block: var(--s-4);
}
.itemAdditionalText {
font-size: var(--fonts-xxsm);
color: var(--text-lighter);
}
button .itemAdditionalText {
display: none;
}
.logo {
width: 24px;
height: 24px;
border-radius: var(--rounded-sm);
object-fit: cover;
}

View File

@ -0,0 +1,224 @@
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import { format, sub } from "date-fns";
import * as React from "react";
import {
Autocomplete,
Button,
Input,
type Key,
ListBox,
ListBoxItem,
Popover,
SearchField,
Select,
type SelectProps,
SelectValue,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouLabel } from "~/components/elements/Label";
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
import { CrossIcon } from "~/components/icons/Cross";
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
import { databaseTimestampToDate } from "~/utils/dates";
import { SearchIcon } from "../icons/Search";
import selectStyles from "./Select.module.css";
import tournamentSearchStyles from "./TournamentSearch.module.css";
type TournamentSearchItem = NonNullable<
Extract<TournamentSearchLoaderData, { tournaments: unknown }>
>["tournaments"][number];
interface TournamentSearchProps<T extends object>
extends Omit<SelectProps<T>, "children"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialTournamentId?: number;
onChange?: (tournament: TournamentSearchItem) => void;
}
export const TournamentSearch = React.forwardRef(function TournamentSearch<
T extends object,
>(
{
name,
label,
bottomText,
errorText,
initialTournamentId,
onChange,
...rest
}: TournamentSearchProps<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
const [selectedKey, setSelectedKey] = React.useState(
initialTournamentId ?? null,
);
const list = useTournamentSearch(setSelectedKey);
const onSelectionChange = (tournamentId: number) => {
setSelectedKey(tournamentId);
const tournament = list.items.find(
(tournament) =>
typeof tournament.id === "number" && tournament.id === tournamentId,
);
if (tournament && typeof tournament.id === "number") {
onChange?.(tournament as TournamentSearchItem);
}
};
return (
<Select
name={name}
placeholder=""
selectedKey={selectedKey}
onSelectionChange={onSelectionChange as (key: Key | null) => void}
aria-label="Tournament search"
{...rest}
>
{label ? (
<SendouLabel required={rest.isRequired}>{label}</SendouLabel>
) : null}
<Button className={selectStyles.button} ref={ref}>
<SelectValue className={tournamentSearchStyles.selectValue} />
<span aria-hidden="true">
<ChevronUpDownIcon className={selectStyles.icon} />
</span>
</Button>
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover
className={clsx(selectStyles.popover, tournamentSearchStyles.popover)}
>
<Autocomplete
inputValue={list.filterText}
onInputChange={list.setFilterText}
>
<SearchField
aria-label="Search"
autoFocus
className={selectStyles.searchField}
>
<SearchIcon aria-hidden className={selectStyles.smallIcon} />
<Input
className={clsx("plain", selectStyles.searchInput)}
data-testid="tournament-search-input"
/>
<Button className={selectStyles.searchClearButton}>
<CrossIcon className={selectStyles.smallIcon} />
</Button>
</SearchField>
<ListBox
items={list.items.filter((tournament) => tournament !== undefined)}
className={selectStyles.listBox}
>
{(item) => <TournamentItem item={item as TournamentSearchItem} />}
</ListBox>
</Autocomplete>
</Popover>
</Select>
);
});
function TournamentItem({
item,
}: {
item:
| TournamentSearchItem
| {
id: "NO_RESULTS";
}
| {
id: "PLACEHOLDER";
};
}) {
const { t } = useTranslation(["common"]);
if (typeof item.id === "string") {
return (
<ListBoxItem
textValue="PLACEHOLDER"
isDisabled
className={tournamentSearchStyles.placeholder}
>
{item.id === "PLACEHOLDER"
? t("common:forms.tournamentSearch.placeholder")
: t("common:forms.tournamentSearch.noResults")}
</ListBoxItem>
);
}
const additionalText = () => {
const date = databaseTimestampToDate(item.startTime);
return format(date, "MMM d, yyyy");
};
return (
<ListBoxItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(tournamentSearchStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="tournament-search-item"
>
<img src={item.logoSrc} alt="" className={tournamentSearchStyles.logo} />
<div className={tournamentSearchStyles.itemTextsContainer}>
<span>{item.name}</span>
{additionalText() ? (
<div className={tournamentSearchStyles.itemAdditionalText}>
{additionalText()}
</div>
) : null}
</div>
</ListBoxItem>
);
}
function useTournamentSearch(
setSelectedKey: (tournamentId: number | null) => void,
) {
const [filterText, setFilterText] = React.useState("");
const queryFetcher = useFetcher<TournamentSearchLoaderData>();
useDebounce(
() => {
if (!filterText) return;
queryFetcher.load(
`/to/search?q=${filterText}&limit=6&minStartTime=${sub(new Date(), { days: 7 }).toISOString()}`,
);
setSelectedKey(null);
},
500,
[filterText],
);
const items = () => {
if (
queryFetcher.data &&
!Array.isArray(queryFetcher.data) &&
queryFetcher.data.query === filterText
) {
if (queryFetcher.data.tournaments.length === 0) {
return [{ id: "NO_RESULTS" }];
}
return queryFetcher.data.tournaments;
}
return [{ id: "PLACEHOLDER" }];
};
return {
filterText,
setFilterText,
items: items(),
};
}

View File

@ -1,7 +1,6 @@
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants";
import {
CALENDAR_NEW_PAGE,
lfgNewPostPage,
@ -62,22 +61,18 @@ export function AnythingAdder() {
imagePath: navIconUrl("t"),
href: NEW_TEAM_PAGE,
},
FF_SCRIMS_ENABLED
? {
id: "scrimPost",
children: t("header.adder.scrimPost"),
imagePath: navIconUrl("scrims"),
href: newScrimPostPage(),
}
: null,
FF_SCRIMS_ENABLED
? {
id: "association",
children: t("header.adder.association"),
imagePath: navIconUrl("associations"),
href: newAssociationsPage(),
}
: null,
{
id: "scrimPost",
children: t("header.adder.scrimPost"),
imagePath: navIconUrl("scrims"),
href: newScrimPostPage(),
},
{
id: "association",
children: t("header.adder.association"),
imagePath: navIconUrl("associations"),
href: newAssociationsPage(),
},
{
id: "lfgPost",
children: t("header.adder.lfgPost"),

View File

@ -1,5 +1,3 @@
import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants";
export const navItems = [
{
name: "settings",
@ -38,13 +36,11 @@ export const navItems = [
url: "leaderboards",
prefetch: false,
},
FF_SCRIMS_ENABLED
? {
name: "scrims",
url: "scrims",
prefetch: false,
}
: null,
{
name: "scrims",
url: "scrims",
prefetch: false,
},
{
name: "lfg",
url: "lfg",

View File

@ -67,7 +67,11 @@ import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
import { nullFilledArray } from "~/utils/arrays";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampNow,
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { mySlugify } from "~/utils/urls";
@ -2354,6 +2358,10 @@ async function scrimPosts() {
return { maxDiv, minDiv };
};
const maps = (): "SZ" | "ALL" | "RANKED" | null => {
return faker.helpers.arrayElement(["SZ", "ALL", "RANKED", null, null]);
};
const users = () => {
const count = faker.helpers.arrayElement([4, 4, 4, 4, 4, 4, 5, 5, 5, 6]);
@ -2372,8 +2380,17 @@ async function scrimPosts() {
for (let i = 0; i < 20; i++) {
const divs = divRange();
const atTime = date();
const hasRangeEnd = Math.random() > 0.5;
await ScrimPostRepository.insert({
at: date(),
at: atTime,
rangeEnd: hasRangeEnd
? dateToDatabaseTimestamp(
add(databaseTimestampToDate(atTime), {
hours: faker.helpers.rangeToNumber({ min: 1, max: 3 }),
}),
)
: null,
isScheduledForFuture: true,
maxDiv: divs?.maxDiv,
minDiv: divs?.minDiv,
@ -2385,11 +2402,14 @@ async function scrimPosts() {
visibility: null,
users: users(),
managedByAnyone: true,
maps: maps(),
mapsTournamentId: null,
});
}
const adminPostAtTime = date(true); // admin's scrim is always at least 1 hour in the future
const adminPostId = await ScrimPostRepository.insert({
at: date(true), // admin's scrim is always at least 1 hour in the future
at: adminPostAtTime,
isScheduledForFuture: true,
text:
faker.number.float(1) > 0.5
@ -2400,14 +2420,24 @@ async function scrimPosts() {
.map((u) => ({ ...u, isOwner: 0 }))
.concat({ userId: ADMIN_ID, isOwner: 1 }),
managedByAnyone: true,
maps: maps(),
mapsTournamentId: null,
});
await ScrimPostRepository.insertRequest({
scrimPostId: adminPostId,
users: users(),
message:
faker.number.float(1) > 0.5
? faker.lorem.sentence({ min: 5, max: 15 })
: null,
});
await ScrimPostRepository.insertRequest({
scrimPostId: adminPostId,
users: users(),
message:
faker.number.float(1) > 0.5
? faker.lorem.sentence({ min: 5, max: 15 })
: null,
});
}
@ -2426,6 +2456,10 @@ async function scrimPostRequests() {
isOwner: member.userId === ADMIN_ID ? 1 : 0,
})),
teamId: 1,
message:
faker.number.float(1) > 0.5
? faker.lorem.sentence({ min: 5, max: 15 })
: null,
});
}

View File

@ -11,6 +11,7 @@ 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 { ScrimFilters } from "~/features/scrims/scrims-types";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
@ -681,7 +682,6 @@ export interface TournamentTeam {
inviteCode: string;
name: string;
prefersNotToHost: Generated<DBBoolean>;
noScreen: Generated<DBBoolean>;
droppedOut: Generated<DBBoolean>;
seed: number | null;
/** For formats that have many starting brackets, where should the team start? */
@ -828,6 +828,7 @@ export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
disallowScrimPickupsFromUntrusted?: boolean;
defaultCalendarFilters?: CalendarFilters;
defaultScrimsFilters?: ScrimFilters;
}
export interface User {
@ -975,6 +976,8 @@ export interface ScrimPost {
id: GeneratedAlways<number>;
/** When is the scrim scheduled to happen */
at: number;
/** Optional end of time range indicating team accepts scrims starting between at and rangeEnd */
rangeEnd: number | null;
/** Highest LUTI div accepted */
maxDiv: number | null;
/** Lowest LUTI div accepted */
@ -997,6 +1000,10 @@ export interface ScrimPost {
cancelReason: string | null;
/** When the post was made was it scheduled for a future time slot (as opposed to looking now) */
isScheduledForFuture: Generated<DBBoolean>;
/** Maps/modes the scrim is available for. If null means no preference unless "mapsTournamentId" is set */
maps: "SZ" | "ALL" | "RANKED" | null;
/** If set, specifies the maps of a tournament to play */
mapsTournamentId: number | null;
createdAt: GeneratedAlways<number>;
updatedAt: Generated<number>;
}
@ -1012,6 +1019,9 @@ export interface ScrimPostRequest {
id: GeneratedAlways<number>;
scrimPostId: number;
teamId: number | null;
message: string | null;
/** Specific time selected by requester (required when post has rangeEnd) */
at: number | null;
isAccepted: Generated<DBBoolean>;
createdAt: GeneratedAlways<number>;
}

View File

@ -846,8 +846,7 @@ function EnableNoScreenToggle() {
onChange={setEnableNoScreen}
/>
<FormMessage type="info">
When registering ask teams if they want to play without Splattercolor
Screen.
Ban Splattercolor Screen in matches depending on the teams' preferences.
</FormMessage>
</div>
);

View File

@ -5,7 +5,6 @@ import { Badge } from "~/components/Badge";
import { LinkButton } from "~/components/elements/Button";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { Main } from "~/components/Main";
import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants";
import { metaTags } from "~/utils/remix";
import {
PATREON_HOW_TO_CONNECT_DISCORD_URL,
@ -169,9 +168,7 @@ function SupportTable() {
<div>Support</div>
<div>Supporter</div>
<div>Supporter+</div>
{PERKS.filter(
(perk) => FF_SCRIMS_ENABLED || perk.name !== "joinMoreAssociations",
).map((perk) => {
{PERKS.map((perk) => {
return (
<React.Fragment key={perk.name}>
<div className="justify-self-start">

View File

@ -64,6 +64,7 @@ export type Notification =
| NotificationItem<"SCRIM_NEW_REQUEST", { fromUsername: string }>
| NotificationItem<"SCRIM_SCHEDULED", { id: number; at: number }>
| NotificationItem<"SCRIM_CANCELED", { id: number; at: number }>
| NotificationItem<"SCRIM_STARTING_SOON", { id: number; at: number }>
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>;
type NotificationItem<

View File

@ -38,6 +38,7 @@ export const notificationNavIcon = (type: Notification["type"]) => {
case "SCRIM_NEW_REQUEST":
case "SCRIM_SCHEDULED":
case "SCRIM_CANCELED":
case "SCRIM_STARTING_SOON":
return "scrims";
default:
assertUnreachable(type);
@ -82,7 +83,8 @@ export const notificationLink = (notification: Notification) => {
return scrimsPage();
}
case "SCRIM_CANCELED":
case "SCRIM_SCHEDULED": {
case "SCRIM_SCHEDULED":
case "SCRIM_STARTING_SOON": {
return scrimPage(notification.meta.id);
}
case "COMMISSIONS_CLOSED": {
@ -100,7 +102,8 @@ export const mapMetaForTranslation = (
) => {
if (
notification.type === "SCRIM_SCHEDULED" ||
notification.type === "SCRIM_CANCELED"
notification.type === "SCRIM_CANCELED" ||
notification.type === "SCRIM_STARTING_SOON"
) {
return {
...notification.meta,

View File

@ -5,17 +5,26 @@ import type { Tables, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { userSubmittedImage } from "~/utils/urls-img";
import { db } from "../../db/sql";
import invariant from "../../utils/invariant";
import type { Unwrapped } from "../../utils/types";
import type { AssociationVisibility } from "../associations/associations-types";
import { HACKY_resolvePicture } from "../tournament/tournament-utils";
import * as Scrim from "./core/Scrim";
import type { ScrimPost, ScrimPostUser } from "./scrims-types";
import { getPostRequestCensor, parseLutiDiv } from "./scrims-utils";
type InsertArgs = Pick<
TablesInsertable["ScrimPost"],
"at" | "maxDiv" | "minDiv" | "teamId" | "text"
| "at"
| "rangeEnd"
| "maxDiv"
| "minDiv"
| "teamId"
| "text"
| "maps"
| "mapsTournamentId"
> & {
/** users related to the post other than the author */
users: Array<Pick<Insertable<Tables["ScrimPostUser"]>, "userId" | "isOwner">>;
@ -34,10 +43,13 @@ export function insert(args: InsertArgs) {
.insertInto("ScrimPost")
.values({
at: args.at,
rangeEnd: args.rangeEnd,
maxDiv: args.maxDiv,
minDiv: args.minDiv,
teamId: args.teamId,
text: args.text,
maps: args.maps,
mapsTournamentId: args.mapsTournamentId,
visibility: args.visibility ? JSON.stringify(args.visibility) : null,
chatCode: shortNanoid(),
managedByAnyone: args.managedByAnyone ? 1 : 0,
@ -57,7 +69,7 @@ export function insert(args: InsertArgs) {
type InsertRequestArgs = Pick<
Insertable<Tables["ScrimPostRequest"]>,
"scrimPostId" | "teamId"
"scrimPostId" | "teamId" | "message" | "at"
> & {
users: Array<
Pick<Insertable<Tables["ScrimPostRequestUser"]>, "userId" | "isOwner">
@ -73,6 +85,8 @@ export function insertRequest(args: InsertRequestArgs) {
.values({
scrimPostId: args.scrimPostId,
teamId: args.teamId,
message: args.message,
at: args.at,
})
.returning("id")
.executeTakeFirstOrThrow();
@ -98,14 +112,27 @@ const baseFindQuery = db
.selectFrom("ScrimPost")
.leftJoin("Team", "ScrimPost.teamId", "Team.id")
.leftJoin("UserSubmittedImage", "Team.avatarImgId", "UserSubmittedImage.id")
.leftJoin(
"CalendarEvent",
"ScrimPost.mapsTournamentId",
"CalendarEvent.tournamentId",
)
.leftJoin(
"UserSubmittedImage as TournamentAvatar",
"CalendarEvent.avatarImgId",
"TournamentAvatar.id",
)
.select((eb) => [
"ScrimPost.id",
"ScrimPost.at",
"ScrimPost.rangeEnd",
"ScrimPost.createdAt",
"ScrimPost.visibility",
"ScrimPost.maxDiv",
"ScrimPost.minDiv",
"ScrimPost.text",
"ScrimPost.maps",
"ScrimPost.mapsTournamentId",
"ScrimPost.managedByAnyone",
"ScrimPost.canceledAt",
"ScrimPost.canceledByUserId",
@ -116,6 +143,11 @@ const baseFindQuery = db
customUrl: eb.ref("Team.customUrl"),
avatarUrl: eb.ref("UserSubmittedImage.url"),
}).as("team"),
jsonBuildObject({
id: eb.ref("CalendarEvent.tournamentId"),
name: eb.ref("CalendarEvent.name"),
avatarUrl: eb.ref("TournamentAvatar.url"),
}).as("mapsTournament"),
jsonArrayFrom(
eb
.selectFrom("ScrimPostUser")
@ -136,6 +168,8 @@ const baseFindQuery = db
"ScrimPostRequest.id",
"ScrimPostRequest.isAccepted",
"ScrimPostRequest.createdAt",
"ScrimPostRequest.message",
"ScrimPostRequest.at",
jsonBuildObject({
name: innerEb.ref("Team.name"),
customUrl: innerEb.ref("Team.customUrl"),
@ -207,9 +241,10 @@ const mapDBRowToScrimPost = (
}
}
return {
const result = {
id: row.id,
at: row.at,
rangeEnd: row.rangeEnd,
createdAt: row.createdAt,
visibility: row.visibility,
text: row.text,
@ -218,6 +253,16 @@ const mapDBRowToScrimPost = (
typeof row.maxDiv === "number" && typeof row.minDiv === "number"
? { max: parseLutiDiv(row.maxDiv), min: parseLutiDiv(row.minDiv) }
: null,
maps: row.maps,
mapsTournament: row.mapsTournament.id
? {
id: row.mapsTournament.id,
name: row.mapsTournament.name!,
avatarUrl: row.mapsTournament.avatarUrl
? userSubmittedImage(row.mapsTournament.avatarUrl)
: HACKY_resolvePicture({ name: row.mapsTournament.name! }),
}
: null,
chatCode: row.chatCode ?? null,
team: row.team.name
? {
@ -231,6 +276,8 @@ const mapDBRowToScrimPost = (
id: request.id,
isAccepted: Boolean(request.isAccepted),
createdAt: request.createdAt,
message: request.message,
at: request.at,
team: request.team.name
? {
name: request.team.name,
@ -256,6 +303,16 @@ const mapDBRowToScrimPost = (
managedByAnyone: Boolean(row.managedByAnyone),
canceled,
};
if (!Scrim.isAccepted(result)) {
return result;
}
return {
...result,
at: Scrim.getStartTime(result),
rangeEnd: null,
};
};
export async function findById(scrimPostId: number): Promise<ScrimPost | null> {
@ -315,3 +372,34 @@ export async function cancelScrim(
.where("canceledAt", "is", null)
.execute();
}
/**
* Finds all accepted scrims scheduled within a specific time range.
*
* @returns Array of accepted (matched) scrim posts within the time range
*/
export async function findAcceptedScrimsBetweenTwoTimestamps({
/** The earliest scrim start time to include (inclusive) */
startTime,
/** The latest scrim start time to include (exclusive) */
endTime,
/** Exclude scrims created after this timestamp */
excludeRecentlyCreated,
}: {
startTime: Date;
endTime: Date;
excludeRecentlyCreated: Date;
}) {
const rows = await baseFindQuery
.where("ScrimPost.at", ">=", dateToDatabaseTimestamp(startTime))
.where("ScrimPost.at", "<", dateToDatabaseTimestamp(endTime))
.where("ScrimPost.canceledAt", "is", null)
.where(
"ScrimPost.createdAt",
"<",
dateToDatabaseTimestamp(excludeRecentlyCreated),
)
.execute();
return rows.map(mapDBRowToScrimPost).filter((post) => Scrim.isAccepted(post));
}

View File

@ -29,7 +29,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
requirePermission(post, "CANCEL", user);
if (databaseTimestampToDate(post.at) < new Date()) {
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
@ -45,7 +45,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
type: "SCRIM_CANCELED",
meta: {
id: post.id,
at: databaseTimestampToJavascriptTimestamp(post.at),
at: databaseTimestampToJavascriptTimestamp(Scrim.getStartTime(post)),
},
},
});

View File

@ -51,10 +51,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await ScrimPostRepository.insert({
at: dateToDatabaseTimestamp(data.at),
rangeEnd: data.rangeEnd ? dateToDatabaseTimestamp(data.rangeEnd) : null,
maxDiv: data.divs ? serializeLutiDiv(data.divs.max!) : null,
minDiv: data.divs ? serializeLutiDiv(data.divs.min!) : null,
text: data.postText,
managedByAnyone: data.managedByAnyone,
maps:
data.maps === "NO_PREFERENCE" || data.maps === "TOURNAMENT"
? null
: data.maps,
mapsTournamentId: data.mapsTournamentId,
isScheduledForFuture:
data.at >
// 10 minutes is an arbitrary threshold

View File

@ -1,16 +1,29 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import {
databaseTimestampToDate,
databaseTimestampToJavascriptTimestamp,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import {
actionError,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { scrimsPage } from "~/utils/urls";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { scrimsActionSchema } from "../scrims-schemas";
import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas";
import { generateTimeOptions } from "../scrims-utils";
import { usersListForPost } from "./scrims.new.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: scrimsActionSchema,
@ -33,9 +46,33 @@ export const action = async ({ request }: ActionFunctionArgs) => {
postId: data.scrimPostId,
});
if (post.rangeEnd && !data.at) {
return actionError<typeof newRequestSchema>({
msg: "Please select a time for the scrim",
field: "at",
});
}
if (post.rangeEnd && data.at) {
const validTimeOptions = generateTimeOptions(
databaseTimestampToDate(post.at),
databaseTimestampToDate(post.rangeEnd),
);
const requestTime = data.at.getTime();
if (!validTimeOptions.includes(requestTime)) {
return actionError<typeof newRequestSchema>({
msg: "Selected time must be one of the available options",
field: "at",
});
}
}
await ScrimPostRepository.insertRequest({
scrimPostId: data.scrimPostId,
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
message: data.message,
at: data.at ? dateToDatabaseTimestamp(data.at) : null,
users: (
await usersListForPost({ authorId: user.id, from: data.from })
).map((userId) => ({
@ -79,7 +116,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
type: "SCRIM_SCHEDULED",
meta: {
id: post.id,
at: databaseTimestampToJavascriptTimestamp(post.at),
at: databaseTimestampToJavascriptTimestamp(request.at ?? post.at),
},
},
});
@ -102,6 +139,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
break;
}
case "PERSIST_SCRIM_FILTERS": {
await UserRepository.updatePreferences(user.id, {
defaultScrimsFilters: data.filters,
});
return redirect(scrimsPage());
}
default: {
assertUnreachable(data);
}

View File

@ -0,0 +1,104 @@
import type * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Label } from "~/components/Label";
import { FormMessage } from "../../../components/FormMessage";
import { LUTI_DIVS } from "../scrims-constants";
import type { LutiDiv } from "../scrims-types";
export function LutiDivsFormField() {
const methods = useFormContext();
const error = methods.formState.errors.divs;
return (
<div>
<Controller
control={methods.control}
name="divs"
render={({ field: { onChange, onBlur, value } }) => (
<LutiDivsSelector value={value} onChange={onChange} onBlur={onBlur} />
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
);
}
type LutiDivEdit = {
max: LutiDiv | null;
min: LutiDiv | null;
};
function LutiDivsSelector({
value,
onChange,
onBlur,
}: {
value: LutiDivEdit | null;
onChange: (value: LutiDivEdit | null) => void;
onBlur: () => void;
}) {
const { t } = useTranslation(["scrims"]);
const onChangeMin = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.max
? { min: newValue, max: value?.max ?? null }
: null,
);
};
const onChangeMax = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.min
? { max: newValue, min: value?.min ?? null }
: null,
);
};
return (
<div className="stack horizontal sm">
<div>
<Label htmlFor="max-div">{t("scrims:forms.divs.maxDiv.title")}</Label>
<select
id="max-div"
value={value?.max ?? ""}
onChange={onChangeMax}
onBlur={onBlur}
>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="min-div">{t("scrims:forms.divs.minDiv.title")}</Label>
<select
id="min-div"
value={value?.min ?? ""}
onChange={onChangeMin}
onBlur={onBlur}
>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
.card {
border: 1px solid var(--border);
border-radius: var(--rounded);
overflow: hidden;
background-color: var(--bg-darker);
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-4);
padding-bottom: var(--s-3);
}
.avatarContainer {
flex-shrink: 0;
}
.teamName {
flex: 1;
font-size: var(--text-lg);
font-weight: var(--semi-bold);
margin: 0;
display: flex;
flex-direction: column;
gap: var(--s-0-5);
line-height: 1.2;
}
.pickupLabel {
font-size: var(--fonts-xxxs);
font-weight: var(--bold);
text-transform: uppercase;
color: var(--text-lighter);
}
.rightIconsContainer {
flex-shrink: 0;
align-self: flex-start;
display: flex;
gap: var(--s-2);
}
.usersIcon {
width: 18px;
height: 18px;
}
.infoRow {
display: flex;
gap: var(--s-6);
padding-inline: var(--s-4);
padding-bottom: var(--s-3);
flex-wrap: wrap;
}
.infoItem {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.infoLabel {
text-transform: uppercase;
color: var(--text-lighter);
font-weight: var(--bold);
font-size: var(--fonts-xxs);
}
.infoValue {
font-weight: var(--semi-bold);
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--fonts-xs);
}
.textContent {
padding-inline: var(--s-4);
padding-bottom: var(--s-3);
font-size: var(--fonts-sm);
line-height: 1.4;
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.expandButton {
background: none;
border: none;
color: var(--theme);
cursor: pointer;
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
padding: 0;
text-align: left;
text-decoration: underline;
}
.expandButton:hover {
opacity: 0.8;
}
.footer {
background-color: var(--bg-lighter);
padding: var(--s-2) var(--s-4);
display: flex;
justify-content: center;
margin-block-start: auto;
}
.requestCard {
background-color: var(--theme-transparent);
}
.requestFooter {
background-image: repeating-linear-gradient(
45deg,
var(--bg-darker),
var(--bg-darker) 10px,
var(--bg-lighter) 10px,
var(--bg-lighter) 20px
);
}
.filteredFooter {
background-color: var(--theme-transparent);
}
.canceledContainer {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.strikethrough button {
text-decoration: line-through;
}
.canceledLabel {
color: var(--theme-error);
font-size: var(--fonts-xxs);
font-weight: var(--bold);
text-transform: uppercase;
}

View File

@ -0,0 +1,567 @@
import { Form, Link } from "@remix-run/react";
import clsx from "clsx";
import { formatDistance } from "date-fns";
import type React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ModeImage } from "~/components/Image";
import { ArrowDownOnSquareIcon } from "~/components/icons/ArrowDownOnSquare";
import { ArrowUpOnSquareIcon } from "~/components/icons/ArrowUpOnSquare";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { SpeechBubbleFilledIcon } from "~/components/icons/SpeechBubbleFilled";
import { TrashIcon } from "~/components/icons/Trash";
import { UsersIcon } from "~/components/icons/Users";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { scrimPage, tournamentRegisterPage, userPage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
import { formatFlexTimeDisplay } from "../scrims-utils";
import styles from "./ScrimCard.module.css";
import { ScrimRequestModal } from "./ScrimRequestModal";
interface ScrimPostCardProps {
post: ScrimPost;
action?: "DELETE" | "REQUEST" | "VIEW_REQUEST" | "CONTACT";
isFilteredOut?: boolean;
}
export function ScrimPostCard({
post,
action,
isFilteredOut,
}: ScrimPostCardProps) {
const { t } = useTranslation(["scrims"]);
const owner = post.users.find((user) => user.isOwner) ?? post.users[0];
const isPickup = !post.team?.name;
const teamName = post.team?.name ?? owner.username;
const flexTimeDisplay = post.rangeEnd
? formatFlexTimeDisplay(post.at, post.rangeEnd)
: null;
return (
<div className={styles.card}>
<div className={styles.header}>
<div className={styles.avatarContainer}>
<ScrimTeamAvatar
teamAvatarUrl={post.team?.avatarUrl}
teamName={teamName}
owner={owner}
/>
</div>
<h3 className={styles.teamName}>
{isPickup ? (
<>
<span className={styles.pickupLabel}>{t("scrims:pickupBy")}</span>
<span>{owner.username}</span>
</>
) : (
teamName
)}
</h3>
<div className={styles.rightIconsContainer}>
{post.isPrivate ? <ScrimVisibilityPopover /> : null}
<ScrimTeamMembersPopover users={post.users} />
</div>
</div>
<div className={styles.infoRow}>
<ScrimInfoItem label="Start">
<ScrimStartTimeDisplay
isScheduledForFuture={post.isScheduledForFuture}
startTimestamp={post.at}
createdAtTimestamp={post.createdAt}
canceled={post.canceled}
/>
</ScrimInfoItem>
{flexTimeDisplay ? (
<ScrimInfoItem label="Flex">{flexTimeDisplay}</ScrimInfoItem>
) : null}
{post.divs ? (
<ScrimInfoItem label="Div">
{post.divs.max === post.divs.min
? post.divs.max
: `${post.divs.min}-${post.divs.max}`}
</ScrimInfoItem>
) : null}
{post.maps || post.mapsTournament ? (
<ScrimInfoItem label="Modes">
{post.mapsTournament ? (
<ScrimTournamentPopover tournament={post.mapsTournament} />
) : (
getModesList(post.maps!).map((mode) => (
<ModeImage key={mode} mode={mode} size={18} />
))
)}
</ScrimInfoItem>
) : null}
</div>
{post.text ? <ScrimExpandableText text={post.text} /> : null}
<div
className={clsx(styles.footer, isFilteredOut && styles.filteredFooter)}
>
<ScrimActionButtons action={action} post={post} key={action} />
</div>
</div>
);
}
function getModesList(maps: string): ModeShort[] {
if (maps === "SZ") {
return ["SZ"];
}
if (maps === "RANKED") {
return ["SZ", "TC", "RM", "CB"];
}
return ["TW", "SZ", "TC", "RM", "CB"];
}
function ScrimTeamAvatar({
teamAvatarUrl,
teamName,
owner,
}: {
teamAvatarUrl: string | null | undefined;
teamName: string;
owner: ScrimPost["users"][number];
}) {
if (teamAvatarUrl) {
return (
<Avatar
size="xs"
url={userSubmittedImage(teamAvatarUrl)}
alt={teamName}
/>
);
}
return <Avatar size="xs" user={owner} alt={owner.username} />;
}
function ScrimVisibilityPopover() {
const { t } = useTranslation(["scrims"]);
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={<EyeSlashIcon className={styles.usersIcon} />}
data-testid="limited-visibility-popover"
/>
}
>
{t("scrims:limitedVisibility")}
</SendouPopover>
);
}
function ScrimTeamMembersPopover({ users }: { users: ScrimPost["users"] }) {
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={<UsersIcon className={styles.usersIcon} />}
/>
}
>
<div className="stack md">
{users.map((user) => (
<Link
to={userPage(user)}
key={user.id}
className="stack horizontal sm"
>
<Avatar size="xxs" user={user} />
{user.username}
</Link>
))}
</div>
</SendouPopover>
);
}
function ScrimTournamentPopover({
tournament,
}: {
tournament: NonNullable<ScrimPost["mapsTournament"]>;
}) {
return (
<SendouPopover
trigger={
<SendouButton variant="minimal">
<Avatar
size="xxxsm"
url={tournament.avatarUrl}
alt={tournament.name}
/>
</SendouButton>
}
>
<div className="stack sm text-center">
<Link
to={`${tournamentRegisterPage(tournament.id)}?tab=description`}
className="text-theme text-xxs"
>
{tournament.name}
</Link>
</div>
</SendouPopover>
);
}
function ScrimStartTimeDisplay({
isScheduledForFuture,
startTimestamp,
createdAtTimestamp,
canceled,
}: {
isScheduledForFuture: boolean;
startTimestamp: number;
createdAtTimestamp: number;
canceled: ScrimPost["canceled"];
}) {
const { t } = useTranslation(["scrims"]);
if (!isScheduledForFuture) {
return canceled ? (
<div className={styles.canceledContainer}>
<span className={styles.strikethrough}>{t("scrims:now")}</span>
<span className={styles.canceledLabel}>Canceled</span>
</div>
) : (
t("scrims:now")
);
}
const startTime = databaseTimestampToDate(startTimestamp);
const timePopoverFooterText = t("scrims:postModal.footer", {
time: formatDistance(
databaseTimestampToDate(createdAtTimestamp),
new Date(),
{
addSuffix: true,
},
),
});
const timeDisplay = (
<TimePopover
time={startTime}
options={{
hour: "numeric",
minute: "numeric",
}}
underline={false}
footerText={timePopoverFooterText}
/>
);
return canceled ? (
<div className={styles.canceledContainer}>
<span className={styles.strikethrough}>{timeDisplay}</span>
<span className={styles.canceledLabel}>Canceled</span>
</div>
) : (
timeDisplay
);
}
function ScrimInfoItem({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className={styles.infoItem}>
<div className={styles.infoLabel}>{label}</div>
<div className={styles.infoValue}>{children}</div>
</div>
);
}
function ScrimExpandableText({
text,
maxBeforeTruncate = 50,
}: {
text: string;
maxBeforeTruncate?: number;
}) {
const { t } = useTranslation(["common"]);
const [isExpanded, setIsExpanded] = useState(false);
const shouldTruncate = text.length > maxBeforeTruncate;
const displayText =
shouldTruncate && !isExpanded
? `${text.slice(0, maxBeforeTruncate)}...`
: text;
return (
<div className={styles.textContent}>
<span>{displayText}</span>
{shouldTruncate ? (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={styles.expandButton}
>
{isExpanded
? t("common:actions.showLess")
: t("common:actions.showMore")}
</button>
) : null}
</div>
);
}
function ScrimActionButtons({
action,
post,
}: {
action: ScrimPostCardProps["action"];
post: ScrimPost;
}) {
const { t, i18n } = useTranslation(["scrims", "common"]);
const user = useUser();
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isViewRequestModalOpen, setIsViewRequestModalOpen] = useState(false);
if (!action) {
return null;
}
if (action === "REQUEST") {
return (
<>
<SendouButton
size="small"
onPress={() => setIsRequestModalOpen(true)}
icon={<ArrowUpOnSquareIcon />}
data-testid="request-scrim-button"
>
{t("scrims:actions.request")}
</SendouButton>
{isRequestModalOpen ? (
<ScrimRequestModal
post={post}
close={() => setIsRequestModalOpen(false)}
/>
) : null}
</>
);
}
if (action === "VIEW_REQUEST") {
const userRequest = post.requests.find((request) =>
request.users.some((rUser) => user?.id === rUser.id),
);
return (
<>
<SendouButton
size="small"
onPress={() => setIsViewRequestModalOpen(true)}
variant="outlined"
icon={<ArrowDownOnSquareIcon />}
data-testid="view-request-button"
>
{t("scrims:actions.viewRequest")}
</SendouButton>
{isViewRequestModalOpen && userRequest ? (
<SendouDialog
heading={t("scrims:cancelRequestModal.title")}
onClose={() => setIsViewRequestModalOpen(false)}
>
<div className="stack md">
{userRequest.message ? (
<div>
<div className="text-sm font-semi-bold mb-1">
{t("scrims:requestModal.message.label")}
</div>
<div className="text-lighter">{userRequest.message}</div>
</div>
) : null}
{userRequest.at ? (
<div>
<div className="text-sm font-semi-bold mb-1">
{t("scrims:requestModal.at.label")}
</div>
<div className="text-lighter">
{databaseTimestampToDate(userRequest.at).toLocaleString(
i18n.language,
{
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "long",
},
)}
</div>
</div>
) : null}
<Form method="post">
<input
type="hidden"
name="scrimPostRequestId"
value={userRequest.id}
/>
<input type="hidden" name="_action" value="CANCEL_REQUEST" />
<SendouButton
type="submit"
variant="destructive"
icon={<TrashIcon />}
>
{t("common:actions.cancel")}
</SendouButton>
</Form>
</div>
</SendouDialog>
) : null}
</>
);
}
if (action === "CONTACT") {
return (
<LinkButton
to={scrimPage(post.id)}
size="small"
icon={<SpeechBubbleFilledIcon />}
>
{t("scrims:actions.contact")}
</LinkButton>
);
}
return (
<FormWithConfirm
dialogHeading={t("scrims:deleteModal.title")}
submitButtonText={t("common:actions.delete")}
fields={[
["scrimPostId", post.id],
["_action", "DELETE_POST"],
]}
>
<SendouButton size="small" variant="destructive" icon={<TrashIcon />}>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
);
}
interface ScrimRequestCardProps {
request: ScrimPostRequest;
postStartTime: number;
canAccept: boolean;
showFooter?: boolean;
}
export function ScrimRequestCard({
request,
postStartTime,
canAccept,
showFooter = true,
}: ScrimRequestCardProps) {
const { t, i18n } = useTranslation(["scrims", "common"]);
const owner = request.users.find((user) => user.isOwner) ?? request.users[0];
const isPickup = !request.team?.name;
const teamName = request.team?.name ?? owner.username;
const confirmedTime = request.at
? databaseTimestampToDate(request.at)
: databaseTimestampToDate(postStartTime);
return (
<div className={clsx(styles.card, styles.requestCard)}>
<div className={styles.header}>
<div className={styles.avatarContainer}>
<ScrimTeamAvatar
teamAvatarUrl={request.team?.avatarUrl}
teamName={teamName}
owner={owner}
/>
</div>
<h3 className={styles.teamName}>
{isPickup ? (
<>
<span className={styles.pickupLabel}>{t("scrims:pickupBy")}</span>
<span>{owner.username}</span>
</>
) : (
teamName
)}
</h3>
<div className={styles.rightIconsContainer}>
<ScrimTeamMembersPopover users={request.users} />
</div>
</div>
{request.message ? (
<ScrimExpandableText text={request.message} maxBeforeTruncate={100} />
) : null}
{showFooter ? (
<div className={clsx(styles.footer, styles.requestFooter)}>
{canAccept ? (
<FormWithConfirm
dialogHeading={t("scrims:acceptModal.title", {
groupName: teamName,
})}
fields={[
["scrimPostRequestId", request.id],
["_action", "ACCEPT_REQUEST"],
]}
submitButtonVariant="primary"
submitButtonText={t("common:actions.confirm")}
>
<SendouButton
size="small"
icon={<CheckmarkIcon />}
data-testid="confirm-modal-trigger-button"
>
{t("scrims:acceptModal.confirmFor", {
time: confirmedTime.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
}),
})}
</SendouButton>
</FormWithConfirm>
) : (
<SendouPopover
trigger={
<SendouButton size="small">
{t("scrims:acceptModal.confirmFor", {
time: confirmedTime.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
}),
})}
</SendouButton>
}
>
{t("scrims:acceptModal.prevented")}
</SendouPopover>
)}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,148 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
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 { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { FilterFilledIcon } from "~/components/icons/FilterFilled";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import { scrimsFiltersSchema } from "../scrims-schemas";
import { LutiDivsFormField } from "./LutiDivsFormField";
export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
const { t } = useTranslation(["scrims"]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<SendouButton
variant="outlined"
size="small"
icon={<FilterFilledIcon />}
onPress={() => setIsOpen(true)}
data-testid="filter-scrims-button"
>
{t("scrims:filters.button")}
</SendouButton>
<SendouDialog
heading={t("scrims:filters.heading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<FiltersForm
filters={filters}
closeDialog={() => {
setIsOpen(false);
}}
/>
</SendouDialog>
</>
);
}
function FiltersForm({
filters,
closeDialog,
}: {
filters: ScrimFilters;
closeDialog: () => void;
}) {
const user = useUser();
const { t } = useTranslation(["scrims"]);
const methods = useForm({
resolver: standardSchemaResolver(scrimsFiltersSchema),
defaultValues: filters,
});
const fetcher = useFetcher<any>();
const [, setSearchParams] = useSearchParams();
const filtersToSearchParams = (newFilters: ScrimFilters) => {
setSearchParams((prev) => {
prev.set("filters", JSON.stringify(newFilters));
return prev;
});
};
const onApply = React.useCallback(
methods.handleSubmit((values) => {
filtersToSearchParams(values as ScrimFilters);
closeDialog();
}),
[],
);
const onApplyAndPersist = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(
// @ts-expect-error TODO: fix
{
_action: "PERSIST_SCRIM_FILTERS",
filters: values as Parameters<typeof fetcher.submit>[0],
},
{
method: "post",
encType: "application/json",
},
),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form
className="stack md-plus items-start"
onSubmit={onApplyAndPersist}
>
<input type="hidden" name="_action" value="PERSIST_SCRIM_FILTERS" />
<div className="stack sm horizontal">
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekdayStart")}
name={"weekdayTimes.start" as const}
type="time"
size="extra-small"
/>
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekdayEnd")}
name={"weekdayTimes.end" as const}
type="time"
size="extra-small"
/>
</div>
<div className="stack sm horizontal">
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekendStart")}
name={"weekendTimes.start" as const}
type="time"
size="extra-small"
/>
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekendEnd")}
name={"weekendTimes.end" as const}
type="time"
size="extra-small"
/>
</div>
<LutiDivsFormField />
<div className="stack horizontal md justify-center mt-6 w-full">
<SendouButton onPress={() => onApply()}>
{t("scrims:filters.apply")}
</SendouButton>
{user ? (
<SubmitButton variant="outlined" state={fetcher.state}>
{t("scrims:filters.applyAndDefault")}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
);
}

View File

@ -0,0 +1,85 @@
import { useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { joinListToNaturalString, nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
import type { NewRequestFormFields } from "../routes/scrims";
import { SCRIM } from "../scrims-constants";
import { newRequestSchema } from "../scrims-schemas";
import type { ScrimPost } from "../scrims-types";
import { generateTimeOptions } from "../scrims-utils";
import { WithFormField } from "./WithFormField";
export function ScrimRequestModal({
post,
close,
}: {
post: ScrimPost;
close: () => void;
}) {
const { t, i18n } = useTranslation(["scrims"]);
const data = useLoaderData<typeof scrimsLoader>();
const timeOptions = post.rangeEnd
? generateTimeOptions(
databaseTimestampToDate(post.at),
databaseTimestampToDate(post.rangeEnd),
).map((timestamp) => ({
value: timestamp,
label: new Date(timestamp).toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
}),
}))
: [];
return (
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
<SendouForm
schema={newRequestSchema}
defaultValues={{
_action: "NEW_REQUEST",
scrimPostId: post.id,
from:
data.teams.length > 0
? { mode: "TEAM", teamId: data.teams[0].id }
: {
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
message: "",
at: post.rangeEnd ? (timeOptions[0]?.value as unknown as Date) : null,
}}
>
<div className="font-semi-bold text-lighter italic">
{joinListToNaturalString(post.users.map((u) => u.username))}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<WithFormField usersTeams={data.teams} />
{post.rangeEnd ? (
<SelectFormField<NewRequestFormFields>
name="at"
label={t("scrims:requestModal.at.label")}
bottomText={t("scrims:requestModal.at.explanation")}
values={timeOptions}
/>
) : null}
<TextAreaFormField<NewRequestFormFields>
name="message"
label={t("scrims:requestModal.message.label")}
maxLength={SCRIM.REQUEST_MESSAGE_MAX_LENGTH}
/>
</SendouForm>
</SendouDialog>
);
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ScrimPost } from "../scrims-types";
import { participantIdsListFromAccepted } from "./Scrim";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
import { applyFilters, participantIdsListFromAccepted } from "./Scrim";
type MockUser = { id: number };
type MockRequest = { isAccepted: boolean; users: MockUser[] };
@ -76,3 +77,362 @@ describe("participantIdsListFromAccepted", () => {
expect(result).toEqual([]);
});
});
describe("applyFilters", () => {
function createPostForFilters(
at: Date,
rangeEnd?: Date,
divs?: { min: string; max: string },
): ScrimPost {
return {
id: 1,
at: dateToDatabaseTimestamp(at),
rangeEnd: rangeEnd ? dateToDatabaseTimestamp(rangeEnd) : null,
divs: divs ? { min: divs.min as any, max: divs.max as any } : null,
users: [],
requests: [],
canceled: null,
createdAt: databaseTimestampNow(),
visibility: null,
chatCode: null,
text: "",
maps: null,
isScheduledForFuture: false,
managedByAnyone: false,
mapsTournament: null,
permissions: { MANAGE_REQUESTS: [], CANCEL: [], DELETE_POST: [] },
team: null,
};
}
describe("with no filters", () => {
it("returns true when all filters are null", () => {
const post = createPostForFilters(new Date("2025-01-15T14:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
});
describe("division filters", () => {
it("returns true when post has no divs but filter has divs", () => {
const post = createPostForFilters(new Date("2025-01-15T14:00:00"));
const filters: ScrimFilters = {
divs: { min: "5", max: "3" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns true when only filter min is set and post max is at or above filter min", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "6", max: "3" },
);
const filters: ScrimFilters = {
divs: { min: "5", max: null },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when only filter min is set and post max is below filter min", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "8", max: "6" },
);
const filters: ScrimFilters = {
divs: { min: "5", max: null },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns true when only filter max is set and post min is at or below filter max", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "6", max: "2" },
);
const filters: ScrimFilters = {
divs: { min: null, max: "5" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when only filter max is set and post min is above filter max", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "3", max: "1" },
);
const filters: ScrimFilters = {
divs: { min: null, max: "5" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns true when post divs overlap with filter divs", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "5", max: "3" },
);
const filters: ScrimFilters = {
divs: { min: "6", max: "2" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns true when post divs exactly match filter divs", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "5", max: "3" },
);
const filters: ScrimFilters = {
divs: { min: "5", max: "3" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when post divs are too high for filter", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "3", max: "1" },
);
const filters: ScrimFilters = {
divs: { min: "6", max: "4" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns false when post divs are too low for filter", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "8", max: "6" },
);
const filters: ScrimFilters = {
divs: { min: "5", max: "3" },
weekdayTimes: null,
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
});
describe("weekday time filters", () => {
it("returns true when post time overlaps with weekday time filter", () => {
const post = createPostForFilters(new Date("2025-01-15T14:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when post time is before weekday time filter", () => {
const post = createPostForFilters(new Date("2025-01-15T08:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns false when post time is after weekday time filter", () => {
const post = createPostForFilters(new Date("2025-01-15T18:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns true when post time range overlaps with weekday time filter", () => {
const post = createPostForFilters(
new Date("2025-01-15T09:00:00"),
new Date("2025-01-15T11:00:00"),
);
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when post time range does not overlap with weekday time filter", () => {
const post = createPostForFilters(
new Date("2025-01-15T06:00:00"),
new Date("2025-01-15T08:00:00"),
);
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns true when post time range ends exactly at the filter start edge", () => {
const post = createPostForFilters(
new Date("2025-01-15T09:00:00"),
new Date("2025-01-15T10:00:00"),
);
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
});
describe("weekend time filters", () => {
it("returns true when post time overlaps with weekend time filter on Saturday", () => {
const post = createPostForFilters(new Date("2025-01-18T14:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: null,
weekendTimes: { start: "10:00", end: "18:00" },
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns true when post time overlaps with weekend time filter on Sunday", () => {
const post = createPostForFilters(new Date("2025-01-19T14:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: null,
weekendTimes: { start: "10:00", end: "18:00" },
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when post time is outside weekend time filter", () => {
const post = createPostForFilters(new Date("2025-01-18T20:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: null,
weekendTimes: { start: "10:00", end: "18:00" },
};
expect(applyFilters(post, filters)).toBe(false);
});
it("ignores weekday time filter on weekends", () => {
const post = createPostForFilters(new Date("2025-01-18T20:00:00"));
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "10:00", end: "18:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
});
describe("combined filters", () => {
it("returns true when both div and time filters match", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "5", max: "3" },
);
const filters: ScrimFilters = {
divs: { min: "6", max: "2" },
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
it("returns false when div filter matches but time filter does not", () => {
const post = createPostForFilters(
new Date("2025-01-15T18:00:00"),
undefined,
{ min: "5", max: "3" },
);
const filters: ScrimFilters = {
divs: { min: "6", max: "2" },
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns false when time filter matches but div filter does not", () => {
const post = createPostForFilters(
new Date("2025-01-15T14:00:00"),
undefined,
{ min: "8", max: "6" },
);
const filters: ScrimFilters = {
divs: { min: "5", max: "3" },
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
it("returns false when neither filter matches", () => {
const post = createPostForFilters(
new Date("2025-01-15T18:00:00"),
undefined,
{ min: "8", max: "6" },
);
const filters: ScrimFilters = {
divs: { min: "5", max: "3" },
weekdayTimes: { start: "10:00", end: "16:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(false);
});
});
});

View File

@ -1,5 +1,9 @@
import { format, isWeekend } from "date-fns";
import * as R from "remeda";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import type { ScrimPost } from "../scrims-types";
import { LUTI_DIVS } from "../scrims-constants";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
/** Returns true if the original poster has accepted any of the requests. */
export function isAccepted(post: ScrimPost) {
@ -35,3 +39,76 @@ export function participantIdsListFromAccepted(post: ScrimPost) {
.map((u) => u.id)
.concat(acceptedRequest?.users.map((u) => u.id) ?? []);
}
/**
* Returns the actual start time of the scrim.
* When the post has a time range (rangeEnd is set), returns the accepted request's specific time if available.
* Otherwise returns the post's start time.
*/
export function getStartTime(post: ScrimPost): number {
const acceptedRequest = post.requests.find((r) => r.isAccepted);
return acceptedRequest?.at ?? post.at;
}
export function applyFilters(post: ScrimPost, filters: ScrimFilters): boolean {
const hasMinFilter = filters.divs?.min !== null;
const hasMaxFilter = filters.divs?.max !== null;
if (filters.divs && (hasMinFilter || hasMaxFilter) && post.divs) {
const postMinIndex = LUTI_DIVS.indexOf(post.divs.min);
const postMaxIndex = LUTI_DIVS.indexOf(post.divs.max);
if (hasMinFilter && hasMaxFilter) {
const filterMinIndex = LUTI_DIVS.indexOf(filters.divs.min!);
const filterMaxIndex = LUTI_DIVS.indexOf(filters.divs.max!);
if (postMinIndex < filterMaxIndex || postMaxIndex > filterMinIndex) {
return false;
}
} else if (hasMinFilter) {
const filterMinIndex = LUTI_DIVS.indexOf(filters.divs.min!);
if (postMaxIndex > filterMinIndex) {
return false;
}
} else if (hasMaxFilter) {
const filterMaxIndex = LUTI_DIVS.indexOf(filters.divs.max!);
if (postMinIndex < filterMaxIndex) {
return false;
}
}
}
const timeFilters = isWeekend(databaseTimestampToDate(post.at))
? filters.weekendTimes
: filters.weekdayTimes;
if (timeFilters) {
const startDate = databaseTimestampToDate(post.at);
const endDate = post.rangeEnd
? databaseTimestampToDate(post.rangeEnd)
: startDate;
const startTimeString = format(startDate, "HH:mm");
const endTimeString = format(endDate, "HH:mm");
const hasOverlap =
startTimeString <= timeFilters.end && endTimeString >= timeFilters.start;
if (!hasOverlap) {
return false;
}
}
return true;
}
export function defaultFilters(): ScrimFilters {
return {
weekdayTimes: null,
weekendTimes: null,
divs: null,
};
}
export function filtersAreDefault(filters: ScrimFilters): boolean {
return R.isShallowEqual(filters, defaultFilters());
}

View File

@ -1,14 +1,15 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "../../../utils/remix.server";
import { requireUser } from "../../auth/core/user.server";
import {
type AuthenticatedUser,
requireUser,
} from "../../auth/core/user.server";
import * as Scrim from "../core/Scrim";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { FF_SCRIMS_ENABLED } from "../scrims-constants";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
notFoundIfFalsy(FF_SCRIMS_ENABLED);
const user = await requireUser(request);
const post = notFoundIfFalsy(
@ -23,10 +24,24 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response(null, { status: 403 });
}
const participantIds = Scrim.participantIdsListFromAccepted(post);
return {
post,
chatUsers: await UserRepository.findChatUsersByUserIds(
Scrim.participantIdsListFromAccepted(post),
),
chatUsers: await UserRepository.findChatUsersByUserIds(participantIds),
anyUserPrefersNoScreen:
await UserRepository.anyUserPrefersNoScreen(participantIds),
tournamentMapPool: post.mapsTournament
? await resolveTournamentMapPool(post.mapsTournament.id, user)
: null,
};
};
async function resolveTournamentMapPool(
tournamentId: number,
user: AuthenticatedUser,
) {
const data = await tournamentDataCached({ tournamentId, user });
return data.ctx.toSetMapPool;
}

View File

@ -2,15 +2,11 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
import { requireUserId } from "~/features/auth/core/user.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import { FF_SCRIMS_ENABLED } from "../scrims-constants";
export type ScrimsNewLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
notFoundIfFalsy(FF_SCRIMS_ENABLED);
const user = await requireUserId(request);
return {

View File

@ -2,16 +2,14 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
import * as Association from "~/features/associations/core/Association";
import { getUser } from "~/features/auth/core/user.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { parseSearchParams } from "~/utils/remix.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as Scrim from "../core/Scrim";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { FF_SCRIMS_ENABLED } from "../scrims-constants";
import { scrimsFiltersSearchParamsObject } from "../scrims-schemas";
import { dividePosts } from "../scrims-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
notFoundIfFalsy(FF_SCRIMS_ENABLED);
const user = await getUser(request);
const now = new Date();
@ -19,6 +17,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
? await AssociationsRepository.findByMemberUserId(user?.id)
: null;
const filtersFromSearchParams = parseSearchParams({
request,
schema: scrimsFiltersSearchParamsObject,
}).filters;
const filters = Scrim.filtersAreDefault(filtersFromSearchParams)
? user?.preferences?.defaultScrimsFilters
: filtersFromSearchParams;
const posts = (await ScrimPostRepository.findAllRelevant(user?.id))
.filter(
(post) =>
@ -41,5 +48,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
posts: dividePosts(posts, user?.id),
teams: user ? await TeamRepository.teamsByMemberUserId(user.id) : [],
filters: filters ?? Scrim.defaultFilters(),
};
};

View File

@ -36,6 +36,7 @@
color: var(--text-lighter);
font-size: var(--fonts-xs);
line-height: 1.1;
font-weight: var(--semi-bold);
}
.infoValue {
@ -49,3 +50,46 @@
background-color: var(--bg-lighter-solid);
padding: var(--s-2-5);
}
.screenBanIndicator {
display: flex;
align-items: center;
gap: var(--s-1);
}
.screenBanImageWrapper {
position: relative;
display: inline-block;
line-height: 0;
}
.screenBanImageWrapper picture {
flex-shrink: 0;
}
.screenBanIconOverlay {
position: absolute;
bottom: 0;
right: 0;
background-color: var(--bg-lightest-solid);
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.screenBanIconOverlay svg {
width: 14px;
height: 14px;
color: var(--bg);
}
.screenBanIndicator svg {
color: var(--theme-success);
}
.screenBanIndicatorWarning svg {
color: var(--theme-warning);
}

View File

@ -1,17 +1,25 @@
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { Image } from "~/components/Image";
import { AlertIcon } from "~/components/icons/Alert";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import TimePopover from "~/components/TimePopover";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { SCRIM } from "~/features/scrims/scrims-constants";
import { cancelScrimSchema } from "~/features/scrims/scrims-schemas";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userSubmittedImage } from "~/utils/urls-img";
import { Avatar } from "../../../components/Avatar";
@ -19,8 +27,10 @@ import { Main } from "../../../components/Main";
import { databaseTimestampToDate } from "../../../utils/dates";
import { logger } from "../../../utils/logger";
import {
mapsPageWithMapPool,
navIconUrl,
scrimsPage,
specialWeaponImageUrl,
teamPage,
userPage,
} from "../../../utils/urls";
@ -28,10 +38,9 @@ import { ConnectedChat } from "../../chat/components/Chat";
import { action } from "../actions/scrims.$id.server";
import * as Scrim from "../core/Scrim";
import { loader } from "../loaders/scrims.$id.server";
import type { ScrimPost as ScrimPostType } from "../scrims-types";
export { loader, action };
import type { ScrimPost, ScrimPost as ScrimPostType } from "../scrims-types";
import styles from "./scrims.$id.module.css";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["scrims", "q"],
@ -96,6 +105,13 @@ export default function ScrimPage() {
header={t("q:match.pool")}
value={Scrim.resolvePoolCode(data.post.id)}
/>
<ScreenBanIndicator />
{data.post.maps || data.tournamentMapPool ? (
<MapsLink
maps={data.post.maps}
tournamentMapPool={data.tournamentMapPool}
/>
) : null}
</div>
<ScrimChat />
</Main>
@ -127,11 +143,14 @@ function ScrimHeader() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
const scrimTime = acceptedRequest?.at ?? data.post.at;
return (
<div className="line-height-tight" data-testid="match-header">
<h2 className="text-lg">
<TimePopover
time={databaseTimestampToDate(data.post.at)}
time={databaseTimestampToDate(scrimTime)}
options={{
weekday: "long",
year: "numeric",
@ -203,6 +222,83 @@ function InfoWithHeader({ header, value }: { header: string; value: string }) {
);
}
function ScreenBanIndicator() {
const { t } = useTranslation(["weapons", "scrims"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<div className={styles.infoHeader}>{t("scrims:screenBan.header")}</div>
<div
className={clsx(styles.screenBanIndicator, {
[styles.screenBanIndicatorWarning]: data.anyUserPrefersNoScreen,
})}
>
<SendouPopover
trigger={
<SendouButton variant="minimal" size="miniscule">
<div className={styles.screenBanImageWrapper}>
<Image
path={specialWeaponImageUrl(SPLATTERCOLOR_SCREEN_ID)}
width={32}
height={32}
alt={t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)}
/>
<div className={styles.screenBanIconOverlay}>
{data.anyUserPrefersNoScreen ? (
<AlertIcon />
) : (
<CheckmarkIcon />
)}
</div>
</div>
</SendouButton>
}
>
<div className="text-xs">
{data.anyUserPrefersNoScreen
? t("scrims:screenBan.warning")
: t("scrims:screenBan.allowed")}
</div>
</SendouPopover>
</div>
</div>
);
}
function MapsLink({
maps,
tournamentMapPool,
}: Pick<ScrimPost, "maps"> &
Pick<SerializeFrom<typeof loader>, "tournamentMapPool">) {
const { t } = useTranslation(["scrims"]);
const mapPool = () => {
if (tournamentMapPool) return new MapPool(tournamentMapPool);
if (maps === "SZ") return MapPool.SZ;
if (maps === "RANKED") return MapPool.ANARCHY;
if (maps === "ALL") return MapPool.ALL;
logger.info(`Unknown scrim maps value: ${maps}`);
return MapPool.ALL;
};
return (
<div>
<div className={styles.infoHeader}>{t("scrims:maps.header")}</div>
<Link to={mapsPageWithMapPool(mapPool())}>
<Image
path={navIconUrl("maps")}
width={32}
height={32}
alt="Generate maplist"
/>
</Link>
</div>
);
}
function ScrimChat() {
const data = useLoaderData<typeof loader>();

View File

@ -9,6 +9,8 @@
padding: var(--s-0-5) var(--s-2);
font-weight: var(--bold);
width: max-content;
display: flex;
gap: var(--s-0-5);
}
.postPrivateCell {
@ -65,3 +67,19 @@
position: sticky;
right: 0;
}
.cardsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--s-4);
}
.filterButtons {
display: flex;
gap: var(--s-6);
align-items: center;
}
.filterButtons button:not(.active) {
color: var(--text-lighter);
}

View File

@ -33,6 +33,8 @@ const defaultNewScrimPostArgs: Parameters<typeof newScrimAction>[0] = {
notFoundVisibility: {
forAssociation: "PUBLIC",
},
maps: "NO_PREFERENCE",
mapsTournamentId: null,
};
describe("New scrim post action", () => {

View File

@ -3,7 +3,9 @@ import * as React from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { TournamentSearch } from "~/components/elements/TournamentSearch";
import { DateFormField } from "~/components/form/DateFormField";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
@ -13,14 +15,14 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { FormMessage } from "../../../components/FormMessage";
import { Main } from "../../../components/Main";
import { action } from "../actions/scrims.new.server";
import { LutiDivsFormField } from "../components/LutiDivsFormField";
import { WithFormField } from "../components/WithFormField";
import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server";
import { LUTI_DIVS, SCRIM } from "../scrims-constants";
import { SCRIM } from "../scrims-constants";
import {
MAX_SCRIM_POST_TEXT_LENGTH,
scrimsNewActionSchema,
} from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
export { loader, action };
export const handle: SendouRouteHandle = {
@ -46,6 +48,7 @@ export default function NewScrimPage() {
defaultValues={{
postText: "",
at: new Date(),
rangeEnd: null,
divs: null,
baseVisibility: "PUBLIC",
notFoundVisibility: DEFAULT_NOT_FOUND_VISIBILITY,
@ -59,6 +62,8 @@ export default function NewScrimPage() {
) as unknown as number[],
},
managedByAnyone: true,
maps: "NO_PREFERENCE",
mapsTournamentId: null,
}}
>
<WithFormField usersTeams={data.teams} />
@ -69,6 +74,12 @@ export default function NewScrimPage() {
bottomText={t("scrims:forms.when.explanation")}
granularity="minute"
/>
<DateFormField<FormFields>
label={t("scrims:forms.rangeEnd.title")}
name="rangeEnd"
bottomText={t("scrims:forms.rangeEnd.explanation")}
granularity="minute"
/>
<BaseVisibilityFormField associations={data.associations} />
@ -76,6 +87,23 @@ export default function NewScrimPage() {
<LutiDivsFormField />
<SelectFormField<FormFields>
label={t("scrims:forms.maps.title")}
name="maps"
values={[
{
value: "NO_PREFERENCE",
label: t("scrims:forms.maps.noPreference"),
},
{ value: "SZ", label: t("scrims:forms.maps.szOnly") },
{ value: "RANKED", label: t("scrims:forms.maps.rankedOnly") },
{ value: "ALL", label: t("scrims:forms.maps.allModes") },
{ value: "TOURNAMENT", label: t("scrims:forms.maps.tournament") },
]}
/>
<TournamentSearchFormField />
<TextAreaFormField<FormFields>
label={t("scrims:forms.text.title")}
name="postText"
@ -209,89 +237,38 @@ const AssociationSelect = React.forwardRef<
);
});
function LutiDivsFormField() {
function TournamentSearchFormField() {
const { t } = useTranslation(["scrims"]);
const methods = useFormContext<FormFields>();
const maps = useWatch<FormFields>({ name: "maps" });
const error = methods.formState.errors.divs;
const error = methods.formState.errors.mapsTournamentId;
React.useEffect(() => {
if (maps !== "TOURNAMENT") {
methods.setValue("mapsTournamentId", null);
}
}, [maps, methods]);
if (maps !== "TOURNAMENT") return null;
return (
<div>
<Controller
control={methods.control}
name="divs"
render={({ field: { onChange, onBlur, value } }) => (
<LutiDivsSelector value={value} onChange={onChange} onBlur={onBlur} />
name="mapsTournamentId"
render={({ field: { onChange, value } }) => (
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={value ?? undefined}
onChange={(tournament) => onChange(tournament.id)}
/>
)}
/>
{error && (
{error ? (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
);
}
type LutiDivEdit = {
max: LutiDiv | null;
min: LutiDiv | null;
};
function LutiDivsSelector({
value,
onChange,
onBlur,
}: {
value: LutiDivEdit | null;
onChange: (value: LutiDivEdit | null) => void;
onBlur: () => void;
}) {
const { t } = useTranslation(["scrims"]);
const onChangeMin = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.max
? { min: newValue, max: value?.max ?? null }
: null,
);
};
const onChangeMax = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.min
? { max: newValue, min: value?.min ?? null }
: null,
);
};
return (
<div className="stack horizontal sm">
<div>
<Label htmlFor="max-div">{t("scrims:forms.divs.maxDiv.title")}</Label>
<select id="max-div" onChange={onChangeMax} onBlur={onBlur}>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="min-div">{t("scrims:forms.divs.minDiv.title")}</Label>
<select id="min-div" onChange={onChangeMin} onBlur={onBlur}>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
) : null}
</div>
);
}

View File

@ -1,40 +1,23 @@
import type { MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { formatDistance } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import type { z } from "zod/v4";
import { AddNewButton } from "~/components/AddNewButton";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { SendouForm } from "~/components/form/SendouForm";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { SpeechBubbleIcon } from "~/components/icons/SpeechBubble";
import { UsersIcon } from "~/components/icons/Users";
import { Table } from "~/components/Table";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { joinListToNaturalString, nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
associationsPage,
navIconUrl,
newScrimPostPage,
scrimPage,
scrimsPage,
userPage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import {
SendouTab,
SendouTabList,
@ -42,20 +25,18 @@ import {
SendouTabs,
} from "../../../components/elements/Tabs";
import { ArrowDownOnSquareIcon } from "../../../components/icons/ArrowDownOnSquare";
import { ArrowUpOnSquareIcon } from "../../../components/icons/ArrowUpOnSquare";
import { CheckmarkIcon } from "../../../components/icons/Checkmark";
import { ClockIcon } from "../../../components/icons/Clock";
import { CrossIcon } from "../../../components/icons/Cross";
import { FilterIcon } from "../../../components/icons/Filter";
import { MegaphoneIcon } from "../../../components/icons/MegaphoneIcon";
import { SpeechBubbleFilledIcon } from "../../../components/icons/SpeechBubbleFilled";
import { Main } from "../../../components/Main";
import { action } from "../actions/scrims.server";
import { WithFormField } from "../components/WithFormField";
import { ScrimPostCard, ScrimRequestCard } from "../components/ScrimCard";
import { ScrimFiltersDialog } from "../components/ScrimFiltersDialog";
import * as Scrim from "../core/Scrim";
import { loader } from "../loaders/scrims.server";
import { SCRIM } from "../scrims-constants";
import { newRequestSchema } from "../scrims-schemas";
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
export { loader, action };
import type { newRequestSchema } from "../scrims-schemas";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
export { action, loader };
import styles from "./scrims.module.css";
@ -85,12 +66,6 @@ export default function ScrimsPage() {
const { t } = useTranslation(["calendar", "scrims"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [scrimToRequestId, setScrimToRequestId] = React.useState<number>();
// biome-ignore lint/correctness/useExhaustiveDependencies: clear modal on submit
React.useEffect(() => {
setScrimToRequestId(undefined);
}, [data]);
if (!isMounted)
return (
@ -102,72 +77,67 @@ export default function ScrimsPage() {
return (
<Main className="stack lg">
<div className="stack horizontal justify-between items-center">
<LinkButton
size="small"
to={associationsPage()}
className={clsx("mr-auto", { invisible: !user })}
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
<div className="stack horizontal sm">
<LinkButton
size="small"
to={associationsPage()}
className={clsx({ invisible: !user })}
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
{user ? (
<ScrimFiltersDialog
key={JSON.stringify(data.filters)}
filters={data.filters}
/>
) : null}
</div>
<AddNewButton to={newScrimPostPage()} navIcon="scrims" />
</div>
{typeof scrimToRequestId === "number" ? (
<RequestScrimModal
postId={scrimToRequestId}
close={() => setScrimToRequestId(undefined)}
/>
) : null}
<SendouTabs
defaultSelectedKey={data.posts.owned.length > 0 ? "owned" : "available"}
defaultSelectedKey={
data.posts.owned.length > 0
? "owned"
: data.posts.booked.length > 0
? "booked"
: "available"
}
>
<SendouTabList sticky>
<SendouTab
id="owned"
isDisabled={!user}
icon={<ArrowDownOnSquareIcon />}
number={data.posts.owned.length}
>
{t("scrims:tabs.owned")}
</SendouTab>
<SendouTab
id="requested"
isDisabled={!user}
icon={<ArrowUpOnSquareIcon />}
number={data.posts.requested.length}
data-testid="requests-scrims-tab"
>
{t("scrims:tabs.requests")}
</SendouTab>
<SendouTab
id="available"
icon={<MegaphoneIcon />}
number={data.posts.neutral.length}
data-testid="available-scrims-tab"
>
{t("scrims:tabs.available")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="owned">
<ScrimsDaySeparatedTables
posts={data.posts.owned}
showDeletePost
showRequestRows
showStatus
/>
</SendouTabPanel>
<SendouTabPanel id="requested">
<ScrimsDaySeparatedTables
posts={data.posts.requested}
requestScrim={setScrimToRequestId}
showStatus
/>
</SendouTabPanel>
{user ? (
<SendouTabList sticky>
<SendouTab
id="available"
icon={<MegaphoneIcon />}
number={data.posts.neutral.length}
data-testid="available-scrims-tab"
>
{t("scrims:tabs.available")}
</SendouTab>
<SendouTab
id="owned"
isDisabled={!user}
icon={<ArrowDownOnSquareIcon />}
number={data.posts.owned.length}
>
{t("scrims:tabs.owned")}
</SendouTab>
<SendouTab
id="booked"
isDisabled={!user}
icon={<CheckmarkIcon />}
number={data.posts.booked.length}
data-testid="booked-scrims-tab"
>
{t("scrims:tabs.booked")}
</SendouTab>
</SendouTabList>
) : null}
<SendouTabPanel id="available">
{data.posts.neutral.length > 0 ? (
<ScrimsDaySeparatedTables
<ScrimsDaySeparatedCards
posts={data.posts.neutral}
requestScrim={setScrimToRequestId}
filters={data.filters}
/>
) : (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
@ -175,6 +145,24 @@ export default function ScrimsPage() {
</div>
)}
</SendouTabPanel>
<SendouTabPanel id="owned">
{data.posts.owned.length > 0 ? (
<ScrimsDaySeparatedOwnedCards posts={data.posts.owned} />
) : (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
{t("scrims:noOwnedPosts")}
</div>
)}
</SendouTabPanel>
<SendouTabPanel id="booked">
{data.posts.booked.length > 0 ? (
<ScrimsDaySeparatedBookedCards posts={data.posts.booked} />
) : (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
{t("scrims:noBookedScrims")}
</div>
)}
</SendouTabPanel>
</SendouTabs>
<div className="mt-6 text-xs text-center text-lighter">
{t("calendar:inYourTimeZone")}{" "}
@ -184,70 +172,240 @@ export default function ScrimsPage() {
);
}
function RequestScrimModal({
postId,
close,
function ScrimsDaySeparatedCards({
posts,
filters,
}: {
postId: number;
close: () => void;
posts: ScrimPost[];
filters: ScrimFilters;
}) {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
// both to avoid crash when requesting
const post = [...data.posts.neutral, ...data.posts.requested].find(
(post) => post.id === postId,
const postsByDay = R.groupBy(posts, (post) =>
databaseTimestampToDate(post.at).getDate(),
);
invariant(post, "Post not found");
return (
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
<SendouForm
schema={newRequestSchema}
defaultValues={{
_action: "NEW_REQUEST",
scrimPostId: postId,
from:
data.teams.length > 0
? { mode: "TEAM", teamId: data.teams[0].id }
: {
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
}}
>
<ScrimsDaySeparatedTables posts={[post]} showPopovers={false} />
<div className="font-semi-bold text-lighter italic">
{joinListToNaturalString(post.users.map((u) => u.username))}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<WithFormField usersTeams={data.teams} />
</SendouForm>
</SendouDialog>
<div className="stack lg">
{Object.entries(postsByDay)
.sort((a, b) => a[1][0].at - b[1][0].at)
.map(([day, dayPosts]) => (
<ScrimsDaySection key={day} posts={dayPosts!} filters={filters} />
))}
</div>
);
}
function ScrimsDaySeparatedTables({
function ScrimsDaySection({
posts,
showPopovers = true,
showDeletePost = false,
showRequestRows = false,
showStatus = false,
requestScrim,
filters,
}: {
posts: ScrimPost[];
showPopovers?: boolean;
showDeletePost?: boolean;
showRequestRows?: boolean;
showStatus?: boolean;
requestScrim?: (postId: number) => void;
filters: ScrimFilters;
}) {
const { i18n } = useTranslation();
const user = useUser();
const [showFiltered, setShowFiltered] = React.useState(false);
const [showRequestPending, setShowRequestPending] = React.useState(false);
const filteredPosts = posts.filter((post) =>
Scrim.applyFilters(post, filters),
);
const pendingRequestsCount = filteredPosts.filter((post) =>
post.requests.some((request) =>
request.users.some((rUser) => user?.id === rUser.id),
),
).length;
return (
<div className="stack md">
<div className="stack xxs">
<h2 className="text-sm">
{databaseTimestampToDate(posts[0].at).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
weekday: "long",
},
)}
</h2>
{user ? (
<AvailableScrimsFilterButtons
showFiltered={showFiltered}
setShowFiltered={setShowFiltered}
showRequestPending={showRequestPending}
setShowRequestPending={setShowRequestPending}
pendingRequestsCount={pendingRequestsCount}
filteredCount={posts.length - filteredPosts.length}
/>
) : null}
</div>
<div className={styles.cardsGrid}>
{(showFiltered ? posts : filteredPosts).map((post) => {
const hasRequested = post.requests.some((request) =>
request.users.some((rUser) => user?.id === rUser.id),
);
if (hasRequested && !showRequestPending) {
return null;
}
const getAction = () => {
if (!user) return undefined;
if (hasRequested) return "VIEW_REQUEST";
if (post.requests.length === 0) return "REQUEST";
return undefined;
};
const isFilteredOut =
showFiltered && !Scrim.applyFilters(post, filters);
return (
<ScrimPostCard
key={post.id}
post={post}
action={getAction()}
isFilteredOut={isFilteredOut}
/>
);
})}
</div>
</div>
);
}
function AvailableScrimsFilterButtons({
showFiltered,
setShowFiltered,
showRequestPending,
setShowRequestPending,
pendingRequestsCount,
filteredCount,
}: {
showFiltered: boolean;
setShowFiltered: (value: boolean) => void;
showRequestPending: boolean;
setShowRequestPending: (value: boolean) => void;
pendingRequestsCount: number;
filteredCount: number;
}) {
const { t } = useTranslation(["scrims"]);
if (filteredCount === 0 && pendingRequestsCount === 0) {
return null;
}
return (
<div className={styles.filterButtons}>
{filteredCount > 0 ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => setShowFiltered(!showFiltered)}
icon={<FilterIcon />}
className={showFiltered ? styles.active : undefined}
>
{showFiltered
? t("scrims:filters.hideFiltered", { count: filteredCount })
: t("scrims:filters.showFiltered", { count: filteredCount })}
</SendouButton>
) : null}
{pendingRequestsCount > 0 ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => setShowRequestPending(!showRequestPending)}
icon={<ArrowDownOnSquareIcon />}
className={showRequestPending ? styles.active : undefined}
>
{showRequestPending
? t("scrims:filters.hidePendingRequests", {
count: pendingRequestsCount,
})
: t("scrims:filters.showPendingRequests", {
count: pendingRequestsCount,
})}
</SendouButton>
) : null}
</div>
);
}
function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
const { i18n, t } = useTranslation(["scrims"]);
const user = useUser();
const postsByDay = R.groupBy(posts, (post) =>
databaseTimestampToDate(post.at).getDate(),
);
return (
<div className="stack lg">
{Object.entries(postsByDay)
.sort((a, b) => a[1][0].at - b[1][0].at)
.map(([day, posts]) => {
return (
<div key={day} className="stack md">
<h2 className="text-sm">
{databaseTimestampToDate(posts![0].at).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
weekday: "long",
},
)}
</h2>
<div className="stack lg">
{posts!.map((post) => {
const isAccepted = post.requests.some(
(request) => request.isAccepted,
);
const canDelete =
user &&
post.permissions.DELETE_POST.includes(user.id) &&
!isAccepted;
return (
<div key={post.id} className="stack sm">
<ScrimPostCard
post={post}
action={canDelete ? "DELETE" : undefined}
/>
{post.requests.length > 0 ? (
<div className="stack sm">
{post.requests.map((request) => (
<ScrimRequestCard
key={request.id}
request={request}
postStartTime={post.at}
canAccept={Boolean(
user &&
post.permissions.MANAGE_REQUESTS.includes(
user.id,
),
)}
/>
))}
</div>
) : (
<div className="text-lighter text-lg font-bold mt-2 text-center">
{t("scrims:noRequestsYet")}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
const { i18n } = useTranslation();
const postsByDay = R.groupBy(posts, (post) =>
databaseTimestampToDate(post.at).getDate(),
@ -270,434 +428,30 @@ function ScrimsDaySeparatedTables({
},
)}
</h2>
<ScrimsTable
posts={posts!}
requestScrim={requestScrim}
showDeletePost={showDeletePost}
showRequestRows={showRequestRows}
showPopovers={showPopovers}
showStatus={showStatus}
/>
<div className="stack lg">
{posts!.map((post) => {
const acceptedRequest = post.requests.find(
(request) => request.isAccepted,
);
return (
<div key={post.id} className="stack sm">
<ScrimPostCard post={post} action="CONTACT" />
{acceptedRequest ? (
<ScrimRequestCard
request={acceptedRequest}
postStartTime={post.at}
canAccept={false}
showFooter={false}
/>
) : null}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
function ScrimsTable({
posts,
showPopovers,
showDeletePost,
showRequestRows,
showStatus,
requestScrim,
}: {
posts: ScrimPost[];
showPopovers: boolean;
showDeletePost: boolean;
showRequestRows: boolean;
showStatus: boolean;
requestScrim?: (postId: number) => void;
}) {
const { t } = useTranslation(["common", "scrims"]);
const user = useUser();
invariant(
!(requestScrim && showDeletePost),
"Can't have both request scrim and delete post",
);
const getStatus = (post: ScrimPost) => {
if (post.canceled) return "CANCELED";
if (post.requests.at(0)?.isAccepted) return "CONFIRMED";
if (
post.requests.some((r) => r.users.some((rUser) => user?.id === rUser.id))
) {
return "PENDING";
}
return null;
};
return (
<Table>
<thead>
<tr>
<th>{t("scrims:table.headers.time")}</th>
<th>{t("scrims:table.headers.team")}</th>
{showPopovers ? <th /> : null}
<th>{t("scrims:table.headers.divs")}</th>
{showStatus ? <th>{t("scrims:table.headers.status")}</th> : null}
{requestScrim || showDeletePost ? <th /> : null}
</tr>
</thead>
<tbody>
{posts.map((post) => {
const owner =
post.users.find((user) => user.isOwner) ?? post.users[0];
const requests = showRequestRows
? post.requests.map((request) => (
<RequestRow
key={request.id}
canAccept={Boolean(
user && post.permissions.MANAGE_REQUESTS.includes(user.id),
)}
request={request}
postId={post.id}
/>
))
: [];
const isAccepted = post.requests.some(
(request) => request.isAccepted,
);
const showContactButton =
isAccepted &&
post.requests.at(0)?.users.some((rUser) => rUser.id === user?.id);
const status = getStatus(post);
return (
<React.Fragment key={post.id}>
<tr>
<td>
<div className="stack horizontal sm">
<div className={styles.postTime}>
{!post.isScheduledForFuture ? (
t("scrims:now")
) : (
<TimePopover
time={databaseTimestampToDate(post.at)}
options={{
hour: "numeric",
minute: "numeric",
}}
underline={false}
footerText={t("scrims:postModal.footer", {
time: formatDistance(
databaseTimestampToDate(post.createdAt),
new Date(),
{
addSuffix: true,
},
),
})}
/>
)}
</div>
{post.isPrivate ? (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={<EyeSlashIcon className={styles.postIcon} />}
data-testid="limited-visibility-popover"
/>
}
>
{t("scrims:limitedVisibility")}
</SendouPopover>
) : null}
</div>
</td>
<td>
<div className="stack horizontal sm items-center min-w-max">
{showPopovers ? (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={<UsersIcon className={styles.postIcon} />}
/>
}
>
<div className="stack md">
{post.users.map((user) => (
<Link
to={userPage(user)}
key={user.id}
className="stack horizontal sm"
>
<Avatar size="xxs" user={user} />
{user.username}
</Link>
))}
</div>
</SendouPopover>
) : null}
{post.team?.avatarUrl ? (
<Avatar
size="xxs"
url={userSubmittedImage(post.team.avatarUrl)}
/>
) : (
<Avatar size="xxs" user={owner} />
)}
{post.team?.name ??
t("scrims:pickup", { username: owner.username })}
</div>
</td>
{showPopovers ? (
<td>
{post.text ? (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={
<SpeechBubbleIcon className={styles.postIcon} />
}
data-testid="scrim-text-popover"
/>
}
>
{post.text}
</SendouPopover>
) : null}
</td>
) : null}
<td className="whitespace-nowrap">
{post.divs ? (
<>
{post.divs.max} - {post.divs.min}
</>
) : null}
</td>
{showStatus ? (
<td
className={clsx({
[styles.postFloatingActionCell]: status !== "CONFIRMED",
})}
>
<div
className={clsx(styles.postStatus, {
[styles.postStatusConfirmed]: status === "CONFIRMED",
[styles.postStatusPending]: status === "PENDING",
[styles.postStatusCanceled]: status === "CANCELED",
})}
>
{status === "CONFIRMED" ? (
<>
<CheckmarkIcon /> {t("scrims:status.booked")}
</>
) : null}
{status === "PENDING" ? (
<>
<ClockIcon /> {t("scrims:status.pending")}
</>
) : null}
{status === "CANCELED" ? (
<>
<CrossIcon /> {t("scrims:status.canceled")}
</>
) : null}
</div>
</td>
) : null}
{user && requestScrim && post.requests.length === 0 ? (
<td className={styles.postFloatingActionCell}>
<SendouButton
size="small"
onPress={() => requestScrim(post.id)}
icon={<ArrowUpOnSquareIcon />}
className="ml-auto"
>
{t("scrims:actions.request")}
</SendouButton>
</td>
) : null}
{showDeletePost && !isAccepted ? (
<td>
{user && post.permissions.DELETE_POST.includes(user.id) ? (
<FormWithConfirm
dialogHeading={t("scrims:deleteModal.title")}
submitButtonText={t("common:actions.delete")}
fields={[
["scrimPostId", post.id],
["_action", "DELETE_POST"],
]}
>
<SendouButton
size="small"
variant="destructive"
className="ml-auto"
>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
) : (
<SendouPopover
trigger={
<SendouButton
variant="destructive"
size="small"
className="ml-auto"
>
{t("common:actions.delete")}
</SendouButton>
}
>
{t("scrims:deleteModal.prevented", {
username: owner.username,
})}
</SendouPopover>
)}
</td>
) : null}
{user &&
requestScrim &&
post.requests.length !== 0 &&
!post.requests.at(0)?.isAccepted &&
post.requests.at(0)?.permissions.CANCEL.includes(user.id) ? (
<td>
<FormWithConfirm
dialogHeading={t("scrims:cancelModal.title")}
submitButtonText={t("common:actions.cancel")}
fields={[
["scrimPostRequestId", post.requests[0].id],
["_action", "CANCEL_REQUEST"],
]}
>
<SendouButton
size="small"
variant="destructive"
icon={<CrossIcon />}
className="ml-auto"
>
{t("common:actions.cancel")}
</SendouButton>
</FormWithConfirm>
</td>
) : null}
{showContactButton ? (
<td className={styles.postFloatingActionCell}>
<ContactButton postId={post.id} />
</td>
) : null}
{isAccepted &&
post.requests.some(
(r) =>
r.isAccepted && !r.users.some((u) => u.id === user?.id),
) ? (
<td />
) : null}
</tr>
{requests}
</React.Fragment>
);
})}
</tbody>
</Table>
);
}
function ContactButton({ postId }: { postId: number }) {
const { t } = useTranslation(["scrims"]);
return (
<LinkButton
to={scrimPage(postId)}
size="small"
className="w-max ml-auto"
icon={<SpeechBubbleFilledIcon />}
>
{t("scrims:actions.contact")}
</LinkButton>
);
}
function RequestRow({
canAccept,
request,
postId,
}: {
canAccept: boolean;
request: ScrimPostRequest;
postId: number;
}) {
const { t } = useTranslation(["common", "scrims"]);
const requestOwner =
request.users.find((user) => user.isOwner) ?? request.users[0];
const groupName =
request.team?.name ??
t("scrims:pickup", {
username: requestOwner.username,
});
return (
<tr className="bg-theme-transparent-important">
<td />
<td>
<div className="stack horizontal sm items-center">
<SendouPopover
trigger={
<SendouButton
icon={<UsersIcon className={styles.postIcon} />}
variant="minimal"
/>
}
>
<div className="stack md">
{request.users.map((user) => (
<Link
to={userPage(user)}
key={user.id}
className="stack horizontal sm"
>
<Avatar size="xxs" user={user} />
{user.username}
</Link>
))}
</div>
</SendouPopover>
{request.team?.avatarUrl ? (
<Avatar
size="xxs"
url={userSubmittedImage(request.team.avatarUrl)}
/>
) : (
<Avatar size="xxs" user={requestOwner} />
)}
{groupName}
</div>
</td>
<td />
<td />
<td />
<td className={styles.postFloatingActionCell}>
{!request.isAccepted && canAccept ? (
<FormWithConfirm
dialogHeading={t("scrims:acceptModal.title", { groupName })}
fields={[
["scrimPostRequestId", request.id],
["_action", "ACCEPT_REQUEST"],
]}
submitButtonVariant="primary"
submitButtonText={t("common:actions.accept")}
>
<SendouButton size="small" className="ml-auto">
{t("common:actions.accept")}
</SendouButton>
</FormWithConfirm>
) : !request.isAccepted && !canAccept ? (
<SendouPopover
trigger={
<SendouButton size="small" className="ml-auto">
{t("common:actions.accept")}
</SendouButton>
}
>
{t("scrims:acceptModal.prevented")}
</SendouPopover>
) : (
<ContactButton postId={postId} />
)}
</td>
</tr>
);
}

View File

@ -17,6 +17,6 @@ export const SCRIM = {
MAX_PICKUP_SIZE_EXCLUDING_OWNER: 5,
MIN_MEMBERS_PER_TEAM: 4,
CANCEL_REASON_MAX_LENGTH: 500,
REQUEST_MESSAGE_MAX_LENGTH: 200,
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
};
export const FF_SCRIMS_ENABLED = true;

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { divsSchema } from "./scrims-schemas";
describe("divsSchema", () => {
it("swaps min and max when max is lower skill than min", () => {
const result = divsSchema.parse({ min: "1", max: "10" });
expect(result).toEqual({ min: "10", max: "1" });
});
it("keeps min and max when they are in correct order", () => {
const result = divsSchema.parse({ min: "10", max: "1" });
expect(result).toEqual({ min: "10", max: "1" });
});
it("keeps min and max when they are equal", () => {
const result = divsSchema.parse({ min: "5", max: "5" });
expect(result).toEqual({ min: "5", max: "5" });
});
});

View File

@ -7,6 +7,8 @@ import {
filterOutNullishMembers,
id,
noDuplicates,
safeJSONParse,
timeString,
} from "~/utils/zod";
import { associationIdentifierSchema } from "../associations/associations-schemas";
import { LUTI_DIVS, SCRIM } from "./scrims-constants";
@ -38,6 +40,11 @@ export const newRequestSchema = z.object({
_action: _action("NEW_REQUEST"),
scrimPostId: id,
from: fromSchema,
message: z.preprocess(
falsyToNull,
z.string().max(SCRIM.REQUEST_MESSAGE_MAX_LENGTH).nullable(),
),
at: z.preprocess(date, z.date()).nullish(),
});
export const acceptRequestSchema = z.object({
@ -54,11 +61,65 @@ export const cancelScrimSchema = z.object({
reason: z.string().trim().min(1).max(SCRIM.CANCEL_REASON_MAX_LENGTH),
});
const timeRangeSchema = z.object({
start: timeString,
end: timeString,
});
export const divsSchema = z
.object({
min: z.enum(LUTI_DIVS).nullable(),
max: z.enum(LUTI_DIVS).nullable(),
})
.refine(
(div) => {
if (!div) return true;
if (div.max && !div.min) return false;
if (div.min && !div.max) return false;
return true;
},
{
message: "Both min and max div must be set or neither",
},
)
.transform((divs) => {
if (!divs.min || !divs.max) return divs;
const minIndex = LUTI_DIVS.indexOf(divs.min);
const maxIndex = LUTI_DIVS.indexOf(divs.max);
if (maxIndex > minIndex) {
return { min: divs.max, max: divs.min };
}
return divs;
});
export const scrimsFiltersSchema = z.object({
weekdayTimes: timeRangeSchema.nullable().catch(null),
weekendTimes: timeRangeSchema.nullable().catch(null),
divs: divsSchema.nullable().catch(null),
});
export const scrimsFiltersSearchParamsObject = z.object({
filters: z
.preprocess(safeJSONParse, scrimsFiltersSchema)
.catch({ weekdayTimes: null, weekendTimes: null, divs: null }),
});
export const persistScrimFiltersSchema = z.object({
_action: _action("PERSIST_SCRIM_FILTERS"),
filters: scrimsFiltersSchema,
});
export const scrimsActionSchema = z.union([
deletePostSchema,
newRequestSchema,
acceptRequestSchema,
cancelRequestSchema,
persistScrimFiltersSchema,
]);
export const MAX_SCRIM_POST_TEXT_LENGTH = 500;
@ -90,6 +151,33 @@ export const scrimsNewActionSchema = z
},
),
),
rangeEnd: z
.preprocess(date, z.date())
.nullish()
.refine(
(date) => {
if (!date) return true;
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{
message: "Date can not be in the past",
},
)
.refine(
(date) => {
if (!date) return true;
if (date > add(new Date(), { days: 15 })) return false;
return true;
},
{
message: "Date can not be more than 2 weeks in the future",
},
),
baseVisibility: associationIdentifierSchema,
notFoundVisibility: z.object({
at: z
@ -109,44 +197,32 @@ export const scrimsNewActionSchema = z
),
forAssociation: associationIdentifierSchema,
}),
divs: z
.object({
min: z.enum(LUTI_DIVS).nullable(),
max: z.enum(LUTI_DIVS).nullable(),
})
.nullable()
.refine(
(div) => {
if (!div) return true;
if (div.max && !div.min) return false;
if (div.min && !div.max) return false;
return true;
},
{
message: "Both min and max div must be set or neither",
},
)
.refine(
(divs) => {
if (!divs?.min || !divs.max) return true;
const minIndex = LUTI_DIVS.indexOf(divs.min);
const maxIndex = LUTI_DIVS.indexOf(divs.max);
return minIndex >= maxIndex;
},
{ message: "Min div must be less than or equal to max div" },
),
divs: divsSchema.nullable(),
from: fromSchema,
postText: z.preprocess(
falsyToNull,
z.string().max(MAX_SCRIM_POST_TEXT_LENGTH).nullable(),
),
managedByAnyone: z.boolean(),
maps: z.enum(["NO_PREFERENCE", "SZ", "RANKED", "ALL", "TOURNAMENT"]),
mapsTournamentId: z.preprocess(falsyToNull, id.nullable()),
})
.superRefine((post, ctx) => {
if (post.maps === "TOURNAMENT" && !post.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "Tournament must be selected when maps is tournament",
code: z.ZodIssueCode.custom,
});
}
if (post.maps !== "TOURNAMENT" && post.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "Tournament should only be selected when maps is tournament",
code: z.ZodIssueCode.custom,
});
}
if (
post.notFoundVisibility.at &&
post.notFoundVisibility.forAssociation === post.baseVisibility
@ -182,4 +258,24 @@ export const scrimsNewActionSchema = z
code: z.ZodIssueCode.custom,
});
}
if (post.rangeEnd && post.rangeEnd <= post.at) {
ctx.addIssue({
path: ["rangeEnd"],
message: "End time must be after start time",
code: z.ZodIssueCode.custom,
});
}
if (
post.rangeEnd &&
post.rangeEnd.getTime() - post.at.getTime() > SCRIM.MAX_TIME_RANGE_MS
) {
ctx.addIssue({
path: ["rangeEnd"],
message:
"Time range can not be more than 3 hours. Make separate posts instead",
code: z.ZodIssueCode.custom,
});
}
});

View File

@ -7,6 +7,7 @@ export type LutiDiv = (typeof LUTI_DIVS)[number];
export interface ScrimPost {
id: number;
at: number;
rangeEnd: number | null;
createdAt: number;
visibility: AssociationVisibility | null;
text: string | null;
@ -16,6 +17,12 @@ export interface ScrimPost {
/** Min div in the whole system is "11" */
min: LutiDiv;
} | null;
maps: "SZ" | "ALL" | "RANKED" | null;
mapsTournament: {
id: number;
name: string;
avatarUrl: string;
} | null;
team: ScrimPostTeam | null;
users: Array<ScrimPostUser>;
chatCode: string | null;
@ -42,6 +49,8 @@ export interface ScrimPostRequest {
isAccepted: boolean;
users: Array<ScrimPostUser>;
team: ScrimPostTeam | null;
message: string | null;
at: number | null;
permissions: {
CANCEL: number[];
};
@ -57,3 +66,17 @@ interface ScrimPostTeam {
customUrl: string;
avatarUrl: string | null;
}
export interface TimeRange {
start: string;
end: string;
}
export interface ScrimFilters {
weekdayTimes: TimeRange | null;
weekendTimes: TimeRange | null;
divs: {
min: LutiDiv | null;
max: LutiDiv | null;
} | null;
}

View File

@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import { formatFlexTimeDisplay, generateTimeOptions } from "./scrims-utils";
describe("generateTimeOptions", () => {
it("includes both start and end times", () => {
const start = new Date("2025-01-15T14:15:00");
const end = new Date("2025-01-15T16:45:00");
const result = generateTimeOptions(start, end);
expect(result).toContain(start.getTime());
expect(result).toContain(end.getTime());
});
it("includes all :00 and :30 times in range", () => {
const start = new Date("2025-01-15T14:00:00");
const end = new Date("2025-01-15T16:00:00");
const result = generateTimeOptions(start, end);
expect(result).toContain(new Date("2025-01-15T14:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T14:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T16:00:00").getTime());
});
it("clears seconds and milliseconds from all times", () => {
const start = new Date("2025-01-15T14:15:23.456");
const end = new Date("2025-01-15T15:45:59.999");
const result = generateTimeOptions(start, end);
for (const timestamp of result) {
const date = new Date(timestamp);
expect(date.getSeconds()).toBe(0);
expect(date.getMilliseconds()).toBe(0);
}
});
it("returns sorted timestamps", () => {
const start = new Date("2025-01-15T14:15:00");
const end = new Date("2025-01-15T16:45:00");
const result = generateTimeOptions(start, end);
for (let i = 1; i < result.length; i++) {
expect(result[i]).toBeGreaterThan(result[i - 1]);
}
});
it("handles start time between :00 and :30", () => {
const start = new Date("2025-01-15T14:10:00");
const end = new Date("2025-01-15T15:00:00");
const result = generateTimeOptions(start, end);
expect(result).toContain(new Date("2025-01-15T14:10:00").getTime());
expect(result).toContain(new Date("2025-01-15T14:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:00:00").getTime());
});
it("handles start time between :30 and :00", () => {
const start = new Date("2025-01-15T14:45:00");
const end = new Date("2025-01-15T16:00:00");
const result = generateTimeOptions(start, end);
expect(result).toContain(new Date("2025-01-15T14:45:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T16:00:00").getTime());
});
it("handles range less than 30 minutes", () => {
const start = new Date("2025-01-15T14:15:00");
const end = new Date("2025-01-15T14:25:00");
const result = generateTimeOptions(start, end);
expect(result).toEqual([
new Date("2025-01-15T14:15:00").getTime(),
new Date("2025-01-15T14:25:00").getTime(),
]);
});
it("handles exact hour boundaries", () => {
const start = new Date("2025-01-15T14:00:00");
const end = new Date("2025-01-15T17:00:00");
const result = generateTimeOptions(start, end);
expect(result).toContain(new Date("2025-01-15T14:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T14:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T16:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T16:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T17:00:00").getTime());
});
it("handles exact half-hour boundaries", () => {
const start = new Date("2025-01-15T14:30:00");
const end = new Date("2025-01-15T16:30:00");
const result = generateTimeOptions(start, end);
expect(result).toContain(new Date("2025-01-15T14:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T15:30:00").getTime());
expect(result).toContain(new Date("2025-01-15T16:00:00").getTime());
expect(result).toContain(new Date("2025-01-15T16:30:00").getTime());
});
it("does not include duplicate times", () => {
const start = new Date("2025-01-15T14:00:00");
const end = new Date("2025-01-15T15:00:00");
const result = generateTimeOptions(start, end);
const uniqueValues = new Set(result);
expect(result.length).toBe(uniqueValues.size);
});
it("handles maximum 3-hour range", () => {
const start = new Date("2025-01-15T14:00:00");
const end = new Date("2025-01-15T17:00:00");
const result = generateTimeOptions(start, end);
expect(result.length).toBe(7);
});
});
describe("formatFlexTimeDisplay", () => {
it("returns null when totalMinutes is 0", () => {
const timestamp = Math.floor(
new Date("2025-01-15T14:00:00").getTime() / 1000,
);
const result = formatFlexTimeDisplay(timestamp, timestamp);
expect(result).toBeNull();
});
it("returns null when endTimestamp is before startTimestamp", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T13:00:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBeNull();
});
it("returns formatted minutes when only minutes (no hours)", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T14:45:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+45m");
});
it("returns formatted hours when exactly on the hour", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T16:00:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+2h");
});
it("returns formatted hours and minutes when both present", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T15:30:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+1h 30m");
});
it("handles 1 minute difference", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T14:01:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+1m");
});
it("handles 1 hour difference", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T15:00:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+1h");
});
it("handles multiple hours and minutes", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T17:25:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+3h 25m");
});
it("handles 59 minutes", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T14:59:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+59m");
});
it("handles exactly 60 minutes as 1 hour", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T15:00:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+1h");
});
it("handles 61 minutes as 1 hour 1 minute", () => {
const start = Math.floor(new Date("2025-01-15T14:00:00").getTime() / 1000);
const end = Math.floor(new Date("2025-01-15T15:01:00").getTime() / 1000);
const result = formatFlexTimeDisplay(start, end);
expect(result).toBe("+1h 1m");
});
});

View File

@ -1,4 +1,7 @@
import { differenceInMinutes } from "date-fns";
import * as R from "remeda";
import { databaseTimestampToDate } from "~/utils/dates";
import * as Scrim from "./core/Scrim";
import type { LutiDiv, ScrimPost } from "./scrims-types";
export const getPostRequestCensor =
@ -20,16 +23,17 @@ export const getPostRequestCensor =
export function dividePosts(posts: Array<ScrimPost>, userId?: number) {
const grouped = R.groupBy(posts, (post) => {
if (post.users.some((user) => user.id === userId)) {
return "OWNED";
const isAccepted = post.requests.some((request) => request.isAccepted);
const isParticipating = userId
? Scrim.isParticipating(post, userId)
: false;
if (isAccepted && isParticipating) {
return "BOOKED";
}
if (
post.requests.some((request) =>
request.users.some((user) => user.id === userId),
)
) {
return "REQUESTED";
if (post.users.some((user) => user.id === userId)) {
return "OWNED";
}
return "NEUTRAL";
@ -37,8 +41,8 @@ export function dividePosts(posts: Array<ScrimPost>, userId?: number) {
return {
owned: grouped.OWNED ?? [],
requested: grouped.REQUESTED ?? [],
neutral: grouped.NEUTRAL ?? [],
booked: grouped.BOOKED ?? [],
};
}
@ -53,3 +57,56 @@ export const serializeLutiDiv = (div: LutiDiv): number => {
return Number(div);
};
export function generateTimeOptions(startDate: Date, endDate: Date): number[] {
const timestamps = new Set<number>();
const clearSubMinutes = (date: Date) => {
const cleared = new Date(date);
cleared.setSeconds(0, 0);
return cleared;
};
timestamps.add(clearSubMinutes(startDate).getTime());
timestamps.add(clearSubMinutes(endDate).getTime());
const currentDate = clearSubMinutes(startDate);
const minutes = currentDate.getMinutes();
if (minutes > 0 && minutes < 30) {
currentDate.setMinutes(30, 0, 0);
} else if (minutes > 30) {
currentDate.setHours(currentDate.getHours() + 1, 0, 0, 0);
}
while (currentDate <= endDate) {
timestamps.add(currentDate.getTime());
currentDate.setMinutes(currentDate.getMinutes() + 30);
}
return Array.from(timestamps).sort((a, b) => a - b);
}
export function formatFlexTimeDisplay(
startTimestamp: number,
endTimestamp: number,
): string | null {
const totalMinutes = differenceInMinutes(
databaseTimestampToDate(endTimestamp),
databaseTimestampToDate(startTimestamp),
);
const hours = Math.floor(totalMinutes / 60);
const remainingMinutes = totalMinutes % 60;
if (hours > 0 && remainingMinutes > 0) {
return `+${hours}h ${remainingMinutes}m`;
}
if (hours > 0) {
return `+${hours}h`;
}
if (totalMinutes > 0) {
return `+${totalMinutes}m`;
}
return null;
}

View File

@ -1,5 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
@ -25,6 +26,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "UPDATE_NO_SCREEN": {
await QSettingsRepository.updateNoScreen({
userId: user.id,
noScreen: Number(data.newValue),
});
break;
}
case "PLACEHOLDER": {
break;
}

View File

@ -0,0 +1,13 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUserId } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
return {
noScreen: user
? await UserRepository.anyUserPrefersNoScreen([user.id])
: null,
};
};

View File

@ -1,5 +1,10 @@
import type { MetaFunction } from "@remix-run/node";
import { useFetcher, useNavigate, useSearchParams } from "@remix-run/react";
import {
useFetcher,
useLoaderData,
useNavigate,
useSearchParams,
} from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouSwitch } from "~/components/elements/Switch";
@ -7,7 +12,6 @@ import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
@ -15,9 +19,9 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, SETTINGS_PAGE } from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/settings.server";
export { action };
import { loader } from "../loaders/settings.server";
export { loader, action };
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
@ -28,6 +32,7 @@ export const handle: SendouRouteHandle = {
};
export default function SettingsPage() {
const data = useLoaderData<typeof loader>();
const user = useUser();
const { t } = useTranslation(["common"]);
@ -53,20 +58,24 @@ export default function SettingsPage() {
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
{FF_SCRIMS_ENABLED ? (
<PreferenceSelectorSwitch
_action="DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"
defaultSelected={
user?.preferences.disallowScrimPickupsFromUntrusted ?? false
}
label={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label",
)}
bottomText={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText",
)}
/>
) : null}
<PreferenceSelectorSwitch
_action="DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"
defaultSelected={
user?.preferences.disallowScrimPickupsFromUntrusted ?? false
}
label={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label",
)}
bottomText={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText",
)}
/>
<PreferenceSelectorSwitch
_action="UPDATE_NO_SCREEN"
defaultSelected={Boolean(data.noScreen)}
label={t("common:settings.UPDATE_NO_SCREEN.label")}
bottomText={t("common:settings.UPDATE_NO_SCREEN.bottomText")}
/>
</div>
</>
) : null}

View File

@ -10,6 +10,10 @@ export const settingsEditSchema = z.union([
_action: _action("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: z.boolean(),
}),
z.object({
_action: _action("UPDATE_NO_SCREEN"),
newValue: z.boolean(),
}),
z.object({
_action: _action("PLACEHOLDER"),
}),

View File

@ -160,11 +160,9 @@ export function StartedMatch({
bestOf: data.match.bestOf,
})}
</React.Fragment>,
tournament.ctx.settings.enableNoScreenToggle ? (
<ScreenBanIcons
key="screen-ban"
banned={teams.some((team) => team.noScreen)}
/>
tournament.ctx.settings.enableNoScreenToggle &&
typeof data.noScreen === "boolean" ? (
<ScreenBanIcons key="screen-ban" banned={data.noScreen} />
) : null,
];

View File

@ -34,7 +34,6 @@ describe("tournamentSummary()", () => {
name: `Team ${teamId}`,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
seed: 1,
activeRosterUserIds: [],

View File

@ -7573,7 +7573,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Inkbound",
seed: 1,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -7663,7 +7662,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Jet Lag!",
seed: 2,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -7753,7 +7751,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Fearless",
seed: 3,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -7843,7 +7840,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Remix",
seed: 4,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -7921,7 +7917,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Star Allies",
seed: 5,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8006,7 +8001,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "REWIND!!!",
seed: 6,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -8096,7 +8090,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "FlipSide",
seed: 7,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8186,7 +8179,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "o7 Honor Bound",
seed: 8,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8288,7 +8280,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "I love Latinas Sonic",
seed: 9,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8390,7 +8381,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Tidy Tidings",
seed: 10,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -8468,7 +8458,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Los Inklings",
seed: 11,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8570,7 +8559,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Celestial",
seed: 12,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8672,7 +8660,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Serves up",
seed: 13,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8762,7 +8749,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Ghistra",
seed: 14,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -8852,7 +8838,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Moonshine",
seed: 15,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -8954,7 +8939,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Inkception",
seed: 16,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9044,7 +9028,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "FREAKFORCE",
seed: 17,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9146,7 +9129,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Asphyxiation",
seed: 18,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -9236,7 +9218,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Malicious Misery",
seed: 19,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9326,7 +9307,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Magic Beans",
seed: 20,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9398,7 +9378,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Distortion",
seed: 21,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9476,7 +9455,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Ball up top",
seed: 22,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9549,7 +9527,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Caniac Central",
seed: 23,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9634,7 +9611,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Exam Week",
seed: 24,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9719,7 +9695,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "THIS IS FINE",
seed: 25,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9809,7 +9784,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Calamity Forge Neo",
seed: 26,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9899,7 +9873,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Twister Time",
seed: 27,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -9989,7 +9962,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "11 ft 8 Bridge",
seed: 28,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10062,7 +10034,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Loud Noises",
seed: 29,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10152,7 +10123,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Squid Rollups",
seed: 30,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10230,7 +10200,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "✨ Cooler High ✨",
seed: 31,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10320,7 +10289,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Event Horizon",
seed: 32,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10398,7 +10366,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Turbo Torben",
seed: 33,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10494,7 +10461,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Red Velvet Sea",
seed: 34,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10572,7 +10538,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "TAMU Maroon",
seed: 35,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10662,7 +10627,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Error 404",
seed: 36,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10752,7 +10716,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "XName",
seed: 37,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10854,7 +10817,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Brigada Woomy",
seed: 38,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -10944,7 +10906,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Tidal Tempest",
seed: 39,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11022,7 +10983,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Pringle Cat",
seed: 40,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11112,7 +11072,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Cod in 4K",
seed: 41,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11214,7 +11173,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "seasick",
seed: 42,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11292,7 +11250,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "squid squad",
seed: 43,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11377,7 +11334,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Sheer Cold",
seed: 44,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11467,7 +11423,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "idiots in chat",
seed: 45,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11569,7 +11524,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Deep Sea International",
seed: 46,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11647,7 +11601,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "octo rehab",
seed: 47,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11725,7 +11678,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "The Slam Blitzers",
seed: 48,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11815,7 +11767,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Special Force",
seed: 49,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11887,7 +11838,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Octaves",
seed: 50,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -11989,7 +11939,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Tide Breaker",
seed: 51,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -12091,7 +12040,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Nah I'd ink",
seed: 52,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -12164,7 +12112,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Inferno",
seed: 53,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -12242,7 +12189,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Moonslice",
seed: 54,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -12332,7 +12278,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "SplatSCAD-Orange",
seed: 55,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -12434,7 +12379,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Reignfall",
seed: 56,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -12512,7 +12456,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Cloud Strikers!",
seed: 57,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -12585,7 +12528,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Overdive",
seed: 58,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -12675,7 +12617,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Fish Paste",
seed: 59,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -12753,7 +12694,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Gubi Fortnite?!",
seed: 60,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -12850,7 +12790,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Krill Streak",
seed: 61,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -12940,7 +12879,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "EWfish",
seed: 62,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13030,7 +12968,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Starfish★",
seed: 63,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13127,7 +13064,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Zero Magnum",
seed: 64,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13229,7 +13165,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Citronnade",
seed: 65,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -13307,7 +13242,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Milkyway",
seed: 66,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13397,7 +13331,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Crisis Averted",
seed: 67,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13475,7 +13408,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "The British Empire",
seed: 68,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13565,7 +13497,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Pixel per ink",
seed: 69,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13655,7 +13586,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Paladins",
seed: 70,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13745,7 +13675,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Baguette squad",
seed: 71,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13835,7 +13764,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Job Application",
seed: 73,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -13913,7 +13841,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Cosmic Hunters",
seed: 74,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -14003,7 +13930,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Tocinitos de cielo",
seed: 75,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14088,7 +14014,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Chirpy Chips Crusaders",
seed: 76,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -14166,7 +14091,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Abyss Ink",
seed: 77,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14238,7 +14162,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Six-Pack Yokes",
seed: 78,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14328,7 +14251,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Squid's Rendezvous",
seed: 79,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -14406,7 +14328,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "morning struggles",
seed: 80,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -14484,7 +14405,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "squee-g simulator",
seed: 81,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14563,7 +14483,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Luminaria",
seed: 82,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14647,7 +14566,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "C-₅₀",
seed: 83,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14725,7 +14643,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Bad at Math",
seed: 84,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14798,7 +14715,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Starstrikerz",
seed: 85,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
startingBracketIdx: null,
@ -14888,7 +14804,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "[Name Pending]",
seed: 86,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,
@ -14966,7 +14881,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "TAMU White",
seed: 87,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 1,
inviteCode: null,
startingBracketIdx: null,

View File

@ -2439,7 +2439,6 @@ export const SWIM_OR_SINK_167 = (
name: "SOS WARRIORS",
seed: 1,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730771673,
@ -2556,7 +2555,6 @@ export const SWIM_OR_SINK_167 = (
name: "/rw3",
seed: 2,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730931681,
@ -2649,7 +2647,6 @@ export const SWIM_OR_SINK_167 = (
name: "Honkai",
seed: 3,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730864603,
@ -2754,7 +2751,6 @@ export const SWIM_OR_SINK_167 = (
name: "🦌",
seed: 4,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730932511,
@ -2847,7 +2843,6 @@ export const SWIM_OR_SINK_167 = (
name: "cutie patooties",
seed: 5,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730922495,
@ -2940,7 +2935,6 @@ export const SWIM_OR_SINK_167 = (
name: "Triggerfish Zones Supremacy",
seed: 6,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730788095,
@ -3062,7 +3056,6 @@ export const SWIM_OR_SINK_167 = (
name: "Retro Records",
seed: 7,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730774561,
@ -3172,7 +3165,6 @@ export const SWIM_OR_SINK_167 = (
name: "Impulse",
seed: 8,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730935243,
@ -3282,7 +3274,6 @@ export const SWIM_OR_SINK_167 = (
name: "Wave breaker",
seed: 9,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730851309,
@ -3380,7 +3371,6 @@ export const SWIM_OR_SINK_167 = (
name: "FreeFlow",
seed: 10,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730870818,
@ -3485,7 +3475,6 @@ export const SWIM_OR_SINK_167 = (
name: "Poisonous Gas Balloon",
seed: 11,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730872875,
@ -3590,7 +3579,6 @@ export const SWIM_OR_SINK_167 = (
name: "😺",
seed: 12,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730934390,
@ -3683,7 +3671,6 @@ export const SWIM_OR_SINK_167 = (
name: "petrichor.",
seed: 13,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730925172,
@ -3793,7 +3780,6 @@ export const SWIM_OR_SINK_167 = (
name: "New Wave",
seed: 14,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730526186,
@ -3915,7 +3901,6 @@ export const SWIM_OR_SINK_167 = (
name: "Roblox Fishermen",
seed: 15,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730940438,
@ -3970,7 +3955,6 @@ export const SWIM_OR_SINK_167 = (
name: "2 chicken quesadillas",
seed: 16,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730912708,
@ -4063,7 +4047,6 @@ export const SWIM_OR_SINK_167 = (
name: "REBOUND",
seed: 17,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730936919,
@ -4156,7 +4139,6 @@ export const SWIM_OR_SINK_167 = (
name: "hey wanna see something cool",
seed: 18,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730937337,
@ -4249,7 +4231,6 @@ export const SWIM_OR_SINK_167 = (
name: "Sweet Espresso",
seed: 19,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730769135,
@ -4371,7 +4352,6 @@ export const SWIM_OR_SINK_167 = (
name: "Outcast",
seed: 20,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730836875,
@ -4481,7 +4461,6 @@ export const SWIM_OR_SINK_167 = (
name: "Memento Moray",
seed: 21,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730844165,
@ -4579,7 +4558,6 @@ export const SWIM_OR_SINK_167 = (
name: "moi et la gothique",
seed: 22,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730926359,
@ -4672,7 +4650,6 @@ export const SWIM_OR_SINK_167 = (
name: "the fire worms are back",
seed: 23,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730928135,
@ -4765,7 +4742,6 @@ export const SWIM_OR_SINK_167 = (
name: "Meow's the Chance",
seed: 24,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730880730,
@ -4849,7 +4825,6 @@ export const SWIM_OR_SINK_167 = (
name: "Shellfire",
seed: 25,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730939363,
@ -4959,7 +4934,6 @@ export const SWIM_OR_SINK_167 = (
name: "Eepy Dreepy",
seed: 26,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730838132,
@ -5057,7 +5031,6 @@ export const SWIM_OR_SINK_167 = (
name: "Blitz Wave",
seed: 27,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730917380,
@ -5105,7 +5078,6 @@ export const SWIM_OR_SINK_167 = (
name: "YT->MP3",
seed: 28,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730832667,
@ -5198,7 +5170,6 @@ export const SWIM_OR_SINK_167 = (
name: "Wavelength",
seed: 29,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730853887,
@ -5308,7 +5279,6 @@ export const SWIM_OR_SINK_167 = (
name: "fearless I think",
seed: 30,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730928986,
@ -5413,7 +5383,6 @@ export const SWIM_OR_SINK_167 = (
name: "Los Inklings",
seed: 31,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730775625,
@ -5511,7 +5480,6 @@ export const SWIM_OR_SINK_167 = (
name: "High Point",
seed: 32,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730605037,
@ -5621,7 +5589,6 @@ export const SWIM_OR_SINK_167 = (
name: "Magic Beans",
seed: 33,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730862741,
@ -5731,7 +5698,6 @@ export const SWIM_OR_SINK_167 = (
name: "Sonora Inkamita de Iztapalapa",
seed: 34,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730840221,
@ -5815,7 +5781,6 @@ export const SWIM_OR_SINK_167 = (
name: "Squid Rollups",
seed: 35,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730770163,
@ -5937,7 +5902,6 @@ export const SWIM_OR_SINK_167 = (
name: "Strawberry Supernova",
seed: 36,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730753582,
@ -6009,7 +5973,6 @@ export const SWIM_OR_SINK_167 = (
name: "Moonshine",
seed: 37,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730929508,
@ -6107,7 +6070,6 @@ export const SWIM_OR_SINK_167 = (
name: "SSSoda!!!",
seed: 38,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730918770,
@ -6174,7 +6136,6 @@ export const SWIM_OR_SINK_167 = (
name: "Lock out",
seed: 39,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730851475,
@ -6267,7 +6228,6 @@ export const SWIM_OR_SINK_167 = (
name: "Distortion",
seed: 40,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730726309,
@ -6351,7 +6311,6 @@ export const SWIM_OR_SINK_167 = (
name: "California girls",
seed: 41,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730840643,
@ -6456,7 +6415,6 @@ export const SWIM_OR_SINK_167 = (
name: "Anenemies",
seed: 42,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730827259,
@ -6566,7 +6524,6 @@ export const SWIM_OR_SINK_167 = (
name: "Overdive",
seed: 43,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730863477,
@ -6688,7 +6645,6 @@ export const SWIM_OR_SINK_167 = (
name: "The Coastal Service",
seed: 44,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730907528,
@ -6781,7 +6737,6 @@ export const SWIM_OR_SINK_167 = (
name: "Fanshawe Fuel",
seed: 45,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730932702,
@ -6886,7 +6841,6 @@ export const SWIM_OR_SINK_167 = (
name: "Loud Noises",
seed: 46,
prefersNotToHost: 1,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730832003,
@ -6996,7 +6950,6 @@ export const SWIM_OR_SINK_167 = (
name: "Yarg",
seed: 47,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730938507,
@ -7089,7 +7042,6 @@ export const SWIM_OR_SINK_167 = (
name: "HP OfficeJet All-In-One Printer",
seed: 48,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730739211,
@ -7206,7 +7158,6 @@ export const SWIM_OR_SINK_167 = (
name: "C-Dragons",
seed: 49,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1730857328,
@ -7316,7 +7267,6 @@ export const SWIM_OR_SINK_167 = (
name: "United Crushers",
seed: 50,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730591675,
@ -7388,7 +7338,6 @@ export const SWIM_OR_SINK_167 = (
name: "Hal Laboratory Dog Eggs",
seed: 51,
prefersNotToHost: 1,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730859986,
@ -7481,7 +7430,6 @@ export const SWIM_OR_SINK_167 = (
name: "Ultra Kraken",
seed: 52,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1730703689,

View File

@ -401,7 +401,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "Bamboo Pirates",
seed: 1,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734656039,
@ -491,7 +490,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "the usual suspects",
seed: 2,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734423187,
@ -564,7 +562,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "ayam goreng",
seed: 3,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734660846,
@ -637,7 +634,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "Fruitea!",
seed: 5,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1734683349,
@ -727,7 +723,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "The Huh Inkqisition",
seed: 6,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1734608907,
@ -824,7 +819,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "Monkey Barrel",
seed: 7,
prefersNotToHost: 0,
noScreen: 1,
droppedOut: 0,
inviteCode: null,
createdAt: 1734397954,
@ -914,7 +908,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "Ras+1",
seed: 8,
prefersNotToHost: 0,
noScreen: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734598652,

View File

@ -2106,7 +2106,6 @@ export const PADDLING_POOL_257 = () =>
seed: 1,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2221,7 +2220,6 @@ export const PADDLING_POOL_257 = () =>
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2348,7 +2346,6 @@ export const PADDLING_POOL_257 = () =>
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2463,7 +2460,6 @@ export const PADDLING_POOL_257 = () =>
seed: 4,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2578,7 +2574,6 @@ export const PADDLING_POOL_257 = () =>
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2703,7 +2698,6 @@ export const PADDLING_POOL_257 = () =>
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2828,7 +2822,6 @@ export const PADDLING_POOL_257 = () =>
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -2952,7 +2945,6 @@ export const PADDLING_POOL_257 = () =>
seed: 8,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3064,7 +3056,6 @@ export const PADDLING_POOL_257 = () =>
seed: 9,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3202,7 +3193,6 @@ export const PADDLING_POOL_257 = () =>
seed: 10,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3327,7 +3317,6 @@ export const PADDLING_POOL_257 = () =>
seed: 11,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3584,7 +3573,6 @@ export const PADDLING_POOL_257 = () =>
seed: 13,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3708,7 +3696,6 @@ export const PADDLING_POOL_257 = () =>
seed: 14,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3843,7 +3830,6 @@ export const PADDLING_POOL_257 = () =>
seed: 15,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -3959,7 +3945,6 @@ export const PADDLING_POOL_257 = () =>
seed: 16,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -4075,7 +4060,6 @@ export const PADDLING_POOL_257 = () =>
seed: 17,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -4318,7 +4302,6 @@ export const PADDLING_POOL_257 = () =>
seed: 19,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -4542,7 +4525,6 @@ export const PADDLING_POOL_257 = () =>
seed: 21,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -4653,7 +4635,6 @@ export const PADDLING_POOL_257 = () =>
seed: 22,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -4764,7 +4745,6 @@ export const PADDLING_POOL_257 = () =>
seed: 23,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -4880,7 +4860,6 @@ export const PADDLING_POOL_257 = () =>
seed: 24,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5121,7 +5100,6 @@ export const PADDLING_POOL_257 = () =>
seed: 26,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5249,7 +5227,6 @@ export const PADDLING_POOL_257 = () =>
seed: 27,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5372,7 +5349,6 @@ export const PADDLING_POOL_257 = () =>
seed: 28,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5483,7 +5459,6 @@ export const PADDLING_POOL_257 = () =>
seed: 29,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5731,7 +5706,6 @@ export const PADDLING_POOL_257 = () =>
seed: 31,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5842,7 +5816,6 @@ export const PADDLING_POOL_257 = () =>
seed: 32,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -5958,7 +5931,6 @@ export const PADDLING_POOL_257 = () =>
seed: 33,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -6086,7 +6058,6 @@ export const PADDLING_POOL_257 = () =>
seed: 34,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -6197,7 +6168,6 @@ export const PADDLING_POOL_257 = () =>
seed: 35,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -8275,7 +8245,6 @@ export const PADDLING_POOL_255 = () =>
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -8390,7 +8359,6 @@ export const PADDLING_POOL_255 = () =>
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -8641,7 +8609,6 @@ export const PADDLING_POOL_255 = () =>
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -8756,7 +8723,6 @@ export const PADDLING_POOL_255 = () =>
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -8883,7 +8849,6 @@ export const PADDLING_POOL_255 = () =>
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -9108,7 +9073,6 @@ export const PADDLING_POOL_255 = () =>
seed: 9,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -9245,7 +9209,6 @@ export const PADDLING_POOL_255 = () =>
seed: 10,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -9477,7 +9440,6 @@ export const PADDLING_POOL_255 = () =>
seed: 12,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -9732,7 +9694,6 @@ export const PADDLING_POOL_255 = () =>
seed: 14,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -9843,7 +9804,6 @@ export const PADDLING_POOL_255 = () =>
seed: 15,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -9966,7 +9926,6 @@ export const PADDLING_POOL_255 = () =>
seed: 16,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -10077,7 +10036,6 @@ export const PADDLING_POOL_255 = () =>
seed: 17,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -10308,7 +10266,6 @@ export const PADDLING_POOL_255 = () =>
seed: 19,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -10431,7 +10388,6 @@ export const PADDLING_POOL_255 = () =>
seed: 20,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -10547,7 +10503,6 @@ export const PADDLING_POOL_255 = () =>
seed: 21,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -10675,7 +10630,6 @@ export const PADDLING_POOL_255 = () =>
seed: 22,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -10923,7 +10877,6 @@ export const PADDLING_POOL_255 = () =>
seed: 24,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11034,7 +10987,6 @@ export const PADDLING_POOL_255 = () =>
seed: 25,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11157,7 +11109,6 @@ export const PADDLING_POOL_255 = () =>
seed: 26,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11280,7 +11231,6 @@ export const PADDLING_POOL_255 = () =>
seed: 27,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11391,7 +11341,6 @@ export const PADDLING_POOL_255 = () =>
seed: 28,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11532,7 +11481,6 @@ export const PADDLING_POOL_255 = () =>
seed: 29,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11655,7 +11603,6 @@ export const PADDLING_POOL_255 = () =>
seed: 30,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -11783,7 +11730,6 @@ export const PADDLING_POOL_255 = () =>
seed: 31,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -12019,7 +11965,6 @@ export const PADDLING_POOL_255 = () =>
seed: 33,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -12135,7 +12080,6 @@ export const PADDLING_POOL_255 = () =>
seed: 34,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -12287,7 +12231,6 @@ export const PADDLING_POOL_255 = () =>
seed: 35,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -14519,7 +14462,6 @@ export const IN_THE_ZONE_32 = ({
seed: 1,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -14621,7 +14563,6 @@ export const IN_THE_ZONE_32 = ({
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -14723,7 +14664,6 @@ export const IN_THE_ZONE_32 = ({
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -14825,7 +14765,6 @@ export const IN_THE_ZONE_32 = ({
seed: 4,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -14953,7 +14892,6 @@ export const IN_THE_ZONE_32 = ({
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15068,7 +15006,6 @@ export const IN_THE_ZONE_32 = ({
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15183,7 +15120,6 @@ export const IN_THE_ZONE_32 = ({
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15311,7 +15247,6 @@ export const IN_THE_ZONE_32 = ({
seed: 8,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15439,7 +15374,6 @@ export const IN_THE_ZONE_32 = ({
seed: 9,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15541,7 +15475,6 @@ export const IN_THE_ZONE_32 = ({
seed: 10,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15643,7 +15576,6 @@ export const IN_THE_ZONE_32 = ({
seed: 11,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15745,7 +15677,6 @@ export const IN_THE_ZONE_32 = ({
seed: 12,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15847,7 +15778,6 @@ export const IN_THE_ZONE_32 = ({
seed: 13,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -15962,7 +15892,6 @@ export const IN_THE_ZONE_32 = ({
seed: 14,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16064,7 +15993,6 @@ export const IN_THE_ZONE_32 = ({
seed: 15,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16165,7 +16093,6 @@ export const IN_THE_ZONE_32 = ({
seed: 16,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16280,7 +16207,6 @@ export const IN_THE_ZONE_32 = ({
seed: 17,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16400,7 +16326,6 @@ export const IN_THE_ZONE_32 = ({
seed: 18,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16515,7 +16440,6 @@ export const IN_THE_ZONE_32 = ({
seed: 19,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16643,7 +16567,6 @@ export const IN_THE_ZONE_32 = ({
seed: 20,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16745,7 +16668,6 @@ export const IN_THE_ZONE_32 = ({
seed: 21,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16876,7 +16798,6 @@ export const IN_THE_ZONE_32 = ({
seed: 22,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -16985,7 +16906,6 @@ export const IN_THE_ZONE_32 = ({
seed: 23,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17084,7 +17004,6 @@ export const IN_THE_ZONE_32 = ({
seed: 24,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17184,7 +17103,6 @@ export const IN_THE_ZONE_32 = ({
seed: 25,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17306,7 +17224,6 @@ export const IN_THE_ZONE_32 = ({
seed: 26,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17416,7 +17333,6 @@ export const IN_THE_ZONE_32 = ({
seed: 27,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17532,7 +17448,6 @@ export const IN_THE_ZONE_32 = ({
seed: 28,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17642,7 +17557,6 @@ export const IN_THE_ZONE_32 = ({
seed: 29,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -17871,7 +17785,6 @@ export const IN_THE_ZONE_32 = ({
seed: 31,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -18082,7 +17995,6 @@ export const IN_THE_ZONE_32 = ({
seed: 33,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,
@ -18199,7 +18111,6 @@ export const IN_THE_ZONE_32 = ({
seed: 34,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
avgSeedingSkillOrdinal: null,

View File

@ -23,7 +23,6 @@ export const tournamentCtxTeam = (
name: `Team ${teamId}`,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
seed: teamId + 1,
...partial,
};

View File

@ -1,6 +1,9 @@
import cachified from "@epic-web/cachified";
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { resolveMapList } from "../core/mapList.server";
@ -27,6 +30,22 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? await TournamentRepository.pickBanEventsByMatchId(match.id)
: [];
// cached so that some user changing their noScreen preference doesn't
// change the selection once the match has started
const noScreen =
match.opponentOne?.id && match.opponentTwo?.id
? await cachified({
key: `no-screen-mid-${matchId}`,
cache,
ttl: ttl(IN_MILLISECONDS.TWO_DAYS),
async getFreshValue() {
return UserRepository.anyUserPrefersNoScreen(
match.players.map((p) => p.id),
);
},
})
: null;
const mapList =
match.opponentOne?.id && match.opponentTwo?.id
? resolveMapList({
@ -55,5 +74,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
matchIsOver:
match.opponentOne?.result === "win" ||
match.opponentTwo?.result === "win",
noScreen,
};
};

View File

@ -163,7 +163,6 @@ export async function findById(id: number) {
"TournamentTeam.name",
"TournamentTeam.seed",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.noScreen",
"TournamentTeam.droppedOut",
"TournamentTeam.inviteCode",
"TournamentTeam.createdAt",
@ -1136,3 +1135,54 @@ export function deleteSwissMatches({
.where("roundId", "=", roundId)
.execute();
}
export async function searchByName({
query,
limit,
minStartTime,
}: {
query: string;
limit: number;
minStartTime?: Date;
}) {
let sqlQuery = db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.leftJoin(
"UnvalidatedUserSubmittedImage",
"CalendarEvent.avatarImgId",
"UnvalidatedUserSubmittedImage.id",
)
.select([
"Tournament.id",
"CalendarEvent.name",
"CalendarEventDate.startTime",
"UnvalidatedUserSubmittedImage.url as logoUrl",
])
.where("CalendarEvent.name", "like", `%${query}%`)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startTime", "desc")
.limit(limit);
if (minStartTime) {
sqlQuery = sqlQuery.where(
"CalendarEventDate.startTime",
">=",
dateToDatabaseTimestamp(minStartTime),
);
}
const results = await sqlQuery.execute();
return results.map((result) => ({
...result,
logoSrc: result.logoUrl
? userSubmittedImage(result.logoUrl)
: HACKY_resolvePicture({ name: result.name }),
}));
}

View File

@ -115,10 +115,7 @@ export function create({
tournamentId,
ownerInGameName,
}: {
team: Pick<
Tables["TournamentTeam"],
"name" | "prefersNotToHost" | "noScreen" | "teamId"
>;
team: Pick<Tables["TournamentTeam"], "name" | "prefersNotToHost" | "teamId">;
avatarFileName?: string;
userId: number;
tournamentId: number;
@ -140,7 +137,6 @@ export function create({
name: team.name,
inviteCode: shortNanoid(),
prefersNotToHost: team.prefersNotToHost,
noScreen: team.noScreen,
teamId: team.teamId,
avatarImgId,
})
@ -179,7 +175,6 @@ export function copyFromAnotherTournament({
"TournamentTeam.avatarImgId",
"TournamentTeam.createdAt",
"TournamentTeam.name",
"TournamentTeam.noScreen",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.teamId",
@ -270,7 +265,7 @@ export function update({
}: {
team: Pick<
Tables["TournamentTeam"],
"id" | "name" | "prefersNotToHost" | "noScreen" | "teamId"
"id" | "name" | "prefersNotToHost" | "teamId"
>;
avatarFileName?: string;
userId: number;
@ -291,7 +286,6 @@ export function update({
.set({
name: team.name,
prefersNotToHost: team.prefersNotToHost,
noScreen: team.noScreen,
teamId: team.teamId,
avatarImgId,
})

View File

@ -65,7 +65,6 @@ export const action: ActionFunction = async ({ request, params }) => {
}),
team: {
name: data.teamName,
noScreen: 0,
prefersNotToHost: 0,
teamId: null,
},

View File

@ -90,7 +90,6 @@ export const action: ActionFunction = async ({ request, params }) => {
id: ownTeam.id,
name: data.teamName,
prefersNotToHost: Number(data.prefersNotToHost),
noScreen: Number(data.noScreen),
teamId: data.teamId ?? null,
},
});
@ -125,7 +124,6 @@ export const action: ActionFunction = async ({ request, params }) => {
}),
team: {
name: data.teamName,
noScreen: Number(data.noScreen),
prefersNotToHost: Number(data.prefersNotToHost),
teamId: data.teamId ?? null,
},

View File

@ -819,21 +819,6 @@ function TeamInfo({
{t("tournament:pre.info.noHost")}
</label>
</div>
{tournament.ctx.settings.enableNoScreenToggle ? (
<div className="text-lighter text-sm stack horizontal sm items-center">
<input
id="no-screen"
type="checkbox"
name="noScreen"
defaultChecked={Boolean(ownTeam?.noScreen)}
data-testid="no-screen-checkbox"
/>
<label htmlFor="no-screen" className="mb-0">
{t("tournament:pre.info.noScreen")}
</label>
</div>
) : null}
</div>
</div>
<SendouButton

View File

@ -0,0 +1,34 @@
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import { getUserId } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { parseSearchParams } from "~/utils/remix.server";
import { tournamentSearchSearchParamsSchema } from "../tournament-schemas.server";
export type TournamentSearchLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
if (!user) {
return [];
}
const {
q: query,
limit,
minStartTime,
} = parseSearchParams({
request,
schema: tournamentSearchSearchParamsSchema,
});
if (!query) return [];
return {
tournaments: await TournamentRepository.searchByName({
query,
limit,
minStartTime,
}),
query,
};
};

View File

@ -23,7 +23,6 @@ export const registerSchema = z.union([
_action: _action("UPSERT_TEAM"),
teamName,
prefersNotToHost: z.preprocess(checkboxValueToBoolean, z.boolean()),
noScreen: z.preprocess(checkboxValueToBoolean, z.boolean()),
teamId: optionalId,
}),
z.object({
@ -78,6 +77,12 @@ export const joinSchema = z.object({
trust: z.preprocess(checkboxValueToBoolean, z.boolean()),
});
export const tournamentSearchSearchParamsSchema = z.object({
q: z.string().max(100),
limit: z.coerce.number().int().min(1).max(25).catch(25),
minStartTime: z.coerce.date().optional().catch(undefined),
});
export const adminActionSchema = z.union([
z.object({
_action: _action("CHANGE_TEAM_OWNER"),

View File

@ -62,7 +62,6 @@ export async function dbInsertTournamentTeam({
ownerInGameName: null,
team: {
name: `Test Team ${ownerId}`,
noScreen: 0,
prefersNotToHost: 0,
teamId: null,
},

View File

@ -1050,3 +1050,18 @@ export const updateMany = dbDirect.transaction(
}
},
);
export async function anyUserPrefersNoScreen(
userIds: number[],
): Promise<boolean> {
if (userIds.length === 0) return false;
const result = await db
.selectFrom("User")
.select("User.noScreen")
.where("User.id", "in", userIds)
.where("User.noScreen", "=", 1)
.executeTakeFirst();
return Boolean(result);
}

View File

@ -85,6 +85,7 @@ export default [
"features/object-damage-calculator/routes/object-damage-calculator.tsx",
),
route("/to/search", "features/tournament/routes/to.search.ts"),
route("/to/:id", "features/tournament/routes/to.$id.tsx", [
index("features/tournament/routes/to.$id.index.ts"),
route("register", "features/tournament/routes/to.$id.register.tsx"),

View File

@ -3,6 +3,7 @@ import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldTrustRoutine } from "./deleteOldTrusts";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
import { NotifySeasonStartRoutine } from "./notifySeasonStart";
import { SetOldGroupsAsInactiveRoutine } from "./setOldGroupsAsInactive";
import { UpdatePatreonDataRoutine } from "./updatePatreonData";
@ -12,6 +13,7 @@ export const everyHourAt00 = [
NotifySeasonStartRoutine,
NotifyPlusServerVotingRoutine,
NotifyCheckInStartRoutine,
NotifyScrimStartingSoonRoutine,
];
/** List of Routines that should occur hourly at XX:30 */

View File

@ -0,0 +1,42 @@
import { add, sub } from "date-fns";
import { notify } from "../features/notifications/core/notify.server";
import * as Scrim from "../features/scrims/core/Scrim";
import * as ScrimPostRepository from "../features/scrims/ScrimPostRepository.server";
import { databaseTimestampToJavascriptTimestamp } from "../utils/dates";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const NotifyScrimStartingSoonRoutine = new Routine({
name: "NotifyScrimStartingSoon",
func: async () => {
const now = new Date();
const scrims =
await ScrimPostRepository.findAcceptedScrimsBetweenTwoTimestamps({
startTime: now,
endTime: add(now, { hours: 1 }),
excludeRecentlyCreated: sub(now, { hours: 2 }),
});
for (const scrim of scrims) {
const participantIds = Scrim.participantIdsListFromAccepted(scrim);
logger.info(
`Notifying scrim starting soon for scrim ${scrim.id} with ${participantIds.length} participants`,
);
await notify({
notification: {
type: "SCRIM_STARTING_SOON",
meta: {
id: scrim.id,
at: databaseTimestampToJavascriptTimestamp(
Scrim.getStartTime(scrim),
),
},
},
userIds: participantIds,
});
}
},
});

View File

@ -122,6 +122,12 @@ input[type="checkbox"] {
display: none !important;
}
input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.55;
cursor: pointer;
}
label {
display: block;
font-size: var(--fonts-xs);
@ -208,6 +214,11 @@ select:disabled {
background-image: url('data:image/svg+xml;utf8,<svg width="17px" color="rgb(0 0 0 / 55%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>');
}
.light input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(0);
opacity: 0.55;
}
select::selection {
overflow: hidden;
font-weight: bold;

View File

@ -29,4 +29,5 @@ export const IN_MILLISECONDS = {
HALF_HOUR: 30 * 60 * 1000,
ONE_HOUR: 60 * 60 * 1000,
TWO_HOURS: 2 * 60 * 60 * 1000,
TWO_DAYS: 2 * 24 * 60 * 60 * 1000,
};

View File

@ -409,7 +409,7 @@ export const getWeaponUsage = ({
};
export const mapsPageWithMapPool = (mapPool: MapPool) =>
`/maps?readonly&pool=${mapPool.serialized}`;
`${MAPS_URL}?readonly&pool=${mapPool.serialized}`;
export const articlePage = (slug: string) => `${ARTICLES_MAIN_PAGE}/${slug}`;
export const analyzerPage = (args?: {
weaponId: MainWeaponId;

View File

@ -3,6 +3,7 @@ import {
actuallyNonEmptyStringOrNull,
hasZalgo,
normalizeFriendCode,
timeString,
} from "./zod";
describe("normalizeFriendCode", () => {
@ -77,3 +78,45 @@ describe("actuallyNonEmptyStringOrNull", () => {
expect(actuallyNonEmptyStringOrNull("󠀠󠀠󠀠󠀠󠀠")).toBeNull();
});
});
describe("timeString", () => {
it("accepts valid time in HH:MM format", () => {
expect(timeString.safeParse("00:00").success).toBe(true);
expect(timeString.safeParse("12:30").success).toBe(true);
expect(timeString.safeParse("23:59").success).toBe(true);
});
it("accepts times with leading zeros", () => {
expect(timeString.safeParse("01:05").success).toBe(true);
expect(timeString.safeParse("09:00").success).toBe(true);
});
it("rejects invalid hour values", () => {
expect(timeString.safeParse("24:00").success).toBe(false);
expect(timeString.safeParse("25:30").success).toBe(false);
expect(timeString.safeParse("99:00").success).toBe(false);
});
it("rejects invalid minute values", () => {
expect(timeString.safeParse("12:60").success).toBe(false);
expect(timeString.safeParse("12:99").success).toBe(false);
});
it("rejects malformed time strings", () => {
expect(timeString.safeParse("1:30").success).toBe(false);
expect(timeString.safeParse("12:3").success).toBe(false);
expect(timeString.safeParse("12-30").success).toBe(false);
expect(timeString.safeParse("1230").success).toBe(false);
expect(timeString.safeParse("12:30:00").success).toBe(false);
});
it("rejects non-string values", () => {
expect(timeString.safeParse(1230).success).toBe(false);
expect(timeString.safeParse(null).success).toBe(false);
expect(timeString.safeParse(undefined).success).toBe(false);
});
it("rejects empty string", () => {
expect(timeString.safeParse("").success).toBe(false);
});
});

View File

@ -32,6 +32,9 @@ export const dbBoolean = z.coerce.number().min(0).max(1).int();
const hexCodeRegex = /^#(?:[0-9a-fA-F]{3}){1,2}[0-9]{0,2}$/; // https://stackoverflow.com/a/1636354
export const hexCode = z.string().regex(hexCodeRegex);
const timeStringRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
export const timeString = z.string().regex(timeStringRegex);
const abilityNameToType = (val: string) =>
abilities.find((ability) => ability.name === val)?.type;
export const headMainSlotAbility = z

Binary file not shown.

View File

@ -54,6 +54,8 @@ test.describe("Scrims", () => {
test("requests an existing scrim post & cancels the request", async ({
page,
}) => {
const INITIAL_AVAILABLE_TO_REQUEST_COUNT = 15;
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
@ -61,20 +63,33 @@ test.describe("Scrims", () => {
url: scrimsPage(),
});
const requestScrimButtonLocator = page.getByTestId("request-scrim-button");
await page.getByTestId("available-scrims-tab").click();
await page.getByRole("button", { name: "Request" }).first().click();
await requestScrimButtonLocator.first().click();
await submit(page);
await page.getByTestId("requests-scrims-tab").click();
await expect(requestScrimButtonLocator).toHaveCount(
INITIAL_AVAILABLE_TO_REQUEST_COUNT - 1,
);
const cancelRequestButton = page.getByRole("button", {
const togglePendingRequestsButton = page.getByTestId(
"toggle-pending-requests-button",
);
await togglePendingRequestsButton.first().click();
await page.getByTestId("view-request-button").first().click();
const cancelButton = page.getByRole("button", {
name: "Cancel",
});
expect(cancelRequestButton).toHaveCount(5);
await cancelRequestButton.first().click();
await page.getByTestId("confirm-button").click();
await expect(cancelRequestButton).toHaveCount(4);
await cancelButton.click();
await expect(requestScrimButtonLocator).toHaveCount(
INITIAL_AVAILABLE_TO_REQUEST_COUNT,
);
});
test("accepts a request", async ({ page }) => {
@ -85,10 +100,16 @@ test.describe("Scrims", () => {
url: scrimsPage(),
});
await page.getByRole("button", { name: "Accept" }).first().click();
await page.getByTestId("confirm-modal-trigger-button").first().click();
await page.getByTestId("confirm-button").click();
await page.getByRole("link", { name: "Contact" }).click();
await page.getByTestId("booked-scrims-tab").click();
const contactButtonLocator = page.getByRole("link", { name: "Contact" });
await expect(contactButtonLocator).toHaveCount(2);
await page.getByRole("link", { name: "Contact" }).first().click();
await expect(page.getByText("Scheduled scrim")).toBeVisible();
});
@ -102,10 +123,12 @@ test.describe("Scrims", () => {
});
// Accept the first available scrim request to make it possible to access the scrim details page
await page.getByRole("button", { name: "Accept" }).first().click();
await page.getByTestId("confirm-modal-trigger-button").first().click();
await page.getByTestId("confirm-button").click();
await page.getByRole("link", { name: "Contact" }).click();
await page.getByTestId("booked-scrims-tab").click();
await page.getByRole("link", { name: "Contact" }).first().click();
// Cancel the scrim
await page.getByRole("button", { name: "Cancel" }).click();

View File

@ -12,11 +12,11 @@ import {
} from "~/utils/playwright";
import {
NOTIFICATIONS_URL,
SETTINGS_PAGE,
tournamentAdminPage,
tournamentBracketsPage,
tournamentMatchPage,
tournamentPage,
tournamentRegisterPage,
userResultsPage,
} from "~/utils/urls";
@ -825,7 +825,7 @@ test.describe("Tournament bracket", () => {
await expect(page.locator('[data-match-id="1"]')).toBeVisible();
});
test("tournament no screen toggle works", async ({ page }) => {
test("user no screen setting affects tournament match", async ({ page }) => {
const tournamentId = 4;
await seed(page);
@ -833,22 +833,25 @@ test.describe("Tournament bracket", () => {
await navigate({
page,
url: tournamentRegisterPage(tournamentId),
url: SETTINGS_PAGE,
});
await page.getByTestId("no-screen-checkbox").click();
await page.getByTestId("save-team-button").click();
await page.getByTestId("UPDATE_NO_SCREEN-switch").click();
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("brackets-tab").click();
await page.getByTestId("finalize-bracket-button").click();
await page.getByTestId("confirm-finalize-bracket-button").click();
await page.locator('[data-match-id="2"]').click();
await expect(page.getByTestId("screen-allowed")).toBeVisible();
await backToBracket(page);
await page.locator('[data-match-id="1"]').click();
await expect(page.getByTestId("screen-banned")).toBeVisible();
await backToBracket(page);
await page.locator('[data-match-id="2"]').click();
await expect(page.getByTestId("screen-allowed")).toBeVisible();
});
test("hosts a 'play all' round robin stage", async ({ page }) => {

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "logindforsøg afbrudt",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -159,6 +162,8 @@
"forms.errors.noSearchMatches": "Ingen resultater fundet",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -293,6 +298,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Afregistrer",
"pre.info.unregister.confirm": "Afregistrer fra turnering og slet holdinformationer?",
"pre.info.noHost": "Mit hold foretrækker ikke at være vært for rummet",
"pre.info.noScreen": "Mit hold foretrækker at undgå Splattercolor Screen",
"pre.roster.header": "Udfyld holdmedlemslisten",
"pre.roster.footer": "Mindst {{atLeastCount}} holdmedlemmer kræves for at deltage. Der kan maks være {{maxCount}} på holdet",
"pre.roster.addTrusted.header": "Tilføj personer, som du har spillet med",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Einloggen abgebrochen",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -159,6 +162,8 @@
"forms.errors.noSearchMatches": "Keine Suchergebnisse",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -293,6 +298,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,37 +1,53 @@
{
"tabs.owned": "Eigene",
"tabs.requests": "Anfragen",
"tabs.booked": "",
"tabs.available": "Verfügbar",
"now": "Jetzt",
"noneAvailable": "Zur Zeit sind keine Scrims verfügbar. Schau später vorbei oder erstelle einen Post!",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "Scrim-Anfrage wird gesendet",
"table.headers.time": "Zeitpunkt",
"table.headers.team": "Team",
"table.headers.divs": "Divs",
"table.headers.status": "Status",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "Diese Anfrage hat aktuell eingeschränkte Sichtbarkeit aufgrund von Assoziationen.",
"pickup": "Pickup von {{username}}",
"status.booked": "Gebucht",
"status.pending": "Ausstehend",
"status.canceled": "",
"actions.request": "Anfragen",
"deleteModal.title": "Scrim post löschen?",
"deleteModal.prevented": "Der Post muss von Ersteller ({{username}}) gelöscht werden.",
"cancelModal.title": "Anfrage zurückziehen?",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "Kontaktieren",
"acceptModal.title": "Scrim-Anfrage von {{groupName}} akzeptieren & andere ablehnen (falls vorhanden)?",
"acceptModal.prevented": "Bitte den Ersteller des Scrim-Posts, diese Anfrage anzunehmen.",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "Neuen Scrim-Post erstellen",
"forms.with.title": "Mit",
"forms.with.explanation": "Du kannst nach Usern per Nutzername, Discord ID oder sendou.ink Profil-URL suchen.",
"forms.with.user": "Nutzer {{nth}}",
"forms.with.pick-up": "Pick-up",
"forms.when.title": "Wann",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "Text",
"forms.visibility.title": "Sichtbarkeit",
"forms.visibility.public": "Öffentlich",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "Leer lassen, wenn sich die Sichtbarkeit deines Posts nicht mit der Zeit ändern soll.",
"forms.divs.minDiv.title": "Min div",
"forms.divs.maxDiv.title": "Max div",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "Geplanter Scrim",
"associations.title": "Assoziationen",
"associations.explanation": "Erstelle eine Assoziation, um in einer kleineren Gruppe zu suchen (zum Beispiel mit regelmäßgien Übungsgegnern deines Teams oder deiner LUTI-Division).",
@ -55,5 +78,9 @@
"associations.forms.name.title": "Name",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Von Turnier abmelden",
"pre.info.unregister.confirm": "Vom Turnier abmelden und Teaminfo löschen?",
"pre.info.noHost": "Mein Team zieht es vor, keine Räume zu hosten",
"pre.info.noScreen": "Mein Team möchte Unsichtbarriere vermeiden",
"pre.roster.header": "Roster füllen",
"pre.roster.footer": "Mindestens {{atLeastCount}} Teammitglieder sind zum Spielen erforderlich. Maximale Rostergröße ist {{maxCount}}",
"pre.roster.addTrusted.header": "Spieler hinzufügen, mit denen du gespielt hast",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "New scrim scheduled at {{timeString}}",
"notifications.title.SCRIM_CANCELED": "Scrim Canceled",
"notifications.text.SCRIM_CANCELED": "The scrim at {{timeString}} was canceled",
"notifications.title.SCRIM_STARTING_SOON": "Scrim Starting Soon",
"notifications.text.SCRIM_STARTING_SOON": "Your scrim at {{timeString}} is starting soon",
"notifications.title.COMMISSIONS_CLOSED": "Commissions Closed",
"notifications.text.COMMISSIONS_CLOSED": "If your commissions are still open, please re-enable them",
"auth.errors.aborted": "Login Aborted",
@ -123,6 +125,7 @@
"actions.enable": "Enable",
"actions.disable": "Disable",
"actions.accept": "Accept",
"actions.confirm": "Confirm",
"actions.next": "Next",
"actions.previous": "Previous",
"actions.back": "Back",
@ -159,6 +162,8 @@
"forms.errors.noSearchMatches": "No matches found",
"forms.userSearch.placeholder": "Search users by username, profile URL or Discord ID...",
"forms.userSearch.noResults": "No users matching your search found",
"forms.tournamentSearch.placeholder": "Search tournaments by name...",
"forms.tournamentSearch.noResults": "No tournaments matching your search found",
"forms.weaponSearch.placeholder": "Select a weapon",
"forms.weaponSearch.search.placeholder": "Search weapons...",
"forms.weaponSearch.quickSelect": "Recent",
@ -293,6 +298,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere.",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "Scrims: No adding to pickups by untrusted",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "By default anyone can add you. If you prefer to only allow users you explicitly set as trusted to add you to pickups, enable this setting.",
"settings.UPDATE_NO_SCREEN.label": "[Accessibility] Avoid Splattercolor Screen",
"settings.UPDATE_NO_SCREEN.bottomText": "Affects tournaments, scrims and SendouQ.",
"settings.notifications.title": "Push notifications",
"settings.notifications.description": "Receive push notifications to your device even if you don't currently have sendou.ink open.",
"settings.notifications.disableInfo": "To disable push notifications check your browser settings",

View File

@ -1,37 +1,53 @@
{
"tabs.owned": "Owned",
"tabs.requests": "Requests",
"tabs.booked": "Booked",
"tabs.available": "Available",
"now": "Now",
"noneAvailable": "No scrims available right now. Check back later or add your own!",
"noRequestsYet": "No requests yet",
"noOwnedPosts": "You don't have any open scrim posts currently",
"noBookedScrims": "No booked scrims",
"requestModal.title": "Sending a scrim request",
"table.headers.time": "Time",
"table.headers.team": "Team",
"table.headers.divs": "Divs",
"table.headers.status": "Status",
"requestModal.message.label": "Message",
"requestModal.at.label": "Start time",
"requestModal.at.explanation": "Select a time within the post's time range",
"pickupBy": "Pickup by",
"filters.button": "Filters",
"filters.heading": "Scrim Filters",
"filters.weekdayTimes": "Weekday times",
"filters.weekdayStart": "Weekday start",
"filters.weekdayEnd": "Weekday end",
"filters.weekendTimes": "Weekend times",
"filters.weekendStart": "Weekend start",
"filters.weekendEnd": "Weekend end",
"filters.apply": "Apply",
"filters.applyAndDefault": "Apply & Set as Default",
"filters.showFiltered": "Show filtered ({{count}})",
"filters.hideFiltered": "Hide filtered ({{count}})",
"filters.showPendingRequests": "Show pending requests ({{count}})",
"filters.hidePendingRequests": "Hide pending requests ({{count}})",
"limitedVisibility": "This post currently has limited visibility based on associations.",
"pickup": "{{username}}'s pickup",
"status.booked": "Booked",
"status.pending": "Pending",
"status.canceled": "Canceled",
"actions.request": "Request",
"actions.viewRequest": "Request pending...",
"actions.contact": "Contact",
"deleteModal.title": "Delete the scrim post?",
"deleteModal.prevented": "The post must be deleted by the owner ({{username}})",
"cancelModal.title": "Cancel the request?",
"cancelRequestModal.title": "Cancel your request?",
"cancelModal.scrim.title": "Cancel the scrim?",
"cancelModal.scrim.reasonLabel": "Reason for cancellation",
"cancelModal.scrim.reasonExplanation": "Explain why you are cancelling the scrim. This will be visible to the other team.",
"actions.contact": "Contact",
"acceptModal.title": "Accept the request to scrim by {{groupName}} & reject others (if any)?",
"acceptModal.prevented": "Ask the person who posted the scrim to accept this request",
"acceptModal.confirmFor": "Confirm for {{time}}",
"postModal.footer": "Post created {{time}}",
"forms.title": "Creating a new scrim post",
"forms.with.title": "With",
"forms.with.explanation": "You can search for users with username, Discord ID or sendou.ink profile URL.",
"forms.with.user": "User {{nth}}",
"forms.with.pick-up": "Pick-up",
"forms.when.title": "When",
"forms.when.title": "Start",
"forms.when.explanation": "Leave to default if you want to look for a scrim now",
"forms.rangeEnd.title": "Start time range end",
"forms.rangeEnd.explanation": "If set, allow requests for any time between start and end",
"forms.text.title": "Text",
"forms.visibility.title": "Visibility",
"forms.visibility.public": "Public",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "Leave blank if you don't want the visibility of your post to change over time",
"forms.divs.minDiv.title": "Min div",
"forms.divs.maxDiv.title": "Max div",
"forms.maps.title": "Maps",
"forms.maps.noPreference": "No preference",
"forms.maps.szOnly": "SZ only",
"forms.maps.rankedOnly": "Ranked modes only",
"forms.maps.allModes": "All modes",
"forms.maps.tournament": "Tournament...",
"forms.mapsTournament.title": "Tournament",
"page.scheduledScrim": "Scheduled scrim",
"associations.title": "Associations",
"associations.explanation": "Create an association to look in a smaller group (for example make one with your team's regular practice opponents or LUTI division).",
@ -55,5 +78,9 @@
"associations.forms.name.title": "Name",
"forms.managedByAnyone.title": "Anyone can manage",
"forms.managedByAnyone.explanation": "If enabled, all users in this post can accept requests and delete the post, not just the owner.",
"alert.canceled": "This scrim was canceled by {{user}}. Reason: {{reason}}"
"alert.canceled": "This scrim was canceled by {{user}}. Reason: {{reason}}",
"maps.header": "Maps",
"screenBan.header": "Screen",
"screenBan.warning": "Ask before playing Splattercolor Screen",
"screenBan.allowed": "Nobody in this scrim has indicated they want to avoid Splattercolor Screen"
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Unregister",
"pre.info.unregister.confirm": "Unregister from the tournament and delete team info?",
"pre.info.noHost": "My team prefers not to host rooms",
"pre.info.noScreen": "My team prefers to avoid Splattercolor Screen",
"pre.roster.header": "Fill roster",
"pre.roster.footer": "At least {{atLeastCount}} members are required to participate. Max roster size is {{maxCount}}",
"pre.roster.addTrusted.header": "Add people you have played with",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Ingreso cancelado",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -160,6 +163,8 @@
"forms.errors.noSearchMatches": "No se encuentran partidos",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -295,6 +300,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Cancelar registro",
"pre.info.unregister.confirm": "¿Cancelar registro del torneo y borrar info del equipo?",
"pre.info.noHost": "Mi equipo prefiere no ser a cargo de salas",
"pre.info.noScreen": "Mi equipo prefiere evitar Muro marmoleado",
"pre.roster.header": "Llenar equipo",
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. Cantidad máxima son {{maxCount}}",
"pre.roster.addTrusted.header": "Agregar personas con quienes has jugado",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Ingreso cancelado",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -160,6 +163,8 @@
"forms.errors.noSearchMatches": "No se encuentran partidos",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -295,6 +300,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Cancelar registro",
"pre.info.unregister.confirm": "¿Cancelar registro del torneo y borrar info del equipo?",
"pre.info.noHost": "Mi equipo prefiere no ser a cargo de salas",
"pre.info.noScreen": "Mi equipo prefiere evitar Muro marmoleado",
"pre.roster.header": "Llenar equipo",
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. Cantidad máxima son {{maxCount}}",
"pre.roster.addTrusted.header": "Agregar personas con quienes has jugado",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Connexion abandonnée",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -160,6 +163,8 @@
"forms.errors.noSearchMatches": "Pas de correspondance trouvée",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -295,6 +300,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Se désinscrire",
"pre.info.unregister.confirm": "Se désinscrire du tournoi et effacer les info de l'équipe ?",
"pre.info.noHost": "Mon équipe préfère ne par héberger",
"pre.info.noScreen": "",
"pre.roster.header": "Remplir la liste",
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
"pre.roster.addTrusted.header": "Ajoutez des personnes avec lesquelles vous avez joué",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "Nouveau scrim programmé à {{timeString}}",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Connexion abandonnée",
@ -123,6 +125,7 @@
"actions.enable": "Activer",
"actions.disable": "Désactiver",
"actions.accept": "Accepter",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -160,6 +163,8 @@
"forms.errors.noSearchMatches": "Pas de correspondance trouvée",
"forms.userSearch.placeholder": "Cherchez les utilisateur par leur pseudo, l'URL de leur profil ou l'ID discord...",
"forms.userSearch.noResults": "Aucun utilisateur trouvé",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -295,6 +300,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "En dehors de votre profil, les bonus des sets sont triées de manière à ce que les mêmes bonus soient côte à côte. Ce paramètre vous permet de voir les bonus dans l'ordre dans lequel elles ont été créées partout.",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "Scrims: Les ajouts aux pickups peuvent êtres effectuer uniquement par des personnes de confiance",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "Par défaut, n'importe qui peut vous ajouter. Si vous préférez autoriser uniquement les utilisateurs que vous avez explicitement définis comme fiables à vous ajouter aux pickups, activez ce paramètre.",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "Notifications push",
"settings.notifications.description": "Recevoir une Receive notification push sur votre appareil quand vous n'êtes pas dessus.",
"settings.notifications.disableInfo": "Regarder les paramètre de votre navigateur pour désactiver les notification push.",

View File

@ -1,37 +1,53 @@
{
"tabs.owned": "Possédé",
"tabs.requests": "Demandes",
"tabs.booked": "",
"tabs.available": "Disponible",
"now": "Maintenant",
"noneAvailable": "Aucun scrim disponible pour le moment. Revenez plus tard ou ajoutez le vôtre!",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "Envoi d'une demande de scrim",
"table.headers.time": "Heure",
"table.headers.team": "Team",
"table.headers.divs": "Divs",
"table.headers.status": "Statut",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "Cet article a actuellement une visibilité limitée en fonction des associations.",
"pickup": "Pickup de {{username}}",
"status.booked": "Réservé",
"status.pending": "En attente",
"status.canceled": "",
"actions.request": "Demande",
"deleteModal.title": "Supprimer le post?",
"deleteModal.prevented": "Le post doit être supprimée par le propriétaire ({{username}})",
"cancelModal.title": "Annuler la demande ?",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "Contact",
"acceptModal.title": "Accepter la demande de scrim de {{groupName}} et rejeter les autres (s'il y en a)?",
"acceptModal.prevented": "Demandez à la personne qui a posté le scrim d'accepter cette demande",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "Créer un nouveau post",
"forms.with.title": "Avec",
"forms.with.explanation": "Vous pouvez rechercher des utilisateurs avec leur pseudo, l'ID Discord ou l'URL de leur profil sendou.ink.",
"forms.with.user": "Utilisateur {{nth}}",
"forms.with.pick-up": "Pick-up",
"forms.when.title": "Quand",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "Texte",
"forms.visibility.title": "Visibilité",
"forms.visibility.public": "Public",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "Laissez vide si vous ne souhaitez pas que la visibilité de votre post change au fil du temps",
"forms.divs.minDiv.title": "Min div",
"forms.divs.maxDiv.title": "Max div",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "Scrim programmé",
"associations.title": "Association",
"associations.explanation": "Créez une ''association'' pour regarder dans un groupe plus petit (par exemple, créez une association avec les adversaires habituels de votre équipe ou avec la division LUTI).",
@ -55,5 +78,9 @@
"associations.forms.name.title": "Nom",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Se désinscrire",
"pre.info.unregister.confirm": "Se désinscrire du tournoi et effacer les info de l'équipe ?",
"pre.info.noHost": "Mon équipe préfère ne par héberger",
"pre.info.noScreen": "Ma team préfère éviter le Screen",
"pre.roster.header": "Remplir la liste",
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
"pre.roster.addTrusted.header": "Ajoutez des personnes avec lesquelles vous avez joué",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "הכניסה בוטלה",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -159,6 +162,8 @@
"forms.errors.noSearchMatches": "לא נמצאה התאמה",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -294,6 +299,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "ביטול הרשמה",
"pre.info.unregister.confirm": "לבטל את הרישום לטורניר ולמחוק את פרטי הצוות?",
"pre.info.noHost": "הצוות שלי מעדיף לא לארח חדרים",
"pre.info.noScreen": "",
"pre.roster.header": "מלא צוות",
"pre.roster.footer": "לפחות {{atLeastCount}} חברי צוות נדרשים כדי להשתתף. גודל הצוות המרבי הוא {{maxCount}}",
"pre.roster.addTrusted.header": "הוסיפו אנשים ששיחקתם איתם",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Accesso cancellato",
@ -123,6 +125,7 @@
"actions.enable": "Attiva",
"actions.disable": "Disattiva",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -160,6 +163,8 @@
"forms.errors.noSearchMatches": "Nessun risultato trovato.",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -295,6 +300,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Aldilà della tua pagina profilo, le build delle abilità sono ordinate in modo tale da rendere vicine le abilità uguali. Quest'impostazione ti permette di vedere le abilità come intese dall'autore ovunque.",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "Notifiche push",
"settings.notifications.description": "Ricevi notifiche push sul tuo dispositivo anche se non hai attualmente sendou.ink aperto.",
"settings.notifications.disableInfo": "Per disattivare le notifiche push, controllare le impostazioni del browser",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "Disiscriviti",
"pre.info.unregister.confirm": "Disiscriversi dal torneo e cancellare le info del team?",
"pre.info.noHost": "Il mio team preferisce non hostare le stanze",
"pre.info.noScreen": "Il mio team preferisce evitare la Cortina ingannevole",
"pre.roster.header": "Riempi roster",
"pre.roster.footer": "Sono necessari almeno {{atLeastCount}} membri per partecipare. La dimensione massima del roster è {{maxCount}}",
"pre.roster.addTrusted.header": "Aggiungi persone con cui hai giocato",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "ログインを中断しました",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -157,6 +160,8 @@
"forms.errors.noSearchMatches": "検索結果がみつかりませんでした",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -289,6 +294,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

View File

@ -1,29 +1,43 @@
{
"tabs.owned": "",
"tabs.requests": "",
"tabs.booked": "",
"tabs.available": "",
"now": "",
"noneAvailable": "",
"noRequestsYet": "",
"noOwnedPosts": "",
"noBookedScrims": "",
"requestModal.title": "",
"table.headers.time": "",
"table.headers.team": "",
"table.headers.divs": "",
"table.headers.status": "",
"requestModal.message.label": "",
"requestModal.at.label": "",
"requestModal.at.explanation": "",
"pickupBy": "",
"filters.button": "",
"filters.heading": "",
"filters.weekdayTimes": "",
"filters.weekdayStart": "",
"filters.weekdayEnd": "",
"filters.weekendTimes": "",
"filters.weekendStart": "",
"filters.weekendEnd": "",
"filters.apply": "",
"filters.applyAndDefault": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
"filters.hidePendingRequests": "",
"limitedVisibility": "",
"pickup": "",
"status.booked": "",
"status.pending": "",
"status.canceled": "",
"actions.request": "",
"actions.viewRequest": "",
"actions.contact": "",
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelRequestModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",
@ -32,6 +46,8 @@
"forms.with.pick-up": "",
"forms.when.title": "",
"forms.when.explanation": "",
"forms.rangeEnd.title": "",
"forms.rangeEnd.explanation": "",
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
@ -40,6 +56,13 @@
"forms.notFoundVisibility.explanation": "",
"forms.divs.minDiv.title": "",
"forms.divs.maxDiv.title": "",
"forms.maps.title": "",
"forms.maps.noPreference": "",
"forms.maps.szOnly": "",
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"associations.title": "",
"associations.explanation": "",
@ -55,5 +78,9 @@
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
"alert.canceled": "",
"maps.header": "",
"screenBan.header": "",
"screenBan.warning": "",
"screenBan.allowed": ""
}

View File

@ -25,7 +25,6 @@
"pre.info.unregister": "登録解除",
"pre.info.unregister.confirm": "トーナメント登録をキャンセルして、チーム情報を削除しますか?",
"pre.info.noHost": "部屋作成はできれば遠慮したい",
"pre.info.noScreen": "スミナガシートはできるだけ使ってほしくない",
"pre.roster.header": "参加プレイヤーを登録",
"pre.roster.footer": "少なくとも {{atLeastCount}} 人の参加が必要です。最大メンバー数は {{maxCount}} です。",
"pre.roster.addTrusted.header": "一緒にプレイしたことがあるプレイヤーを追加する",

View File

@ -78,6 +78,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "로그인 중단됨",
@ -123,6 +125,7 @@
"actions.enable": "",
"actions.disable": "",
"actions.accept": "",
"actions.confirm": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
@ -157,6 +160,8 @@
"forms.errors.noSearchMatches": "검색 결과 없음",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.weaponSearch.quickSelect": "",
@ -289,6 +294,8 @@
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "",
"settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "",
"settings.UPDATE_NO_SCREEN.label": "",
"settings.UPDATE_NO_SCREEN.bottomText": "",
"settings.notifications.title": "",
"settings.notifications.description": "",
"settings.notifications.disableInfo": "",

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