@@ -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 000000000..f41787c60
Binary files /dev/null and b/public/static-assets/img/modes/SR.avif differ
diff --git a/public/static-assets/img/modes/SR.png b/public/static-assets/img/modes/SR.png
new file mode 100644
index 000000000..5fd06bea5
Binary files /dev/null and b/public/static-assets/img/modes/SR.png differ
diff --git a/public/static-assets/img/modes/TB.avif b/public/static-assets/img/modes/TB.avif
new file mode 100644
index 000000000..e96cac8a3
Binary files /dev/null and b/public/static-assets/img/modes/TB.avif differ
diff --git a/public/static-assets/img/modes/TB.png b/public/static-assets/img/modes/TB.png
new file mode 100644
index 000000000..2ec6875a0
Binary files /dev/null and b/public/static-assets/img/modes/TB.png differ
diff --git a/scripts/check-translation-jsons.ts b/scripts/check-translation-jsons.ts
index 791d26895..2f27dab9e 100644
--- a/scripts/check-translation-jsons.ts
+++ b/scripts/check-translation-jsons.ts
@@ -74,12 +74,6 @@ for (const file of fileNames) {
file,
);
- validateNoExtraKeysInOther({
- english: getKeysWithoutSuffix(englishContent, lang, file),
- other: otherLanguageContentKeys,
- lang,
- file,
- });
validateVariables({
english: englishContent,
other: otherLanguageContent,
@@ -130,26 +124,6 @@ const translationProgressPath = path.join(
fs.writeFileSync(translationProgressPath, markdown);
-function validateNoExtraKeysInOther({
- english,
- other,
- lang,
- file,
-}: {
- english: string[];
- other: string[];
- lang: string;
- file: string;
-}) {
- const validKeys = english;
-
- for (const key of other) {
- if (validKeys.includes(key)) continue;
-
- throw new Error(`unknown key in ${lang}/${file}: ${key}`);
- }
-}
-
function validateVariables({
english,
other,