@@ -247,7 +243,7 @@ function ShowcaseTournamentScroller({
))}
@@ -270,141 +266,6 @@ function AllTournamentsLinkCard() {
);
}
-function TournamentCard({
- tournament,
- topSpaced,
-}: {
- tournament: ShowcaseTournaments.ShowcaseTournament;
- topSpaced?: boolean;
-}) {
- const isMounted = useIsMounted();
- const { t, i18n } = useTranslation(["front", "common"]);
-
- const time = () => {
- if (!isMounted) return "Placeholder";
-
- const date = databaseTimestampToDate(tournament.startTime);
- return date.toLocaleString(i18n.language, {
- month: "short",
- day: "numeric",
- hour: "numeric",
- weekday: "short",
- minute: date.getMinutes() !== 0 ? "numeric" : undefined,
- });
- };
-
- return (
-
-
-
-
-

-
- {tournament.organization ? (
-
- {tournament.organization.name}
-
- ) : null}
-
-
- {tournament.name}{" "}
-
-
- {tournament.firstPlacer ? (
-
- ) : null}
-
-
-
- {tournament.teamsCount}
-
- {tournament.isRanked ? (
-
- {t("front:showcase.card.ranked")}
-
- ) : (
-
- {t("front:showcase.card.unranked")}
-
- )}
-
-
- );
-}
-
-function TournamentFirstPlacers({
- firstPlacer,
-}: {
- firstPlacer: NonNullable<
- ShowcaseTournaments.ShowcaseTournament["firstPlacer"]
- >;
-}) {
- const { t } = useTranslation(["front"]);
-
- return (
-
-
- {firstPlacer.logoUrl ? (
-
})
- ) : null}{" "}
-
-
- {firstPlacer.teamName}
-
-
- {t("front:showcase.card.winner")}
-
-
-
-
- {firstPlacer.members.map((member) => (
-
- {member.country ? : null}
- {member.username}{" "}
-
- ))}
- {firstPlacer.notShownMembersCount > 0 ? (
-
- +{firstPlacer.notShownMembersCount}
-
- ) : null}
-
-
- );
-}
-
function ResultHighlights() {
const { t } = useTranslation(["front"]);
const data = useLoaderData
();
diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx
index 99ca53045..42062db17 100644
--- a/app/features/scrims/routes/scrims.new.tsx
+++ b/app/features/scrims/routes/scrims.new.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { Label } from "~/components/Label";
import { DateTimeFormField } from "~/components/form/DateTimeFormField";
-import { MyForm } from "~/components/form/MyForm";
+import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { nullFilledArray } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -35,7 +35,7 @@ export default function NewScrimPage() {
return (
-
-
+
);
}
diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx
index 902fabbdb..185fcfdd9 100644
--- a/app/features/scrims/routes/scrims.tsx
+++ b/app/features/scrims/routes/scrims.tsx
@@ -13,7 +13,7 @@ import { Table } from "~/components/Table";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
-import { MyForm } from "~/components/form/MyForm";
+import { SendouForm } from "~/components/form/SendouForm";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { SpeechBubbleIcon } from "~/components/icons/SpeechBubble";
import { UsersIcon } from "~/components/icons/Users";
@@ -186,7 +186,7 @@ function RequestScrimModal({
return (
-
-
+
);
}
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts
index f96e4c880..310d9103d 100644
--- a/app/features/tournament-bracket/core/Tournament.ts
+++ b/app/features/tournament-bracket/core/Tournament.ts
@@ -1,4 +1,3 @@
-import * as R from "remeda";
import type {
Tables,
TournamentStage,
@@ -10,11 +9,13 @@ import {
LEAGUES,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
-import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
+import {
+ modesIncluded,
+ tournamentIsRanked,
+} from "~/features/tournament/tournament-utils";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Match, Stage } from "~/modules/brackets-model";
import type { ModeShort } from "~/modules/in-game-lists";
-import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { isAdmin } from "~/modules/permissions/utils";
import {
databaseTimestampNow,
@@ -728,32 +729,7 @@ export class Tournament {
}
get modesIncluded(): ModeShort[] {
- switch (this.ctx.mapPickingStyle) {
- case "AUTO_SZ": {
- return ["SZ"];
- }
- case "AUTO_TC": {
- return ["TC"];
- }
- case "AUTO_RM": {
- return ["RM"];
- }
- case "AUTO_CB": {
- return ["CB"];
- }
- default: {
- const pickedModes = R.unique(
- this.ctx.toSetMapPool.map((map) => map.mode),
- );
- if (pickedModes.length === 0) {
- return [...rankedModesShort];
- }
-
- return pickedModes.sort(
- (a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
- );
- }
- }
+ return modesIncluded(this.ctx.mapPickingStyle, this.ctx.toSetMapPool);
}
tournamentTeamLogoSrc(team: TournamentDataTeam) {
diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-bracket/core/mapList.server.ts
index d254afc90..b56e800a8 100644
--- a/app/features/tournament-bracket/core/mapList.server.ts
+++ b/app/features/tournament-bracket/core/mapList.server.ts
@@ -1,7 +1,7 @@
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
-import { modesIncluded } from "~/features/tournament/tournament-utils";
+import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils";
import type { Round } from "~/modules/brackets-model";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
@@ -132,7 +132,7 @@ export function resolveFreshTeamPickedMapList(
if (count() === 1) {
return starterMap({
seed: String(args.matchId),
- modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
+ modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
tiebreakerMaps: new MapPool(tieBreakerMapPool),
teams: [
{
@@ -151,7 +151,7 @@ export function resolveFreshTeamPickedMapList(
return createTournamentMapList({
count: count(),
seed: String(args.matchId),
- modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
+ modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
tiebreakerMaps: new MapPool(tieBreakerMapPool),
teams: [
{
@@ -173,7 +173,7 @@ export function resolveFreshTeamPickedMapList(
return createTournamentMapList({
count: count(),
seed: String(args.matchId),
- modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
+ modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
tiebreakerMaps: new MapPool(tieBreakerMapPool),
teams: [
{
diff --git a/app/features/tournament-organization/routes/org.$slug.edit.tsx b/app/features/tournament-organization/routes/org.$slug.edit.tsx
index 3ddd82434..21a3d8bf1 100644
--- a/app/features/tournament-organization/routes/org.$slug.edit.tsx
+++ b/app/features/tournament-organization/routes/org.$slug.edit.tsx
@@ -7,11 +7,11 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { AddFieldButton } from "~/components/form/AddFieldButton";
import { FormFieldset } from "~/components/form/FormFieldset";
-import { MyForm } from "~/components/form/MyForm";
+import { InputFormField } from "~/components/form/InputFormField";
import { SelectFormField } from "~/components/form/SelectFormField";
+import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
-import { TextFormField } from "~/components/form/TextFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
@@ -44,7 +44,7 @@ export default function TournamentOrganizationEditPage() {
return (
-
- label={t("common:forms.name")} name="name" />
+
+ label={t("common:forms.name")}
+ name="name"
+ />
-
+
label={t("common:forms.description")}
name="description"
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
@@ -84,16 +87,16 @@ export default function TournamentOrganizationEditPage() {
-
+
label={t("org:edit.form.socialLinks.title")}
name="socials"
- defaultFieldValue=""
+ format="object"
/>
-
+
);
}
@@ -159,7 +162,7 @@ function MemberFieldset({
}))}
/>
-
+
label={t("org:edit.form.members.roleDisplayName.title")}
name={`members.${idx}.roleDisplayName` as const}
/>
@@ -213,7 +216,7 @@ function SeriesFieldset({
clearErrors("series");
}}
>
-
+
label={t("org:edit.form.series.seriesName.title")}
name={`series.${idx}.name` as const}
/>
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts
index 18eec0bef..21378d9f5 100644
--- a/app/features/tournament/TournamentRepository.server.ts
+++ b/app/features/tournament/TournamentRepository.server.ts
@@ -452,6 +452,7 @@ export function forShowcase() {
.select((eb) => [
"Tournament.id",
"Tournament.settings",
+ "CalendarEvent.authorId",
"CalendarEvent.name",
"CalendarEventDate.startTime",
eb
@@ -538,6 +539,36 @@ function databaseTimestampWeekAgo() {
return dateToDatabaseTimestamp(now);
}
+export function findAllBetweenTwoTimestamps({
+ startTime,
+ endTime,
+}: {
+ startTime: Date;
+ endTime: Date;
+}) {
+ return db
+ .selectFrom("CalendarEvent")
+ .innerJoin(
+ "CalendarEventDate",
+ "CalendarEvent.id",
+ "CalendarEventDate.eventId",
+ )
+ .innerJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
+ .select(["Tournament.id as tournamentId"])
+ .where(
+ "CalendarEventDate.startTime",
+ ">=",
+ dateToDatabaseTimestamp(startTime),
+ )
+ .where(
+ "CalendarEventDate.startTime",
+ "<=",
+ dateToDatabaseTimestamp(endTime),
+ )
+ .where("CalendarEvent.hidden", "=", 0)
+ .execute();
+}
+
export function topThreeResultsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentResult")
diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts
index 93308874c..5095e30aa 100644
--- a/app/features/tournament/actions/to.$id.register.server.ts
+++ b/app/features/tournament/actions/to.$id.register.server.ts
@@ -14,7 +14,6 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
- notFoundIfFalsy,
parseFormData,
parseParams,
uploadImageIfSubmitted,
@@ -25,7 +24,6 @@ import { idObject } from "~/utils/zod";
import { checkIn } from "../queries/checkIn.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import deleteTeamMember from "../queries/deleteTeamMember.server";
-import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
@@ -52,7 +50,6 @@ export const action: ActionFunction = async ({ request, params }) => {
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
- const event = notFoundIfFalsy(findByIdentifier(tournamentId));
errorToastIfFalsy(
!tournament.hasStarted,
@@ -198,7 +195,10 @@ export const action: ActionFunction = async ({ request, params }) => {
errorToastIfFalsy(
validateCounterPickMapPool(
mapPool,
- isOneModeTournamentOf(event),
+ isOneModeTournamentOf(
+ tournament.ctx.mapPickingStyle,
+ tournament.ctx.toSetMapPool,
+ ),
tournament.ctx.tieBreakerMapPool,
) === "VALID",
"Invalid map pool",
diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts
index 025927ebf..663627863 100644
--- a/app/features/tournament/tournament-utils.ts
+++ b/app/features/tournament/tournament-utils.ts
@@ -1,6 +1,7 @@
+import * as R from "remeda";
import { INVITE_CODE_LENGTH } from "~/constants";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
-import { rankedModesShort } from "~/modules/in-game-lists/modes";
+import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { weekNumberToDate } from "~/utils/dates";
import { tournamentLogoUrl } from "~/utils/urls";
import type { Tables, TournamentStageSettings } from "../../db/tables";
@@ -13,33 +14,46 @@ import type { TournamentData } from "../tournament-bracket/core/Tournament.serve
import type { PlayedSet } from "./core/sets.server";
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
+const mapPickingStyleToModeRecord = {
+ AUTO_SZ: ["SZ"],
+ AUTO_TC: ["TC"],
+ AUTO_RM: ["RM"],
+ AUTO_CB: ["CB"],
+ AUTO_ALL: rankedModesShort,
+} as const;
+
+export const mapPickingStyleToModes = (
+ mapPickingStyle: Exclude,
+) => {
+ return mapPickingStyleToModeRecord[mapPickingStyle].slice();
+};
+
export function modesIncluded(
- tournament: Pick,
+ mapPickingStyle: Tables["Tournament"]["mapPickingStyle"],
+ toSetMapPool: Array<{ mode: ModeShort }>,
): ModeShort[] {
- switch (tournament.mapPickingStyle) {
- case "AUTO_SZ": {
- return ["SZ"];
- }
- case "AUTO_TC": {
- return ["TC"];
- }
- case "AUTO_RM": {
- return ["RM"];
- }
- case "AUTO_CB": {
- return ["CB"];
- }
- default: {
- return [...rankedModesShort];
- }
+ if (mapPickingStyle !== "TO") {
+ return mapPickingStyleToModes(mapPickingStyle);
}
+
+ const pickedModes = R.unique(toSetMapPool.map((map) => map.mode));
+
+ // fallback
+ if (pickedModes.length === 0) {
+ return [...rankedModesShort];
+ }
+
+ return pickedModes.sort(
+ (a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
+ );
}
export function isOneModeTournamentOf(
- tournament: Pick,
+ mapPickingStyle: Tables["Tournament"]["mapPickingStyle"],
+ toSetMapPool: Array<{ mode: ModeShort }>,
) {
- return modesIncluded(tournament).length === 1
- ? modesIncluded(tournament)[0]
+ return modesIncluded(mapPickingStyle, toSetMapPool).length === 1
+ ? modesIncluded(mapPickingStyle, toSetMapPool)[0]
: null;
}
@@ -370,3 +384,10 @@ export function validateCanJoinTeam({
return "VALID";
}
+
+export function normalizedTeamCount({
+ teamsCount,
+ minMembersPerTeam,
+}: { teamsCount: number; minMembersPerTeam: number }) {
+ return teamsCount * minMembersPerTeam;
+}
diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx
index e87fc5d4a..3475f4d2d 100644
--- a/app/features/user-page/routes/u.$identifier.index.tsx
+++ b/app/features/user-page/routes/u.$identifier.index.tsx
@@ -256,6 +256,7 @@ function ExtraInfos() {
return (
+
#{data.user.id}
{data.user.discordUniqueName && (
diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx
index 8e75fb4f7..4e600e71c 100644
--- a/app/features/vods/routes/vods.new.tsx
+++ b/app/features/vods/routes/vods.new.tsx
@@ -27,9 +27,9 @@ import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Alert } from "../../../components/Alert";
import { DateFormField } from "../../../components/form/DateFormField";
-import { MyForm } from "../../../components/form/MyForm";
+import { InputFormField } from "../../../components/form/InputFormField";
import { SelectFormField } from "../../../components/form/SelectFormField";
-import { TextFormField } from "../../../components/form/TextFormField";
+import { SendouForm } from "../../../components/form/SendouForm";
import { videoMatchTypes } from "../vods-constants";
import { videoInputSchema } from "../vods-schemas";
@@ -58,7 +58,7 @@ export default function NewVodPage() {
return (
-
-
+
);
}
@@ -96,7 +96,7 @@ function FormFields() {
return (
<>
-
+
label={t("vods:forms.title.youtubeUrl")}
name="video.youtubeUrl"
placeholder="https://www.youtube.com/watch?v=-dQ6JsVIKdY"
@@ -104,7 +104,7 @@ function FormFields() {
size="medium"
/>
-
+
label={t("vods:forms.title.videoTitle")}
name="video.title"
placeholder="[SCL 47] (Grand Finals) Team Olive vs. Kraken Paradise"
@@ -278,7 +278,7 @@ function MatchesFieldset({
{canRemove ? remove(idx)} /> : null}
-
+
required
label={t("vods:forms.title.startTimestamp")}
name={`video.matches.${idx}.startsAt`}
diff --git a/app/modules/in-game-lists/games.ts b/app/modules/in-game-lists/games.ts
new file mode 100644
index 000000000..b5cd3cb39
--- /dev/null
+++ b/app/modules/in-game-lists/games.ts
@@ -0,0 +1,2 @@
+export const gamesShort = ["S1", "S2", "S3"] as const;
+export const versusShort = ["1v1", "2v2", "3v3", "4v4"] as const;
diff --git a/app/modules/in-game-lists/index.ts b/app/modules/in-game-lists/index.ts
index 73cb9d8a4..37bb0b53d 100644
--- a/app/modules/in-game-lists/index.ts
+++ b/app/modules/in-game-lists/index.ts
@@ -1,4 +1,4 @@
-export { modes, modesShort } from "./modes";
+export { modesShort } from "./modes";
export {
weaponCategories,
mainWeaponIds,
diff --git a/app/modules/in-game-lists/modes.ts b/app/modules/in-game-lists/modes.ts
index aadd7c80f..bbe52469d 100644
--- a/app/modules/in-game-lists/modes.ts
+++ b/app/modules/in-game-lists/modes.ts
@@ -1,12 +1,6 @@
import type { RankedModeShort } from "./types";
-export const modes = [
- { short: "TW" },
- { short: "SZ" },
- { short: "TC" },
- { short: "RM" },
- { short: "CB" },
-] as const;
-
-export const modesShort = modes.map((mode) => mode.short);
+export const modesShort = ["TW", "SZ", "TC", "RM", "CB"] as const;
export const rankedModesShort = modesShort.slice(1) as RankedModeShort[];
+
+export const modesShortWithSpecial = [...modesShort, "TB", "SR"] as const;
diff --git a/app/modules/in-game-lists/types.ts b/app/modules/in-game-lists/types.ts
index a68d48505..cefd47a4b 100644
--- a/app/modules/in-game-lists/types.ts
+++ b/app/modules/in-game-lists/types.ts
@@ -1,5 +1,5 @@
import type { abilities } from "./abilities";
-import type { modes } from "./modes";
+import type { modesShort, modesShortWithSpecial } from "./modes";
import type { stageIds } from "./stage-ids";
import type {
mainWeaponIds,
@@ -7,8 +7,9 @@ import type {
subWeaponIds,
} from "./weapon-ids";
-export type ModeShort = (typeof modes)[number]["short"];
-export type RankedModeShort = "SZ" | "TC" | "RM" | "CB";
+export type ModeShort = (typeof modesShort)[number];
+export type ModeShortWithSpecial = (typeof modesShortWithSpecial)[number];
+export type RankedModeShort = Exclude;
export type StageId = (typeof stageIds)[number];
diff --git a/app/routines/notifyCheckInStart.ts b/app/routines/notifyCheckInStart.ts
index 32ce018b5..6034d67d6 100644
--- a/app/routines/notifyCheckInStart.ts
+++ b/app/routines/notifyCheckInStart.ts
@@ -1,6 +1,6 @@
-import * as CalendarRepository from "../features/calendar/CalendarRepository.server";
import { notify } from "../features/notifications/core/notify.server";
import { tournamentDataCached } from "../features/tournament-bracket/core/Tournament.server";
+import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
@@ -9,10 +9,9 @@ export const NotifyCheckInStartRoutine = new Routine({
func: async () => {
const now = new Date();
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
- const tournaments = await CalendarRepository.findAllBetweenTwoTimestamps({
+ const tournaments = await TournamentRepository.findAllBetweenTwoTimestamps({
startTime: now,
endTime: oneHourFromNow,
- onlyTournaments: true,
});
for (const { tournamentId } of tournaments) {
diff --git a/app/styles/calendar.css b/app/styles/calendar.css
deleted file mode 100644
index a38c7e635..000000000
--- a/app/styles/calendar.css
+++ /dev/null
@@ -1,191 +0,0 @@
-.calendar__placeholder {
- height: 48rem;
-}
-
-.calendar__weeks {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: var(--s-2);
- overflow-x: hidden;
- padding-inline: var(--s-2-5);
-
- --full-size-week-height: 6.75rem;
- --full-size-week-width: 5rem;
-}
-
-.calendar__week {
- display: flex;
- min-width: calc(var(--full-size-week-width) - 2rem);
- height: auto;
- min-height: calc(var(--full-size-week-height) - 2rem);
- flex-direction: column;
- justify-content: space-between;
- padding: var(--s-1-5);
- border-radius: var(--rounded);
- background-color: var(--theme-very-transparent);
- cursor: pointer;
- font-size: var(--fonts-xxxs);
- font-weight: var(--bold);
- text-align: center;
-}
-
-.calendar__week:hover {
- background-color: var(--theme-transparent);
-}
-
-.calendar__week:focus {
- outline: 2px solid var(--theme);
-}
-
-.calendar__event-count {
- font-size: var(--fonts-xxxs);
- font-weight: var(--body);
-}
-
-.calendar__week:nth-child(1),
-.calendar__week:nth-child(2),
-.calendar__week:nth-child(8),
-.calendar__week:nth-child(9) {
- min-width: calc(var(--full-size-week-width) - 2.5rem);
- height: calc(var(--full-size-week-height) - 2.5rem);
- cursor: initial;
- font-size: var(--fonts-xxxxs);
- opacity: 0;
-}
-
-.calendar__week:nth-child(5) {
- min-width: var(--full-size-week-width);
- min-height: var(--full-size-week-height);
- color: var(--text);
- font-size: var(--fonts-sm);
-}
-
-.calendar__week:nth-child(5) > .calendar__event-count {
- font-size: var(--fonts-sm);
-}
-
-.calendar__week:nth-child(4),
-.calendar__week:nth-child(6) {
- min-width: calc(var(--full-size-week-width) - 1rem);
- min-height: calc(var(--full-size-week-height) - 1rem);
- font-size: var(--fonts-xs);
-}
-
-.calendar__week:nth-child(4) > .calendar__event-count,
-.calendar__week:nth-child(6) > .calendar__event-count {
- font-size: var(--fonts-xxs);
-}
-
-.calendar__week__relative {
- max-width: 3rem;
- margin: 0 auto;
- text-align: center;
- white-space: pre-line;
-}
-
-.calendar__week__dash {
- line-height: 0.8;
-}
-
-.calendar__events-to-report {
- line-height: 1.25rem;
-}
-
-.calendar__events-container {
- background-color: var(--bg-lighter);
-}
-
-.calendar__event {
- display: flex;
- flex-direction: column;
- padding-top: var(--s-3);
- font-size: var(--fonts-lg);
- padding-inline: var(--s-4);
-}
-
-.calendar__event + .calendar__event {
- border-top: 2px solid var(--divider);
-}
-
-.calendar__event:last-child {
- padding-block-end: var(--s-4);
-}
-
-.calendar__event__date-container {
- background-color: var(--bg);
-}
-
-.calendar__event__divider {
- padding-block-start: 40px;
-}
-
-.calendar__event__date {
- font-weight: var(--semi-bold);
- padding-block-start: var(--s-6);
-}
-
-.calendar__event__time {
- font-weight: var(--semi-bold);
-}
-
-.calendar__event__top-info-container {
- display: flex;
- height: 1.25rem;
- align-items: center;
- font-size: var(--fonts-sm);
- gap: var(--s-2);
-}
-
-.calendar__event__author {
- color: var(--text-lighter);
-}
-
-.calendar__event__title {
- color: var(--text);
- font-size: var(--fonts-xl);
- line-height: 1.35;
-}
-
-.calendar__event__day {
- color: var(--text-lighter);
-}
-
-.calendar__event__participant-counts {
- display: flex;
- align-items: center;
- color: var(--text-lighter);
- font-size: var(--fonts-xs);
- gap: var(--s-2);
- margin-block-end: var(--s-1);
-}
-
-.calendar__event__participant-counts > svg {
- width: 1rem;
- margin-block-end: 1px;
-}
-
-.calendar__event__bottom-info-container {
- display: flex;
- align-items: center;
- font-size: var(--fonts-sm);
- gap: var(--s-2);
-}
-
-.calendar__no-events {
- color: var(--text-lighter);
- padding-block: var(--s-16);
- text-align: center;
-}
-
-.calendar__time-zone-info {
- color: var(--text-lighter);
- font-size: var(--fonts-xs);
- padding-block-end: var(--s-2);
- text-align: center;
-}
-
-.calendar__event-logo {
- border-radius: 100%;
- min-width: 40px;
-}
diff --git a/app/styles/common.css b/app/styles/common.css
index 0b05d42f4..394776eae 100644
--- a/app/styles/common.css
+++ b/app/styles/common.css
@@ -712,6 +712,18 @@ abbr[title] {
list-style: none;
}
+.calendar__event__tags.small {
+ font-size: var(--fonts-xxxs);
+}
+
+.calendar__event__tags.centered {
+ justify-content: center;
+}
+
+.calendar__event__tags.small > li {
+ padding: 0 var(--s-1);
+}
+
.calendar__event__tags > li {
display: flex;
align-items: center;
@@ -724,20 +736,6 @@ abbr[title] {
color: var(--black-text);
}
-.calendar__event__badge-tag {
- color: var(--badge-text);
-}
-
-.calendar__event__ranked-tag {
- background-color: var(--theme-info-transparent);
- color: var(--theme-info);
-}
-
-.calendar__event__unranked-tag {
- background-color: var(--theme-success-transparent);
- color: var(--theme-success);
-}
-
.calendar__event__tag-delete-button {
margin-left: auto;
}
diff --git a/app/styles/front.css b/app/styles/front.css
index 88be782c4..ed41cad90 100644
--- a/app/styles/front.css
+++ b/app/styles/front.css
@@ -195,114 +195,6 @@
background-color: var(--bg-lightest);
}
-.front__tournament-card__container {
- min-width: var(--card-width);
- max-width: var(--card-width);
- height: calc(var(--card-height) + 22px);
- display: flex;
- flex-direction: column;
- gap: var(--s-1);
-}
-
-.front__tournament-card__container__tall {
- height: 100%;
-}
-
-.front__tournament-card {
- background-color: var(--bg-lightest);
- border-radius: var(--rounded);
- color: var(--text);
- padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2);
- height: 100%;
- min-width: var(--card-width);
- max-width: var(--card-width);
-}
-
-.front__tournament-card:hover .front__tournament-card__tournament-avatar-img {
- outline: 6px solid var(--theme-transparent);
-}
-
-.front__tournament-card__img-container {
- background-color: var(--bg);
- padding: var(--s-1-5);
- border-radius: 100%;
- margin-left: -10px;
- margin-top: -10px;
-}
-
-.front__tournament-card__tournament-avatar-img {
- border-radius: 100%;
-}
-
-.front__tournament-card__org {
- display: flex;
- gap: var(--s-1);
- font-size: var(--fonts-xxs);
- color: var(--text-lighter);
-}
-
-.front__tournament-card__time {
- font-size: var(--fonts-xs);
- font-weight: var(--semi-bold);
- color: var(--text-lighter);
- display: block;
-}
-
-.front__tournament-card__name {
- text-align: center;
- font-weight: var(--semi-bold);
- font-size: var(--fonts-sm);
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 225px;
- overflow: hidden;
-}
-
-.front__tournament-card__team-count {
- font-size: var(--fonts-xxs);
- font-weight: var(--bold);
- background-color: var(--bg-lightest);
- border-radius: var(--rounded-sm);
- width: max-content;
- padding: var(--s-0-5) var(--s-1-5);
- display: flex;
- gap: var(--s-1);
-}
-
-.front__tournament-card__team-count svg {
- width: 12px;
-}
-
-.front__tournament-card__tag {
- font-size: var(--fonts-xxs);
- font-weight: var(--bold);
- border-radius: var(--rounded-sm);
- padding: var(--s-0-5) var(--s-1-5);
-}
-
-.front__tournament-card__ranked {
- background-color: var(--theme-info-transparent);
- color: var(--theme-info);
-}
-
-.front__tournament-card__unranked {
- background-color: var(--theme-success-transparent);
- color: var(--theme-success);
-}
-
-.front__tournament-card__first-placers {
- margin-inline: auto;
- margin-top: var(--s-5);
- width: max-content;
-}
-
-.front__tournament-card__first-placers__team-name {
- max-width: 150px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
.front__result-highlights {
display: flex;
gap: var(--s-2);
diff --git a/app/styles/layout.css b/app/styles/layout.css
index e7c055a59..c17318d0f 100644
--- a/app/styles/layout.css
+++ b/app/styles/layout.css
@@ -402,7 +402,6 @@
.layout__overlay-nav__dialog {
padding-block: var(--s-12) !important;
- padding-block-end: var(--s-32) !important;
padding-inline: 0 !important;
}
diff --git a/app/utils/dates.ts b/app/utils/dates.ts
index 020d4b555..94fdab6a7 100644
--- a/app/utils/dates.ts
+++ b/app/utils/dates.ts
@@ -1,9 +1,14 @@
+import { parseDate } from "@internationalized/date";
import { getWeek } from "date-fns";
import type { MonthYear } from "~/features/plus-voting/core";
import type { DayMonthYear } from "./zod";
export function databaseTimestampToDate(timestamp: number) {
- return new Date(timestamp * 1000);
+ return new Date(databaseTimestampToJavascriptTimestamp(timestamp));
+}
+
+export function databaseTimestampToJavascriptTimestamp(timestamp: number) {
+ return timestamp * 1000;
}
export function dateToDatabaseTimestamp(date: Date) {
@@ -21,6 +26,15 @@ export function dayMonthYearToDate({ day, month, year }: DayMonthYear) {
return new Date(Date.UTC(year, month, day, 12));
}
+/**
+ * Converts a date represented by day, month, and year into a DateValue object (used by react-aria-components), noon UTC.
+ */
+export function dayMonthYearToDateValue({ day, month, year }: DayMonthYear) {
+ const isoString = dateToYYYYMMDD(new Date(Date.UTC(year, month, day, 12)));
+
+ return parseDate(isoString);
+}
+
/**
* Converts a date represented by day, month, and year into a database timestamp, noon UTC.
*/
diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts
index 77a0cece1..1ae461d59 100644
--- a/app/utils/playwright.ts
+++ b/app/utils/playwright.ts
@@ -59,6 +59,11 @@ export async function selectComboboxValue({
/** page.goto that waits for the page to be hydrated before proceeding */
export async function navigate({ page, url }: { page: Page; url: string }) {
await page.goto(url);
+ await expectIsHydrated(page);
+}
+
+/** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */
+export async function expectIsHydrated(page: Page) {
await expect(page.getByTestId("hydrated")).toHaveCount(1);
}
diff --git a/app/utils/types.ts b/app/utils/types.ts
index 974f5cab1..3cab1748d 100644
--- a/app/utils/types.ts
+++ b/app/utils/types.ts
@@ -1,3 +1,11 @@
+/**
+ * Asserts that a code path is unreachable by accepting a value of type `never`.
+ * This function is useful for exhaustive checks in switch statements or discriminated unions.
+ * If called, it throws an error with a message containing the unexpected value.
+ *
+ * @param x - The value that should never occur (of type `never`).
+ * @throws {Error} Throws an error indicating an unexpected value was encountered.
+ */
export function assertUnreachable(x: never): never {
throw new Error(
`Didn't expect to get here. Unexpected value: ${JSON.stringify(x)}`,
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index 36c0541d3..f7a173c1b 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -3,6 +3,7 @@ import type { GearType, Preference, Tables } from "~/db/tables";
import type { ArtSource } from "~/features/art/art-types";
import type { AuthErrorCode } from "~/features/auth/core/errors";
import { serializeBuild } from "~/features/build-analyzer";
+import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { StageBackgroundStyle } from "~/features/map-planner";
import type { TierName } from "~/features/mmr/mmr-constants";
import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants";
@@ -12,10 +13,12 @@ import type {
AbilityWithUnknown,
BuildAbilitiesTupleWithUnknown,
MainWeaponId,
+ ModeShortWithSpecial,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
+import type { DayMonthYear } from "~/utils/zod";
const staticAssetsUrl = ({
folder,
@@ -262,6 +265,31 @@ export const weaponBuildStatsPage = (weaponSlug: string) =>
export const weaponBuildPopularPage = (weaponSlug: string) =>
`${weaponBuildPage(weaponSlug)}/popular`;
+export const calendarPage = (args?: {
+ filters?: CalendarFilters;
+ dayMonthYear?: DayMonthYear;
+}) => {
+ const params = new URLSearchParams();
+ if (args?.filters) {
+ params.set("filters", JSON.stringify(args.filters));
+ }
+ if (args?.dayMonthYear) {
+ params.set("day", String(args.dayMonthYear.day));
+ params.set("month", String(args.dayMonthYear.month));
+ params.set("year", String(args.dayMonthYear.year));
+ }
+
+ return `${CALENDAR_PAGE}${params.toString() ? `?${params.toString()}` : ""}`;
+};
+
+export const calendarIcalFeed = (filters?: CalendarFilters) => {
+ const params = new URLSearchParams();
+ if (filters) {
+ params.set("filters", JSON.stringify(filters));
+ }
+ return `${SENDOU_INK_BASE_URL}/calendar.ics${params.toString() ? `?${params.toString()}` : ""}`;
+};
+
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
export const calendarEditPage = (eventId?: number) =>
`/calendar/new${eventId ? `?eventId=${eventId}` : ""}`;
@@ -452,7 +480,7 @@ export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) =>
`/static-assets/img/special-weapons/${specialWeaponSplId}`;
export const abilityImageUrl = (ability: AbilityWithUnknown) =>
`/static-assets/img/abilities/${ability}`;
-export const modeImageUrl = (mode: ModeShort) =>
+export const modeImageUrl = (mode: ModeShortWithSpecial) =>
`/static-assets/img/modes/${mode}`;
export const stageImageUrl = (stageId: StageId) =>
`/static-assets/img/stages/${stageId}`;
diff --git a/app/utils/zod.ts b/app/utils/zod.ts
index f7471a9bc..af9aaaff2 100644
--- a/app/utils/zod.ts
+++ b/app/utils/zod.ts
@@ -106,6 +106,17 @@ export const qWeapon = z.object({
});
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
+export const modeShortWithSpecial = z.enum([
+ "TW",
+ "SZ",
+ "TC",
+ "RM",
+ "CB",
+ "SR",
+ "TB",
+]);
+
+export const gamesShortSchema = z.enum(["S1", "S2", "S3"]);
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
@@ -334,9 +345,9 @@ export function numericEnum(
}
export const dayMonthYear = z.object({
- day: z.number().int().min(1).max(31),
- month: z.number().int().min(0).max(11),
- year: z.number().int().min(2015).max(2100),
+ day: z.coerce.number().int().min(1).max(31),
+ month: z.coerce.number().int().min(0).max(11),
+ year: z.coerce.number().int().min(2015).max(2100),
});
export type DayMonthYear = z.infer;
diff --git a/e2e/badges.spec.ts b/e2e/badges.spec.ts
index f41261e3e..cd26af515 100644
--- a/e2e/badges.spec.ts
+++ b/e2e/badges.spec.ts
@@ -34,7 +34,7 @@ test.describe("Badges", () => {
await expect(page).toHaveURL(badgePage(1));
await page.getByTestId("notifications-button").click();
- await page.getByText("See all").click();
+ await page.getByTestId("notifications-see-all-button").click();
await expect(
page.getByRole("heading", { name: "Notifications" }),
diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts
new file mode 100644
index 000000000..930936a05
--- /dev/null
+++ b/e2e/calendar.spec.ts
@@ -0,0 +1,104 @@
+import { expect, test } from "@playwright/test";
+import { NZAP_TEST_ID } from "~/db/seed/constants";
+import {
+ expectIsHydrated,
+ impersonate,
+ isNotVisible,
+ navigate,
+ seed,
+} from "~/utils/playwright";
+import { calendarPage } from "~/utils/urls";
+
+const SENDOU_INK_TOURNAMENTS_COUNT = 6;
+
+test.describe("Calendar", () => {
+ test("applies filters and operates hidden events toggle", async ({
+ page,
+ }) => {
+ await seed(page);
+ await navigate({
+ page,
+ url: calendarPage(),
+ });
+
+ await page.getByTestId("filter-events-button").click();
+ await page.getByText("Only events hosted on sendou.ink").click();
+
+ await page.getByText("Apply", { exact: true }).click();
+
+ const tournamentCardLocator = page.getByTestId("tournament-card");
+ const hiddenEventsToggleButtonLocator = page.getByTestId(
+ "hidden-events-button",
+ );
+
+ await expect(tournamentCardLocator).toHaveCount(
+ SENDOU_INK_TOURNAMENTS_COUNT,
+ );
+
+ await page.reload();
+ await expectIsHydrated(page);
+
+ // remembers selection via search params
+ await expect(tournamentCardLocator).toHaveCount(
+ SENDOU_INK_TOURNAMENTS_COUNT,
+ );
+
+ await hiddenEventsToggleButtonLocator.first().click();
+
+ await expect
+ .poll(() => tournamentCardLocator.count())
+ .toBeGreaterThan(SENDOU_INK_TOURNAMENTS_COUNT);
+
+ const countAfterToggle = await tournamentCardLocator.count();
+
+ await hiddenEventsToggleButtonLocator.first().click();
+ await expect
+ .poll(() => tournamentCardLocator.count())
+ .toBeLessThan(countAfterToggle); // not SENDOU_INK_TOURNAMENTS_COUNT as it's possible we untoggle more than one tournament
+ });
+
+ test("sets default filters", async ({ page }) => {
+ await seed(page);
+ await impersonate(page, NZAP_TEST_ID);
+ await navigate({
+ page,
+ url: calendarPage(),
+ });
+
+ const hiddenEventsToggleButtonLocator = page.getByTestId(
+ "hidden-events-button",
+ );
+
+ await isNotVisible(hiddenEventsToggleButtonLocator);
+
+ await page.getByTestId("filter-events-button").click();
+ await page.getByText("Only ranked events").click();
+
+ await page.getByText("Apply & make default", { exact: true }).click();
+
+ await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible();
+
+ await navigate({
+ page,
+ url: calendarPage(),
+ });
+
+ // remembers selection via user preferences
+ await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible();
+ });
+
+ test("navigates view more buttons", async ({ page }) => {
+ await seed(page);
+ await navigate({
+ page,
+ url: calendarPage(),
+ });
+
+ await page.getByTestId("calendar-navigate-button").first().click();
+
+ await isNotVisible(page.getByTestId("today-header"));
+
+ await page.getByTestId("calendar-navigate-button").nth(1).click();
+ await expect(page.getByTestId("today-header")).toBeVisible();
+ });
+});
diff --git a/locales/da/calendar.json b/locales/da/calendar.json
index a55160032..369f35e4e 100644
--- a/locales/da/calendar.json
+++ b/locales/da/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Discordserver-invitations-URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Vælg et tag",
- "forms.tags.info": "\"Præmiemærker\" tag tilføjes automatisk, hvis den er anvendelig",
"forms.badges": "Præmiemærker",
"forms.badges.placeholder": "Vælg et premiemærke",
"forms.mapPool": "Banepulje",
diff --git a/locales/da/common.json b/locales/da/common.json
index 38fb5faa4..04772bc1a 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -92,7 +92,6 @@
"errors.genericReload": "Noget gik galt. Prøv at genindlæse siden.",
- "tag.name.BADGE": "Premiemærker",
"tag.name.SPECIAL": "Særregler",
"tag.name.ART": "Kunstpræmier",
"tag.name.MONEY": "Pengepræmier",
diff --git a/locales/de/calendar.json b/locales/de/calendar.json
index 61f738235..6d5c91823 100644
--- a/locales/de/calendar.json
+++ b/locales/de/calendar.json
@@ -17,7 +17,6 @@
"forms.discordInvite": "Discord-Server Einladungs-URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Wähle einen Tag",
- "forms.tags.info": "\"Abzeichen-Preis\"-Tag wird automatisch hinzugefügt (falls anwendbar)",
"forms.badges": "Abzeichen-Preis",
"forms.badges.placeholder": "Wähle ein Abzeichen für das Event",
"forms.mapPool": "Arenen-Pool",
diff --git a/locales/de/common.json b/locales/de/common.json
index a4e367e78..ca139a546 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -89,7 +89,6 @@
"errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.",
- "tag.name.BADGE": "Abzeichen-Preise",
"tag.name.SPECIAL": "Spezielle Regeln",
"tag.name.ART": "Kunst-Preise",
"tag.name.MONEY": "Geld-Preise",
diff --git a/locales/en/calendar.json b/locales/en/calendar.json
index 5b68e9b2e..4965d753a 100644
--- a/locales/en/calendar.json
+++ b/locales/en/calendar.json
@@ -1,8 +1,5 @@
{
"inYourTimeZone": "Times in your local time zone:",
- "addNew": "Add new",
- "noEvents": "No events for the selected week",
- "reportResults": "You can report results:",
"day": "Day {{number}}",
"actions.reportWinners": "Report winners",
"actions.delete": "Delete event",
@@ -12,8 +9,6 @@
"members": "Members",
"results": "Results",
"createMapList": "Create map list",
- "from": "From {{author}}",
- "pastEvents.dividerText": "Past events",
"count.teams_one": "{{count}} team",
"count.players_one": "{{count}} player",
"count.teams_other": "{{count}} teams",
@@ -24,7 +19,6 @@
"forms.discordInvite": "Discord server invite URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Choose a tag",
- "forms.tags.info": "\"Badge prizes\" tag is added automatically if applicable",
"forms.badges": "Badge prizes",
"forms.badges.placeholder": "Choose a badge prize",
"forms.mapPool": "Map pool",
@@ -47,11 +41,6 @@
"forms.errors.duplicatePlayer": "Can't have the same player twice in the same team.",
"forms.errors.emptyTeam": "Each team must have at least one player.",
- "week.this": "This Week",
- "week.next": "Next Week",
- "week.last": "Last Week",
-
- "tag.desc.BADGE": "Winner of this event gets a sendou.ink badge.",
"tag.desc.SPECIAL": "Ruleset that differs from the standard e.g. limited what weapons can be used.",
"tag.desc.ART": "You can win art by playing in this tournament.",
"tag.desc.MONEY": "You can win money by playing in this tournament.",
@@ -66,7 +55,34 @@
"tag.desc.S2": "The game played is Splatoon 2.",
"tag.desc.SR": "Salmon Run event.",
"tag.desc.CARDS": "Tableturf Battle event.",
- "tag.filter.label": "Filter by tags",
- "tournament.filter.label": "Hosted on sendou.ink"
+ "icalFeed": "iCal feed",
+
+ "filter.button": "Filter",
+ "filter.heading": "Filter calendar events",
+ "filter.modes": "Modes",
+ "filter.exactModes": "Exact modes",
+ "filter.exactModesBottom": "Only show events that match all selected modes",
+ "filter.games": "Games",
+ "filter.vs": "Vs.",
+ "filter.vs.4v4": "4v4",
+ "filter.vs.3v3": "3v3",
+ "filter.vs.2v2": "2v2",
+ "filter.vs.1v1": "1v1",
+ "filter.startTime": "Start time",
+ "filter.startTime.any": "Any",
+ "filter.startTime.eu": "Europe friendly",
+ "filter.startTime.na": "Americas friendly",
+ "filter.startTime.au": "AU/NZ friendly",
+ "filter.tagsIncluded": "Tags included",
+ "filter.tagsExcluded": "Tags excluded",
+ "filter.isSendou": "Only events hosted on sendou.ink",
+ "filter.isRanked": "Only ranked events",
+ "filter.minTeamCount": "Minimum team count",
+ "filter.orgsIncluded": "Visible organizations",
+ "filter.orgsExcluded": "Hidden organizations",
+ "filter.authorIdsExcluded": "Authors excluded",
+ "filter.authorIdsExcludedBottom": "You can find a user's id on their profile page",
+ "filter.apply": "Apply",
+ "filter.applyAndDefault": "Apply & make default"
}
diff --git a/locales/en/common.json b/locales/en/common.json
index 475021ae8..bb2fc5776 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -123,6 +123,8 @@
"actions.enable": "Enable",
"actions.disable": "Disable",
"actions.accept": "Accept",
+ "actions.next": "Next",
+ "actions.previous": "Previous",
"noResults": "No results",
@@ -165,7 +167,6 @@
"errors.genericReload": "Something went wrong. Try reloading the page.",
- "tag.name.BADGE": "Badge prizes",
"tag.name.SPECIAL": "Special rules",
"tag.name.ART": "Art prizes",
"tag.name.MONEY": "Money prizes",
diff --git a/locales/en/game-misc.json b/locales/en/game-misc.json
index c50bf48b0..9daa53ec5 100644
--- a/locales/en/game-misc.json
+++ b/locales/en/game-misc.json
@@ -58,5 +58,10 @@
"MODE_LONG_SZ": "Splat Zones",
"MODE_LONG_TC": "Tower Control",
"MODE_LONG_RM": "Rainmaker",
- "MODE_LONG_CB": "Clam Blitz"
+ "MODE_LONG_CB": "Clam Blitz",
+ "MODE_LONG_SR": "Salmon Run",
+ "MODE_LONG_TB": "Tableturf Battle",
+ "GAME_S1": "Splatoon 1",
+ "GAME_S2": "Splatoon 2",
+ "GAME_S3": "Splatoon 3"
}
diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json
index 7313e6d5a..76df06d40 100644
--- a/locales/es-ES/calendar.json
+++ b/locales/es-ES/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Enlace de invitación a Discord",
"forms.tags": "Etiquetas",
"forms.tags.placeholder": "Elegir etiquetas",
- "forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.",
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
"forms.mapPool": "Grupo de mapas",
diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json
index 4d4d59238..58f588cbe 100644
--- a/locales/es-ES/common.json
+++ b/locales/es-ES/common.json
@@ -98,7 +98,6 @@
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
- "tag.name.BADGE": "Premios de insignia",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",
diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json
index 7313e6d5a..76df06d40 100644
--- a/locales/es-US/calendar.json
+++ b/locales/es-US/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Enlace de invitación a Discord",
"forms.tags": "Etiquetas",
"forms.tags.placeholder": "Elegir etiquetas",
- "forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.",
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
"forms.mapPool": "Grupo de mapas",
diff --git a/locales/es-US/common.json b/locales/es-US/common.json
index 5dd59652d..9b3b2740e 100644
--- a/locales/es-US/common.json
+++ b/locales/es-US/common.json
@@ -104,7 +104,6 @@
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
- "tag.name.BADGE": "Premios de insignia",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",
diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json
index a4ffbbe08..3d5b08b5b 100644
--- a/locales/fr-CA/calendar.json
+++ b/locales/fr-CA/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Lien d'invitation du Discord",
"forms.tags": "Tags",
"forms.tags.placeholder": "Choisir un tag",
- "forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible",
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
"forms.mapPool": "Stages disponibles",
diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json
index bec7e9047..c99bbb79e 100644
--- a/locales/fr-CA/common.json
+++ b/locales/fr-CA/common.json
@@ -95,7 +95,6 @@
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
- "tag.name.BADGE": "Badge à gagner",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",
diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json
index a2f52d966..106df6527 100644
--- a/locales/fr-EU/calendar.json
+++ b/locales/fr-EU/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Lien d'invitation du Discord",
"forms.tags": "Tags",
"forms.tags.placeholder": "Choisir un tag",
- "forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible",
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
"forms.mapPool": "Stages disponibles",
diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json
index bf3f50e93..9b00f0238 100644
--- a/locales/fr-EU/common.json
+++ b/locales/fr-EU/common.json
@@ -165,7 +165,6 @@
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
- "tag.name.BADGE": "Badge à gagner",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",
diff --git a/locales/he/calendar.json b/locales/he/calendar.json
index c158c94f8..f17ae0a45 100644
--- a/locales/he/calendar.json
+++ b/locales/he/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "כתובת URL הזמנה לשרת Discord",
"forms.tags": "תגים",
"forms.tags.placeholder": "בחירת תג",
- "forms.tags.info": "\"Badge prizes\" תג נוסף אוטומטית אם רלוונטי",
"forms.badges": "פרסי תגים",
"forms.badges.placeholder": "בחירת פרס תג",
"forms.mapPool": "מאגר מפות",
diff --git a/locales/he/common.json b/locales/he/common.json
index d5838a443..eab78bba2 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -95,7 +95,6 @@
"errors.genericReload": "משהו השתבש. נסו לטעון מחדש את העמוד.",
- "tag.name.BADGE": "פרסי תג",
"tag.name.SPECIAL": "חוקים מיוחדים",
"tag.name.ART": "פרסי ציור",
"tag.name.MONEY": "פרסים בכסף",
diff --git a/locales/it/calendar.json b/locales/it/calendar.json
index 5e0b76943..b273e262b 100644
--- a/locales/it/calendar.json
+++ b/locales/it/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "URL invito server Discord",
"forms.tags": "Tag",
"forms.tags.placeholder": "Scegli un tag",
- "forms.tags.info": "Il tag \"Badge prizes\" è aggiunto automaticamente se necessario",
"forms.badges": "Medaglie in palio",
"forms.badges.placeholder": "Scegli una medaglia come premio",
"forms.mapPool": "Pool di scenari",
diff --git a/locales/it/common.json b/locales/it/common.json
index f0b6a09db..82ba4fe0f 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -151,7 +151,6 @@
"errors.genericReload": "Qualcosa è andato storto. Prova a ricaricare la pagina.",
- "tag.name.BADGE": "Premi medaglie",
"tag.name.SPECIAL": "Regole speciali",
"tag.name.ART": "Premi artistici",
"tag.name.MONEY": "Premi monetari",
diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json
index a2d58fb28..b5c94de99 100644
--- a/locales/ja/calendar.json
+++ b/locales/ja/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Discord サーバー招待 URL",
"forms.tags": "タグ",
"forms.tags.placeholder": "タグを選択",
- "forms.tags.info": "\"バッジプライズ\" タグは可能な場合に自動的に適用されます",
"forms.badges": "バッジプライズ",
"forms.badges.placeholder": "バッジプライズを選択する",
"forms.mapPool": "ステージプール",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index bf2f8a19c..723032dbe 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -115,7 +115,6 @@
"errors.genericReload": "エラーが発生しました。ページを再読込してください。",
- "tag.name.BADGE": "バッジプライズ",
"tag.name.SPECIAL": "特別ルール",
"tag.name.ART": "イラスト賞品",
"tag.name.MONEY": "賞金",
diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json
index 2a4d6befb..dcf64883c 100644
--- a/locales/ko/calendar.json
+++ b/locales/ko/calendar.json
@@ -13,7 +13,6 @@
"forms.discordInvite": "디스코드 서버 초대 주소",
"forms.tags": "태그",
"forms.tags.placeholder": "태그를 선택하세요",
- "forms.tags.info": "\"배지 상품\" 태그는 적용 가능하면 자동으로 추가됩니다",
"forms.badges": "배지 상품",
"forms.badges.placeholder": "배지 상품을 선택하세요",
"forms.participantCount": "참여자 수",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 92bfe9cc1..4d2e34eb0 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -94,7 +94,6 @@
"errors.genericReload": "뭔가 잘못됐습니다 페이지를 새로고침해보세요.",
- "tag.name.BADGE": "배지 상품",
"tag.name.SPECIAL": "특별 규칙",
"tag.name.ART": "그림 상품",
"tag.name.MONEY": "상금",
diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json
index 283d66500..b6bb559c4 100644
--- a/locales/nl/calendar.json
+++ b/locales/nl/calendar.json
@@ -17,7 +17,6 @@
"forms.discordInvite": "Discord server uitnodiging URL",
"forms.tags": "Tags",
"forms.tags.placeholder": "Kies een tag",
- "forms.tags.info": "De \"Badge prizes\" tag wordt indien mogelijk automatisch toegevoegd",
"forms.badges": "Badge prijzen",
"forms.badges.placeholder": "Kies een badge prijs",
"forms.mapPool": "Beschikbare levels",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 3c72da104..b6d8e31b2 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -39,7 +39,6 @@
"forms.description": "Beschrijving",
"forms.errors.title": "De volgende errors moeten nog worden opgelost",
- "tag.name.BADGE": "Badge prijzen",
"tag.name.SPECIAL": "Speciale spelregels",
"tag.name.ART": "Kunst prijzen",
"tag.name.MONEY": "Geld prijzen",
diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json
index 45a2b0478..780547196 100644
--- a/locales/pl/calendar.json
+++ b/locales/pl/calendar.json
@@ -17,7 +17,6 @@
"forms.discordInvite": "URL do servera Discord",
"forms.tags": "Tagi",
"forms.tags.placeholder": "Wybierz tag",
- "forms.tags.info": "Tag \"Odznaki\" dodawany automatycznie jeśli możliwe",
"forms.badges": "Odznaki",
"forms.badges.placeholder": "Wybierz odznakę",
"forms.mapPool": "Pula map",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index cd4ed8330..a1e76e8e7 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -88,7 +88,6 @@
"errors.genericReload": "Coś poszło nie tak. Spróbuj ponownie załadować stronę.",
- "tag.name.BADGE": "Oznaki",
"tag.name.SPECIAL": "Zasady specjalne",
"tag.name.ART": "Rysunki",
"tag.name.MONEY": "Nagrody pieniężne",
diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json
index 23350ba2d..fb741d223 100644
--- a/locales/pt-BR/calendar.json
+++ b/locales/pt-BR/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "URL de convite do servidor do Discord",
"forms.tags": "Marcações",
"forms.tags.placeholder": "Escolha uma marcação",
- "forms.tags.info": "\"Prêmio(s) de insígnia\" é adicionado automaticamente como marcação se aplicável",
"forms.badges": "Prêmio(s) de insígnia",
"forms.badges.placeholder": "Escolha um prêmio de insígnia",
"forms.mapPool": "Conjunto de mapas",
diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json
index 27a5da663..2a490b285 100644
--- a/locales/pt-BR/common.json
+++ b/locales/pt-BR/common.json
@@ -104,7 +104,6 @@
"errors.genericReload": "Algo deu errado. Tente recarregar a página.",
- "tag.name.BADGE": "Prêmio(s) em insígnia",
"tag.name.SPECIAL": "Regras especiais",
"tag.name.ART": "Prêmio(s) em arte",
"tag.name.MONEY": "Prêmio(s) em dinheiro",
diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json
index 1afc860be..b0b955733 100644
--- a/locales/ru/calendar.json
+++ b/locales/ru/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "URL приглашения на Discord-сервер",
"forms.tags": "Теги",
"forms.tags.placeholder": "Выберите тег",
- "forms.tags.info": "Тег \"Призовые значки\" добавляется автоматически, если это возможно",
"forms.badges": "Призовые значки",
"forms.badges.placeholder": "Выберите призовой значок",
"forms.mapPool": "Список карт",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index e80602f62..21e60745f 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -163,7 +163,6 @@
"errors.genericReload": "Что-то пошло не так. Попробуйте обновить страницу.",
- "tag.name.BADGE": "Призовые значки",
"tag.name.SPECIAL": "Особые правила",
"tag.name.ART": "Арт-призы",
"tag.name.MONEY": "Днежные призы",
diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json
index eb6fae9d4..8e8a72b98 100644
--- a/locales/zh/calendar.json
+++ b/locales/zh/calendar.json
@@ -24,7 +24,6 @@
"forms.discordInvite": "Discord服务器的邀请链接",
"forms.tags": "标签",
"forms.tags.placeholder": "选择标签",
- "forms.tags.info": "标签\"徽章奖励\"将根据情况自动添加",
"forms.badges": "徽章奖励",
"forms.badges.placeholder": "选择一项徽章奖励",
"forms.mapPool": "地图池",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 3d372529a..85fd30284 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -104,7 +104,6 @@
"errors.genericReload": "发生错误,请重新加载本页面。",
- "tag.name.BADGE": "徽章奖励",
"tag.name.SPECIAL": "特殊规则",
"tag.name.ART": "插画奖励",
"tag.name.MONEY": "奖金",
diff --git a/package-lock.json b/package-lock.json
index c70b9e7f2..5222c5a99 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.797.0",
"@aws-sdk/lib-storage": "^3.797.0",
+ "@date-fns/tz": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -1633,6 +1634,12 @@
"node": ">=14.21.3"
}
},
+ "node_modules/@date-fns/tz": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
+ "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==",
+ "license": "MIT"
+ },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
diff --git a/package.json b/package.json
index 8a126f891..c6ae05702 100644
--- a/package.json
+++ b/package.json
@@ -22,11 +22,13 @@
"typecheck": "tsc --noEmit",
"test:unit": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only run",
"test:e2e": "npx playwright test",
+ "test:e2e:flaky-detect": "npx playwright test --repeat-each=10 --max-failures=1",
"checks": "npm run biome:fix && npm run test:unit && npm run check-translation-jsons && npm run typecheck"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.797.0",
"@aws-sdk/lib-storage": "^3.797.0",
+ "@date-fns/tz": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
diff --git a/public/static-assets/img/modes/SR.avif b/public/static-assets/img/modes/SR.avif
new file mode 100644
index 0000000000000000000000000000000000000000..f41787c607e46a87b2b073913f9ee65531ac0055
GIT binary patch
literal 3595
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
P)`oymp<8@9tB?N;w3xp$
literal 0
HcmV?d00001
diff --git a/public/static-assets/img/modes/TB.png b/public/static-assets/img/modes/TB.png
new file mode 100644
index 0000000000000000000000000000000000000000..2ec6875a09e5c07cbee24175d898c4756ee2a08a
GIT binary patch
literal 10709
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