-
{t("calendar:events.title")}
- {hasNoEventsAtAll ? null : (
-
- {VIEW_FILTERS.map((value) => (
-
- {viewLabels[value]}
-
- ))}
-
+
+ {/* keyed on the week so a revalidation across Monday midnight resets
+ the editor instead of leaving it holding the rolled-over week */}
+
+
+
+
{t("calendar:events.title")}
+ {hasNoEventsAtAll ? null : (
+
+ {VIEW_FILTERS.map((value) => (
+
+ {viewLabels[value]}
+
+ ))}
+
+ )}
+
+ {hasNoEventsAtAll ? (
+
+ {t("calendar:events.emptyAll")}{" "}
+
+ {t("calendar:events.findOnCalendar")}
+
+
+ ) : shownEvents.length === 0 ? (
+
+ {t("calendar:events.empty")}
+
+ ) : (
+
)}
- {hasNoEventsAtAll ? (
-
- {t("calendar:events.emptyAll")}{" "}
- {t("calendar:events.findOnCalendar")}
-
- ) : shownEvents.length === 0 ? (
- {t("calendar:events.empty")}
- ) : (
-
- )}
);
}
diff --git a/app/features/components-showcase/components-showcase.module.css b/app/features/components-showcase/components-showcase.module.css
index ada31b8d2..eeb0e8674 100644
--- a/app/features/components-showcase/components-showcase.module.css
+++ b/app/features/components-showcase/components-showcase.module.css
@@ -35,3 +35,7 @@
.trophyExampleLarge {
width: 200px;
}
+
+.scheduleNarrow {
+ max-width: 360px;
+}
diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx
index 14c673891..5b1f3ef86 100644
--- a/app/features/components-showcase/routes/components.tsx
+++ b/app/features/components-showcase/routes/components.tsx
@@ -52,6 +52,11 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
import { Table } from "~/components/Table";
import { TierPill } from "~/components/TierPill";
import { WeaponSelect } from "~/components/WeaponSelect";
+import type {
+ AvailabilityEditorWeek,
+ EditorCommitment,
+} from "~/features/availability/availability-types";
+import { WeekAvailabilityEditor } from "~/features/availability/components/WeekAvailabilityEditor";
import {
ChangelogGraphic,
type ChangelogGraphicEntry,
@@ -85,7 +90,7 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
import { formFieldsShowcaseSchema } from "../form-examples-schema";
export const handle: SendouRouteHandle = {
- i18n: ["user", "q", "calendar", "tournament"],
+ i18n: ["user", "q", "calendar", "tournament", "schedule"],
};
export const SECTIONS = [
@@ -153,6 +158,7 @@ export const SECTIONS = [
{ title: "Tier Pills", id: "tier-pills", component: TierPillSection },
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
+ { title: "Schedule", id: "schedule", component: ScheduleSection },
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
] as const;
@@ -3012,6 +3018,85 @@ function FormFieldsSection({ id }: { id: string }) {
);
}
+const SCHEDULE_EXAMPLE_WEEK: AvailabilityEditorWeek = [
+ { date: "2026-08-24", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" },
+ { date: "2026-08-25", ranges: [], note: "" },
+ {
+ date: "2026-08-26",
+ ranges: [{ start: 19 * 60, end: 23 * 60 }],
+ note: "Have to stop earlier, work trip next morning",
+ },
+ { date: "2026-08-27", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" },
+ { date: "2026-08-28", ranges: [], note: "" },
+ { date: "2026-08-29", ranges: [{ start: 12 * 60, end: 26 * 60 }], note: "" },
+ { date: "2026-08-30", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" },
+];
+
+const SCHEDULE_EXAMPLE_COMMITMENTS: Array
= [
+ {
+ date: "2026-08-26",
+ range: { start: 20 * 60, end: 21 * 60 + 30 },
+ name: "VoD review vs. FTWin",
+ },
+ {
+ date: "2026-08-30",
+ range: { start: 12 * 60, end: 18 * 60 },
+ name: "In The Zone 42",
+ },
+];
+
+function ScheduleSection({ id }: { id: string }) {
+ const [week, setWeek] = useState(SCHEDULE_EXAMPLE_WEEK);
+ const rangeCount = week.reduce((acc, day) => acc + day.ranges.length, 0);
+
+ return (
+
+ Schedule
+
+
+
+
Week availability editor
+
+
+ setWeek(SCHEDULE_EXAMPLE_WEEK)}
+ >
+ Reset
+
+
+ toastQueue.add({
+ message: `Saved week with ${rangeCount} time ranges`,
+ variant: "success",
+ })
+ }
+ >
+ Save week
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
function MiscSection({ id }: { id: string }) {
const [rangeValue, setRangeValue] = useState(50);
const [colorValue, setColorValue] = useState("#3b82f6");
diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts
index 0d0ffbe5a..4f7262d07 100644
--- a/app/features/friends/loaders/friends.server.ts
+++ b/app/features/friends/loaders/friends.server.ts
@@ -1,5 +1,7 @@
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
+import * as FriendSchedule from "~/features/availability/core/FriendSchedule.server";
+import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import { userPage } from "~/utils/urls";
import * as FriendRepository from "../FriendRepository.server";
import { friendActivitySortValue } from "../friends-constants";
@@ -13,20 +15,24 @@ export type FriendsLoaderData = typeof loader;
export const loader = async () => {
const user = requireUser();
- const [
- friendsWithActivity,
- pendingRequests,
- incomingRequests,
- streamedSendouQMatches,
- ] = await Promise.all([
- FriendRepository.findByUserIdWithActivity(user.id),
- FriendRepository.findPendingSentRequests(user.id),
- FriendRepository.findPendingReceivedRequests(user.id),
- resolveSendouQMatchStreams(),
- ]);
-
+ const friendsWithActivity = await FriendRepository.findByUserIdWithActivity(
+ user.id,
+ );
const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
+ const [pendingRequests, incomingRequests, streamedSendouQMatches, schedules] =
+ await Promise.all([
+ FriendRepository.findPendingSentRequests(user.id),
+ FriendRepository.findPendingReceivedRequests(user.id),
+ resolveSendouQMatchStreams(),
+ // everyone listed is a friend or a teammate, which is what makes their
+ // schedule theirs to see
+ FriendSchedule.findByUserIds({
+ userIds: unique.map((f) => f.id),
+ timezone: getViewerTimezone() ?? "UTC",
+ }),
+ ]);
+
const friends = R.sortBy(
unique
.filter((f) => f.friendshipId !== null)
@@ -58,9 +64,11 @@ export const loader = async () => {
tournamentId: activity.tournamentId ?? friend.tournamentId,
streamUrl: activity.streamUrl,
friendshipCreatedAt: friend.friendshipCreatedAt,
+ schedule: schedules.get(friend.id) ?? null,
};
}),
[(friend) => friendActivitySortValue(friend.activityType), "desc"],
+ [(friend) => (friend.schedule ? 1 : 0), "desc"],
[(friend) => friend.friendshipCreatedAt ?? 0, "desc"],
);
@@ -93,9 +101,11 @@ export const loader = async () => {
matchId: activity.matchId,
tournamentId: activity.tournamentId ?? tm.tournamentId,
streamUrl: activity.streamUrl,
+ schedule: schedules.get(tm.id) ?? null,
};
}),
[(tm) => friendActivitySortValue(tm.activityType), "desc"],
+ [(tm) => (tm.schedule ? 1 : 0), "desc"],
);
return {
diff --git a/app/features/friends/routes/friends.module.css b/app/features/friends/routes/friends.module.css
index 734ecb8b4..c2f34dfe4 100644
--- a/app/features/friends/routes/friends.module.css
+++ b/app/features/friends/routes/friends.module.css
@@ -29,3 +29,19 @@
gap: var(--s-2);
margin-block-end: var(--s-2);
}
+
+.friendRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: var(--s-1-5);
+}
+
+.scheduleSlot {
+ display: flex;
+ width: 18px;
+ margin-block-end: var(--s-1);
+ & > button {
+ height: auto;
+ }
+}
diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx
index 9b6499205..448ab350c 100644
--- a/app/features/friends/routes/friends.tsx
+++ b/app/features/friends/routes/friends.tsx
@@ -1,11 +1,14 @@
+import { CalendarDays } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, type MetaFunction, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
+import { SendouButton } from "~/components/elements/Button";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
+import { ScheduleWeekDialog } from "~/features/availability/components/ScheduleWeekDialog";
import { SendouForm } from "~/form/SendouForm";
import { markFriendRequestsSeen } from "~/hooks/useUnseenFriendRequests";
import { useSearchParam } from "~/modules/search-params/hooks";
@@ -39,7 +42,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
- i18n: ["friends"],
+ i18n: ["friends", "schedule"],
};
export default function FriendsPage() {
@@ -221,7 +224,7 @@ function FriendsListSection() {
) : (
{shownItems.map((item) => (
-
+
))}
)}
@@ -230,6 +233,58 @@ function FriendsListSection() {
);
}
+function FriendRow({ item }: { item: ShownItem }) {
+ return (
+
+
+
+ {item.schedule ? (
+
+ ) : null}
+
+
+ );
+}
+
+function ScheduleButton({
+ userId,
+ username,
+ weeks,
+}: {
+ userId: number;
+ username: string;
+ weeks: NonNullable;
+}) {
+ const { t } = useTranslation(["schedule"]);
+ const [dialogOpen, setDialogOpen] = React.useState(false);
+
+ return (
+ <>
+ }
+ aria-label={t("schedule:friends.availabilityOf", { name: username })}
+ testId={`friend-schedule-button-${userId}`}
+ onPress={() => setDialogOpen(true)}
+ />
+ {dialogOpen ? (
+ setDialogOpen(false)}
+ />
+ ) : null}
+ >
+ );
+}
+
+type ShownItem = ReturnType[number];
+
function resolveShownItems(
filter: ViewFilter,
data: Awaited>,
@@ -243,9 +298,10 @@ function resolveShownItems(
...data.teamMembers.filter((tm) => !friendIds.has(tm.id)),
];
- return combined.sort((a, b) => {
- const aActive = a.subtitle ? 1 : 0;
- const bActive = b.subtitle ? 1 : 0;
- return bActive - aActive;
- });
+ // same order the loader sorted each group in: active first, then the ones
+ // who shared a schedule
+ const sortValue = (item: (typeof combined)[number]) =>
+ (item.subtitle ? 2 : 0) + (item.schedule ? 1 : 0);
+
+ return combined.sort((a, b) => sortValue(b) - sortValue(a));
}
diff --git a/app/features/layout/core/layout.server.ts b/app/features/layout/core/layout.server.ts
index 4bc7b0acc..b4dd08477 100644
--- a/app/features/layout/core/layout.server.ts
+++ b/app/features/layout/core/layout.server.ts
@@ -12,7 +12,7 @@ import { GIT_COMMIT } from "~/utils/git-commit";
export async function resolveLayoutData(user: AuthenticatedUser | undefined) {
return {
loggedInUserId: user?.id ?? null,
- sidebar: await resolveSidebarData(user?.id ?? null),
+ sidebar: await resolveSidebarData(user),
buildCommit: GIT_COMMIT,
};
}
diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts
index fcf4212db..a2886933c 100644
--- a/app/features/notifications/core/notify.server.ts
+++ b/app/features/notifications/core/notify.server.ts
@@ -39,6 +39,8 @@ const NOTIFICATION_URGENCY: Record = {
SCRIM_AUTO_DELETED: "normal",
COMMISSIONS_CLOSED: "normal",
FRIEND_REQUEST_RECEIVED: "normal",
+ TEAM_EVENT_ADDED: "normal",
+ SCHEDULE_TEAM_REMINDER: "normal",
};
/** How long a push notification is held back before sending. Anything marking the notification as seen during this window (the user addressing what it is about, opening the notification list, `defaultSeenUserIds`) cancels the push for that user. */
diff --git a/app/features/notifications/core/resolve.server.ts b/app/features/notifications/core/resolve.server.ts
index 4b07b3dcd..1a2ce4987 100644
--- a/app/features/notifications/core/resolve.server.ts
+++ b/app/features/notifications/core/resolve.server.ts
@@ -50,6 +50,8 @@ const RESOLUTION_TRIGGERS = {
COMMISSIONS_CLOSED: null,
FRIEND_REQUEST_RECEIVED:
"accepts or declines the request, or the sender cancels it",
+ TEAM_EVENT_ADDED: "visits the team's schedule page",
+ SCHEDULE_TEAM_REMINDER: "saves any week of their own schedule",
} as const satisfies Record;
type ResolvableNotificationType = {
diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts
index 016ec8c2d..2955fcc8a 100644
--- a/app/features/notifications/notifications-types.ts
+++ b/app/features/notifications/notifications-types.ts
@@ -106,7 +106,16 @@ export type Notification =
tournamentName: string;
accepterUsername: string;
}
- >;
+ >
+ | NotificationItem<
+ "TEAM_EVENT_ADDED",
+ {
+ eventName: string;
+ teamName: string;
+ teamCustomUrl: string;
+ }
+ >
+ | NotificationItem<"SCHEDULE_TEAM_REMINDER">;
type NotificationItem<
T extends string,
diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts
index 8488128be..0d8f659d1 100644
--- a/app/features/notifications/notifications-utils.ts
+++ b/app/features/notifications/notifications-utils.ts
@@ -5,6 +5,7 @@ import { userSeasonsPage } from "~/features/user-page/user-page-urls";
import { assertUnreachable } from "~/utils/types";
import {
badgePage,
+ EVENTS_PAGE,
FRIENDS_PAGE,
NEW_TROPHY_PAGE,
PLUS_VOTING_PAGE,
@@ -13,6 +14,7 @@ import {
scrimPage,
scrimsPage,
sendouQMatchPage,
+ teamSchedulePage,
tournamentRegisterPage,
tournamentSubsPage,
tournamentTeamPage,
@@ -62,6 +64,10 @@ export const notificationNavIcon = (type: Notification["type"]) => {
return "scrims";
case "FRIEND_REQUEST_RECEIVED":
return "sendou_love";
+ case "TEAM_EVENT_ADDED":
+ return "t";
+ case "SCHEDULE_TEAM_REMINDER":
+ return "calendar";
default:
assertUnreachable(type);
}
@@ -137,6 +143,12 @@ export const notificationLink = (
case "TO_LIKE_ACCEPTED": {
return tournamentSubsPage(notification.meta.tournamentId);
}
+ case "TEAM_EVENT_ADDED": {
+ return teamSchedulePage(notification.meta.teamCustomUrl);
+ }
+ case "SCHEDULE_TEAM_REMINDER": {
+ return EVENTS_PAGE;
+ }
default:
assertUnreachable(notification);
}
diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts
index 18e88b56d..5ac77c36a 100644
--- a/app/features/scrims/ScrimPostRepository.server.ts
+++ b/app/features/scrims/ScrimPostRepository.server.ts
@@ -1,5 +1,5 @@
import { addHours, sub } from "date-fns";
-import type { Insertable, NotNull } from "kysely";
+import { type Insertable, type NotNull, sql } from "kysely";
import type { Tables, TablesInsertable } from "~/db/tables";
import { actorId, actorIdOrNull } from "~/features/auth/core/user.server";
import * as ChatRepository from "~/features/chat/ChatRepository.server";
@@ -566,6 +566,58 @@ export async function findAcceptedScrimsBetweenTwoTimestamps({
return rows.map(mapDBRowToScrimPost).filter((post) => Scrim.isAccepted(post));
}
+/**
+ * Finds the accepted (booked), uncanceled scrims of the given users whose
+ * resolved start time — the accepted request's chosen time for a range post,
+ * the post's own otherwise — falls within the given window. Used to resolve
+ * availability commitments.
+ *
+ * @returns one row per participating user per scrim
+ */
+export async function findAllAcceptedByUserIds({
+ userIds,
+ startsAt,
+ endsAt,
+}: {
+ userIds: Array;
+ startsAt: number;
+ endsAt: number;
+}) {
+ if (userIds.length === 0) return [];
+
+ const resolvedStartsAt = sql`coalesce("ScrimPostRequest"."startsAt", "ScrimPost"."startsAt")`;
+
+ const acceptedInWindow = db
+ .selectFrom("ScrimPost")
+ .innerJoin("ScrimPostRequest", (join) =>
+ join
+ .onRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
+ .on("ScrimPostRequest.isAccepted", "=", 1),
+ )
+ .where("ScrimPost.canceledAt", "is", null)
+ .where(resolvedStartsAt, ">=", startsAt)
+ .where(resolvedStartsAt, "<=", endsAt);
+
+ const [postSideUsers, requestSideUsers] = await Promise.all([
+ acceptedInWindow
+ .innerJoin("ScrimPostUser", "ScrimPostUser.scrimPostId", "ScrimPost.id")
+ .select(["ScrimPostUser.userId", resolvedStartsAt.as("startsAt")])
+ .where("ScrimPostUser.userId", "in", userIds)
+ .execute(),
+ acceptedInWindow
+ .innerJoin(
+ "ScrimPostRequestUser",
+ "ScrimPostRequestUser.scrimPostRequestId",
+ "ScrimPostRequest.id",
+ )
+ .select(["ScrimPostRequestUser.userId", resolvedStartsAt.as("startsAt")])
+ .where("ScrimPostRequestUser.userId", "in", userIds)
+ .execute(),
+ ]);
+
+ return [...postSideUsers, ...requestSideUsers];
+}
+
/**
* Finds pending (unaccepted, uncanceled, future) scrim posts and requests
* involving any of the given users whose time overlaps [startTime, endTime].
diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts
index 44243c0ce..c49faf939 100644
--- a/app/features/scrims/actions/scrims.new.server.ts
+++ b/app/features/scrims/actions/scrims.new.server.ts
@@ -9,19 +9,19 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
import { toDBBoolean } from "~/utils/sql";
-import { assertUnreachable } from "~/utils/types";
import { scrimsPage } from "~/utils/urls";
import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import { getMemberRoleType } from "../../team/team-utils";
import * as ScrimPickupRosterRepository from "../ScrimPickupRosterRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
-import { LUTI_DIVS, SCRIM } from "../scrims-constants";
import {
- type fromSchema,
- type RANGE_END_OPTIONS,
- scrimsNewFormSchema,
-} from "../scrims-schemas";
+ LUTI_DIVS,
+ RANGE_END_MINUTES,
+ type RangeEndOption,
+ SCRIM,
+} from "../scrims-constants";
+import { type fromSchema, scrimsNewFormSchema } from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
import { serializeLutiDiv } from "../scrims-utils";
@@ -193,25 +193,9 @@ async function validatePickupAllUnbanned(userIds: number[]) {
function resolveRangeEndToDate(
startDate: Date,
- rangeEnd: (typeof RANGE_END_OPTIONS)[number],
+ rangeEnd: RangeEndOption,
): Date {
- switch (rangeEnd) {
- case "+30min":
- return add(startDate, { minutes: 30 });
- case "+1hour":
- return add(startDate, { hours: 1 });
- case "+1.5hours":
- return add(startDate, { hours: 1, minutes: 30 });
- case "+2hours":
- return add(startDate, { hours: 2 });
- case "+2.5hours":
- return add(startDate, { hours: 2, minutes: 30 });
- case "+3hours":
- return add(startDate, { hours: 3 });
- default: {
- assertUnreachable(rangeEnd);
- }
- }
+ return add(startDate, { minutes: RANGE_END_MINUTES[rangeEnd] });
}
function resolveDivs(
diff --git a/app/features/scrims/components/ScrimAvailability.module.css b/app/features/scrims/components/ScrimAvailability.module.css
new file mode 100644
index 000000000..e6e2b52d5
--- /dev/null
+++ b/app/features/scrims/components/ScrimAvailability.module.css
@@ -0,0 +1,53 @@
+.stripe {
+ width: 100%;
+ height: auto;
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: var(--s-1) var(--s-4);
+ margin-block-start: auto;
+ border-block-start: var(--border-style);
+ border-radius: 0;
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-semi);
+
+ &:hover {
+ background-color: var(--color-bg-high);
+ }
+}
+
+.stripeTeam {
+ color: var(--color-text-high);
+ text-transform: uppercase;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.stripeCount {
+ margin-inline-start: auto;
+ color: var(--color-text);
+ white-space: nowrap;
+}
+
+.popover {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ max-width: 20rem;
+}
+
+.rowsSection {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+}
+
+.rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2-5);
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
diff --git a/app/features/scrims/components/ScrimAvailability.tsx b/app/features/scrims/components/ScrimAvailability.tsx
new file mode 100644
index 000000000..eb084e326
--- /dev/null
+++ b/app/features/scrims/components/ScrimAvailability.tsx
@@ -0,0 +1,145 @@
+import { useTranslation } from "react-i18next";
+import { useLoaderData } from "react-router";
+import { SendouButton } from "~/components/elements/Button";
+import { SendouPopover } from "~/components/elements/Popover";
+import {
+ AvailabilityMemberRow,
+ type AvailabilityPanelUser,
+ AvailabilityStatusDots,
+ AvailabilitySummary,
+ AvailabilityWindowText,
+ availabilityRowStatus,
+} from "~/features/availability/components/RegistrationAvailabilityPanel";
+import * as Scrim from "../core/Scrim";
+import type { loader as scrimsLoader } from "../loaders/scrims.server";
+import type { ScrimPost } from "../scrims-types";
+import { requestStarts } from "../scrims-utils";
+import styles from "./ScrimAvailability.module.css";
+
+export interface ScrimRosterFit {
+ team: { id: number; name: string };
+ roster: Array;
+ fit: Scrim.RosterFit;
+}
+
+/**
+ * How one of the viewer's teams fits a post they could request, resolved from
+ * the schedules the browsing page loaded. `teamId` picks the team (their main
+ * one by default) and `at` narrows the fit to one start inside the post's
+ * flexibility instead of the best one on offer.
+ *
+ * Null whenever there is nothing to show: no team, a post past the reportable
+ * horizon, or a week nobody filled in.
+ */
+export function useRosterFit({
+ post,
+ teamId,
+ at,
+}: {
+ post: ScrimPost;
+ teamId?: number;
+ at?: number | null;
+}): ScrimRosterFit | null {
+ const data = useLoaderData();
+
+ const team =
+ teamId !== undefined
+ ? data.teams.find((team) => team.id === teamId)
+ : (data.teams.find((team) => team.isMainTeam) ?? data.teams[0]);
+ const schedules = data.availability.windows.find(
+ (window) => window.id === post.id,
+ );
+ if (!team || !schedules) return null;
+
+ const roster = Scrim.teamPlayers(team.members);
+ const fit = Scrim.rosterFit({
+ starts: at ? [at] : requestStarts({ post, now: data.availability.now }),
+ members: roster.flatMap((member) => {
+ const schedule = schedules.members.find(
+ (schedule) => schedule.userId === member.id,
+ );
+
+ return schedule ? [schedule] : [];
+ }),
+ });
+ if (!fit) return null;
+
+ return { team, roster, fit };
+}
+
+/**
+ * The post card's fit indicator: a stripe above the card's actions saying how
+ * much of the viewer's roster could play it, the who and when a click away.
+ *
+ * Left out when none of them could — a row of zeroes down the page is noise,
+ * and the request button says all there is to say then.
+ */
+export function ScrimFitStripe({ post }: { post: ScrimPost }) {
+ const { t } = useTranslation(["schedule"]);
+ const fit = useRosterFit({ post });
+
+ if (!fit || fit.fit.availableCount === 0) return null;
+
+ return (
+
+ {fit.team.name}
+
+
+ {t("schedule:scrims.availableOfRoster", {
+ amount: fit.fit.availableCount,
+ total: fit.roster.length,
+ })}
+
+
+ }
+ >
+
+
+ );
+}
+
+/** The roster's members and how each of them relates to the scrim being requested. */
+export function ScrimAvailabilityRows({ fit }: { fit: ScrimRosterFit }) {
+ const entryByUserId = new Map(
+ fit.fit.entries.map((entry) => [entry.userId, entry]),
+ );
+
+ return (
+
+
+ {fit.roster.map((member) => (
+
+ ))}
+
+
+ availabilityRowStatus(entryByUserId.get(member.id)),
+ )}
+ />
+
+ );
+}
+
+/** How each of the roster relates to the scrim, in roster order. */
+function rosterStatuses(fit: ScrimRosterFit) {
+ const entryByUserId = new Map(
+ fit.fit.entries.map((entry) => [entry.userId, entry]),
+ );
+
+ return fit.roster.map((member) =>
+ availabilityRowStatus(entryByUserId.get(member.id)),
+ );
+}
diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx
index c5101b2ee..bc2687617 100644
--- a/app/features/scrims/components/ScrimCard.tsx
+++ b/app/features/scrims/components/ScrimCard.tsx
@@ -37,6 +37,7 @@ import { scrimsActionSchema } from "../scrims-schemas";
import { scrimsSearchParams } from "../scrims-search-params";
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
import { formatFlexTimeDisplay } from "../scrims-utils";
+import { ScrimFitStripe } from "./ScrimAvailability";
import styles from "./ScrimCard.module.css";
import { ScrimRequestModal } from "./ScrimRequestModal";
@@ -142,6 +143,10 @@ export function ScrimPostCard({
{post.text ? : null}
+ {action === "REQUEST" || action === "VIEW_REQUEST" ? (
+
+ ) : null}
+
diff --git a/app/features/scrims/components/ScrimRequestModal.tsx b/app/features/scrims/components/ScrimRequestModal.tsx
index fac497a47..3c7be3d7a 100644
--- a/app/features/scrims/components/ScrimRequestModal.tsx
+++ b/app/features/scrims/components/ScrimRequestModal.tsx
@@ -3,16 +3,21 @@ import { useLoaderData } from "react-router";
import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormMessage } from "~/components/FormMessage";
+import { AvailabilityWindowText } from "~/features/availability/components/RegistrationAvailabilityPanel";
import type { CustomFieldRenderProps } from "~/form";
-import { SendouForm } from "~/form/SendouForm";
+import { SendouForm, useFormValue } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { nullFilledArray } from "~/utils/arrays";
-import { databaseTimestampToDate } from "~/utils/dates";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
import { SCRIM } from "../scrims-constants";
import { scrimRequestFormSchema } from "../scrims-schemas";
import type { ScrimPost } from "../scrims-types";
import { generateTimeOptions } from "../scrims-utils";
+import { ScrimAvailabilityRows, useRosterFit } from "./ScrimAvailability";
import { WithFormField } from "./WithFormField";
export function ScrimRequestModal({
@@ -29,15 +34,25 @@ export function ScrimRequestModal({
minute: "numeric",
});
- const timeOptions = post.rangeEndsAt
+ // only the starts still on offer: the server clips the roster schedules to
+ // them, and defaulting to a time already past would show the whole roster
+ // as unavailable. Once every start has passed the full list stays on offer.
+ const allTimeOptions = post.rangeEndsAt
? generateTimeOptions(
databaseTimestampToDate(post.startsAt),
databaseTimestampToDate(post.rangeEndsAt),
- ).map((timestamp) => ({
- value: String(timestamp),
- label: timeFormatter.format(new Date(timestamp)) ?? "",
- }))
+ )
: [];
+ const upcomingTimeOptions = allTimeOptions.filter(
+ (timestamp) =>
+ dateToDatabaseTimestamp(new Date(timestamp)) >= data.availability.now,
+ );
+ const timeOptions = (
+ upcomingTimeOptions.length > 0 ? upcomingTimeOptions : allTimeOptions
+ ).map((timestamp) => ({
+ value: String(timestamp),
+ label: timeFormatter.format(new Date(timestamp)) ?? "",
+ }));
return (
@@ -77,6 +92,7 @@ export function ScrimRequestModal({
{post.rangeEndsAt ? (
) : null}
+
{t("scrims:autoCancelInfo")}
>
@@ -85,3 +101,32 @@ export function ScrimRequestModal({
);
}
+
+/** How the roster the request is made with fits the exact slot being asked for. */
+function ScrimRequestAvailability({ post }: { post: ScrimPost }) {
+ const { t } = useTranslation(["schedule"]);
+ const from = useFormValue("from") as
+ | { mode: "TEAM"; teamId: number }
+ | { mode: "PICKUP" }
+ | null;
+ const at = useFormValue("at") as string | null;
+
+ const teamId = from?.mode === "TEAM" ? from.teamId : undefined;
+ const fit = useRosterFit({
+ post,
+ teamId,
+ at: at ? dateToDatabaseTimestamp(new Date(Number(at))) : null,
+ });
+
+ if (teamId === undefined || !fit) return null;
+
+ return (
+
+
+ {t("schedule:registration.title")}
+
+
+
+
+ );
+}
diff --git a/app/features/scrims/components/ScrimSchedulePicker.module.css b/app/features/scrims/components/ScrimSchedulePicker.module.css
new file mode 100644
index 000000000..fdfb884ad
--- /dev/null
+++ b/app/features/scrims/components/ScrimSchedulePicker.module.css
@@ -0,0 +1,128 @@
+/* The tracks need more room than the form column gives them, so the picker
+ sizes against the whole page container and centers back under the column. */
+.picker {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ width: min(100cqw, 48rem);
+ margin-inline: calc(50% - min(50cqw, 24rem));
+}
+
+.header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-2);
+}
+
+.heading {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-bold);
+}
+
+.slotBar {
+ container: bar / inline-size;
+ position: absolute;
+ top: 3px;
+ bottom: 3px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ padding: 0;
+ background-color: var(--color-success);
+ border: 1px solid var(--color-success);
+ border-radius: var(--radius-field);
+ cursor: pointer;
+ z-index: 1;
+
+ &:focus-visible {
+ outline: var(--focus-ring);
+ }
+
+ &.oneShort {
+ background-color: var(--color-success-low);
+ }
+
+ &.picked {
+ box-shadow:
+ 0 0 0 2px var(--color-bg),
+ 0 0 0 4px var(--color-text);
+ }
+}
+
+/* inside a slot the team is only partly complete for, the part it is: where picking it starts */
+.slotFull {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ background-color: var(--color-success);
+ pointer-events: none;
+}
+
+/* the pill keeps the times legible whichever tier's green is under them */
+.slotLabel {
+ display: none;
+ max-width: 100%;
+ padding-inline: var(--s-1);
+ background-color: var(--color-bg);
+ border-radius: var(--radius-full);
+ font-size: var(--font-3xs);
+ color: var(--color-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+ z-index: 1;
+}
+
+@container bar (min-width: 9rem) {
+ .slotLabel {
+ display: block;
+ }
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-3);
+ font-size: var(--font-3xs);
+ color: var(--color-text-high);
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+}
+
+.slotSwatch {
+ width: 14px;
+ height: 10px;
+ background-color: var(--color-success);
+ border: 1px solid var(--color-success);
+ border-radius: var(--radius-field);
+
+ &.oneShort {
+ background-color: var(--color-success-low);
+ }
+}
+
+.unknown {
+ font-size: var(--font-2xs);
+ color: var(--color-text-high);
+}
+
+.slotChip {
+ &.oneShort {
+ border-style: dashed;
+ opacity: 0.85;
+ }
+
+ &.picked {
+ box-shadow:
+ 0 0 0 2px var(--color-bg),
+ 0 0 0 4px var(--color-text);
+ }
+}
diff --git a/app/features/scrims/components/ScrimSchedulePicker.tsx b/app/features/scrims/components/ScrimSchedulePicker.tsx
new file mode 100644
index 000000000..4aa116b37
--- /dev/null
+++ b/app/features/scrims/components/ScrimSchedulePicker.tsx
@@ -0,0 +1,388 @@
+import clsx from "clsx";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import * as R from "remeda";
+import { useUser } from "~/features/auth/core/user";
+import type {
+ DayTimeRange,
+ TimeRange,
+} from "~/features/availability/availability-types";
+import {
+ ClockAxis,
+ type ClockWindow,
+ TrackTicks,
+ useClockWindow,
+} from "~/features/availability/components/ScheduleTracks";
+import trackStyles from "~/features/availability/components/ScheduleTracks.module.css";
+import { WeekToggle } from "~/features/availability/components/WeekToggle";
+import * as Availability from "~/features/availability/core/Availability";
+import type { RosterScheduleData } from "~/features/availability/core/RosterSchedule.server";
+import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "~/utils/dates";
+import * as Scrim from "../core/Scrim";
+import type { ScrimsNewLoaderData } from "../loaders/scrims.new.server";
+import { SCRIM } from "../scrims-constants";
+import styles from "./ScrimSchedulePicker.module.css";
+
+const MINUTE_IN_SECONDS = 60;
+
+type Week = RosterScheduleData["weeks"][number];
+type Day = Week["days"][number];
+/** The "With" field as the form holds it while it is being filled in. */
+type FromValue =
+ | { mode: "TEAM"; teamId: number }
+ | { mode: "PICKUP"; users: Array
};
+
+interface DaySlot extends Scrim.PickableSlot {
+ /** The slot on its day's track, in minutes from that day's midnight. */
+ range: DayTimeRange;
+ /** The part of it the whole team is free for, on the same track. */
+ fullRange: DayTimeRange | null;
+}
+
+/**
+ * The roster's merged free time as a week of day tracks, one click on which
+ * fills in the post's start and start-time flexibility. Which roster is merged
+ * follows the "With" field, so this only appears once a team or a full pick-up
+ * has been picked.
+ *
+ * Only ever a prefill: the start inputs stay authoritative, and a start the
+ * schedules do not cover is warned about, never blocked.
+ */
+export function ScrimSchedulePicker({
+ schedule,
+ scheduleUsers,
+ teams,
+ from,
+ at,
+ onPick,
+}: {
+ schedule: RosterScheduleData;
+ scheduleUsers: ScrimsNewLoaderData["scheduleUsers"];
+ teams: ScrimsNewLoaderData["teams"];
+ from: FromValue;
+ at: Date | undefined;
+ onPick: (pick: { at: Date; rangeEnd: string | null }) => void;
+}) {
+ const user = useUser();
+
+ const roster = rosterUserIds({ from, teams, viewerId: user?.id });
+ if (roster.length < SCRIM.MIN_MEMBERS_PER_TEAM) return null;
+
+ return (
+ team.members),
+ ...scheduleUsers,
+ ...(user ? [user] : []),
+ ]}
+ roster={roster}
+ at={at}
+ onPick={onPick}
+ />
+ );
+}
+
+function RosterTimeline({
+ schedule,
+ names,
+ roster,
+ at,
+ onPick,
+}: {
+ schedule: RosterScheduleData;
+ /** Everyone whose name the timeline may need, the viewer included. */
+ names: Array<{ id: number; username: string }>;
+ roster: Array;
+ at: Date | undefined;
+ onPick: (pick: { at: Date; rangeEnd: string | null }) => void;
+}) {
+ const { t } = useTranslation(["schedule"]);
+ const [weekIndex, setWeekIndex] = React.useState(0);
+ const { formatter: dayFormatter } = useDateTimeFormat({
+ weekday: "short",
+ day: "numeric",
+ });
+ const { formatter: timeFormatter } = useDateTimeFormat({
+ hour: "numeric",
+ minute: "2-digit",
+ });
+
+ const week = schedule.weeks[weekIndex];
+ const memberById = new Map(
+ schedule.members.map((member) => [member.userId, member]),
+ );
+ const minPlayers = Math.min(SCRIM.MIN_MEMBERS_PER_TEAM, roster.length);
+
+ // only what can still be posted for: a start earlier today, let alone
+ // earlier this week, is not a start the form would accept
+ const pickableWeek = {
+ startsAt: Math.max(week.startsAt, schedule.now),
+ endsAt: week.endsAt,
+ };
+ const slots = Scrim.pickableSlots({
+ members: roster.map((userId) => ({
+ userId,
+ ranges: Availability.clip(
+ memberById.get(userId)?.ranges ?? [],
+ pickableWeek,
+ ),
+ })),
+ minPlayers,
+ });
+
+ const dayRows = week.days.map((day) => ({
+ day,
+ slots: slotsOfDay({ slots, day }),
+ }));
+ const clockWindow = useClockWindow({
+ fitTo: dayRows.flatMap((row) => row.slots.map((slot) => slot.range)),
+ });
+
+ const nameById = new Map(names.map((member) => [member.id, member.username]));
+ const namesOf = (userIds: Array) =>
+ userIds.flatMap((userId) => {
+ const username = nameById.get(userId);
+
+ return username ? [username] : [];
+ });
+ const unknownUserIds = roster.filter(
+ (userId) =>
+ !memberById.get(userId)?.reportedWeekStarts.includes(week.startsAt),
+ );
+ const unknownNamed = namesOf(unknownUserIds);
+ const unknownUnnamed = unknownUserIds.length - unknownNamed.length;
+
+ const pickedAt = at ? dateToDatabaseTimestamp(at) : null;
+
+ const pick = (slot: Scrim.PickableSlot) =>
+ onPick({
+ at: databaseTimestampToDate(slot.pick.startsAt),
+ rangeEnd: slot.pick.rangeEnd,
+ });
+
+ const rangeText = (range: TimeRange) =>
+ `${timeFormatter.format(range.startsAt)} – ${timeFormatter.format(range.endsAt)}`;
+
+ const dayRow = ({ day, slots: daySlots }: (typeof dayRows)[number]) => {
+ return (
+
+
+ {dayFormatter.format(day.noonAt)}
+
+
+
+ {daySlots.map((slot) => (
+ pick(slot)}
+ />
+ ))}
+
+ {/* keeps the day rows in step with the axis row's "later" expander */}
+
+
+ );
+ };
+
+ return (
+
+
+
{t("schedule:picker.title")}
+ setWeekIndex(value === "next" ? 1 : 0)}
+ />
+
+
+
+
+ {dayRows.map(dayRow)}
+
+
+ {dayRows.map(({ day, slots: daySlots }) => {
+ return (
+
+
+ {dayFormatter.format(day.noonAt)}
+
+ {daySlots.length === 0 ? (
+
—
+ ) : (
+
+ {daySlots.map((slot) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+
+ {unknownUserIds.length > 0 ? (
+
+ {t("schedule:picker.noSchedule", {
+ users: [
+ ...unknownNamed,
+ ...(unknownUnnamed > 0
+ ? [t("schedule:picker.andOthers", { amount: unknownUnnamed })]
+ : []),
+ ].join(", "),
+ })}
+
+ ) : null}
+
+ );
+}
+
+function SlotBar({
+ clockWindow,
+ slot,
+ label,
+ members,
+ isPicked,
+ onPick,
+}: {
+ clockWindow: ClockWindow;
+ slot: DaySlot;
+ label: string;
+ /** Who is free for the whole slot, named on hover. */
+ members: string;
+ isPicked: boolean;
+ onPick: () => void;
+}) {
+ const barStart = clockWindow.pct(slot.range.start);
+ const barEnd = clockWindow.pct(slot.range.end);
+ if (barEnd <= barStart) return null;
+
+ const withinBar = (minutes: number) =>
+ ((clockWindow.pct(minutes) - barStart) / (barEnd - barStart)) * 100;
+ return (
+
+ );
+}
+
+function Legend({ minPlayers }: { minPlayers: number }) {
+ const { t } = useTranslation(["schedule"]);
+
+ return (
+
+
+
+ {t("schedule:picker.legend.full", { players: minPlayers })}
+
+ {minPlayers > 1 ? (
+
+
+ {t("schedule:picker.legend.oneShort", { players: minPlayers - 1 })}
+
+ ) : null}
+
+ );
+}
+
+function rosterUserIds({
+ from,
+ teams,
+ viewerId,
+}: {
+ from: FromValue;
+ teams: ScrimsNewLoaderData["teams"];
+ viewerId?: number;
+}): Array {
+ if (!viewerId) return [];
+
+ if (from.mode === "PICKUP") {
+ return R.unique([
+ viewerId,
+ ...from.users.filter((userId) => typeof userId === "number"),
+ ]);
+ }
+
+ const team = teams.find((team) => team.id === from.teamId);
+ if (!team) return [];
+
+ return R.unique([
+ viewerId,
+ ...Scrim.teamPlayers(team.members).map((member) => member.id),
+ ]);
+}
+
+function slotsOfDay({
+ slots,
+ day,
+}: {
+ slots: Array;
+ day: Day;
+}): Array {
+ return slots
+ .filter((slot) => withinDay(slot.startsAt, day))
+ .map((slot) => ({
+ ...slot,
+ range: dayRange(slot, day),
+ fullRange: slot.fullSpan ? dayRange(slot.fullSpan, day) : null,
+ }));
+}
+
+const withinDay = (timestamp: number, day: Day) =>
+ timestamp >= day.startsAt && timestamp < day.endsAt;
+
+const dayRange = (range: TimeRange, day: Day) => ({
+ start: (range.startsAt - day.startsAt) / MINUTE_IN_SECONDS,
+ end: (range.endsAt - day.startsAt) / MINUTE_IN_SECONDS,
+});
diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts
index 116f36717..b237f0851 100644
--- a/app/features/scrims/core/Scrim.test.ts
+++ b/app/features/scrims/core/Scrim.test.ts
@@ -6,10 +6,15 @@ import {
applyFilters,
isTrackingLocked,
participantIdsListFromAccepted,
+ pickableSlots,
+ rosterFit,
sideDisplayName,
sideOfUser,
+ teamPlayers,
} from "./Scrim";
+const HOUR = 60 * 60;
+
type MockUser = { id: number };
type MockRequest = { isAccepted: boolean; users: MockUser[] };
@@ -607,3 +612,218 @@ describe("isTrackingLocked", () => {
).toBe(false);
});
});
+
+const freeFrom = (userId: number, startsAt: number, endsAt: number) => ({
+ userId,
+ ranges: [{ startsAt, endsAt }],
+});
+
+describe("pickableSlots", () => {
+ const evening = (hours: number) => hours * HOUR;
+
+ test("starts a slot the whole team is free for at its own start", () => {
+ const members = [1, 2, 3, 4].map((userId) =>
+ freeFrom(userId, evening(18), evening(23)),
+ );
+
+ expect(pickableSlots({ members, minPlayers: 4 })).toEqual([
+ {
+ startsAt: evening(18),
+ endsAt: evening(23),
+ userIds: [1, 2, 3, 4],
+ tier: "FULL",
+ fullSpan: null,
+ pick: { startsAt: evening(18), rangeEnd: "+3hours" },
+ },
+ ]);
+ });
+
+ test("starts a mixed slot where the whole team becomes free", () => {
+ const members = [
+ freeFrom(1, evening(18), evening(23)),
+ freeFrom(2, evening(18), evening(23)),
+ freeFrom(3, evening(18), evening(23)),
+ freeFrom(4, evening(20), evening(23)),
+ ];
+
+ expect(pickableSlots({ members, minPlayers: 4 })).toEqual([
+ {
+ startsAt: evening(18),
+ endsAt: evening(23),
+ userIds: [1, 2, 3],
+ tier: "ONE_SHORT",
+ fullSpan: {
+ startsAt: evening(20),
+ endsAt: evening(23),
+ tier: "FULL",
+ userIds: [1, 2, 3, 4],
+ },
+ pick: { startsAt: evening(20), rangeEnd: "+2hours" },
+ },
+ ]);
+ });
+
+ test("leaves an hour of the slot to play, capped at the longest flexibility", () => {
+ const twoHours = [1, 2, 3, 4].map((userId) =>
+ freeFrom(userId, evening(18), evening(20)),
+ );
+
+ expect(pickableSlots({ members: twoHours, minPlayers: 4 })[0].pick).toEqual(
+ { startsAt: evening(18), rangeEnd: "+1hour" },
+ );
+ });
+
+ test("gives an hour long slot no flexibility at all", () => {
+ const oneHour = [1, 2, 3, 4].map((userId) =>
+ freeFrom(userId, evening(18), evening(19)),
+ );
+
+ expect(pickableSlots({ members: oneHour, minPlayers: 4 })[0].pick).toEqual({
+ startsAt: evening(18),
+ rangeEnd: null,
+ });
+ });
+
+ test("shows the longest whole-team span when the slot contains several", () => {
+ const members = [
+ freeFrom(1, evening(18), evening(23)),
+ freeFrom(2, evening(18), evening(23)),
+ freeFrom(3, evening(18), evening(23)),
+ {
+ userId: 4,
+ ranges: [
+ { startsAt: evening(18), endsAt: evening(19) },
+ { startsAt: evening(20), endsAt: evening(23) },
+ ],
+ },
+ ];
+
+ const [slot] = pickableSlots({ members, minPlayers: 4 });
+
+ expect(slot.fullSpan).toEqual({
+ startsAt: evening(20),
+ endsAt: evening(23),
+ tier: "FULL",
+ userIds: [1, 2, 3, 4],
+ });
+ expect(slot.pick.startsAt).toBe(evening(20));
+ });
+
+ test("has no slots when the team is more than one player short", () => {
+ const members = [
+ freeFrom(1, evening(18), evening(21)),
+ freeFrom(2, evening(18), evening(21)),
+ freeFrom(3, evening(21), evening(23)),
+ freeFrom(4, evening(21), evening(23)),
+ ];
+
+ expect(pickableSlots({ members, minPlayers: 4 })).toEqual([]);
+ });
+});
+
+describe("teamPlayers", () => {
+ const player = { id: 1, role: "FRONTLINE" as const, roleType: null };
+ const coach = { id: 2, role: "COACH" as const, roleType: null };
+
+ test("leaves the non-players out", () => {
+ const members = [
+ player,
+ { ...coach, id: 3 },
+ ...[4, 5, 6].map((id) => ({ ...player, id })),
+ ];
+
+ expect(teamPlayers(members).map((member) => member.id)).toEqual([
+ 1, 4, 5, 6,
+ ]);
+ });
+
+ test("keeps everyone when the players alone could not field a team", () => {
+ const members = [player, { ...player, id: 2 }, { ...player, id: 3 }, coach];
+
+ expect(teamPlayers(members)).toHaveLength(4);
+ });
+});
+
+describe("rosterFit", () => {
+ const evening = (hours: number) => hours * HOUR;
+ const free = (userId: number, startsAt: number, endsAt: number) => ({
+ userId,
+ reported: true,
+ ranges: [{ startsAt, endsAt }],
+ busy: [],
+ });
+
+ test("measures the fit at the start the most of the roster is free for", () => {
+ const members = [
+ free(1, evening(18), evening(23)),
+ free(2, evening(18), evening(23)),
+ free(3, evening(18), evening(23)),
+ free(4, evening(20), evening(23)),
+ ];
+
+ const fit = rosterFit({
+ starts: [evening(18), evening(19), evening(20)],
+ members,
+ });
+
+ expect(fit?.startsAt).toBe(evening(20));
+ expect(fit?.availableCount).toBe(4);
+ expect(fit?.window).toEqual({
+ startsAt: evening(20),
+ endsAt: evening(21.5),
+ });
+ });
+
+ test("gives the earliest of equally good starts", () => {
+ const members = [1, 2, 3, 4].map((userId) =>
+ free(userId, evening(18), evening(23)),
+ );
+
+ expect(
+ rosterFit({ starts: [evening(18), evening(19)], members })?.startsAt,
+ ).toBe(evening(18));
+ });
+
+ test("leaves a member free for only part of the scrim out of the count", () => {
+ const members = [
+ free(1, evening(18), evening(23)),
+ free(2, evening(18), evening(18.5)),
+ ];
+
+ const fit = rosterFit({ starts: [evening(18)], members });
+
+ expect(fit?.availableCount).toBe(1);
+ expect(fit?.entries[1].availability.status).toBe("partial");
+ });
+
+ test("reports a member committed elsewhere as busy", () => {
+ const members = [
+ {
+ ...free(1, evening(18), evening(23)),
+ busy: [
+ {
+ startsAt: evening(19),
+ endsAt: evening(21),
+ type: "tournament" as const,
+ name: "ITZ",
+ },
+ ],
+ },
+ ];
+
+ expect(
+ rosterFit({ starts: [evening(19)], members })?.entries[0].availability
+ .status,
+ ).toBe("busy");
+ });
+
+ test("returns null when nobody filled in the week", () => {
+ const members = [1, 2].map((userId) => ({
+ ...free(userId, evening(18), evening(23)),
+ reported: false,
+ ranges: [],
+ }));
+
+ expect(rosterFit({ starts: [evening(18)], members })).toBeNull();
+ });
+});
diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts
index 98b572575..6a61ec4b6 100644
--- a/app/features/scrims/core/Scrim.ts
+++ b/app/features/scrims/core/Scrim.ts
@@ -1,9 +1,29 @@
import { format, isWeekend } from "date-fns";
import * as R from "remeda";
import type { Tables } from "~/db/tables";
+import { AVAILABILITY } from "~/features/availability/availability-constants";
+import type {
+ MemberAvailability,
+ PlayableWindowTier,
+ TimeRange,
+ WindowAvailabilityEntry,
+ WindowSchedule,
+} from "~/features/availability/availability-types";
+import * as Availability from "~/features/availability/core/Availability";
+import type {
+ MemberRole,
+ MemberRoleType,
+} from "~/features/team/team-constants";
+import { getMemberRoleType } from "~/features/team/team-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
-import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants";
+import {
+ LUTI_DIVS,
+ RANGE_END_MINUTES,
+ type RangeEndOption,
+ SCRIM,
+ SCRIM_TRACKING_AUTO_LOCK_HOURS,
+} from "../scrims-constants";
import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types";
/** Returns true if the original poster has accepted any of the requests. */
@@ -214,6 +234,138 @@ export function lastReportedMap<
);
}
+export interface PickableSlot extends TimeRange {
+ tier: PlayableWindowTier;
+ /** Members free for the whole slot. */
+ userIds: Array;
+ /** The part of the slot the whole team is free for, when that is only part of it. */
+ fullSpan: TimeRange | null;
+ /** What picking the slot fills the post's start and start-time flexibility with. */
+ pick: { startsAt: number; rangeEnd: RangeEndOption | null };
+}
+
+/**
+ * The roster's shared free time as the slots a scrim post can be picked from:
+ * maximal spans where the team is at most one player short, the `ONE_SHORT`
+ * ones being the "grab a sub" case.
+ */
+export function pickableSlots({
+ members,
+ minPlayers,
+}: {
+ members: Array;
+ minPlayers: number;
+}): Array {
+ const spansFreeFor = (playerCount: number) =>
+ Availability.playableWindows({
+ members,
+ minPlayers: playerCount,
+ }).filter((window) => window.tier === "FULL");
+
+ const fullSpans = spansFreeFor(minPlayers);
+
+ return spansFreeFor(Math.max(1, minPlayers - 1)).map((slot) => {
+ // the longest one: a slot can contain several whole-team spans
+ const fullSpan = R.firstBy(
+ fullSpans.filter(
+ (span) => span.startsAt >= slot.startsAt && span.endsAt <= slot.endsAt,
+ ),
+ [(span) => span.endsAt - span.startsAt, "desc"],
+ );
+ const wholeSlotIsFull =
+ fullSpan?.startsAt === slot.startsAt && fullSpan?.endsAt === slot.endsAt;
+
+ return {
+ startsAt: slot.startsAt,
+ endsAt: slot.endsAt,
+ userIds: slot.userIds,
+ tier: wholeSlotIsFull ? "FULL" : "ONE_SHORT",
+ fullSpan: wholeSlotIsFull ? null : (fullSpan ?? null),
+ pick: startPick({ slot, at: fullSpan?.startsAt ?? slot.startsAt }),
+ };
+ });
+}
+
+/**
+ * The members a scrim is played with: the team's players, or its whole roster
+ * when there are not enough players on it to field a team.
+ */
+export function teamPlayers<
+ T extends { role: MemberRole | null; roleType: MemberRoleType | null },
+>(members: Array): Array {
+ const players = members.filter(
+ (member) => getMemberRoleType(member) !== "OTHER",
+ );
+
+ return players.length >= SCRIM.MIN_MEMBERS_PER_TEAM ? players : members;
+}
+
+export interface RosterFit {
+ /** The start the fit is measured at, the best one of those offered. */
+ startsAt: number;
+ /** The scrim played from that start, its length assumed. */
+ window: TimeRange;
+ entries: Array;
+ /** How many of the roster are free for the whole window. */
+ availableCount: number;
+}
+
+/**
+ * How well a roster fits a scrim post: the start among `starts` the most of
+ * them are free for, and how each member relates to a scrim played from it.
+ * Ties go to the earliest start.
+ *
+ * Null when nobody on the roster filled in the week the post falls in — a fit
+ * nothing is known about is not worth showing.
+ */
+export function rosterFit({
+ starts,
+ members,
+}: {
+ starts: Array;
+ members: Array;
+}): RosterFit | null {
+ if (members.length === 0 || members.every((member) => !member.reported)) {
+ return null;
+ }
+
+ const fits = starts.map((startsAt) => fitAt({ startsAt, members }));
+
+ return R.firstBy(fits, [(fit) => fit.availableCount, "desc"]) ?? null;
+}
+
+function fitAt({
+ startsAt,
+ members,
+}: {
+ startsAt: number;
+ members: Array;
+}): RosterFit {
+ const window = {
+ startsAt,
+ endsAt: startsAt + AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
+ };
+
+ const entries = members.map((member) => ({
+ userId: member.userId,
+ availability: Availability.availabilityInWindow({
+ reported: member.reported,
+ slots: member.ranges,
+ busy: member.busy,
+ window,
+ }),
+ }));
+
+ return {
+ startsAt,
+ window,
+ entries,
+ availableCount: entries.filter(
+ (entry) => entry.availability.status === "available",
+ ).length,
+ };
+}
+
/** Splits a "HH:mm" time range into segments, breaking a range that crosses midnight (e.g. 23:00 -> 01:00) into two. */
function timeRangeToSegments(start: string, end: string) {
return end < start
@@ -223,3 +375,26 @@ function timeRangeToSegments(start: string, end: string) {
]
: [{ start, end }];
}
+
+function startPick({ slot, at }: { slot: TimeRange; at: number }) {
+ const lastStartsAt = slot.endsAt - AVAILABILITY.MIN_WINDOW_MINUTES * 60;
+ const startsAt = R.clamp(at, {
+ min: slot.startsAt,
+ max: Math.max(slot.startsAt, lastStartsAt),
+ });
+ const flexMinutes =
+ Math.min(lastStartsAt - startsAt, SCRIM.MAX_TIME_RANGE_MS / 1000) / 60;
+
+ return { startsAt, rangeEnd: longestRangeEndWithin(flexMinutes) };
+}
+
+function longestRangeEndWithin(minutes: number): RangeEndOption | null {
+ const fitting = R.entries(RANGE_END_MINUTES).filter(
+ ([, optionMinutes]) => optionMinutes <= minutes,
+ );
+
+ return (
+ R.firstBy(fitting, [([, optionMinutes]) => optionMinutes, "desc"])?.[0] ??
+ null
+ );
+}
diff --git a/app/features/scrims/loaders/scrims.new.server.ts b/app/features/scrims/loaders/scrims.new.server.ts
index b45ab1fa8..234ed720b 100644
--- a/app/features/scrims/loaders/scrims.new.server.ts
+++ b/app/features/scrims/loaders/scrims.new.server.ts
@@ -1,5 +1,9 @@
+import * as R from "remeda";
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
+import * as RosterSchedule from "~/features/availability/core/RosterSchedule.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
+import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import type { SerializeFrom } from "~/utils/remix";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as ScrimPickupRosterRepository from "../ScrimPickupRosterRepository.server";
@@ -9,9 +13,34 @@ export type ScrimsNewLoaderData = SerializeFrom;
export const loader = async () => {
const user = requireUser();
+ const [teams, friendsAndTeammates] = await Promise.all([
+ TeamRepository.findAllByMemberUserId(user.id),
+ SQGroupRepository.findFriendsAndTeammates(user.id),
+ ]);
+
+ // everyone the post could be made with whose schedule the author may see:
+ // their teams' rosters and their friends, the same visibility rule the rest
+ // of the schedule surfaces follow
+ const scheduleUserIds = R.unique([
+ user.id,
+ ...teams.flatMap((team) => team.members.map((member) => member.id)),
+ ...friendsAndTeammates.friends.map((friend) => friend.id),
+ ]);
+
return {
- teams: await TeamRepository.findAllByMemberUserId(user.id),
+ teams,
associations: await AssociationRepository.findByMemberUserId(user.id),
recentPickupRosters: await ScrimPickupRosterRepository.findAllOwnRecent(),
+ schedule: await RosterSchedule.rosterScheduleData({
+ userIds: scheduleUserIds,
+ timezone: getViewerTimezone() ?? "UTC",
+ }),
+ scheduleUsers: R.uniqueBy(
+ friendsAndTeammates.friends.map((friend) => ({
+ id: friend.id,
+ username: friend.username,
+ })),
+ (friend) => friend.id,
+ ),
};
};
diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts
index 70d578157..5bd14300b 100644
--- a/app/features/scrims/loaders/scrims.server.ts
+++ b/app/features/scrims/loaders/scrims.server.ts
@@ -3,12 +3,15 @@ import * as R from "remeda";
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
import * as Association from "~/features/associations/core/Association";
import { getUser } from "~/features/auth/core/user.server";
+import * as RosterSchedule from "~/features/availability/core/RosterSchedule.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as Scrim from "../core/Scrim";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { scrimsSearchParams } from "../scrims-search-params";
-import { dividePosts } from "../scrims-utils";
+import type { ScrimPost } from "../scrims-types";
+import { dividePosts, postSpan } from "../scrims-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = getUser();
@@ -55,12 +58,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
]),
);
+ const dividedPosts = dividePosts(posts, user?.id);
+ const teams = user ? await TeamRepository.findAllByMemberUserId(user.id) : [];
+
return {
...(await UserCardRepository.findAllByUserIds({
userIds: cardUserIds,
})),
- posts: dividePosts(posts, user?.id),
- teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [],
+ posts: dividedPosts,
+ teams,
+ availability: await rosterAvailability({
+ posts: dividedPosts.neutral,
+ teams,
+ }),
filters,
canSaveAsDefault:
user != null &&
@@ -70,3 +80,35 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
),
};
};
+
+/**
+ * How the viewer's teams relate to the posts they could request: the material
+ * the fit indicators on the post cards and in the request dialog are resolved
+ * from, one entry per post.
+ */
+async function rosterAvailability({
+ posts,
+ teams,
+}: {
+ posts: Array;
+ teams: Awaited>;
+}) {
+ const userIds = R.unique(
+ teams.flatMap((team) =>
+ Scrim.teamPlayers(team.members).map((member) => member.id),
+ ),
+ );
+ const now = dateToDatabaseTimestamp(new Date());
+
+ return {
+ /** Server clock, so that the shown fit does not change on hydration. */
+ now,
+ windows: await RosterSchedule.windowSchedules({
+ windows: posts.map((post) => ({
+ id: post.id,
+ ...postSpan({ post, now }),
+ })),
+ userIds,
+ }),
+ };
+}
diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx
index 34635ad17..db0d40de8 100644
--- a/app/features/scrims/routes/scrims.new.tsx
+++ b/app/features/scrims/routes/scrims.new.tsx
@@ -17,6 +17,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { FormMessage } from "../../../components/FormMessage";
import { Main } from "../../../components/Main";
import { action } from "../actions/scrims.new.server";
+import { ScrimSchedulePicker } from "../components/ScrimSchedulePicker";
import { WithFormField } from "../components/WithFormField";
import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server";
import { SCRIM } from "../scrims-constants";
@@ -34,7 +35,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
- i18n: "scrims",
+ i18n: ["scrims", "schedule"],
};
type FormFields = v.InferOutput;
@@ -87,6 +88,8 @@ export default function NewScrimPage() {
)}
+
+
@@ -117,6 +120,28 @@ export default function NewScrimPage() {
);
}
+function SchedulePicker() {
+ const data = useLoaderData();
+ const { values, setValue } = useFormFieldContext();
+
+ const from = values.from as FormFields["from"] | null;
+ if (!from) return null;
+
+ return (
+ {
+ setValue("at", at);
+ setValue("rangeEnd", rangeEnd);
+ }}
+ />
+ );
+}
+
function BaseVisibilityFormField({
associations,
name,
diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx
index 4a8c4ff24..882ee511e 100644
--- a/app/features/scrims/routes/scrims.tsx
+++ b/app/features/scrims/routes/scrims.tsx
@@ -49,7 +49,7 @@ import styles from "./scrims.module.css";
export type NewRequestFormFields = v.InferOutput;
export const handle: SendouRouteHandle = {
- i18n: ["calendar", "scrims", "user", "q"],
+ i18n: ["calendar", "schedule", "scrims", "user", "q"],
breadcrumb: () => ({
imgPath: navIconUrl("scrims"),
href: scrimsPage(),
diff --git a/app/features/scrims/scrims-constants.ts b/app/features/scrims/scrims-constants.ts
index d3ca410b5..a84b0bfbf 100644
--- a/app/features/scrims/scrims-constants.ts
+++ b/app/features/scrims/scrims-constants.ts
@@ -13,6 +13,18 @@ export const LUTI_DIVS = [
"11",
] as const;
+/** Start-time flexibility a scrim post can be given, as minutes added to its start. */
+export const RANGE_END_MINUTES = {
+ "+30min": 30,
+ "+1hour": 60,
+ "+1.5hours": 90,
+ "+2hours": 120,
+ "+2.5hours": 150,
+ "+3hours": 180,
+} as const;
+
+export type RangeEndOption = keyof typeof RANGE_END_MINUTES;
+
export const SCRIM = {
MAX_PICKUP_SIZE_EXCLUDING_OWNER: 5,
MAX_SAVED_PICKUP_ROSTERS: 5,
diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts
index ea9efdc93..ca6d2d7f3 100644
--- a/app/features/scrims/scrims-schemas.ts
+++ b/app/features/scrims/scrims-schemas.ts
@@ -271,15 +271,6 @@ export const scrimIdActionSchema = v.union([
const MAX_SCRIM_POST_TEXT_LENGTH = 500;
-export const RANGE_END_OPTIONS = [
- "+30min",
- "+1hour",
- "+1.5hours",
- "+2hours",
- "+2.5hours",
- "+3hours",
-] as const;
-
export const scrimRequestFormSchema = v.object({
_action: stringConstant("NEW_REQUEST"),
scrimPostId: idConstant(),
diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts
index 914324c5e..5e05ee41a 100644
--- a/app/features/scrims/scrims-utils.test.ts
+++ b/app/features/scrims/scrims-utils.test.ts
@@ -1,10 +1,13 @@
import { describe, expect, test } from "vitest";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
formatFlexTimeDisplay,
generateTimeOptions,
parseLutiDivFromName,
parseMapPoolInput,
+ postSpan,
+ requestStarts,
} from "./scrims-utils";
describe("parseLutiDivFromName", () => {
@@ -323,3 +326,52 @@ describe("parseMapPoolInput", () => {
);
});
});
+
+describe("requestStarts", () => {
+ const at = (time: string) =>
+ dateToDatabaseTimestamp(new Date(`2025-01-15T${time}:00`));
+ const post = { startsAt: at("19:00"), rangeEndsAt: at("20:30") };
+
+ test("offers every half hour of the post's flexibility", () => {
+ expect(requestStarts({ post, now: at("12:00") })).toEqual([
+ at("19:00"),
+ at("19:30"),
+ at("20:00"),
+ at("20:30"),
+ ]);
+ });
+
+ test("drops the starts already gone by", () => {
+ expect(requestStarts({ post, now: at("19:45") })).toEqual([
+ at("20:00"),
+ at("20:30"),
+ ]);
+ });
+
+ test("offers now for a post with no flexibility", () => {
+ expect(
+ requestStarts({
+ post: { startsAt: at("18:00"), rangeEndsAt: null },
+ now: at("19:00"),
+ }),
+ ).toEqual([at("19:00")]);
+ });
+
+ test("offers now once the whole flexibility has passed", () => {
+ expect(requestStarts({ post, now: at("21:00") })).toEqual([at("21:00")]);
+ });
+});
+
+describe("postSpan", () => {
+ const at = (time: string) =>
+ dateToDatabaseTimestamp(new Date(`2025-01-15T${time}:00`));
+
+ test("reaches from the earliest start to the end of a scrim from the latest", () => {
+ expect(
+ postSpan({
+ post: { startsAt: at("19:00"), rangeEndsAt: at("20:30") },
+ now: at("12:00"),
+ }),
+ ).toEqual({ startsAt: at("19:00"), endsAt: at("22:00") });
+ });
+});
diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts
index 381dd6f33..962bc0481 100644
--- a/app/features/scrims/scrims-utils.ts
+++ b/app/features/scrims/scrims-utils.ts
@@ -1,7 +1,12 @@
import { differenceInMinutes } from "date-fns";
import * as R from "remeda";
+import { AVAILABILITY } from "~/features/availability/availability-constants";
+import type { TimeRange } from "~/features/availability/availability-types";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
-import { databaseTimestampToDate } from "~/utils/dates";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "~/utils/dates";
import * as Scrim from "./core/Scrim";
import { LUTI_DIVS } from "./scrims-constants";
import type { LutiDiv, ScrimPost } from "./scrims-types";
@@ -74,6 +79,50 @@ export const serializeLutiDiv = (div: LutiDiv): number => {
return Number(div);
};
+/**
+ * The starts a request for the post can still be made for: its start, the half
+ * hours inside its start-time flexibility and the end of that flexibility,
+ * with the ones already past dropped. A post with no flexibility left offers
+ * `now` — "looking now" is what it means.
+ */
+export function requestStarts({
+ post,
+ now,
+}: {
+ post: Pick;
+ now: number;
+}): Array {
+ const starts = post.rangeEndsAt
+ ? generateTimeOptions(
+ databaseTimestampToDate(post.startsAt),
+ databaseTimestampToDate(post.rangeEndsAt),
+ ).map((timestamp) => dateToDatabaseTimestamp(new Date(timestamp)))
+ : [post.startsAt];
+
+ const upcoming = starts.filter((startsAt) => startsAt >= now);
+
+ return upcoming.length > 0 ? upcoming : [now];
+}
+
+/**
+ * The whole span the post's scrim could take up: from the earliest start still
+ * on offer to the end of a scrim played from the latest one.
+ */
+export function postSpan({
+ post,
+ now,
+}: {
+ post: Pick;
+ now: number;
+}): TimeRange {
+ const starts = requestStarts({ post, now });
+
+ return {
+ startsAt: starts[0],
+ endsAt: starts[starts.length - 1] + AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
+ };
+}
+
export function generateTimeOptions(startDate: Date, endDate: Date): number[] {
const timestamps = new Set();
diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts
index 088dc0b61..576e07744 100644
--- a/app/features/sendouq/SQGroupRepository.server.ts
+++ b/app/features/sendouq/SQGroupRepository.server.ts
@@ -535,6 +535,8 @@ export async function findFriendsAndTeammates(userId: number) {
...commonUserSelect(eb),
"User.inGameName",
"TeamMemberWithSecondary.teamId",
+ "TeamMemberWithSecondary.role",
+ "TeamMemberWithSecondary.roleType",
])
.where(
"TeamMemberWithSecondary.teamId",
@@ -562,6 +564,8 @@ export async function findFriendsAndTeammates(userId: number) {
...commonUserSelect(eb),
"User.inGameName",
sql`null`.as("teamId"),
+ sql`null`.as("role"),
+ sql`null`.as("roleType"),
]),
)
.execute();
diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts
index 91fa39663..61ccee313 100644
--- a/app/features/sidebar/core/sidebar.server.ts
+++ b/app/features/sidebar/core/sidebar.server.ts
@@ -1,8 +1,11 @@
import { cachified } from "@epic-web/cachified";
-import { addDays } from "date-fns";
+import { addDays, addWeeks } from "date-fns";
import { href } from "react-router";
import * as R from "remeda";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
+import type { AuthenticatedUser } from "~/features/auth/core/user.server";
+import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server";
+import * as Availability from "~/features/availability/core/Availability";
import { userIsBanned } from "~/features/ban/core/banned.server";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import {
@@ -27,6 +30,7 @@ import type { SidebarScrim } from "~/features/scrims/ScrimPostRepository.server"
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { scrimsSearchParams } from "~/features/scrims/scrims-search-params";
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
+import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import { cache, ttl } from "~/utils/cache.server";
@@ -36,6 +40,7 @@ import {
BLANK_IMAGE_URL,
discordAvatarUrl,
navIconUrl,
+ teamSchedulePage,
twitchUrl,
userPage,
} from "~/utils/urls";
@@ -49,7 +54,7 @@ export type SidebarEvent = {
/** Whose avatar the event shows instead of a logo of its own. */
user: CommonUser | null;
startsAt: number;
- type: "tournament" | "scrim";
+ type: "tournament" | "scrim" | "teamEvent";
scrimStatus?: "booked" | "looking" | "requestPending";
};
@@ -75,7 +80,9 @@ const UPCOMING_TOURNAMENT_WINDOW_DAYS = 3;
const SENDOUQ_QUOTA = 2;
const TOURNAMENT_SUB_QUOTA = 2;
-export async function resolveSidebarData(userId: number | null) {
+export async function resolveSidebarData(user: AuthenticatedUser | undefined) {
+ const userId = user?.id ?? null;
+
if (!userId) {
return {
events: [] as SidebarEvent[],
@@ -83,24 +90,22 @@ export async function resolveSidebarData(userId: number | null) {
streams: await combinedStreamsCached(),
savedTournamentIds: [] as number[],
incomingFriendRequestIds: [] as number[],
+ scheduleNudge: false,
};
}
- const [
- tournamentsData,
- scrimsData,
- friendsWithActivity,
- savedTournaments,
- incomingFriendRequestIds,
- streamedSendouQMatches,
- ] = await Promise.all([
- ShowcaseTournaments.categorizedTournamentsByUserId(userId),
- ScrimPostRepository.findUserScrims(userId),
- FriendRepository.findByUserIdWithActivity(userId),
- SavedCalendarEventRepository.findAllUpcomingByUserId(userId),
- FriendRepository.findPendingReceivedRequestIds(userId),
- resolveSendouQMatchStreams(),
- ]);
+ const tournamentsData =
+ await ShowcaseTournaments.categorizedTournamentsByUserId(userId);
+ const scrimsData = await ScrimPostRepository.findUserScrims(userId);
+ const friendsWithActivity =
+ await FriendRepository.findByUserIdWithActivity(userId);
+ const savedTournaments =
+ await SavedCalendarEventRepository.findAllUpcomingByUserId(userId);
+ const incomingFriendRequestIds =
+ await FriendRepository.findPendingReceivedRequestIds(userId);
+ const streamedSendouQMatches = await resolveSendouQMatchStreams();
+ const teamEvents = await findUpcomingTeamEvents(userId);
+ const scheduleNudge = await showScheduleNudge(user);
const seenTournamentIds = new Set();
const tournamentEvents: SidebarEvent[] = [
@@ -123,7 +128,16 @@ export async function resolveSidebarData(userId: number | null) {
const scrimEvents: SidebarEvent[] = scrimsData.map(scrimToSidebarEvent);
- const events = [...tournamentEvents, ...savedEvents, ...scrimEvents]
+ const teamEventEvents: SidebarEvent[] = teamEvents.map(
+ teamEventToSidebarEvent,
+ );
+
+ const events = [
+ ...tournamentEvents,
+ ...savedEvents,
+ ...scrimEvents,
+ ...teamEventEvents,
+ ]
.sort((a, b) => a.startsAt - b.startsAt)
.slice(0, MAX_EVENTS_VISIBLE);
@@ -137,9 +151,38 @@ export async function resolveSidebarData(userId: number | null) {
streams: await combinedStreamsCached(),
savedTournamentIds,
incomingFriendRequestIds,
+ scheduleNudge,
};
}
+/**
+ * Whether to prompt the user to report next week: they are on its last day, it
+ * is still empty, and they have not waved the prompt away for this week.
+ */
+async function showScheduleNudge(user: AuthenticatedUser | undefined) {
+ if (!user) return false;
+
+ const timezone = getViewerTimezone() ?? "UTC";
+ const now = new Date();
+
+ if (!Availability.isLastDayOfWeek(now, timezone)) return false;
+
+ const weekStartsAt = Availability.weekStartsAt(addWeeks(now, 1), timezone);
+ const dismissedAt = user.preferences?.scheduleNudgeDismissedWeekStartsAt;
+
+ if (
+ dismissedAt !== undefined &&
+ Availability.isSameWeek(dismissedAt, weekStartsAt)
+ ) {
+ return false;
+ }
+
+ return !(await AvailabilityRepository.hasReportedWeek({
+ userId: user.id,
+ weekStartsAt,
+ }));
+}
+
function combinedStreamsCached(): Promise {
return cachified({
key: COMBINED_STREAMS_KEY,
@@ -416,6 +459,39 @@ export function tournamentToSidebarEvent(
};
}
+const TEAM_EVENT_WINDOW_DAYS = 14;
+
+/** Team events shown on the sidebar and the personal calendar page: ongoing ones and those starting within the next two weeks. */
+export function findUpcomingTeamEvents(userId: number) {
+ const now = new Date();
+
+ return AvailabilityRepository.findAllUpcomingTeamEventsByUserId({
+ userId,
+ startsAt: dateToDatabaseTimestamp(now),
+ endsAt: dateToDatabaseTimestamp(addDays(now, TEAM_EVENT_WINDOW_DAYS)),
+ });
+}
+
+type UpcomingTeamEvent = Awaited<
+ ReturnType
+>[number];
+
+const TEAM_ICON_URL = `${navIconUrl("t")}.avif`;
+
+export function teamEventToSidebarEvent(
+ event: UpcomingTeamEvent,
+): SidebarEvent {
+ return {
+ id: event.id,
+ name: event.name,
+ url: teamSchedulePage(event.teamCustomUrl),
+ logoUrl: event.teamAvatarUrl ?? TEAM_ICON_URL,
+ user: null,
+ startsAt: event.startsAt,
+ type: "teamEvent" as const,
+ };
+}
+
const SCRIMS_ICON_URL = `${navIconUrl("scrims")}.avif`;
export function scrimToSidebarEvent(s: SidebarScrim): SidebarEvent {
diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx
index ccd5c92de..aa3afc976 100644
--- a/app/features/team/routes/t.$customUrl.index.tsx
+++ b/app/features/team/routes/t.$customUrl.index.tsx
@@ -1,4 +1,5 @@
import {
+ CalendarDays,
LogOut,
Menu,
SquarePen,
@@ -115,13 +116,26 @@ function ActionButtons() {
const team = layoutData.team;
const canManageRoster = useHasPermission(team, "MANAGE_ROSTER");
const canEditTeam = useHasPermission(team, "EDIT");
+ const isMember = isTeamMember({ user, team });
- if (!isTeamMember({ user, team }) && !canManageRoster && !canEditTeam) {
+ if (!isMember && !canManageRoster && !canEditTeam) {
return null;
}
return (
+ {isMember ? (
+
}
+ testId="team-schedule-button"
+ >
+ {t("team:actionButtons.schedule")}
+
+ ) : null}
{canManageRoster ? (
-
-
+
+
-
-
);
}
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
index fbbdd42d2..bf7b3e096 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
@@ -1,7 +1,6 @@
import { sub } from "date-fns";
import {
Check,
- Clipboard,
Eye,
EyeOff,
Map as MapIcon,
@@ -29,6 +28,7 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
+import { InviteLinkInput } from "~/components/InviteLinkInput";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { useUser } from "~/features/auth/core/user";
import { useTopicRevalidation } from "~/features/chat/chat-hooks";
@@ -37,7 +37,6 @@ import {
TournamentProvider,
useTournament,
} from "~/features/tournament/tournament-context";
-import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHydrated } from "~/hooks/useHydrated";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { useSearchParam } from "~/modules/search-params/hooks";
@@ -438,7 +437,6 @@ function MapPreparer({
function AddSubsPopOver() {
const { t } = useTranslation(["common", "tournament"]);
- const { copyToClipboard, copySuccess } = useCopyToClipboard();
const tournament = useTournament();
const user = useUser();
const data = useLoaderData
();
@@ -465,19 +463,7 @@ function AddSubsPopOver() {
{subsAvailableToAdd > 0 ? (
<>
- {t("tournament:actions.shareLink", { inviteLink })}
-
- : }
- onPress={() => copyToClipboard(inviteLink)}
- variant="minimal"
- className="tiny"
- data-testid="copy-invite-link-button"
- >
- {t("common:actions.copyToClipboard")}
-
-
+
>
) : null}
diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
index 2a67ae5b6..2fbcde250 100644
--- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
+++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
@@ -1,5 +1,5 @@
import { isFuture } from "date-fns";
-import { type ExpressionBuilder, sql } from "kysely";
+import { type ExpressionBuilder, type NotNull, sql } from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { DB, Tables, TablesInsertable } from "~/db/tables";
@@ -373,6 +373,61 @@ export async function findEventsByMonth({
return events.map(mapEvent);
}
+/** Every tournament series of every organization. */
+export function findAllSeries() {
+ return db
+ .selectFrom("TournamentOrganizationSeries")
+ .select([
+ "TournamentOrganizationSeries.organizationId",
+ "TournamentOrganizationSeries.substringMatches",
+ "TournamentOrganizationSeries.tierHistory",
+ ])
+ .execute();
+}
+
+/**
+ * How many teams each organization's already started tournaments drew within the
+ * given window, oldest first. Counts what the tournament's own page shows:
+ * placeholder teams excluded, dropped out ones included.
+ */
+export function findAllOrganizedTournamentTeamCounts({
+ startedAfter,
+}: {
+ startedAfter: number;
+}) {
+ return db
+ .selectFrom("CalendarEvent")
+ .innerJoin(
+ "CalendarEventDate",
+ "CalendarEventDate.eventId",
+ "CalendarEvent.id",
+ )
+ .select((eb) => [
+ "CalendarEvent.name",
+ "CalendarEvent.organizationId",
+ eb.fn.min("CalendarEventDate.startsAt").as("startsAt"),
+ eb
+ .selectFrom("TournamentTeam")
+ .select(({ fn }) => fn.countAll().as("count"))
+ .whereRef(
+ "TournamentTeam.tournamentId",
+ "=",
+ "CalendarEvent.tournamentId",
+ )
+ .where("TournamentTeam.isPlaceholder", "=", 0)
+ .as("teamCount"),
+ ])
+ .$narrowType<{ organizationId: NotNull; teamCount: NotNull }>()
+ .where("CalendarEvent.organizationId", "is not", null)
+ .where("CalendarEvent.tournamentId", "is not", null)
+ .where("CalendarEvent.hidden", "=", 0)
+ .where("CalendarEventDate.startsAt", ">=", startedAfter)
+ .where("CalendarEventDate.startsAt", "<=", databaseTimestampNow())
+ .groupBy("CalendarEvent.id")
+ .orderBy("startsAt", "asc")
+ .execute();
+}
+
export function findAllUnfinalizedEvents(organizationId: number) {
return db
.selectFrom("Tournament")
@@ -885,13 +940,6 @@ export function deleteById(organizationId: number) {
.execute();
}
-export function findAllSeriesWithTierHistory() {
- return db
- .selectFrom("TournamentOrganizationSeries")
- .select(["organizationId", "substringMatches", "tierHistory"])
- .execute();
-}
-
export async function updateSeriesTierHistory({
organizationId,
eventName,
diff --git a/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts b/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts
new file mode 100644
index 000000000..b1ffc1d84
--- /dev/null
+++ b/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts
@@ -0,0 +1,178 @@
+import { subDays } from "date-fns";
+import * as R from "remeda";
+import { beforeEach, describe, expect, test } from "vitest";
+import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
+import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
+import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
+import * as UserFactory from "~/db/seed/factories/UserFactory";
+import { cache } from "~/utils/cache.server";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import * as SeriesTeamCount from "./SeriesTeamCount.server";
+
+const users = UserFactory.pool();
+const authorId = () => users.id(1);
+
+const SERIES_NAME = "Swim or Sink";
+
+describe("SeriesTeamCount.lookup", () => {
+ beforeEach(async () => {
+ // the counts are cached for the process, but every test seeds its own
+ cache.clear();
+ await users.create(6);
+ });
+
+ test("raises the registered count to the median of the series' recent editions", async () => {
+ const organizationId = await createOrganization();
+ await createEdition({ organizationId, startedDaysAgo: 21, teamCount: 2 });
+ await createEdition({ organizationId, startedDaysAgo: 14, teamCount: 6 });
+ await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 4 });
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId,
+ name: `${SERIES_NAME} 4`,
+ teamCount: 1,
+ }),
+ ).toBe(4);
+ });
+
+ test("keeps the registered count when it is already above the series median", async () => {
+ const organizationId = await createOrganization();
+ await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 });
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId,
+ name: `${SERIES_NAME} 2`,
+ teamCount: 5,
+ }),
+ ).toBe(5);
+ });
+
+ test("counts only the latest editions of the series", async () => {
+ const organizationId = await createOrganization();
+ await createEdition({ organizationId, startedDaysAgo: 28, teamCount: 6 });
+ await createEdition({ organizationId, startedDaysAgo: 21, teamCount: 6 });
+ await createEdition({ organizationId, startedDaysAgo: 14, teamCount: 1 });
+ await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 1 });
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId,
+ name: `${SERIES_NAME} 5`,
+ teamCount: 0,
+ }),
+ ).toBe(1);
+ });
+
+ test("ignores editions that have not started yet", async () => {
+ const organizationId = await createOrganization();
+ await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 });
+ await createEdition({ organizationId, startedDaysAgo: -7, teamCount: 6 });
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId,
+ name: `${SERIES_NAME} 3`,
+ teamCount: 0,
+ }),
+ ).toBe(2);
+ });
+
+ test("ignores the organization's tournaments outside the series", async () => {
+ const organizationId = await createOrganization();
+ await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 });
+ await createEdition({
+ organizationId,
+ name: "One off invitational",
+ startedDaysAgo: 5,
+ teamCount: 6,
+ });
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId,
+ name: `${SERIES_NAME} 3`,
+ teamCount: 0,
+ }),
+ ).toBe(2);
+ });
+
+ test("returns the registered count for a tournament of no organization", async () => {
+ const organizationId = await createOrganization();
+ await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 6 });
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId: null,
+ name: `${SERIES_NAME} 2`,
+ teamCount: 1,
+ }),
+ ).toBe(1);
+ });
+
+ test("returns the registered count when the series has no edition yet", async () => {
+ const organizationId = await createOrganization();
+
+ const expectedTeamCount = await SeriesTeamCount.lookup();
+
+ expect(
+ expectedTeamCount({
+ organizationId,
+ name: `${SERIES_NAME} 1`,
+ teamCount: 1,
+ }),
+ ).toBe(1);
+ });
+});
+
+async function createOrganization() {
+ const organization = await TournamentOrganizationFactory.create(
+ { ownerId: authorId() },
+ {
+ series: [
+ { name: SERIES_NAME, description: null, showLeaderboard: false },
+ ],
+ },
+ );
+
+ return organization.id;
+}
+
+async function createEdition({
+ organizationId,
+ name = SERIES_NAME,
+ startedDaysAgo,
+ teamCount,
+}: {
+ organizationId: number;
+ name?: string;
+ startedDaysAgo: number;
+ teamCount: number;
+}) {
+ const tournament = await TournamentFactory.create({
+ authorId: authorId(),
+ name,
+ organizationId,
+ startTimes: [dateToDatabaseTimestamp(subDays(new Date(), startedDaysAgo))],
+ });
+
+ for (const idx of R.range(0, teamCount)) {
+ await TournamentTeamFactory.create({
+ tournamentId: tournament.id,
+ memberUserIds: [users.id(idx + 1)],
+ });
+ }
+}
diff --git a/app/features/tournament-organization/core/SeriesTeamCount.server.ts b/app/features/tournament-organization/core/SeriesTeamCount.server.ts
new file mode 100644
index 000000000..dccb26015
--- /dev/null
+++ b/app/features/tournament-organization/core/SeriesTeamCount.server.ts
@@ -0,0 +1,93 @@
+import { cachified } from "@epic-web/cachified";
+import { subDays } from "date-fns";
+import * as R from "remeda";
+import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
+
+const CACHE_KEY = "series-team-counts";
+/** How old an edition can be and still say something about the next one. */
+const LOOKBACK_DAYS = 90;
+/** How many of a series' latest editions the typical count is taken from. */
+const EDITIONS_CONSIDERED = 3;
+
+interface Tournament {
+ organizationId: number | null;
+ name: string;
+ /** Teams registered so far. */
+ teamCount: number;
+}
+
+interface SeriesTeamCounts {
+ substringMatches: Array;
+ teamCounts: Array;
+}
+
+/**
+ * Resolves the team count a tournament is *expected* to draw: its registered
+ * count raised to the median of the last {@link EDITIONS_CONSIDERED} editions of
+ * its series, never lowered.
+ */
+export async function lookup() {
+ const seriesByOrganizationId = await cachedSeriesTeamCounts();
+
+ return (tournament: Tournament) => {
+ if (!tournament.organizationId) return tournament.teamCount;
+
+ const series = seriesByOrganizationId.get(tournament.organizationId);
+ if (!series) return tournament.teamCount;
+
+ const nameLower = tournament.name.toLowerCase();
+ const match = series.find((candidate) =>
+ candidate.substringMatches.some((substring) =>
+ nameLower.includes(substring.toLowerCase()),
+ ),
+ );
+ if (!match) return tournament.teamCount;
+
+ return Math.max(
+ tournament.teamCount,
+ R.median(match.teamCounts) ?? tournament.teamCount,
+ );
+ };
+}
+
+function cachedSeriesTeamCounts() {
+ return cachified({
+ key: CACHE_KEY,
+ cache,
+ ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
+ getFreshValue: seriesTeamCounts,
+ });
+}
+
+async function seriesTeamCounts() {
+ const [series, tournaments] = await Promise.all([
+ TournamentOrganizationRepository.findAllSeries(),
+ TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts({
+ startedAfter: dateToDatabaseTimestamp(subDays(new Date(), LOOKBACK_DAYS)),
+ }),
+ ]);
+
+ const result = new Map>();
+ for (const row of series) {
+ const teamCounts = tournaments
+ .filter(
+ (tournament) =>
+ tournament.organizationId === row.organizationId &&
+ row.substringMatches.some((substring) =>
+ tournament.name.toLowerCase().includes(substring.toLowerCase()),
+ ),
+ )
+ .slice(-EDITIONS_CONSIDERED)
+ .map((tournament) => tournament.teamCount);
+
+ if (teamCounts.length === 0) continue;
+
+ const existing = result.get(row.organizationId) ?? [];
+ existing.push({ substringMatches: row.substringMatches, teamCounts });
+ result.set(row.organizationId, existing);
+ }
+
+ return result;
+}
diff --git a/app/features/tournament-organization/core/tentativeTiers.server.ts b/app/features/tournament-organization/core/tentativeTiers.server.ts
index 8bb0c7fe7..423118808 100644
--- a/app/features/tournament-organization/core/tentativeTiers.server.ts
+++ b/app/features/tournament-organization/core/tentativeTiers.server.ts
@@ -8,8 +8,7 @@ interface SeriesMatch {
}
async function loadCache(): Promise
{startTimes.map((date) => (
-
+
+
+ {estimatedEndsAt ? (
+
+ ~
+
+
+ ) : null}
+
))}
diff --git a/app/features/tournament/loaders/to.$id.info.server.ts b/app/features/tournament/loaders/to.$id.info.server.ts
index b224d820d..fc69a5f92 100644
--- a/app/features/tournament/loaders/to.$id.info.server.ts
+++ b/app/features/tournament/loaders/to.$id.info.server.ts
@@ -1,18 +1,30 @@
import type { LoaderFunctionArgs } from "react-router";
+import { estimatedEndsAt } from "~/features/availability/core/TournamentDuration.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import { logger } from "~/utils/logger";
export const loader = async ({ params }: LoaderFunctionArgs) => {
- const { tournamentId, user } = await tournamentFromParams(params, {
- for: "view",
- });
+ const { tournament, tournamentId, user } = await tournamentFromParams(
+ params,
+ {
+ for: "view",
+ },
+ );
- const description =
- await TournamentRepository.findDescriptionById(tournamentId);
+ const [description, endsAt] = await Promise.all([
+ TournamentRepository.findDescriptionById(tournamentId),
+ estimatedEnd(tournament)?.catch((error) => {
+ logger.error("Failed to estimate the tournament's end", error);
+ return null;
+ }) ?? null,
+ ]);
if (!user) {
- return { isSaved: false, description };
+ return { isSaved: false, description, endsAt };
}
return {
@@ -21,5 +33,26 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
}),
description,
+ endsAt,
};
};
+
+function estimatedEnd(tournament: Tournament) {
+ if (tournament.isLeague) return null;
+ if (tournament.ctx.startsAt <= new Date()) return null;
+ const isMultiSession = tournament.ctx.settings.bracketProgression.some(
+ (bracket) => bracket.startTime,
+ );
+ if (isMultiSession) return null;
+
+ return estimatedEndsAt({
+ name: tournament.ctx.name,
+ organizationId: tournament.ctx.organization?.id ?? null,
+ startsAt: dateToDatabaseTimestamp(tournament.ctx.startsAt),
+ minMembersPerTeam: tournament.minMembersPerTeam,
+ bracketTypes: tournament.ctx.settings.bracketProgression.map(
+ (bracket) => bracket.type,
+ ),
+ teamCount: tournament.ctx.teams.length,
+ });
+}
diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts
index 805f3b865..c843b0201 100644
--- a/app/features/tournament/loaders/to.$id.register.server.ts
+++ b/app/features/tournament/loaders/to.$id.register.server.ts
@@ -1,11 +1,17 @@
import type { LoaderFunctionArgs } from "react-router";
+import * as R from "remeda";
+import * as RegistrationAvailability from "~/features/availability/core/RegistrationAvailability.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
+import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
+import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
tournamentFromParams,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import { logger } from "~/utils/logger";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { tournament, tournamentId, user } = await tournamentFromParams(
@@ -15,13 +21,28 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
if (!user) return null;
const teamMemberOf = tournament.teamMemberOfByUser(user);
+ const friendPlayers = await SQGroupRepository.findFriendsAndTeammates(
+ user.id,
+ );
+ const [availability, teams] = await Promise.all([
+ rosterAvailability({
+ tournament,
+ userId: user.id,
+ friendIds: friendPlayers.friends.map((friend) => friend.id),
+ })?.catch((error) => {
+ logger.error("Failed to resolve registration availability", error);
+ return null;
+ }) ?? null,
+ TeamRepository.findAllMemberOfByUserId(user.id),
+ ]);
if (!teamMemberOf) {
return {
ownTeam: null,
mapPool: null,
- friendPlayers: null,
- teams: await TeamRepository.findAllMemberOfByUserId(user.id),
+ friendPlayers,
+ availability,
+ teams,
isSaved: await SavedCalendarEventRepository.isSaved({
userId: user.id,
tournamentId,
@@ -37,10 +58,42 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return {
ownTeam,
mapPool: ownTeam?.mapPool ?? null,
- friendPlayers: await SQGroupRepository.findFriendsAndTeammates(user.id),
- teams: await TeamRepository.findAllMemberOfByUserId(user.id),
+ friendPlayers,
+ availability,
+ teams,
isSaved: false,
};
};
+function rosterAvailability({
+ tournament,
+ userId,
+ friendIds,
+}: {
+ tournament: Tournament;
+ userId: number;
+ friendIds: Array;
+}) {
+ if (tournament.isLeague) return null;
+
+ const startsAt = dateToDatabaseTimestamp(tournament.ctx.startsAt);
+ if (tournament.ctx.startsAt <= new Date()) return null;
+
+ return RegistrationAvailability.registrationAvailability({
+ tournament: {
+ id: tournament.ctx.id,
+ name: tournament.ctx.name,
+ organizationId: tournament.ctx.organization?.id ?? null,
+ startsAt,
+ minMembersPerTeam: tournament.minMembersPerTeam,
+ bracketTypes: tournament.ctx.settings.bracketProgression.map(
+ (bracket) => bracket.type,
+ ),
+ teamCount: tournament.ctx.teams.length,
+ },
+ userIds: R.unique([userId, ...friendIds]),
+ timezone: getViewerTimezone() ?? "UTC",
+ });
+}
+
export type TournamentRegisterPageLoader = typeof loader;
diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx
index f76220154..0e604744e 100644
--- a/app/features/tournament/routes/to.$id.info.tsx
+++ b/app/features/tournament/routes/to.$id.info.tsx
@@ -58,7 +58,7 @@ export default function TournamentInfoPage() {
return (
-
+
li {
+ padding-block: var(--s-2);
+
+ & + li {
+ border-top: 1px solid var(--color-border);
+ }
+ }
+}
+
+.emptySlotRow {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1-5);
+ font-size: var(--font-xs);
+ font-weight: var(--weight-semi);
color: var(--color-text-accent);
+}
+
+.emptySlotRowOptional {
+ color: var(--color-text-high);
+}
+
+.emptySlotCircle {
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
display: grid;
place-items: center;
- margin: 0 auto;
+ border-radius: var(--radius-full);
+ border: var(--border-style-accent);
+ color: var(--color-text-accent);
}
-.missingPlayerOptional {
- border: 2px dashed var(--color-text-accent);
- color: var(--color-text-accent);
+.emptySlotCircleOptional {
+ border: var(--border-width) dashed var(--color-text-accent);
+}
+
+.addMembers {
+ border-top: 1px solid var(--color-border);
+ padding-top: var(--s-3);
+}
+
+.addMembersHeading {
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.quickAddRow {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--s-2);
+}
+
+.quickAddSelect {
+ flex: 1;
+ min-width: 0;
+}
+
+.quickAddAllRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-1-5);
+}
+
+.quickAddItem {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-0-5);
+ min-width: 0;
+}
+
+.quickAddItemAvailability {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-1-5);
+ font-size: var(--font-xs);
+ font-weight: var(--weight-body);
}
@container (width >= 640px) {
.section {
margin: 0;
border-radius: var(--radius-box);
+ padding: var(--s-6) var(--s-5);
}
}
diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx
index 90a50156e..4051391f5 100644
--- a/app/features/tournament/routes/to.$id.register.tsx
+++ b/app/features/tournament/routes/to.$id.register.tsx
@@ -1,21 +1,43 @@
import clsx from "clsx";
-import { AlertCircle, Check, Clipboard, X } from "lucide-react";
+import { AlertCircle, Check, UserRound, UsersRound, X } from "lucide-react";
import * as React from "react";
+import { Text } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
+import * as R from "remeda";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
-import { Avatar } from "~/components/Avatar";
-import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
+import {
+ SendouSelect,
+ SendouSelectItem,
+ SendouSelectItemSection,
+} from "~/components/elements/Select";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { FriendCodePopover } from "~/components/FriendCodePopover";
-import { Label } from "~/components/Label";
+import { InviteLinkInput } from "~/components/InviteLinkInput";
import { containerClassName } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
+import {
+ AvailabilityMemberRow,
+ type AvailabilityPanelEntry,
+ AvailabilityRowDetail,
+ type AvailabilityRowStatus,
+ AvailabilityStatusDots,
+ AvailabilitySummary,
+ AvailabilityWindowText,
+ availabilityRowStatus,
+ RegistrationAvailabilityPanel,
+} from "~/features/availability/components/RegistrationAvailabilityPanel";
+import type {
+ MemberRole,
+ MemberRoleType,
+} from "~/features/team/team-constants";
+import { getMemberRoleType } from "~/features/team/team-utils";
+import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
import {
type CounterPickMapPool,
CounterPickMapPoolPicker,
@@ -30,8 +52,8 @@ import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
-import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHydrated } from "~/hooks/useHydrated";
+import type { SendouRouteHandle } from "~/utils/remix.server";
import {
LOG_IN_URL,
SENDOU_INK_BASE_URL,
@@ -46,14 +68,38 @@ import {
} from "../tournament-register-schemas";
import {
addPlayerSchema,
+ addTeamPlayersSchema,
checkInSchema,
- deleteTeamMemberSchema,
updateMapPoolSchema,
} from "../tournament-schemas";
+import type { Route } from "./+types/to.$id.register";
import styles from "./to.$id.register.module.css";
export { action, loader };
+const QUICK_ADD_STATUS_ORDER: Record = {
+ available: 0,
+ partial: 1,
+ unknown: 2,
+ hidden: 3,
+ busy: 4,
+ unavailable: 5,
+};
+
+interface QuickAddPlayer {
+ id: number;
+ username: string;
+ teamId: number | null;
+ role: MemberRole | null;
+ roleType: MemberRoleType | null;
+}
+
+export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware];
+
+export const handle: SendouRouteHandle = {
+ i18n: ["schedule"],
+};
+
export default function TournamentRegisterPage() {
const user = useUser();
const tournament = useTournament();
@@ -531,7 +577,7 @@ function TeamInfo({
-
-
-
-
-
-
+
+
>
);
@@ -564,30 +606,42 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
const isLinked = Boolean(values.teamId);
- const teamOptions = (data?.teams ?? []).map((team) => ({
- value: String(team.id),
- label: team.name,
- }));
+ const entryByUserId = availabilityEntryByUserId(data);
+
+ const teamOptions = (data?.teams ?? []).map((team) => {
+ const statuses = (
+ entryByUserId
+ ? teamMemberStatuses({ data, teamId: team.id, entryByUserId })
+ : []
+ ).filter((status) => status === "available" || status === "partial");
+
+ return {
+ value: String(team.id),
+ label: team.name,
+ description:
+ statuses.length > 0 ? (
+
+
+
+
+ ) : undefined,
+ };
+ });
const showTeamSelect = teamOptions.length > 0 && tournament.registrationOpen;
return (
<>
{showTeamSelect ? (
-
-
-
+
) : null}
+ {!data?.ownTeam ? : null}
{!isLinked ? (
<>
-
-
-
-
-
-
+
+
>
) : null}
@@ -653,8 +707,11 @@ function FillRoster({
}) {
const data = useLoaderData();
const tournament = useTournament();
- const { copyToClipboard, copySuccess } = useCopyToClipboard();
- const { t } = useTranslation(["common", "tournament"]);
+ const { t } = useTranslation(["common", "tournament", "schedule"]);
+ const { formatter: dateFormatter } = useDateTimeFormat({
+ month: "long",
+ day: "numeric",
+ });
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
tournamentId: tournament.ctx.id,
@@ -673,14 +730,14 @@ function FillRoster({
0,
);
- const showDeleteMemberSection =
+ const canRemoveMembers =
!readOnly &&
!tournament.isInvitational &&
((!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
(ownTeamCheckedIn &&
ownTeamMembers.length > tournament.minMembersPerTeam));
- const playersAvailableToDirectlyAdd = (() => {
+ const quickAddPlayers = (() => {
if (readOnly) return [];
return (data?.friendPlayers?.friends ?? []).filter((user) => {
const isNotInTeam = tournament.ctx.teams.every(
@@ -697,89 +754,107 @@ function FillRoster({
const teamIsFull = ownTeamMembers.length >= tournament.maxMembersPerTeam;
const canAddMembers = !teamIsFull && tournament.registrationOpen && !readOnly;
+ const availability = data?.availability;
+ const entryByUserId = availabilityEntryByUserId(data);
+ const requireInGameNames = tournament.ctx.settings.requireInGameNames;
+
return (
-
- 2. {t("tournament:pre.roster.header")}
-
-
- {playersAvailableToDirectlyAdd.length > 0 && canAddMembers ? (
- <>
-
- {t("common:or")}
- >
+
+
+ 2. {t("tournament:pre.roster.header")}
+
+ {availability?.window ? (
+
) : null}
- {canAddMembers ? (
-
-
- {t("tournament:actions.shareLink", { inviteLink })}
-
-
- : }
- onPress={() => copyToClipboard(inviteLink)}
- variant="outlined"
- >
- {t("common:actions.copyToClipboard")}
-
-
+
+
+ {availability?.beyondHorizon ? (
+
+ {t("schedule:registration.beyondHorizon", {
+ date: dateFormatter.format(availability.beyondHorizon.opensAt),
+ })}
) : null}
-
- {ownTeamMembers.map((member, i) => {
- return (
-
-
- {tournament.ctx.settings.requireInGameNames ? (
-
-
- {member.inGameName ?? member.username}
-
- {member.inGameName ? (
-
- {member.username}
-
- ) : null}
-
- ) : (
-
- {member.username}
-
- )}
-
- );
- })}
- {new Array(missingMembers).fill(null).map((_, i) => {
- return (
-
- ?
-
- );
- })}
- {new Array(optionalMembers).fill(null).map((_, i) => {
- return (
-
+ {ownTeamMembers.map((member, i) => (
+
+ ) : null
+ }
+ />
+ ))}
+ {Array.from({ length: missingMembers }).map((_, i) => (
+
+
+
+
+ {t("tournament:pre.roster.emptySlot")}
+
+ ))}
+ {Array.from({ length: optionalMembers }).map((_, i) => (
+
+
- ?
-
- );
- })}
-
- {showDeleteMemberSection ? (
-
+
+
+ {t("tournament:pre.roster.emptySlot.optional")}
+
+ ))}
+
+ {entryByUserId ? (
+
+ availabilityRowStatus(entryByUserId.get(member.userId)),
+ )}
+ />
+ ) : null}
+ {canAddMembers ? (
+
+
+ {t("tournament:pre.roster.addMembers")}
+
+ {quickAddPlayers.length > 0 ? (
+ player.id).join(",")}
+ players={quickAddPlayers}
+ teams={data?.friendPlayers?.teams ?? []}
+ spotsLeft={tournament.maxMembersPerTeam - ownTeamMembers.length}
+ entryByUserId={entryByUserId}
+ />
+ ) : null}
+
+
) : null}
{tournament.ctx.settings.requireInGameNames ? (
@@ -802,111 +877,207 @@ function FillRoster({
);
}
-function DirectlyAddPlayerSelect({
+function QuickAddPlayers({
players,
teams,
+ spotsLeft,
+ entryByUserId,
}: {
- players: { id: number; username: string; teamId?: number }[];
- teams: { id: number; name: string }[];
+ players: Array
;
+ teams: Array<{ id: number; name: string }>;
+ spotsLeft: number;
+ entryByUserId: Map | null;
}) {
const { t } = useTranslation(["tournament", "common"]);
const fetcher = useFetcher();
- const id = React.useId();
- const othersOptions = players
- .filter((player) => !player.teamId)
- .map((player) => {
- return (
-
- );
- });
+ const sortByAvailability = (toSort: Array) =>
+ entryByUserId
+ ? R.sortBy(
+ toSort,
+ (player) =>
+ QUICK_ADD_STATUS_ORDER[
+ availabilityRowStatus(entryByUserId.get(player.id))
+ ],
+ )
+ : toSort;
+
+ const uniquePlayers = R.uniqueBy(players, (player) => player.id);
+
+ const teamGroups = teams
+ .map((team) => ({
+ team,
+ players: sortByAvailability(
+ uniquePlayers.filter((player) => player.teamId === team.id),
+ ),
+ }))
+ .filter((group) => group.players.length > 0);
+
+ const pickupPlayers = sortByAvailability(
+ uniquePlayers.filter((player) => !player.teamId),
+ );
+
+ const sections = [
+ ...teamGroups.map((group) => ({
+ key: `team-${group.team.id}`,
+ heading: group.team.name,
+ players: group.players,
+ })),
+ ...(pickupPlayers.length > 0
+ ? [
+ {
+ key: "pickup",
+ heading: t("tournament:pre.roster.quickAdd.pickup"),
+ players: pickupPlayers,
+ },
+ ]
+ : []),
+ ];
+
+ const [selectedUserId, setSelectedUserId] = React.useState(
+ sections[0]?.players[0]?.id ?? null,
+ );
+
+ const addAllByTeam = teams
+ .map((team) => ({
+ team,
+ // in the loader's order so the list matches what the action adds when clamped
+ playersToAdd: players
+ .filter(
+ (player) =>
+ player.teamId === team.id && getMemberRoleType(player) !== "OTHER",
+ )
+ .slice(0, spotsLeft),
+ }))
+ .filter((entry) => entry.playersToAdd.length > 0);
+
+ const renderPlayerItem = (player: QuickAddPlayer) => (
+
+ {entryByUserId ? (
+
+ {player.username}
+
+
+
+
+
+
+
+ ) : (
+ player.username
+ )}
+
+ );
return (
-
-
-
-
-
-
- {t("common:actions.add")}
-
-
+
+
+
+ setSelectedUserId(key as number | null)}
+ estimatedRowHeight={entryByUserId ? 52 : undefined}
+ className={styles.quickAddSelect}
+ data-testid="quick-add-select"
+ >
+ {(section) => (
+
+ {section.players.map(renderPlayerItem)}
+
+ )}
+
+ {selectedUserId ? (
+
+ ) : null}
+
+ {t("common:actions.add")}
+
+
+
+ {addAllByTeam.length > 0 ? (
+
+ {addAllByTeam.map(({ team, playersToAdd }) => (
+
}
+ testId={`add-team-players-button-${team.id}`}
+ confirm={{
+ dialogHeading: t(
+ "tournament:pre.roster.quickAdd.addAll.confirm",
+ { team: team.name },
+ ),
+ description: playersToAdd
+ .map((player) => player.username)
+ .join(", "),
+ submitButtonText: t("common:actions.add"),
+ submitButtonVariant: "primary",
+ }}
+ >
+ {t("tournament:pre.roster.quickAdd.addAll", {
+ team: team.name,
+ })}
+
+ ))}
+
+ ) : null}
+
);
}
-function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) {
+function RemoveMemberButton({
+ member,
+}: {
+ member: TournamentTeamFull["members"][number];
+}) {
const { t } = useTranslation(["tournament", "common"]);
- const id = React.useId();
- const fetcher = useFetcher();
- const [expanded, setExpanded] = React.useState(false);
- if (!expanded) {
- return (
+ return (
+
setExpanded(true)}
- >
- {t("tournament:pre.roster.delete.button")}
-
- );
- }
-
- return (
-
-
-
-
-
- {t("common:actions.delete")}
-
-
-
+ icon={}
+ aria-label={t("common:actions.remove")}
+ testId={`remove-member-${member.userId}`}
+ />
+
);
}
@@ -961,3 +1132,96 @@ function TeamCounterPickMapPoolPicker({
);
}
+
+function SelectedTeamAvailability() {
+ const data = useLoaderData();
+ const tournament = useTournament();
+ const { values } = useFormFieldContext();
+
+ const availability = data?.availability;
+ if (!availability) return null;
+
+ const teamId = values.teamId ? Number(values.teamId) : null;
+
+ const inTournament = (userId: number) =>
+ tournament.ctx.teams.some((team) => team.memberUserIds.includes(userId));
+
+ const entryByUserId = availabilityEntryByUserId(data);
+ const isFree = (userId: number) => {
+ const status = availabilityRowStatus(entryByUserId?.get(userId));
+ return status === "available" || status === "partial";
+ };
+
+ // with a team selected the panel shows its full roster, every status
+ // included; signing up as a pickup it instead lists everyone the viewer
+ // could recruit (all their teams' members and friends) in one list, kept
+ // to those actually free during the event
+ const roster = teamId
+ ? (data?.friendPlayers?.friends ?? []).filter(
+ (friend) => friend.teamId === teamId,
+ )
+ : R.uniqueBy(
+ data?.friendPlayers?.friends ?? [],
+ (friend) => friend.id,
+ ).filter((friend) => !inTournament(friend.id) && isFree(friend.id));
+ if (roster.length === 0 && !availability.beyondHorizon) return null;
+
+ return (
+ rosterUser.id),
+ })
+ : []
+ }
+ />
+ );
+}
+
+function availabilityEntryByUserId(
+ data: ReturnType>,
+) {
+ const availability = data?.availability;
+ if (!availability || availability.beyondHorizon) return null;
+
+ return new Map(availability.entries.map((entry) => [entry.userId, entry]));
+}
+
+function teamMemberStatuses({
+ data,
+ teamId,
+ entryByUserId,
+}: {
+ data: ReturnType>;
+ teamId: number;
+ entryByUserId: Map;
+}): Array {
+ return (data?.friendPlayers?.friends ?? [])
+ .filter((friend) => friend.teamId === teamId)
+ .map((friend) => availabilityRowStatus(entryByUserId.get(friend.id)));
+}
+
+function subCandidates({
+ data,
+ tournament,
+ rosterUserIds,
+}: {
+ data: ReturnType>;
+ tournament: ReturnType;
+ rosterUserIds: number[];
+}) {
+ const inTournament = (userId: number) =>
+ tournament.ctx.teams.some((team) => team.memberUserIds.includes(userId));
+
+ return R.uniqueBy(
+ data?.friendPlayers?.friends ?? [],
+ (friend) => friend.id,
+ ).filter(
+ (friend) => !rosterUserIds.includes(friend.id) && !inTournament(friend.id),
+ );
+}
diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts
index 37fe4060c..05cc7f81e 100644
--- a/app/features/tournament/tournament-schemas.server.ts
+++ b/app/features/tournament/tournament-schemas.server.ts
@@ -4,6 +4,7 @@ import { _action } from "~/utils/schema";
import { registerTeamFormSchemaServer } from "./tournament-register-schemas.server";
import {
addPlayerSchema,
+ addTeamPlayersSchema,
checkInSchema,
deleteTeamMemberSchema,
updateMapPoolSchema,
@@ -25,6 +26,7 @@ export function registerSchema({
}),
checkInSchema,
addPlayerSchema,
+ addTeamPlayersSchema,
v.object({
_action: _action("UNREGISTER"),
}),
diff --git a/app/features/tournament/tournament-schemas.ts b/app/features/tournament/tournament-schemas.ts
index 11ea658bc..5f608842c 100644
--- a/app/features/tournament/tournament-schemas.ts
+++ b/app/features/tournament/tournament-schemas.ts
@@ -25,6 +25,11 @@ export const addPlayerSchema = v.object({
userId: id,
});
+export const addTeamPlayersSchema = v.object({
+ _action: _action("ADD_TEAM_PLAYERS"),
+ teamId: id,
+});
+
export const deleteTeamMemberSchema = v.object({
_action: _action("DELETE_TEAM_MEMBER"),
userId: id,
diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts
index 6c6d5a2c6..df745fad2 100644
--- a/app/features/tournament/tournament-utils.server.ts
+++ b/app/features/tournament/tournament-utils.server.ts
@@ -15,19 +15,27 @@ export async function requireNotBannedByOrganization({
user: { id: number };
message?: string;
}) {
- if (!tournament.ctx.organization) return;
-
- const isBanned =
- await TournamentOrganizationRepository.isUserBannedByOrganization({
- organizationId: tournament.ctx.organization.id,
- userId: user.id,
- });
-
- if (isBanned) {
+ if (await isBannedByOrganization({ tournament, userId: user.id })) {
errorToast(message);
}
}
+/** Whether the user is banned by the organization hosting the tournament (`false` if the tournament has no organization). */
+export async function isBannedByOrganization({
+ tournament,
+ userId,
+}: {
+ tournament: Tournament;
+ userId: number;
+}) {
+ if (!tournament.ctx.organization) return false;
+
+ return TournamentOrganizationRepository.isUserBannedByOrganization({
+ organizationId: tournament.ctx.organization.id,
+ userId,
+ });
+}
+
/**
* Whether the given team name is already used by another team in the tournament.
* Single source of truth for the uniqueness rule shared by the player registration
@@ -57,17 +65,25 @@ export async function requireSendouQParticipationIfNeeded({
tournament: Tournament;
userId: number;
}) {
- if (!tournament.ctx.settings.requireSendouQParticipation) return;
-
- const hasEnough =
- await LeaderboardRepository.hasEnoughSqMatchesByUserId(userId);
-
errorToastIfFalsy(
- hasEnough,
+ await fulfillsSendouQParticipation({ tournament, userId }),
`Must have played ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD} SendouQ matches this season to join`,
);
}
+/** Whether the user fulfills the tournament's SendouQ participation requirement (`true` if the tournament has none). */
+export async function fulfillsSendouQParticipation({
+ tournament,
+ userId,
+}: {
+ tournament: Tournament;
+ userId: number;
+}) {
+ if (!tournament.ctx.settings.requireSendouQParticipation) return true;
+
+ return LeaderboardRepository.hasEnoughSqMatchesByUserId(userId);
+}
+
/**
* Ends all unfinished matches involving dropped teams by awarding wins to their opponents.
* If both teams in a match have dropped, a random winner is selected. Pure over the given
diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx
index f72b7c1eb..437b96148 100644
--- a/app/form/FormField.tsx
+++ b/app/form/FormField.tsx
@@ -289,6 +289,7 @@ export function FormField({
items={selectOptions.map((opt) => ({
value: opt.value,
label: opt.label,
+ description: opt.description,
}))}
value={value as string | null}
onChange={handleChange as (v: string | null) => void}
diff --git a/app/form/UnsavedChangesGuard.tsx b/app/form/UnsavedChangesGuard.tsx
index b6583b8ad..d923a95b3 100644
--- a/app/form/UnsavedChangesGuard.tsx
+++ b/app/form/UnsavedChangesGuard.tsx
@@ -1,10 +1,21 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
-import { useBlocker } from "react-router";
+import { type Location, useBlocker } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
-const dirtyCheckers = new Set<() => boolean>();
+/**
+ * Reports whether the registering component has unsaved changes. For an in-app
+ * navigation the blocked locations are passed, so a checker whose state
+ * survives same-route navigations can ignore those; a full page unload passes
+ * nothing and every dirty checker should warn.
+ */
+type UnsavedChangesChecker = (navigation?: {
+ currentLocation: Location;
+ nextLocation: Location;
+}) => boolean;
+
+const dirtyCheckers = new Set();
/**
* Confirms navigating away when any mounted form has unsaved changes.
@@ -19,7 +30,7 @@ export function UnsavedChangesGuard() {
({ currentLocation, nextLocation }) =>
(currentLocation.pathname !== nextLocation.pathname ||
currentLocation.search !== nextLocation.search) &&
- hasUnsavedChanges(),
+ hasUnsavedChanges({ currentLocation, nextLocation }),
);
React.useEffect(() => {
@@ -65,10 +76,11 @@ export function UnsavedChangesGuard() {
* form state without re-registering on every render.
*/
export function useUnsavedChangesChecker(
- checkerRef: React.RefObject<() => boolean>,
+ checkerRef: React.RefObject,
) {
React.useEffect(() => {
- const checker = () => checkerRef.current();
+ const checker: UnsavedChangesChecker = (navigation) =>
+ checkerRef.current(navigation);
dirtyCheckers.add(checker);
return () => {
dirtyCheckers.delete(checker);
@@ -76,9 +88,9 @@ export function useUnsavedChangesChecker(
}, [checkerRef]);
}
-function hasUnsavedChanges() {
+function hasUnsavedChanges(navigation?: Parameters[0]) {
for (const checker of dirtyCheckers) {
- if (checker()) return true;
+ if (checker(navigation)) return true;
}
return false;
}
diff --git a/app/form/fields/SelectFormField.module.css b/app/form/fields/SelectFormField.module.css
index 1cde6010b..12e39f777 100644
--- a/app/form/fields/SelectFormField.module.css
+++ b/app/form/fields/SelectFormField.module.css
@@ -1,3 +1,18 @@
.searchable {
--select-width: 100%;
}
+
+.twoLineItem {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-0-5);
+ min-width: 0;
+}
+
+.itemDescription {
+ font-size: var(--font-xs);
+ font-weight: var(--weight-body);
+ color: var(--color-text-high);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/app/form/fields/SelectFormField.tsx b/app/form/fields/SelectFormField.tsx
index 035c2815a..1ce504c65 100644
--- a/app/form/fields/SelectFormField.tsx
+++ b/app/form/fields/SelectFormField.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { Text } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import type { FormFieldItems, FormFieldProps } from "../types";
@@ -10,6 +11,8 @@ import {
} from "./FormFieldWrapper";
import styles from "./SelectFormField.module.css";
+const TWO_LINE_ROW_HEIGHT = 52;
+
type SelectFormFieldProps = Omit<
FormFieldProps<"select">,
"items" | "clearable" | "onBlur" | "name" | "searchable"
@@ -56,12 +59,17 @@ export function SelectFormField({
return {
value: item.value,
resolvedLabel,
+ description: item.description,
};
});
- if (searchable) {
+ const hasDescriptions = itemsWithResolvedLabels.some(
+ (item) => item.description,
+ );
+
+ if (searchable || hasDescriptions) {
return (
- ({
onBlur={onBlur}
clearable={clearable}
disabled={disabled}
- searchPlaceholder={t("common:actions.search")}
+ searchPlaceholder={searchable ? t("common:actions.search") : undefined}
/>
);
}
@@ -113,7 +121,7 @@ export function SelectFormField({
);
}
-function SearchableSelect({
+function CustomSelect({
name,
label,
bottomText,
@@ -130,39 +138,68 @@ function SearchableSelect({
label?: string;
bottomText?: string;
error?: string;
- items: Array<{ value: V; resolvedLabel: string }>;
+ items: Array<{
+ value: V;
+ resolvedLabel: string;
+ description?: React.ReactNode;
+ }>;
value: V | null;
onChange: (value: V | null) => void;
onBlur?: () => void;
clearable?: boolean;
disabled?: boolean;
- searchPlaceholder: string;
+ searchPlaceholder?: string;
}) {
const { translatedLabel } = useTranslatedTexts({ label });
- const selectItems = items.map((item) => ({
- id: item.value,
- textValue: item.resolvedLabel,
- }));
+ const hasDescriptions = items.some((item) => item.description);
+
+ // the Autocomplete wrapper of searchable selects drops falsy keys, so only
+ // plain selects render the clear choice as a list item like the native
+ // select's "—" option; searchable ones keep the clear button
+ const hasEmptyItem = Boolean(clearable && !searchPlaceholder);
+
+ const selectItems = [
+ ...(hasEmptyItem
+ ? [{ id: "", textValue: "—", description: undefined }]
+ : []),
+ ...items.map((item) => ({
+ id: item.value as string,
+ textValue: item.resolvedLabel,
+ description: item.description,
+ })),
+ ];
return (
{
const newValue = key === "" ? null : (key as V);
onChange(newValue);
onBlur?.();
}}
items={selectItems}
- search={{ placeholder: searchPlaceholder }}
- clearable={clearable}
+ search={
+ searchPlaceholder ? { placeholder: searchPlaceholder } : undefined
+ }
+ clearable={clearable && !hasEmptyItem}
isDisabled={disabled}
+ estimatedRowHeight={hasDescriptions ? TWO_LINE_ROW_HEIGHT : undefined}
>
{(item) => (
- {item.textValue}
+ {item.description ? (
+
+ {item.textValue}
+
+ {item.description}
+
+
+ ) : (
+ item.textValue
+ )}
)}
diff --git a/app/form/types.ts b/app/form/types.ts
index de732c9fa..e4e01eef8 100644
--- a/app/form/types.ts
+++ b/app/form/types.ts
@@ -1,3 +1,4 @@
+import type * as React from "react";
import type * as v from "valibot";
import type { TeamSearchResult } from "~/components/elements/TeamSearch";
import type { TournamentSearchItem } from "~/components/elements/TournamentSearch";
@@ -59,6 +60,8 @@ interface FormFieldInGameName
extends FormFieldBase {
interface FormFieldItem {
label: string | number | ((lang: string) => string);
value: V;
+ /** Second line rendered under the label in the dropdown. Any item having one switches the field to the custom select. */
+ description?: React.ReactNode;
}
interface FormFieldItemWithImage extends FormFieldItem {
@@ -251,6 +254,8 @@ export type TrophyOption = {
export type SelectOption = {
value: string;
label: string;
+ /** Second line rendered under the label in the dropdown. Any option having one switches the field to the custom select. */
+ description?: React.ReactNode;
};
/** Brand type to encode required options directly in schema types */
diff --git a/app/modules/i18n/resources.browser.ts b/app/modules/i18n/resources.browser.ts
index a62c5f313..67165ace4 100644
--- a/app/modules/i18n/resources.browser.ts
+++ b/app/modules/i18n/resources.browser.ts
@@ -15,6 +15,7 @@ import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import params from "../../../locales/en/params.json";
import q from "../../../locales/en/q.json";
+import schedule from "../../../locales/en/schedule.json";
import scrims from "../../../locales/en/scrims.json";
import settings from "../../../locales/en/settings.json";
import team from "../../../locales/en/team.json";
@@ -44,6 +45,7 @@ export const resources = {
org,
params,
q,
+ schedule,
scrims,
settings,
team,
diff --git a/app/modules/i18n/resources.server.ts b/app/modules/i18n/resources.server.ts
index faf9546f5..ee6a938c1 100644
--- a/app/modules/i18n/resources.server.ts
+++ b/app/modules/i18n/resources.server.ts
@@ -16,6 +16,7 @@ import lfgDa from "../../../locales/da/lfg.json";
import orgDa from "../../../locales/da/org.json";
import paramsDa from "../../../locales/da/params.json";
import qDa from "../../../locales/da/q.json";
+import scheduleDa from "../../../locales/da/schedule.json";
import scrimsDa from "../../../locales/da/scrims.json";
import settingsDa from "../../../locales/da/settings.json";
import teamDa from "../../../locales/da/team.json";
@@ -44,6 +45,7 @@ import lfgDe from "../../../locales/de/lfg.json";
import orgDe from "../../../locales/de/org.json";
import paramsDe from "../../../locales/de/params.json";
import qDe from "../../../locales/de/q.json";
+import scheduleDe from "../../../locales/de/schedule.json";
import scrimsDe from "../../../locales/de/scrims.json";
import settingsDe from "../../../locales/de/settings.json";
import teamDe from "../../../locales/de/team.json";
@@ -72,6 +74,7 @@ import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import params from "../../../locales/en/params.json";
import q from "../../../locales/en/q.json";
+import scheduleEn from "../../../locales/en/schedule.json";
import scrimsEn from "../../../locales/en/scrims.json";
import settings from "../../../locales/en/settings.json";
import team from "../../../locales/en/team.json";
@@ -100,6 +103,7 @@ import lfgEsEs from "../../../locales/es-ES/lfg.json";
import orgEsEs from "../../../locales/es-ES/org.json";
import paramsEsEs from "../../../locales/es-ES/params.json";
import qEsEs from "../../../locales/es-ES/q.json";
+import scheduleEsEs from "../../../locales/es-ES/schedule.json";
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
import settingsEsEs from "../../../locales/es-ES/settings.json";
import teamEsEs from "../../../locales/es-ES/team.json";
@@ -128,6 +132,7 @@ import lfgEsUs from "../../../locales/es-US/lfg.json";
import orgEsUs from "../../../locales/es-US/org.json";
import paramsEsUs from "../../../locales/es-US/params.json";
import qEsUs from "../../../locales/es-US/q.json";
+import scheduleEsUs from "../../../locales/es-US/schedule.json";
import scrimsEsUs from "../../../locales/es-US/scrims.json";
import settingsEsUs from "../../../locales/es-US/settings.json";
import teamEsUs from "../../../locales/es-US/team.json";
@@ -156,6 +161,7 @@ import lfgFrCa from "../../../locales/fr-CA/lfg.json";
import orgFrCa from "../../../locales/fr-CA/org.json";
import paramsFrCa from "../../../locales/fr-CA/params.json";
import qFrCa from "../../../locales/fr-CA/q.json";
+import scheduleFrCa from "../../../locales/fr-CA/schedule.json";
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
import settingsFrCa from "../../../locales/fr-CA/settings.json";
import teamFrCa from "../../../locales/fr-CA/team.json";
@@ -184,6 +190,7 @@ import lfgFrEu from "../../../locales/fr-EU/lfg.json";
import orgFrEu from "../../../locales/fr-EU/org.json";
import paramsFrEu from "../../../locales/fr-EU/params.json";
import qFrEu from "../../../locales/fr-EU/q.json";
+import scheduleFrEu from "../../../locales/fr-EU/schedule.json";
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
import settingsFrEu from "../../../locales/fr-EU/settings.json";
import teamFrEu from "../../../locales/fr-EU/team.json";
@@ -212,6 +219,7 @@ import lfgHe from "../../../locales/he/lfg.json";
import orgHe from "../../../locales/he/org.json";
import paramsHe from "../../../locales/he/params.json";
import qHe from "../../../locales/he/q.json";
+import scheduleHe from "../../../locales/he/schedule.json";
import scrimsHe from "../../../locales/he/scrims.json";
import settingsHe from "../../../locales/he/settings.json";
import teamHe from "../../../locales/he/team.json";
@@ -240,6 +248,7 @@ import lfgIt from "../../../locales/it/lfg.json";
import orgIt from "../../../locales/it/org.json";
import paramsIt from "../../../locales/it/params.json";
import qIt from "../../../locales/it/q.json";
+import scheduleIt from "../../../locales/it/schedule.json";
import scrimsIt from "../../../locales/it/scrims.json";
import settingsIt from "../../../locales/it/settings.json";
import teamIt from "../../../locales/it/team.json";
@@ -268,6 +277,7 @@ import lfgJa from "../../../locales/ja/lfg.json";
import orgJa from "../../../locales/ja/org.json";
import paramsJa from "../../../locales/ja/params.json";
import qJa from "../../../locales/ja/q.json";
+import scheduleJa from "../../../locales/ja/schedule.json";
import scrimsJa from "../../../locales/ja/scrims.json";
import settingsJa from "../../../locales/ja/settings.json";
import teamJa from "../../../locales/ja/team.json";
@@ -296,6 +306,7 @@ import lfgKo from "../../../locales/ko/lfg.json";
import orgKo from "../../../locales/ko/org.json";
import paramsKo from "../../../locales/ko/params.json";
import qKo from "../../../locales/ko/q.json";
+import scheduleKo from "../../../locales/ko/schedule.json";
import scrimsKo from "../../../locales/ko/scrims.json";
import settingsKo from "../../../locales/ko/settings.json";
import teamKo from "../../../locales/ko/team.json";
@@ -324,6 +335,7 @@ import lfgNl from "../../../locales/nl/lfg.json";
import orgNl from "../../../locales/nl/org.json";
import paramsNl from "../../../locales/nl/params.json";
import qNl from "../../../locales/nl/q.json";
+import scheduleNl from "../../../locales/nl/schedule.json";
import scrimsNl from "../../../locales/nl/scrims.json";
import settingsNl from "../../../locales/nl/settings.json";
import teamNl from "../../../locales/nl/team.json";
@@ -352,6 +364,7 @@ import lfgPl from "../../../locales/pl/lfg.json";
import orgPl from "../../../locales/pl/org.json";
import paramsPl from "../../../locales/pl/params.json";
import qPl from "../../../locales/pl/q.json";
+import schedulePl from "../../../locales/pl/schedule.json";
import scrimsPl from "../../../locales/pl/scrims.json";
import settingsPl from "../../../locales/pl/settings.json";
import teamPl from "../../../locales/pl/team.json";
@@ -380,6 +393,7 @@ import lfgPtBr from "../../../locales/pt-BR/lfg.json";
import orgPtBr from "../../../locales/pt-BR/org.json";
import paramsPtBr from "../../../locales/pt-BR/params.json";
import qPtBr from "../../../locales/pt-BR/q.json";
+import schedulePtBr from "../../../locales/pt-BR/schedule.json";
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
import settingsPtBr from "../../../locales/pt-BR/settings.json";
import teamPtBr from "../../../locales/pt-BR/team.json";
@@ -408,6 +422,7 @@ import lfgRu from "../../../locales/ru/lfg.json";
import orgRu from "../../../locales/ru/org.json";
import paramsRu from "../../../locales/ru/params.json";
import qRu from "../../../locales/ru/q.json";
+import scheduleRu from "../../../locales/ru/schedule.json";
import scrimsRu from "../../../locales/ru/scrims.json";
import settingsRu from "../../../locales/ru/settings.json";
import teamRu from "../../../locales/ru/team.json";
@@ -436,6 +451,7 @@ import lfgZh from "../../../locales/zh/lfg.json";
import orgZh from "../../../locales/zh/org.json";
import paramsZh from "../../../locales/zh/params.json";
import qZh from "../../../locales/zh/q.json";
+import scheduleZh from "../../../locales/zh/schedule.json";
import scrimsZh from "../../../locales/zh/scrims.json";
import settingsZh from "../../../locales/zh/settings.json";
import teamZh from "../../../locales/zh/team.json";
@@ -454,6 +470,7 @@ export const resources = {
forms: formsEsUs,
friends: friendsEsUs,
weapons: weaponsEsUs,
+ schedule: scheduleEsUs,
scrims: scrimsEsUs,
settings: settingsEsUs,
common: commonEsUs,
@@ -484,6 +501,7 @@ export const resources = {
forms: forms,
friends: friends,
weapons: weapons,
+ schedule: scheduleEn,
scrims: scrimsEn,
settings: settings,
common: common,
@@ -514,6 +532,7 @@ export const resources = {
forms: formsKo,
friends: friendsKo,
weapons: weaponsKo,
+ schedule: scheduleKo,
scrims: scrimsKo,
settings: settingsKo,
common: commonKo,
@@ -544,6 +563,7 @@ export const resources = {
forms: formsDe,
friends: friendsDe,
weapons: weaponsDe,
+ schedule: scheduleDe,
scrims: scrimsDe,
settings: settingsDe,
common: commonDe,
@@ -574,6 +594,7 @@ export const resources = {
forms: formsNl,
friends: friendsNl,
weapons: weaponsNl,
+ schedule: scheduleNl,
scrims: scrimsNl,
settings: settingsNl,
common: commonNl,
@@ -604,6 +625,7 @@ export const resources = {
forms: formsPtBr,
friends: friendsPtBr,
weapons: weaponsPtBr,
+ schedule: schedulePtBr,
scrims: scrimsPtBr,
settings: settingsPtBr,
common: commonPtBr,
@@ -634,6 +656,7 @@ export const resources = {
forms: formsZh,
friends: friendsZh,
weapons: weaponsZh,
+ schedule: scheduleZh,
scrims: scrimsZh,
settings: settingsZh,
common: commonZh,
@@ -664,6 +687,7 @@ export const resources = {
forms: formsFrCa,
friends: friendsFrCa,
weapons: weaponsFrCa,
+ schedule: scheduleFrCa,
scrims: scrimsFrCa,
settings: settingsFrCa,
common: commonFrCa,
@@ -694,6 +718,7 @@ export const resources = {
forms: formsRu,
friends: friendsRu,
weapons: weaponsRu,
+ schedule: scheduleRu,
scrims: scrimsRu,
settings: settingsRu,
common: commonRu,
@@ -724,6 +749,7 @@ export const resources = {
forms: formsIt,
friends: friendsIt,
weapons: weaponsIt,
+ schedule: scheduleIt,
scrims: scrimsIt,
settings: settingsIt,
common: commonIt,
@@ -754,6 +780,7 @@ export const resources = {
forms: formsJa,
friends: friendsJa,
weapons: weaponsJa,
+ schedule: scheduleJa,
scrims: scrimsJa,
settings: settingsJa,
common: commonJa,
@@ -784,6 +811,7 @@ export const resources = {
forms: formsDa,
friends: friendsDa,
weapons: weaponsDa,
+ schedule: scheduleDa,
scrims: scrimsDa,
settings: settingsDa,
common: commonDa,
@@ -814,6 +842,7 @@ export const resources = {
forms: formsEsEs,
friends: friendsEsEs,
weapons: weaponsEsEs,
+ schedule: scheduleEsEs,
scrims: scrimsEsEs,
settings: settingsEsEs,
common: commonEsEs,
@@ -844,6 +873,7 @@ export const resources = {
forms: formsHe,
friends: friendsHe,
weapons: weaponsHe,
+ schedule: scheduleHe,
scrims: scrimsHe,
settings: settingsHe,
common: commonHe,
@@ -874,6 +904,7 @@ export const resources = {
forms: formsFrEu,
friends: friendsFrEu,
weapons: weaponsFrEu,
+ schedule: scheduleFrEu,
scrims: scrimsFrEu,
settings: settingsFrEu,
common: commonFrEu,
@@ -904,6 +935,7 @@ export const resources = {
forms: formsPl,
friends: friendsPl,
weapons: weaponsPl,
+ schedule: schedulePl,
scrims: scrimsPl,
settings: settingsPl,
common: commonPl,
diff --git a/app/root.tsx b/app/root.tsx
index db5e0c229..4a6344691 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -56,6 +56,7 @@ import {
useTheme,
} from "./features/theme/core/provider";
import { getThemeSession } from "./features/theme/core/theme-session.server";
+import { timezoneMiddleware } from "./features/timezone/timezone-middleware.server";
import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard";
import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference";
import { useHydrated } from "./hooks/useHydrated";
@@ -87,6 +88,7 @@ export const middleware: Route.MiddlewareFunction[] = [
sessionIdMiddleware,
userMiddleware,
i18nMiddleware,
+ timezoneMiddleware,
];
import "~/styles/fonts.css";
@@ -102,6 +104,16 @@ import "nprogress/nprogress.css";
// already targets the header instead of briefly rendering over the sidebar.
NProgress.configure({ parent: `#${NPROGRESS_ANCHOR_ID}` });
+type DevFaviconColors = { fill: string; stroke: string };
+
+// tints the favicon per local dev instance so the browser tabs of parallel
+// worktrees are told apart, matching each one's VS Code (Peacock) colors
+const DEV_FAVICON_COLORS: Record = {
+ yellow: { fill: "#eae4c8", stroke: "#dcd2a3" },
+ pink: { fill: "#eac8dd", stroke: "#dca3c6" },
+ cyan: { fill: "#c8e3ea", stroke: "#a3d0dc" },
+};
+
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
if (isMatchResultsScopedRevalidation(args)) return false;
if (isRevalidation(args)) return true;
@@ -162,6 +174,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
}
: undefined,
customTheme: isSupporter(user) ? user?.customTheme : undefined,
+ devFaviconColors: devFaviconColors(request),
...layoutData,
},
{
@@ -247,6 +260,9 @@ function Document({
/>
))}
+ {data?.devFaviconColors ? (
+
+ ) : null}
@@ -491,6 +507,26 @@ function HydrationTestIndicator() {
);
}
+function devFaviconColors(request: Request) {
+ if (process.env.NODE_ENV !== "development") return;
+
+ const [subdomain] = new URL(request.url).hostname.split(".");
+
+ return DEV_FAVICON_COLORS[subdomain];
+}
+
+function DevFavicon({ colors }: { colors: DevFaviconColors }) {
+ const svg = ``;
+
+ return (
+
+ );
+}
+
function Fonts() {
return (
+ AvailabilityWeekFactory.create({
+ userId: users.id(1),
+ weekStartsAt: Availability.weekStartsAt(date, "UTC"),
+ timezone: "UTC",
+ });
+
+const remainingWeekStarts = async () =>
+ (
+ await AvailabilityRepository.findAllWeeksByUserIds({
+ userIds: [users.id(1)],
+ startsAt: 0,
+ endsAt: Availability.weekStartsAt(NOW, "UTC") + 1,
+ })
+ ).map((week) => week.weekStartsAt);
+
+describe("DeleteOldAvailabilityRoutine", () => {
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ await users.create(1);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("deletes weeks that ended over the retention period ago, keeping the rest", async () => {
+ const retentionAgo = subMonths(NOW, AVAILABILITY.RETENTION_MONTHS);
+ const longGone = subWeeks(retentionAgo, 4);
+ // the week the retention period reaches into still ends inside it
+ const justInside = retentionAgo;
+
+ await seedWeekOf(longGone);
+ await seedWeekOf(justInside);
+ await seedWeekOf(NOW);
+
+ await DeleteOldAvailabilityRoutine.run();
+
+ expect(await remainingWeekStarts()).toEqual([
+ Availability.weekStartsAt(justInside, "UTC"),
+ Availability.weekStartsAt(NOW, "UTC"),
+ ]);
+ });
+
+ test("deletes team events that ended over the retention period ago, keeping the rest", async () => {
+ const team = await TeamFactory.create({ memberUserIds: [users.id(1)] });
+ const retentionAgo = subMonths(NOW, AVAILABILITY.RETENTION_MONTHS);
+ await TeamEventFactory.create({
+ teamId: team.id,
+ authorId: users.id(1),
+ startsAt: dateToDatabaseTimestamp(subWeeks(retentionAgo, 4)),
+ endsAt: dateToDatabaseTimestamp(subWeeks(retentionAgo, 4)) + 3600,
+ });
+ await TeamEventFactory.create({
+ teamId: team.id,
+ authorId: users.id(1),
+ startsAt: dateToDatabaseTimestamp(NOW),
+ endsAt: dateToDatabaseTimestamp(NOW) + 3600,
+ });
+
+ await DeleteOldAvailabilityRoutine.run();
+
+ const remaining = await AvailabilityRepository.findTeamEventsByTeamId({
+ teamId: team.id,
+ startsAt: 0,
+ endsAt: dateToDatabaseTimestamp(NOW) + 7200,
+ });
+
+ expect(remaining).toHaveLength(1);
+ expect(remaining[0].startsAt).toBe(dateToDatabaseTimestamp(NOW));
+ });
+});
diff --git a/app/routines/deleteOldAvailability.ts b/app/routines/deleteOldAvailability.ts
new file mode 100644
index 000000000..f38602189
--- /dev/null
+++ b/app/routines/deleteOldAvailability.ts
@@ -0,0 +1,36 @@
+import { subDays, subMonths } from "date-fns";
+import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server";
+import { AVAILABILITY } from "../features/availability/availability-constants";
+import { dateToDatabaseTimestamp } from "../utils/dates";
+import { logger } from "../utils/logger";
+import { Routine } from "./routine.server";
+
+const WEEK_DAYS = 7;
+
+export const DeleteOldAvailabilityRoutine = new Routine({
+ name: "DeleteOldAvailability",
+ func: async () => {
+ // weeks are indexed by their start, so a week that ended long enough ago
+ // is one that started a week further back than that
+ const cutOff = subDays(
+ subMonths(new Date(), AVAILABILITY.RETENTION_MONTHS),
+ WEEK_DAYS,
+ );
+
+ const { numDeletedRows } =
+ await AvailabilityRepository.deleteWeeksStartedBefore(
+ dateToDatabaseTimestamp(cutOff),
+ );
+
+ const { numDeletedRows: deletedTeamEvents } =
+ await AvailabilityRepository.deleteTeamEventsEndedBefore(
+ dateToDatabaseTimestamp(
+ subMonths(new Date(), AVAILABILITY.RETENTION_MONTHS),
+ ),
+ );
+
+ logger.info(
+ `Deleted ${numDeletedRows} old availability weeks and ${deletedTeamEvents} old team events`,
+ );
+ },
+});
diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts
index b96fea5ef..057b7c167 100644
--- a/app/routines/list.server.ts
+++ b/app/routines/list.server.ts
@@ -3,6 +3,7 @@ import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
import { ComputeLutiDivsRoutine } from "./computeLutiDivs";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
+import { DeleteOldAvailabilityRoutine } from "./deleteOldAvailability";
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldPendingFriendRequestsRoutine } from "./deleteOldPendingFriendRequests";
@@ -13,6 +14,7 @@ import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTourname
import { ExpireReadyChecksRoutine } from "./expireReadyChecks";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
+import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder";
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
import { NotifySeasonEndRoutine } from "./notifySeasonEnd";
import { NotifySeasonStartRoutine } from "./notifySeasonStart";
@@ -53,6 +55,8 @@ export const daily = [
DeleteOldPendingFriendRequestsRoutine,
DeleteOldTournamentAuditLogsRoutine,
DeleteOldScrimPickupRostersRoutine,
+ DeleteOldAvailabilityRoutine,
+ NotifyScheduleTeamReminderRoutine,
CloseExpiredCommissionsRoutine,
CloseExpiredChatRoomsRoutine,
DeleteOrphanArtTagsRoutine,
diff --git a/app/routines/notifyScheduleTeamReminder.test.ts b/app/routines/notifyScheduleTeamReminder.test.ts
new file mode 100644
index 000000000..ade1ef77e
--- /dev/null
+++ b/app/routines/notifyScheduleTeamReminder.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory";
+import * as TeamFactory from "~/db/seed/factories/TeamFactory";
+import * as UserFactory from "~/db/seed/factories/UserFactory";
+import * as Availability from "~/features/availability/core/Availability";
+import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder";
+
+const users = UserFactory.pool();
+
+const { mockNotify } = vi.hoisted(() => ({
+ mockNotify: vi.fn(),
+}));
+
+vi.mock("~/features/notifications/core/notify.server", () => ({
+ notify: mockNotify,
+}));
+
+const MONDAY = new Date("2026-08-24T09:00:00Z");
+const WEDNESDAY = new Date("2026-08-26T09:00:00Z");
+
+const reportCurrentWeek = (userId: number) =>
+ AvailabilityWeekFactory.create({
+ userId,
+ weekStartsAt: Availability.weekStartsAt(MONDAY, "UTC"),
+ timezone: "UTC",
+ });
+
+describe("NotifyScheduleTeamReminderRoutine", () => {
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(MONDAY);
+ await users.create(2);
+ mockNotify.mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("notifies the teammate who has not reported the week", async () => {
+ await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
+ await reportCurrentWeek(users.id(1));
+
+ await NotifyScheduleTeamReminderRoutine.run();
+
+ expect(mockNotify).toHaveBeenCalledWith({
+ notification: { type: "SCHEDULE_TEAM_REMINDER" },
+ userIds: [users.id(2)],
+ });
+ });
+
+ test("notifies nobody when no teammate reported the week", async () => {
+ await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
+
+ await NotifyScheduleTeamReminderRoutine.run();
+
+ expect(mockNotify).not.toHaveBeenCalled();
+ });
+
+ test("does nothing on a day that is not the first of the week", async () => {
+ vi.setSystemTime(WEDNESDAY);
+ await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
+ await reportCurrentWeek(users.id(1));
+
+ await NotifyScheduleTeamReminderRoutine.run();
+
+ expect(mockNotify).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/routines/notifyScheduleTeamReminder.ts b/app/routines/notifyScheduleTeamReminder.ts
new file mode 100644
index 000000000..5caf3d332
--- /dev/null
+++ b/app/routines/notifyScheduleTeamReminder.ts
@@ -0,0 +1,39 @@
+import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../features/admin/core/dev-controls";
+import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server";
+import * as Availability from "../features/availability/core/Availability";
+import { notify } from "../features/notifications/core/notify.server";
+import { logger } from "../utils/logger";
+import { Routine } from "./routine.server";
+
+/**
+ * Reminds users whose teammates have reported the week that just started while
+ * they have not. Runs on Mondays only, which is also what keeps it to at most
+ * one reminder per user per week.
+ */
+export const NotifyScheduleTeamReminderRoutine = new Routine({
+ name: "NotifyScheduleTeamReminder",
+ func: async () => {
+ const now = new Date();
+
+ // runs whatever the day is when triggered by hand in development
+ if (
+ !Availability.isFirstDayOfWeek(now, "UTC") &&
+ !DANGEROUS_CAN_ACCESS_DEV_CONTROLS
+ ) {
+ return;
+ }
+
+ const userIds = await AvailabilityRepository.findWeekReminderUserIds(
+ Availability.weekStartsAt(now, "UTC"),
+ );
+
+ if (userIds.length === 0) return;
+
+ logger.info(`Reminding ${userIds.length} users about their schedule`);
+
+ await notify({
+ notification: { type: "SCHEDULE_TEAM_REMINDER" },
+ userIds,
+ });
+ },
+});
diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts
index 2d33a126a..d2951b36d 100644
--- a/app/utils/cache.server.ts
+++ b/app/utils/cache.server.ts
@@ -10,7 +10,7 @@ declare global {
// biome-ignore lint/suspicious/noAssignInExpressions: trick to only create one
export const cache = (global.__lruCache = global.__lruCache
? global.__lruCache
- : new LRUCache>({ max: 5000 }));
+ : new LRUCache>({ max: 6000 }));
export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms);
diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts
index 9908fc525..a83f44e9f 100644
--- a/app/utils/i18n.ts
+++ b/app/utils/i18n.ts
@@ -19,6 +19,7 @@ const ALL_NAMESPACES = [
"user",
"weapons",
"scrims",
+ "schedule",
"tournament",
"team",
"tier-list-maker",
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index bc67638a8..a8bbebc4f 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -259,6 +259,8 @@ export const editTeamPage = (customUrl: string) =>
`${teamPage(customUrl)}/edit`;
export const manageTeamRosterPage = (customUrl: string) =>
`${teamPage(customUrl)}/roster`;
+export const teamSchedulePage = (customUrl: string) =>
+ `${teamPage(customUrl)}/schedule`;
export const authErrorUrl = (errorCode: AuthErrorCode) =>
`/?authError=${errorCode}`;
diff --git a/changelog/2026-08-30-schedules.md b/changelog/2026-08-30-schedules.md
new file mode 100644
index 000000000..f22179293
--- /dev/null
+++ b/changelog/2026-08-30-schedules.md
@@ -0,0 +1,17 @@
+---
+navItem: [calendar, scrims]
+type: feature
+---
+Schedules: share when your availability and plan your team activities
+
+- Fill in your availability for this week and the next under "My schedule" on the events page
+- Intuitive tool to enter your availability supporting drag gestures to quickly copy your availability from day to day
+- Tournaments you have signed up for, scrims you have accepted and your team's events automatically count as busy
+- Team pages have a new schedule tab: the whole roster's week side by side, when the team can play with a full roster or one player short
+- Add activities to your team members calendar about events that are not tournament and scrims, e.g. "VoD review"
+- Signing up for a tournament shows your roster's availability for the event
+- Tournaments now show their estimated runtime
+- Tournament "add all team members" shotcut action
+- When posting a scrim you can pick the start time straight from your team's free time
+- Your friends list shows the week of friends and teammates who have filled theirs in (ask your friend to sub for example!)
+- Your schedule is only visible to your teammates and friends
diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts
index 607bde65c..6eabfa126 100644
--- a/e2e/events.spec.ts
+++ b/e2e/events.spec.ts
@@ -1,12 +1,22 @@
-import { addHours } from "date-fns";
+import { addHours, subWeeks } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
+import * as Availability from "~/features/availability/core/Availability";
import { dateToDatabaseTimestamp } from "~/utils/dates";
-import { expect, impersonate, test } from "./helpers/playwright";
+import {
+ expect,
+ impersonate,
+ isNotVisible,
+ MACHINE_TIMEZONE,
+ setTimezoneCookie,
+ test,
+} from "./helpers/playwright";
import { EventsPage } from "./pages/calendar/events-page";
const JOINED_TOURNAMENT_NAME = "Joined Tournament";
const ORGANIZED_TOURNAMENT_NAME = "Organized Tournament";
+const WEDNESDAY = 2;
+const DAY_SECONDS = 24 * 60 * 60;
test.describe("Events", () => {
test("filters between tabs and navigates to an event", async ({
@@ -58,3 +68,177 @@ test.describe("Events", () => {
await expect(page).not.toHaveURL(/\/events/);
});
});
+
+test.describe("My schedule", () => {
+ test("saves a week, edits it and submits an empty week", async ({ page }) => {
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const events = new EventsPage(page);
+ await events.goto();
+
+ await expect(events.weekNotFilledMarker("current")).toBeVisible();
+
+ await events.dayEditButton(WEDNESDAY).click();
+ const popover = events.locators.dayEditorPopover;
+ await popover.getByLabel("Start").fill("18:00");
+ await popover.getByLabel("End").fill("22:00");
+ await popover.getByLabel("Note").fill("Leaving early");
+ await page.keyboard.press("Escape");
+
+ await expect(events.locators.availabilityBars).toHaveCount(1);
+
+ // leaving the page with the unsaved week warns first
+ await page
+ .getByRole("link", { name: "Find an event to join on the calendar!" })
+ .click();
+ await page.getByText("Unsaved changes").waitFor();
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page).toHaveURL(/\/events/);
+
+ await events.locators.saveWeekButton.click();
+ await expect(page.getByText("Availability saved")).toBeAttached();
+
+ await events.goto();
+ await expect(events.locators.availabilityBars).toHaveCount(1);
+ await isNotVisible(events.weekNotFilledMarker("current"));
+ await expect(events.weekNotFilledMarker("next")).toBeVisible();
+
+ await events.dayEditButton(WEDNESDAY).click();
+ await expect(popover.getByLabel("Note")).toHaveValue("Leaving early");
+ // deleting the only range commits instantly: the popover closes and the
+ // bar disappears without waiting for a popover close + save
+ await popover.getByRole("button", { name: "Delete" }).click();
+ await isNotVisible(events.locators.dayEditorPopover);
+ await isNotVisible(events.locators.availabilityBars);
+ await events.locators.saveWeekButton.click();
+ await expect(page.getByText("Availability saved")).toBeAttached();
+
+ // an empty submitted week is "unavailable all week", not missing
+ await events.goto();
+ await isNotVisible(events.locators.availabilityBars);
+ await isNotVisible(events.weekNotFilledMarker("current"));
+ });
+
+ test("shows a commitment as a locked block on the editor", async ({
+ page,
+ factories,
+ }) => {
+ const currentWeek = Availability.weekRange(new Date(), MACHINE_TIMEZONE);
+ const wednesday = Availability.dateInTimezone(
+ currentWeek.startsAt + WEDNESDAY * DAY_SECONDS + DAY_SECONDS / 2,
+ MACHINE_TIMEZONE,
+ );
+ const team = await factories.TeamFactory.create({
+ memberUserIds: [ADMIN_ID],
+ });
+ await factories.TeamEventFactory.create({
+ teamId: team.id,
+ authorId: ADMIN_ID,
+ name: "VoD review",
+ startsAt: Availability.localToTimestamp({
+ date: wednesday,
+ time: "20:00",
+ timezone: MACHINE_TIMEZONE,
+ }),
+ endsAt: Availability.localToTimestamp({
+ date: wednesday,
+ time: "21:30",
+ timezone: MACHINE_TIMEZONE,
+ }),
+ });
+
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const events = new EventsPage(page);
+ await events.goto();
+
+ await expect(events.locators.commitments.first()).toBeVisible();
+ await expect(events.locators.commitments.first()).toHaveText("VoD review");
+ });
+
+ test("paints a range reaching past the hours the tracks show", async ({
+ page,
+ }) => {
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const events = new EventsPage(page);
+ await events.goto();
+
+ // the tracks end at 2 AM until they are expanded; the paint runs past
+ // their right edge and the window widens to fit what it produced
+ await events.paintAvailability(WEDNESDAY, 0.5, 1.25);
+
+ await expect(events.locators.availabilityBars).toHaveAttribute(
+ "title",
+ "8:00 PM – 5:00 AM",
+ );
+ });
+
+ test("opens the day editor on the click following a drag", async ({
+ page,
+ }) => {
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const events = new EventsPage(page);
+ await events.goto();
+
+ await events.paintAvailability(WEDNESDAY, 0.3, 0.5);
+ // the drag ends with a click of its own, which must not open the popover
+ // without swallowing the click that comes after it either
+ await events.dragAvailabilityBar(events.locators.availabilityBars, 60);
+ await isNotVisible(events.locators.dayEditorPopover);
+
+ await events.locators.availabilityBars.click();
+ await expect(events.locators.dayEditorPopover).toBeVisible();
+ });
+
+ test("copies last week's ranges into the current week", async ({
+ page,
+ factories,
+ }) => {
+ const lastWeekRange = Availability.weekRange(
+ subWeeks(new Date(), 1),
+ MACHINE_TIMEZONE,
+ );
+ const lastWednesday = Availability.dateInTimezone(
+ lastWeekRange.startsAt + WEDNESDAY * DAY_SECONDS + DAY_SECONDS / 2,
+ MACHINE_TIMEZONE,
+ );
+ await factories.AvailabilityWeekFactory.create({
+ userId: ADMIN_ID,
+ weekStartsAt: lastWeekRange.startsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [
+ {
+ startsAt: Availability.localToTimestamp({
+ date: lastWednesday,
+ time: "19:00",
+ timezone: MACHINE_TIMEZONE,
+ }),
+ endsAt: Availability.localToTimestamp({
+ date: lastWednesday,
+ time: "21:00",
+ timezone: MACHINE_TIMEZONE,
+ }),
+ },
+ ],
+ });
+
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const events = new EventsPage(page);
+ await events.goto();
+
+ await isNotVisible(events.locators.availabilityBars);
+ await events.locators.copyLastWeekButton.click();
+ await expect(events.locators.availabilityBars).toHaveCount(1);
+
+ await events.locators.saveWeekButton.click();
+ await expect(page.getByText("Availability saved")).toBeAttached();
+ });
+});
diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts
index 64f06bd1c..13ab4baf7 100644
--- a/e2e/friends.spec.ts
+++ b/e2e/friends.spec.ts
@@ -1,8 +1,25 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
-import { expect, impersonate, test } from "./helpers/playwright";
+import { ADMIN_ID } from "~/features/admin/admin-constants";
+import * as Availability from "~/features/availability/core/Availability";
+import { weekDates, weekRange } from "./helpers/availability";
+import {
+ expect,
+ impersonate,
+ isNotVisible,
+ MACHINE_TIMEZONE,
+ setTimezoneCookie,
+ test,
+} from "./helpers/playwright";
+import {
+ befriend,
+ createNamedUsers,
+ expectTopToBottom,
+} from "./helpers/sidebar";
import { FriendsPage } from "./pages/friends/friends-page";
import { NotificationPopover } from "./pages/layout/notification-popover";
+const WEDNESDAY = 2;
+
test.describe("Friends", () => {
test("send friend request, accept it, then delete friend", async ({
page,
@@ -40,4 +57,78 @@ test.describe("Friends", () => {
await expect(friends.locators.noFriendsText).toBeVisible();
});
+
+ test("sorts friends who shared a schedule up and shows their week", async ({
+ page,
+ factories,
+ }) => {
+ const [scheduled, unscheduled, queueing] = await createNamedUsers(
+ factories,
+ ["ScheduleFriend", "NoScheduleFriend", "QueueFriend"],
+ );
+ await befriend(
+ factories,
+ [unscheduled.id, scheduled.id, queueing.id],
+ ADMIN_ID,
+ );
+ await factories.SQGroupFactory.create({ memberUserIds: [queueing.id] });
+
+ await factories.AvailabilityWeekFactory.create({
+ userId: scheduled.id,
+ weekStartsAt: weekRange().startsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [daySlot(WEDNESDAY, "18:00", "22:00")],
+ });
+ // a commitment of their own team, which the modal shows only as the free
+ // time it takes away
+ const { id: teamId } = await factories.TeamFactory.create({
+ name: "Schedule Team",
+ memberUserIds: [scheduled.id],
+ });
+ await factories.TeamEventFactory.create({
+ teamId,
+ authorId: scheduled.id,
+ name: "VoD review",
+ ...daySlot(WEDNESDAY, "20:00", "22:00"),
+ });
+
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const friends = new FriendsPage(page);
+ await friends.goto();
+
+ await expectTopToBottom([
+ friends.row(queueing.id),
+ friends.row(scheduled.id),
+ friends.row(unscheduled.id),
+ ]);
+ await isNotVisible(friends.scheduleButton(unscheduled.id));
+
+ await friends.scheduleButton(scheduled.id).click();
+ await expect(friends.locators.scheduleRanges).toHaveCount(1);
+ await expect(friends.day(WEDNESDAY)).toContainText("6:00");
+ await expect(friends.day(WEDNESDAY)).not.toContainText("VoD review");
+
+ // they only filled in the current week
+ await friends.locators.nextWeekToggle.click();
+ await expect(friends.locators.noScheduleText).toBeVisible();
+ });
});
+
+function daySlot(dayIndex: number, start: string, end: string) {
+ const date = weekDates()[dayIndex];
+
+ return {
+ startsAt: Availability.localToTimestamp({
+ date,
+ time: start,
+ timezone: MACHINE_TIMEZONE,
+ }),
+ endsAt: Availability.localToTimestamp({
+ date,
+ time: end,
+ timezone: MACHINE_TIMEZONE,
+ }),
+ };
+}
diff --git a/e2e/helpers/availability.ts b/e2e/helpers/availability.ts
new file mode 100644
index 000000000..5eaa9a0e4
--- /dev/null
+++ b/e2e/helpers/availability.ts
@@ -0,0 +1,22 @@
+import * as R from "remeda";
+import * as Availability from "~/features/availability/core/Availability";
+import { MACHINE_TIMEZONE } from "./playwright";
+
+const DAY_SECONDS = 24 * 60 * 60;
+
+/** The week `date` (default: now) falls in, as the test machine's timezone sees it. */
+export function weekRange(date = new Date()) {
+ return Availability.weekRange(date, MACHINE_TIMEZONE);
+}
+
+/** The seven `YYYY-MM-DD` dates of the week `date` (default: now) falls in. */
+export function weekDates(date = new Date()) {
+ const { startsAt } = weekRange(date);
+
+ return R.range(0, 7).map((dayIndex) =>
+ Availability.dateInTimezone(
+ startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
+ MACHINE_TIMEZONE,
+ ),
+ );
+}
diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts
index 1e1ac0dac..22a2f4285 100644
--- a/e2e/helpers/factories.ts
+++ b/e2e/helpers/factories.ts
@@ -35,6 +35,9 @@ export async function loadFactories(parallelIndex: number) {
ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"),
ArtFactory: await import("~/db/seed/factories/ArtFactory"),
AssociationFactory: await import("~/db/seed/factories/AssociationFactory"),
+ AvailabilityWeekFactory: await import(
+ "~/db/seed/factories/AvailabilityWeekFactory"
+ ),
BadgeFactory: await import("~/db/seed/factories/BadgeFactory"),
BuildFactory: await import("~/db/seed/factories/BuildFactory"),
CalendarEventFactory: await import(
@@ -80,6 +83,7 @@ export async function loadFactories(parallelIndex: number) {
SQReportedWeaponFactory: await import(
"~/db/seed/factories/SQReportedWeaponFactory"
),
+ TeamEventFactory: await import("~/db/seed/factories/TeamEventFactory"),
TeamFactory: await import("~/db/seed/factories/TeamFactory"),
TournamentFactory: await import("~/db/seed/factories/TournamentFactory"),
TournamentLFGTeamFactory: await import(
diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts
index 961060005..a349a1581 100644
--- a/e2e/helpers/playwright.ts
+++ b/e2e/helpers/playwright.ts
@@ -492,3 +492,23 @@ export async function clickNavTab(page: Page, testId: string) {
}
await visibleTab.click();
}
+
+/** The IANA timezone of the machine running the tests, the one fixture times should be computed in. */
+export const MACHINE_TIMEZONE =
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+/**
+ * Writes the timezone cookie the browser would after hydration, so that the
+ * very first document request already renders in the machine's timezone the
+ * test computed its fixture times in.
+ */
+export function setTimezoneCookie(page: Page) {
+ return page.context().addCookies([
+ {
+ name: "timezone",
+ value: MACHINE_TIMEZONE,
+ domain: "localhost",
+ path: "/",
+ },
+ ]);
+}
diff --git a/e2e/pages/calendar/events-page.ts b/e2e/pages/calendar/events-page.ts
index 873605ec5..15d831b7d 100644
--- a/e2e/pages/calendar/events-page.ts
+++ b/e2e/pages/calendar/events-page.ts
@@ -20,12 +20,62 @@ export class EventsPage {
this.page = page;
this.main = page.locator("main");
this.locators = {
- title: page.getByRole("heading", { name: "My Events" }),
+ title: page.getByRole("heading", { name: "My events" }),
viewTabs: this.main.getByRole("navigation"),
emptyCategoryText: page.getByText("No events in this category"),
+ mySchedule: page.getByTestId("my-schedule"),
+ availabilityBars: page.getByTestId("availability-bar"),
+ commitments: page.getByTestId("availability-commitment"),
+ saveWeekButton: page.getByTestId("save-week-button"),
+ copyLastWeekButton: page.getByTestId("copy-last-week-button"),
+ dayEditorPopover: page.getByRole("dialog"),
};
}
+ /** The "• not filled" marker on a week toggle chip. */
+ weekNotFilledMarker(week: "current" | "next") {
+ return this.page.getByTestId(`week-not-filled-${week}`);
+ }
+
+ /** The pencil button opening the day editor popover of a day track. */
+ dayEditButton(dayIndex: number) {
+ return this.page.getByTestId(`availability-day-edit-${dayIndex}`);
+ }
+
+ /**
+ * Paints a range on a day track by dragging across it, `from` and `to` being
+ * fractions of the track's width. Past 1 the drag runs beyond the hours the
+ * track shows.
+ */
+ async paintAvailability(dayIndex: number, from: number, to: number) {
+ const track = this.page.getByTestId(`availability-track-${dayIndex}`);
+ const box = await track.boundingBox();
+ if (!box) {
+ throw new Error("Missing bounding box for the day track");
+ }
+
+ const y = box.y + box.height / 2;
+ await this.page.mouse.move(box.x + box.width * from, y);
+ await this.page.mouse.down();
+ await this.page.mouse.move(box.x + box.width * to, y, { steps: 10 });
+ await this.page.mouse.up();
+ }
+
+ /** Drags an availability bar sideways by `deltaX` pixels, moving the whole range. */
+ async dragAvailabilityBar(bar: Locator, deltaX: number) {
+ const box = await bar.boundingBox();
+ if (!box) {
+ throw new Error("Missing bounding box for the availability bar");
+ }
+
+ const x = box.x + box.width / 2;
+ const y = box.y + box.height / 2;
+ await this.page.mouse.move(x, y);
+ await this.page.mouse.down();
+ await this.page.mouse.move(x + deltaX, y, { steps: 10 });
+ await this.page.mouse.up();
+ }
+
async goto() {
await navigate({ page: this.page, url: EVENTS_PAGE });
}
diff --git a/e2e/pages/friends/friends-page.ts b/e2e/pages/friends/friends-page.ts
index 08fcdaa7b..101729eda 100644
--- a/e2e/pages/friends/friends-page.ts
+++ b/e2e/pages/friends/friends-page.ts
@@ -22,6 +22,13 @@ export class FriendsPage {
acceptButton: this.page.getByRole("button", { name: "Accept" }),
cancelRequestButton: this.page.getByRole("button", { name: "Cancel" }),
noFriendsText: this.page.getByText("No friends yet"),
+ scheduleDays: this.page.getByTestId("schedule-week-days"),
+ scheduleRanges: this.page.getByTestId("schedule-range"),
+ noScheduleText: this.page.getByTestId("schedule-no-week"),
+ // the chip radio input is visually hidden, so the label is what clicks
+ nextWeekToggle: this.page.locator(
+ 'label[for="chip-radio-friend-schedule-week-next"]',
+ ),
};
}
@@ -52,6 +59,19 @@ export class FriendsPage {
friend(name: string) {
return new FriendMenu(this.page, name);
}
+
+ row(userId: number) {
+ return this.page.getByTestId(`friend-row-${userId}`);
+ }
+
+ scheduleButton(userId: number) {
+ return this.page.getByTestId(`friend-schedule-button-${userId}`);
+ }
+
+ /** One day row of the open week modal, Monday being 0. */
+ day(dayIndex: number) {
+ return this.locators.scheduleDays.getByRole("listitem").nth(dayIndex);
+ }
}
class FriendMenu {
diff --git a/e2e/pages/layout/mobile-nav.ts b/e2e/pages/layout/mobile-nav.ts
index 52cc5cb2e..1c62dbaed 100644
--- a/e2e/pages/layout/mobile-nav.ts
+++ b/e2e/pages/layout/mobile-nav.ts
@@ -64,7 +64,9 @@ export class MobileNav {
}
async closePanel() {
- await this.page.locator("button:has(svg.lucide-x)").first().click();
+ await this.openPanelDialog
+ .locator("button[class*='panelCloseButton']")
+ .click();
}
menuLink(name: string) {
diff --git a/e2e/pages/scrims/new-scrim-post-page.ts b/e2e/pages/scrims/new-scrim-post-page.ts
index 9b94d407c..ba42a19e2 100644
--- a/e2e/pages/scrims/new-scrim-post-page.ts
+++ b/e2e/pages/scrims/new-scrim-post-page.ts
@@ -12,10 +12,21 @@ import { createFormHelpers } from "../../helpers/playwright-form";
export class NewScrimPostPage {
private readonly page: Page;
readonly form;
+ readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, scrimsNewFormSchema);
+ this.locators = {
+ schedulePicker: page.getByTestId("scrim-schedule-picker"),
+ scheduleSlots: page.getByTestId("scrim-schedule-slot"),
+ scheduleUnknown: page.getByTestId("scrim-schedule-unknown"),
+ flexibility: page.getByLabel("Start time flexibility"),
+ // the chip radio input is visually hidden, so the label is what clicks
+ nextWeekToggle: page.locator(
+ 'label[for="chip-radio-scrim-schedule-week-next"]',
+ ),
+ };
}
async goto() {
@@ -52,6 +63,13 @@ export class NewScrimPostPage {
return this.page.getByLabel(`User ${nth}`);
}
+ /** One segment of the Start date picker, e.g. `"hour"` or `"day"`. */
+ startSegment(segmentName: string) {
+ return this.page.getByRole("spinbutton", {
+ name: new RegExp(`^${segmentName}, Start`),
+ });
+ }
+
/** Limits who sees the post to one of the author's associations. */
async selectVisibility(associationName: string) {
await this.page
diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts
index 99f142f60..8cd5f7b2e 100644
--- a/e2e/pages/scrims/scrims-page.ts
+++ b/e2e/pages/scrims/scrims-page.ts
@@ -42,6 +42,7 @@ export class ScrimsPage {
limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"),
tournamentPopover: page.getByTestId("tournament-popover-trigger"),
canceledLabel: page.getByText("Canceled"),
+ fitIndicator: page.getByTestId("scrim-fit-indicator"),
divsFilterPill: page.getByTestId("divs-filter"),
addFilterButton: page.getByTestId("add-filter-button"),
saveFiltersAsDefaultButton: page.getByTestId(
@@ -96,6 +97,11 @@ export class ScrimsPage {
await this.page.getByTestId("menu-item-divs-filter").click();
}
+ /** One roster member's row of the fit indicator's popover, its status in `data-status`. */
+ availabilityRow(userId: number) {
+ return this.page.getByTestId(`availability-row-${userId}`);
+ }
+
async openTab(tab: Tab) {
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
}
diff --git a/e2e/pages/team/team-page.ts b/e2e/pages/team/team-page.ts
index 06e422168..1998a2c71 100644
--- a/e2e/pages/team/team-page.ts
+++ b/e2e/pages/team/team-page.ts
@@ -8,6 +8,7 @@ import {
import { TeamEditPage } from "./team-edit-page";
import { TeamResultsPage } from "./team-results-page";
import { TeamRosterPage } from "./team-roster-page";
+import { TeamSchedulePage } from "./team-schedule-page";
export class TeamPage {
private readonly page: Page;
@@ -25,6 +26,7 @@ export class TeamPage {
makeMainTeamButton: page.getByTestId("make-main-team-button"),
leaveTeamButton: page.getByTestId("leave-team-button"),
deleteTeamButton: page.getByTestId("delete-team-button"),
+ scheduleButton: page.getByTestId("team-schedule-button"),
otherRolesTab: page.getByRole("tab", { name: /Other/ }),
confirmDialog: page.getByRole("dialog"),
resultsBannerLink: page.getByRole("link", { name: /View \d+ results/ }),
@@ -62,6 +64,11 @@ export class TeamPage {
return new TeamResultsPage(this.page);
}
+ async openSchedule() {
+ await this.locators.scheduleButton.click();
+ return new TeamSchedulePage(this.page);
+ }
+
async openActionsMenu() {
await this.locators.actionsMenuButton.click();
}
diff --git a/e2e/pages/team/team-schedule-page.ts b/e2e/pages/team/team-schedule-page.ts
new file mode 100644
index 000000000..e2089bcc3
--- /dev/null
+++ b/e2e/pages/team/team-schedule-page.ts
@@ -0,0 +1,48 @@
+import type { Page } from "@playwright/test";
+import { teamPage } from "~/utils/urls";
+import { navigate } from "../../helpers/playwright";
+
+export class TeamSchedulePage {
+ private readonly page: Page;
+ readonly locators;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.locators = {
+ grid: page.getByTestId("schedule-grid"),
+ summary: page.getByTestId("schedule-summary"),
+ hiddenMessage: page.getByTestId("schedule-hidden"),
+ windows: page.getByTestId("schedule-window"),
+ notes: page.getByTestId("schedule-note"),
+ teamEvents: page.getByTestId("schedule-team-event"),
+ addEventButton: page.getByTestId("add-team-event-button"),
+ // the chip radio input is visually hidden, so the label is what clicks
+ nextWeekToggle: page.locator(
+ 'label[for="chip-radio-schedule-week-next"]',
+ ),
+ };
+ }
+
+ async goto(customUrl: string) {
+ await navigate({
+ page: this.page,
+ url: `${teamPage(customUrl)}/schedule`,
+ });
+ }
+
+ cell(userId: number, dayIndex: number) {
+ return this.page.getByTestId(`schedule-cell-${userId}-${dayIndex}`);
+ }
+
+ cellRange(userId: number, dayIndex: number) {
+ return this.cell(userId, dayIndex).getByTestId("schedule-range");
+ }
+
+ cellBusy(userId: number, dayIndex: number) {
+ return this.cell(userId, dayIndex).getByTestId("schedule-busy");
+ }
+
+ dayDot(dayIndex: number) {
+ return this.page.getByTestId(`schedule-day-dot-${dayIndex}`);
+ }
+}
diff --git a/e2e/pages/tournament/tournament-page.ts b/e2e/pages/tournament/tournament-page.ts
index bc5272d66..45e8fd4a9 100644
--- a/e2e/pages/tournament/tournament-page.ts
+++ b/e2e/pages/tournament/tournament-page.ts
@@ -14,6 +14,7 @@ export class TournamentPage {
this.nav = new TournamentNav(page);
this.locators = {
registerCta: page.getByTestId("register-cta"),
+ estimatedEnd: page.getByTestId("estimated-end"),
};
}
diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts
index 0ab0a79dd..b6a072564 100644
--- a/e2e/pages/tournament/tournament-register-page.ts
+++ b/e2e/pages/tournament/tournament-register-page.ts
@@ -6,7 +6,11 @@ import {
counterpickMap,
pickCounterpickMaps,
} from "../../helpers/counterpick-map-pool";
-import { navigate, submit } from "../../helpers/playwright";
+import {
+ modalClickConfirmButton,
+ navigate,
+ submit,
+} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { TournamentNav } from "./tournament-nav";
@@ -51,6 +55,15 @@ export class TournamentRegisterPage {
return this.page.getByTestId(`member-num-${number}`);
}
+ availabilityRow(userId: number) {
+ return this.page.getByTestId(`availability-row-${userId}`);
+ }
+
+ /** Opens the quick add dropdown so its player rows render. */
+ async openQuickAdd() {
+ await this.page.getByTestId("quick-add-select").getByRole("button").click();
+ }
+
/** The roster footer of a format too small to have subs, e.g. "2v2". */
noSubsFooter(format: string) {
return this.page.getByText(`Format is ${format}. No subs allowed.`);
@@ -68,6 +81,12 @@ export class TournamentRegisterPage {
return submit(this.page, "add-player-button");
}
+ /** Adds every player-role member of the sendou.ink team via the quick add all button, confirming the dialog. */
+ async addAllTeamPlayers(teamId: number) {
+ await this.page.getByTestId(`add-team-players-button-${teamId}`).click();
+ await modalClickConfirmButton(this.page);
+ }
+
/** Picks the required amount of counterpick maps for every mode, skipping banned ones. */
pickCounterpickMaps() {
return pickCounterpickMaps(this.page);
diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts
index 4ec7db8f4..8154ae67f 100644
--- a/e2e/scrims.spec.ts
+++ b/e2e/scrims.spec.ts
@@ -1,6 +1,14 @@
-import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns";
+import {
+ addDays,
+ addHours,
+ addWeeks,
+ setHours,
+ setMinutes,
+ startOfHour,
+} from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
+import * as Availability from "~/features/availability/core/Availability";
import { serializeLutiDiv } from "~/features/scrims/scrims-utils";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
@@ -11,7 +19,9 @@ import {
expect,
impersonate,
isNotVisible,
+ MACHINE_TIMEZONE,
navigate,
+ setTimezoneCookie,
test,
} from "./helpers/playwright";
import { AnythingAdder } from "./pages/layout/anything-adder";
@@ -25,6 +35,8 @@ const TOURNAMENT_NAME = "Swim or Sink";
const ASSOCIATION_NAME = "Inkling Alliance";
const PICKUP_NAMES = ["Pickup One", "Pickup Two", "Pickup Three"];
const GROUP_SIZE = 4;
+const DAY_SECONDS = 24 * 60 * 60;
+const WEDNESDAY = 2;
const TOURNAMENT_MAP_POOL: Array<{ mode: ModeShort; stageId: StageId }> = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 2 },
@@ -442,6 +454,105 @@ function createNamedUsers(factories: Factories, names: string[]) {
}));
}
+test.describe("Scrim schedule picker", () => {
+ test("picks a start and its flexibility from the roster's shared free time", async ({
+ page,
+ factories,
+ }) => {
+ const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID);
+ const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00");
+
+ for (const userId of memberUserIds.slice(0, memberUserIds.length - 1)) {
+ await factories.AvailabilityWeekFactory.create({
+ userId,
+ weekStartsAt: nextWeek().startsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [evening],
+ });
+ }
+
+ await impersonate(page, NZAP_TEST_ID);
+ await setTimezoneCookie(page);
+
+ const newPost = new NewScrimPostPage(page);
+ await newPost.goto();
+ await newPost.locators.nextWeekToggle.click();
+
+ // the roster's last member never filled the week in, so the shared
+ // evening is one player short of a full team
+ await expect(newPost.locators.scheduleUnknown).toBeVisible();
+ const slot = newPost.locators.scheduleSlots;
+ await expect(slot).toHaveCount(1);
+ await expect(slot).toHaveAttribute("data-tier", "ONE_SHORT");
+
+ await slot.click();
+
+ // 18:00, with the flexibility that still leaves an hour of the window
+ // to play whichever start is settled on, capped at the longest option
+ await expect(newPost.startSegment("hour")).toHaveText("6");
+ await expect(newPost.startSegment("minute")).toHaveText("00");
+ await expect(newPost.startSegment("AM/PM")).toHaveText("PM");
+ await expect(newPost.locators.flexibility).toHaveValue("+3hours");
+ await expect(slot).toHaveAttribute("data-picked", "true");
+ });
+});
+
+test.describe("Scrim fit indicator", () => {
+ test("shows how much of the viewer's roster could play a post", async ({
+ page,
+ factories,
+ }) => {
+ const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID);
+ const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00");
+ const withoutSchedule = memberUserIds[memberUserIds.length - 1];
+
+ for (const userId of memberUserIds.filter(
+ (userId) => userId !== withoutSchedule,
+ )) {
+ await factories.AvailabilityWeekFactory.create({
+ userId,
+ weekStartsAt: nextWeek().startsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [evening],
+ });
+ }
+
+ await factories.ScrimPostFactory.create({
+ users: await createGroup(factories),
+ startsAt: evening.startsAt,
+ isScheduledForFuture: true,
+ });
+
+ await impersonate(page, NZAP_TEST_ID);
+
+ const scrims = new ScrimsPage(page);
+ await scrims.goto();
+ await scrims.openTab("available");
+
+ await expect(scrims.locators.fitIndicator).toContainText("3/4 available");
+
+ await scrims.locators.fitIndicator.click();
+
+ await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute(
+ "data-status",
+ "available",
+ );
+ await expect(scrims.availabilityRow(withoutSchedule)).toHaveAttribute(
+ "data-status",
+ "unknown",
+ );
+
+ await page.keyboard.press("Escape");
+ await scrims.requestFirst();
+
+ // the same breakdown, for the slot the request would be made for
+ await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute(
+ "data-status",
+ "available",
+ );
+ });
+});
+
async function createTeamFor(factories: Factories, userId: number) {
const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1);
@@ -450,6 +561,22 @@ async function createTeamFor(factories: Factories, userId: number) {
});
}
+function nextWeek() {
+ return Availability.weekRange(addWeeks(new Date(), 1), MACHINE_TIMEZONE);
+}
+
+/** Wall-clock range on a day of next week, so it is always ahead of "now". */
+function nextWeekSlot(dayIndex: number, start: string, end: string) {
+ const date = Availability.dateInTimezone(
+ nextWeek().startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
+ MACHINE_TIMEZONE,
+ );
+ const at = (time: string) =>
+ Availability.localToTimestamp({ date, time, timezone: MACHINE_TIMEZONE });
+
+ return { startsAt: at(start), endsAt: at(end) };
+}
+
/** A pick-up sized group of users, `userId` its owner if one is given. */
async function createGroup(factories: Factories, userId?: number) {
const others = await factories.UserFactory.createMany(
diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts
index f0dca026d..ffe0c4400 100644
--- a/e2e/team.spec.ts
+++ b/e2e/team.spec.ts
@@ -1,25 +1,35 @@
+import { addWeeks } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
+import { addTeamEventSchema } from "~/features/availability/availability-schemas";
+import * as Availability from "~/features/availability/core/Availability";
+import { weekDates, weekRange } from "./helpers/availability";
import type { Factories } from "./helpers/factories";
import {
expect,
impersonate,
isNotVisible,
+ MACHINE_TIMEZONE,
navigate,
+ setTimezoneCookie,
test,
} from "./helpers/playwright";
+import { createFormHelpers } from "./helpers/playwright-form";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { SELECTED_MAP_CLASS } from "./pages/settings/map-mode-preferences-field";
import { JoinTeamPage } from "./pages/team/join-team-page";
import { NewTeamPage } from "./pages/team/new-team-page";
import { TeamEditPage } from "./pages/team/team-edit-page";
import { TeamPage } from "./pages/team/team-page";
+import { TeamSchedulePage } from "./pages/team/team-schedule-page";
import { UserPage } from "./pages/user/user-page";
const TEAM_NAME = "Alliance Rogue";
const SECONDARY_TEAM_NAME = "Team Olive";
const ROSTER_SIZE = 4;
const TOURNAMENT_NAME = "In The Zone 30";
+const WEDNESDAY = 2;
+const THURSDAY = 3;
test.describe("New team creation", () => {
test("creates new team", async ({ page }) => {
@@ -407,3 +417,168 @@ async function createFullTeam(factories: Factories) {
memberUserIds: [ADMIN_ID, ...members.map((member) => member.id)],
});
}
+
+test.describe("Team schedule", () => {
+ test("member sees the grid states and playable windows", async ({
+ page,
+ factories,
+ }) => {
+ const noScheduleMember = await factories.UserFactory.create();
+ const { id: teamId, customUrl } = await factories.TeamFactory.create({
+ name: TEAM_NAME,
+ memberUserIds: [ADMIN_ID, NZAP_TEST_ID, noScheduleMember.id],
+ });
+
+ const { startsAt } = weekRange();
+ await factories.AvailabilityWeekFactory.create({
+ userId: ADMIN_ID,
+ weekStartsAt: startsAt,
+ timezone: MACHINE_TIMEZONE,
+ // the small-hours slot guards day bucketing: on machines off UTC it
+ // falls on another UTC day, so it moves columns if the server ignores
+ // the viewer's timezone
+ slots: [
+ daySlot(WEDNESDAY, "18:00", "22:00"),
+ daySlot(THURSDAY, "00:30", "02:00"),
+ ],
+ dayNotes: [{ date: weekDates()[WEDNESDAY], text: "Leaving early" }],
+ });
+ await factories.AvailabilityWeekFactory.create({
+ userId: NZAP_TEST_ID,
+ weekStartsAt: startsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [daySlot(WEDNESDAY, "19:00", "23:00")],
+ });
+ // a commitment late in the shared Wednesday evening: renders as a busy
+ // block and trims effective availability without removing the window
+ await factories.TeamEventFactory.create({
+ teamId,
+ authorId: ADMIN_ID,
+ name: "VoD review",
+ ...daySlot(WEDNESDAY, "22:00", "23:30"),
+ });
+
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const team = new TeamPage(page);
+ await team.goto(customUrl);
+
+ const schedule = await team.openSchedule();
+ await expect(schedule.locators.grid).toBeVisible();
+
+ await expect(schedule.cellRange(ADMIN_ID, WEDNESDAY)).toBeVisible();
+ await expect(schedule.cellRange(ADMIN_ID, THURSDAY)).toBeVisible();
+ await expect(schedule.cell(ADMIN_ID, 0)).toHaveText("—");
+ await expect(schedule.cell(noScheduleMember.id, 0)).toHaveText("?");
+ await expect(schedule.cellBusy(NZAP_TEST_ID, WEDNESDAY)).toHaveText(
+ "VoD review",
+ );
+ await expect(schedule.locators.notes).toContainText("Leaving early");
+
+ // two members share Wed 19-22 while the third has no schedule, so the
+ // only playable window is the one-short tier
+ await expect(schedule.locators.windows).toHaveText(/Wed/);
+ await expect(schedule.dayDot(WEDNESDAY)).toBeVisible();
+ await isNotVisible(schedule.dayDot(0));
+
+ await expect(schedule.locators.teamEvents).toContainText("VoD review");
+ });
+
+ test("hides the schedule from non-members, a friend of a member included", async ({
+ page,
+ factories,
+ }) => {
+ const friend = await factories.UserFactory.create();
+ const { customUrl } = await factories.TeamFactory.create({
+ name: TEAM_NAME,
+ memberUserIds: [ADMIN_ID],
+ });
+ await factories.FriendshipFactory.create({
+ userOneId: ADMIN_ID,
+ userTwoId: friend.id,
+ });
+ await factories.AvailabilityWeekFactory.create({
+ userId: ADMIN_ID,
+ weekStartsAt: weekRange().startsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [daySlot(WEDNESDAY, "18:00", "22:00")],
+ });
+
+ await impersonate(page, friend.id);
+
+ const schedule = new TeamSchedulePage(page);
+ await schedule.goto(customUrl);
+ await expect(schedule.locators.hiddenMessage).toBeVisible();
+ await isNotVisible(schedule.locators.grid);
+ });
+
+ test("owner adds and deletes a team event, a regular member only sees it", async ({
+ page,
+ factories,
+ }) => {
+ const { customUrl } = await factories.TeamFactory.create({
+ name: TEAM_NAME,
+ memberUserIds: [ADMIN_ID, NZAP_TEST_ID],
+ });
+
+ await impersonate(page, ADMIN_ID);
+ await setTimezoneCookie(page);
+
+ const schedule = new TeamSchedulePage(page);
+ await schedule.goto(customUrl);
+
+ await schedule.locators.addEventButton.click();
+ const form = createFormHelpers(page, addTeamEventSchema);
+ await form.fill("name", "VoD review vs. FTWin");
+ await form.setDateTime("startsAt", nextWeekTime(WEDNESDAY, "20:00"));
+ await form.select("duration", "90");
+ await form.submit();
+
+ await schedule.locators.nextWeekToggle.click();
+ await expect(schedule.locators.teamEvents).toContainText(
+ "VoD review vs. FTWin",
+ );
+
+ await impersonate(page, NZAP_TEST_ID);
+ await schedule.goto(customUrl);
+ await schedule.locators.nextWeekToggle.click();
+ await expect(schedule.locators.teamEvents).toBeVisible();
+ await isNotVisible(schedule.locators.addEventButton);
+ await isNotVisible(page.getByTestId(/delete-team-event/));
+
+ await impersonate(page, ADMIN_ID);
+ await schedule.goto(customUrl);
+ await schedule.locators.nextWeekToggle.click();
+ await page.getByTestId(/delete-team-event/).click();
+ await page.getByTestId("confirm-button").click();
+ await isNotVisible(schedule.locators.teamEvents);
+ });
+});
+
+/** Wall-clock time on a day of next week, always ahead of "now" so the add-event form accepts it. */
+function nextWeekTime(dayIndex: number, time: string) {
+ const date = weekDates(addWeeks(new Date(), 1))[dayIndex];
+
+ return new Date(
+ Availability.localToTimestamp({ date, time, timezone: MACHINE_TIMEZONE }) *
+ 1000,
+ );
+}
+
+function daySlot(dayIndex: number, start: string, end: string) {
+ const dates = weekDates();
+
+ return {
+ startsAt: Availability.localToTimestamp({
+ date: dates[dayIndex],
+ time: start,
+ timezone: MACHINE_TIMEZONE,
+ }),
+ endsAt: Availability.localToTimestamp({
+ date: dates[dayIndex],
+ time: end,
+ timezone: MACHINE_TIMEZONE,
+ }),
+ };
+}
diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts
index a954345eb..55f804e76 100644
--- a/e2e/tournament.spec.ts
+++ b/e2e/tournament.spec.ts
@@ -1,11 +1,17 @@
import { addHours, addMinutes } from "date-fns";
import { ADMIN_ID } from "~/features/admin/admin-constants";
-import { dateToDatabaseTimestamp } from "~/utils/dates";
+import * as Availability from "~/features/availability/core/Availability";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "~/utils/dates";
import {
expect,
impersonate,
isNotVisible,
+ MACHINE_TIMEZONE,
navigate,
+ setTimezoneCookie,
test,
} from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
@@ -18,6 +24,7 @@ import { TournamentTeamsPage } from "./pages/tournament/tournament-teams-page";
const TEAM_NAME = "Chimera";
const ROSTER_SIZE = 4;
const SEEDED_TEAM_COUNT = 8;
+const HOUR_SECONDS = 60 * 60;
/** Views of a tournament whose loaders each ship some of its teams' data. */
const TOURNAMENT_TEAM_VIEWS = ["teams", "results", "brackets", "admin/seeds"];
@@ -74,6 +81,151 @@ test.describe("Tournament", () => {
).toBeVisible();
});
+ test("shows the estimated end time next to the start time", async ({
+ page,
+ factories,
+ }) => {
+ const startsAt = dateToDatabaseTimestamp(addHours(new Date(), 2));
+ const tournament = await factories.TournamentFactory.create({
+ authorId: ADMIN_ID,
+ startTimes: [startsAt],
+ });
+
+ const tournamentPage = new TournamentPage(page);
+ await tournamentPage.goto(tournament.id);
+
+ // a lone single elimination bracket is the estimator's two hour case
+ await expect(tournamentPage.locators.estimatedEnd).toHaveAttribute(
+ "datetime",
+ databaseTimestampToDate(startsAt + 2 * HOUR_SECONDS).toISOString(),
+ );
+ });
+
+ test("quick adds all of the team's players at once", async ({
+ page,
+ factories,
+ }) => {
+ const [captain, slayer, support, coach] =
+ await factories.UserFactory.createMany(4);
+ const team = await factories.TeamFactory.create(
+ { memberUserIds: [captain.id, slayer.id, support.id, coach.id] },
+ {
+ roles: {
+ [slayer.id]: "SLAYER",
+ [support.id]: "SUPPORT",
+ [coach.id]: "COACH",
+ },
+ },
+ );
+
+ const tournament = await factories.TournamentFactory.create({
+ authorId: ADMIN_ID,
+ startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
+ });
+
+ await impersonate(page, captain.id);
+ const tournamentPage = new TournamentPage(page);
+ await tournamentPage.goto(tournament.id);
+
+ const register = await tournamentPage.register();
+ await register.form.fill("pickUpName", TEAM_NAME);
+ await register.form.submit();
+ await expect(register.member(1)).toBeVisible();
+
+ // teammates are offered in the quick add, grouped under the team
+ await register.openQuickAdd();
+ await expect(register.availabilityRow(slayer.id)).toBeVisible();
+ await expect(register.availabilityRow(coach.id)).toBeVisible();
+ await page.keyboard.press("Escape");
+
+ await register.addAllTeamPlayers(team.id);
+
+ await expect(register.member(2)).toBeVisible();
+ await expect(register.member(3)).toBeVisible();
+ // the coach is not part of the competitive lineup
+ await isNotVisible(register.member(4));
+ });
+
+ test("shows the roster's availability for the event window", async ({
+ page,
+ factories,
+ }) => {
+ const [captain, partialMember, unknownMember, stranger, friend] =
+ await factories.UserFactory.createMany(5);
+ await factories.TeamFactory.create({
+ memberUserIds: [captain.id, partialMember.id, unknownMember.id],
+ });
+ await factories.FriendshipFactory.create({
+ userOneId: captain.id,
+ userTwoId: friend.id,
+ });
+
+ const startsAt = addHours(new Date(), 2);
+ const tournament = await factories.TournamentFactory.create({
+ authorId: ADMIN_ID,
+ startTimes: [dateToDatabaseTimestamp(startsAt)],
+ });
+ await factories.TournamentTeamFactory.create({
+ tournamentId: tournament.id,
+ memberUserIds: [
+ captain.id,
+ partialMember.id,
+ unknownMember.id,
+ stranger.id,
+ ],
+ });
+
+ const { startsAt: weekStartsAt } = Availability.weekRange(
+ startsAt,
+ MACHINE_TIMEZONE,
+ );
+ const coveringSlot = {
+ startsAt: dateToDatabaseTimestamp(startsAt),
+ endsAt: dateToDatabaseTimestamp(addHours(startsAt, 5)),
+ };
+ for (const userId of [captain.id, friend.id]) {
+ await factories.AvailabilityWeekFactory.create({
+ userId,
+ weekStartsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [coveringSlot],
+ });
+ }
+ await factories.AvailabilityWeekFactory.create({
+ userId: partialMember.id,
+ weekStartsAt,
+ timezone: MACHINE_TIMEZONE,
+ slots: [
+ {
+ startsAt: dateToDatabaseTimestamp(addHours(startsAt, 1)),
+ endsAt: coveringSlot.endsAt,
+ },
+ ],
+ });
+
+ await impersonate(page, captain.id);
+ await setTimezoneCookie(page);
+ const register = new TournamentRegisterPage(page);
+ await register.goto(tournament.id);
+
+ const row = (userId: number) => register.availabilityRow(userId);
+ await expect(row(captain.id)).toHaveAttribute("data-status", "available");
+ await expect(row(partialMember.id)).toHaveAttribute(
+ "data-status",
+ "partial",
+ );
+ await expect(row(unknownMember.id)).toHaveAttribute(
+ "data-status",
+ "unknown",
+ );
+ // on the tournament roster without being a teammate or a friend, so
+ // their schedule is not the viewer's to see
+ await expect(row(stranger.id)).toHaveAttribute("data-status", "hidden");
+ // the friend with an overlapping submitted range is offered in quick add
+ await register.openQuickAdd();
+ await expect(row(friend.id)).toHaveAttribute("data-status", "available");
+ });
+
test("registers a two player roster for a 2v2 tournament that takes no third member", async ({
page,
factories,
diff --git a/locales/da/calendar.json b/locales/da/calendar.json
index ff0b0f8a4..2a5639c8a 100644
--- a/locales/da/calendar.json
+++ b/locales/da/calendar.json
@@ -83,6 +83,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/da/common.json b/locales/da/common.json
index 234bd28dd..170e4917e 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "logindforsøg afbrudt",
"auth.errors.failed": "Loginforsøg fejlet",
"auth.errors.discordPermissions": "Før at du kan oprette en profil på sendou.ink, skal sendou.ink have adgang til din Discordprofils navn, brugerbillede og sociale forbindelser (de sociale medier, som du har tilknyttet din discordprofil).",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privat",
"or": "Eller",
+ "inviteLink": "",
"yes": "Ja",
"no": "Nej",
"leaderboard.tabs.players": "",
diff --git a/locales/da/forms.json b/locales/da/forms.json
index 88a19d9c6..43209d9dd 100644
--- a/locales/da/forms.json
+++ b/locales/da/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/da/front.json b/locales/da/front.json
index df82d5263..50d180a95 100644
--- a/locales/da/front.json
+++ b/locales/da/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/da/schedule.json b/locales/da/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/da/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/da/team.json b/locales/da/team.json
index 809871de6..b7b7de31e 100644
--- a/locales/da/team.json
+++ b/locales/da/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Forlad hold",
"actionButtons.editTeam": "Rediger hold",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Slet hold",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/da/tournament.json b/locales/da/tournament.json
index 6065bf5fb..4506f24ac 100644
--- a/locales/da/tournament.json
+++ b/locales/da/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Udfyld holdmedlemslisten",
"pre.roster.footer": "Mindst {{atLeastCount}} holdmedlemmer kræves for at deltage. Der kan maks være {{maxCount}} på holdet",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Fjern medlem",
- "pre.roster.delete.header": "Medlem der fjernes",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Vælg banepulje",
"pre.pool.banned": "Bandlyst",
"pre.pool.tiebreaker.short": "Tiebreaker",
@@ -153,7 +157,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "tilføj Suppleant",
- "actions.shareLink": "Del invitationslinket for at tilføje medlemmer: {{inviteLink}}",
"actions.sub.prompt_one": "Du kan stadigvæk tilføje {{count}} Suppleant til din holdliste",
"actions.sub.prompt_other": "Du kan stadigvæk tilføje {{count}} Suppleanter til din holdliste",
"actions.sub.prompt_zero": "Din holdliste er fuld, så du kan ikke tilføje flere Suppleanter",
diff --git a/locales/de/calendar.json b/locales/de/calendar.json
index 8f905c6d0..49b23ecb8 100644
--- a/locales/de/calendar.json
+++ b/locales/de/calendar.json
@@ -83,6 +83,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/de/common.json b/locales/de/common.json
index 8a0b74e7b..75aeb09cb 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Einloggen abgebrochen",
"auth.errors.failed": "Einloggen fehlgeschlagen",
"auth.errors.discordPermissions": "Für dein sendou.ink-Profil benötigt die Seite Zugriff auf den Namen, Avatar und verbundene Social-Media-Accounts in deinem Discord-Profil.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
+ "inviteLink": "",
"yes": "",
"no": "",
"leaderboard.tabs.players": "",
diff --git a/locales/de/forms.json b/locales/de/forms.json
index f6863111b..97ee5eed7 100644
--- a/locales/de/forms.json
+++ b/locales/de/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/de/front.json b/locales/de/front.json
index df82d5263..50d180a95 100644
--- a/locales/de/front.json
+++ b/locales/de/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/de/schedule.json b/locales/de/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/de/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/de/team.json b/locales/de/team.json
index eb9f304a8..888a848ab 100644
--- a/locales/de/team.json
+++ b/locales/de/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Verlassen",
"actionButtons.editTeam": "Team bearbeiten",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Team löschen",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/de/tournament.json b/locales/de/tournament.json
index 72c36c752..f58a1caa2 100644
--- a/locales/de/tournament.json
+++ b/locales/de/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Roster füllen",
"pre.roster.footer": "Mindestens {{atLeastCount}} Teammitglieder sind zum Spielen erforderlich. Maximale Rostergröße ist {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Mitglied löschen",
- "pre.roster.delete.header": "Zu entfernendes Mitglied",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Arenenpool wählen",
"pre.pool.banned": "Gebannt",
"pre.pool.tiebreaker.short": "Tiebreaker",
@@ -153,7 +157,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Ersatzspieler hinzufügen",
- "actions.shareLink": "Teile deinen Invite-Link, um Mitglieder hinzuzufügen: {{inviteLink}}",
"actions.sub.prompt_one": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
"actions.sub.prompt_other": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
"actions.sub.prompt_zero": "Dein Roster ist voll und keine weiteren Ersatzspieler können hinzugefügt werden",
diff --git a/locales/en/calendar.json b/locales/en/calendar.json
index fa8c84773..7d4c99b54 100644
--- a/locales/en/calendar.json
+++ b/locales/en/calendar.json
@@ -79,10 +79,11 @@
"forms.draft": "Draft",
"forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.",
"forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.",
- "events.title": "My Events",
+ "events.title": "My events",
"events.view.registered": "Registered",
"events.view.hosting": "Hosting",
"events.view.scrims": "Scrims",
+ "events.view.team": "Team",
"events.view.saved": "Saved",
"events.view.organization": "Organization",
"events.empty": "No events in this category",
diff --git a/locales/en/common.json b/locales/en/common.json
index 652adf023..27ec78f9f 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} invited your group in {{tournamentName}}",
"notifications.title.TO_LIKE_ACCEPTED": "Group Invitation Accepted",
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} accepted your group invitation in {{tournamentName}}",
+ "notifications.title.TEAM_EVENT_ADDED": "New Team Event",
+ "notifications.text.TEAM_EVENT_ADDED": "{{teamName}} has a new event: {{eventName}}",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "Availability Missing",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "Your teammates are waiting for you to fill in your availability for the week",
"auth.errors.aborted": "Login Aborted",
"auth.errors.failed": "Login Failed",
"auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "Without a link to the player page the request can not be considered. Screenshots are not necessary unless asked for.",
"build.private": "Private",
"or": "Or",
+ "inviteLink": "Invite link",
"yes": "Yes",
"no": "No",
"leaderboard.tabs.players": "Players",
diff --git a/locales/en/forms.json b/locales/en/forms.json
index 42d1b4a84..69047cabc 100644
--- a/locales/en/forms.json
+++ b/locales/en/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "Message",
"labels.scrimRequestStartTime": "Start time",
"bottomTexts.scrimRequestStartTime": "Select a time within the post's time range",
+ "labels.duration": "Duration",
+ "options.duration.30m": "30 minutes",
+ "options.duration.1h": "1 hour",
+ "options.duration.1h30m": "1.5 hours",
+ "options.duration.2h": "2 hours",
+ "options.duration.2h30m": "2.5 hours",
+ "options.duration.3h": "3 hours",
+ "options.duration.4h": "4 hours",
+ "options.duration.5h": "5 hours",
+ "options.duration.6h": "6 hours",
"errors.dateInPast": "Date can not be in the past",
+ "errors.dateTooFarAway": "Date is too far in the future",
"errors.dateTooEarly": "Date is too early",
"errors.dateTooLate": "Date is too late",
"errors.dateTooFarInFuture": "Date can not be more than 2 weeks in the future",
diff --git a/locales/en/front.json b/locales/en/front.json
index 468392bfc..5a460a9c9 100644
--- a/locales/en/front.json
+++ b/locales/en/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Looking for scrim",
"sideNav.scrimRequestPending": "Request pending",
+ "sideNav.scheduleNudge": "Add next week's availability",
+ "sideNav.scheduleNudge.dismiss": "Dismiss",
"mobileNav.menu": "Menu",
"mobileNav.friends": "Friends",
"mobileNav.you": "You",
diff --git a/locales/en/schedule.json b/locales/en/schedule.json
new file mode 100644
index 000000000..a6c0c704d
--- /dev/null
+++ b/locales/en/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "Scrim",
+ "editor.addTime": "Add time",
+ "editor.copyLastWeek": "Copy last week",
+ "editor.earlier": "Earlier",
+ "editor.editDay": "Edit {{day}}",
+ "editor.later": "Later",
+ "editor.notFilled": "not filled",
+ "editor.note": "Note",
+ "editor.saved": "Availability saved",
+ "editor.saveWeek": "Save week",
+ "editor.title": "My availability",
+ "editor.timesInYourTimezone": "Times in your time zone",
+ "editor.visibility": "Visible to your teammates and friends",
+ "events.title": "Team events",
+ "events.add": "Add event",
+ "events.addDialogTitle": "Add team event",
+ "events.membersWillSee": "Members will see this on their calendar.",
+ "events.none": "No events this week",
+ "events.delete": "Delete event",
+ "events.deleteConfirm": "Delete the event {{name}}?",
+ "friends.availabilityOf": "{{name}}'s availability",
+ "registration.title": "Availability",
+ "registration.estimated": "estimated",
+ "registration.friends": "Friends",
+ "registration.beyondHorizon": "Schedules for that week open on {{date}}",
+ "registration.summary.available": "{{amount}} available",
+ "registration.summary.partial": "{{amount}} partial",
+ "registration.summary.out": "{{amount}} out",
+ "registration.summary.unknown": "{{amount}} unknown",
+ "team.canPlay": "Team can play ({{players}}+)",
+ "team.currentWeek": "This week",
+ "team.hidden": "Only team members can see the team schedule",
+ "team.nextWeek": "Next week",
+ "team.noSchedule": "No schedule",
+ "team.notAvailable": "Not available",
+ "team.noWindows": "No shared free time",
+ "team.weekHeading": "Week {{week}}",
+ "team.withSub": "With a sub ({{players}})",
+ "picker.title": "Pick a start time from your team's schedule",
+ "picker.free": "{{amount}} free",
+ "picker.noSchedule": "No schedule this week: {{users}}",
+ "picker.andOthers": "{{amount}} more",
+ "picker.legend.full": "{{players}}+ free",
+ "picker.legend.oneShort": "{{players}} free (sub?)",
+ "scrims.availableOfRoster": "{{amount}}/{{total}} available"
+}
diff --git a/locales/en/team.json b/locales/en/team.json
index 05904fc34..f3e3673d3 100644
--- a/locales/en/team.json
+++ b/locales/en/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Leave",
"actionButtons.editTeam": "Edit Team",
"actionButtons.manageRoster": "Manage Members",
+ "actionButtons.schedule": "Schedule",
"actionButtons.deleteTeam": "Delete Team",
"actionButtons.deleteTeam.profilePicture": "Remove Profile Picture",
"actionButtons.deleteTeam.banner": "Remove Banner",
diff --git a/locales/en/tournament.json b/locales/en/tournament.json
index 0b225d3a0..cb509888b 100644
--- a/locales/en/tournament.json
+++ b/locales/en/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Fill roster",
"pre.roster.footer": "At least {{atLeastCount}} members are required to participate. Max roster size is {{maxCount}}.",
"pre.roster.footer.noSubs": "Format is {{format}}. No subs allowed.",
- "pre.roster.addFriend.header": "Add friends",
- "pre.roster.addFriend.others": "Others",
- "pre.roster.delete.button": "Delete member",
- "pre.roster.delete.header": "Member to delete",
"pre.roster.ignWarning": "Note that you are expected to use the in-game names as listed above. Playing in the event with a different name or using the alias feature might result in disqualification.",
+ "pre.roster.quickAdd": "Quick add",
+ "pre.roster.quickAdd.pickup": "Pickup",
+ "pre.roster.quickAdd.addAll": "Add all from {{team}}",
+ "pre.roster.quickAdd.addAll.confirm": "Add these players from {{team}} to the roster?",
+ "pre.roster.addMembers": "Add members",
+ "pre.roster.emptySlot": "Empty slot",
+ "pre.roster.emptySlot.optional": "Optional slot",
+ "pre.roster.remove.confirm": "Remove {{name}} from the roster?",
"pre.pool.header": "Pick map pool",
"pre.pool.banned": "Banned",
"pre.pool.tiebreaker.short": "Tiebreaker",
@@ -153,7 +157,6 @@
"staff.divider.addedForEvent": "For this event",
"staff.editOrganization": "Edit organization",
"actions.addSub": "Add sub",
- "actions.shareLink": "Share your invite link to add members: {{inviteLink}}",
"actions.sub.prompt_other": "You can still add {{count}} subs to your roster",
"actions.sub.prompt_one": "You can still add {{count}} sub to your roster",
"actions.sub.prompt_zero": "Your roster is full and more subs can't be added",
diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json
index 28817cf22..fbe650018 100644
--- a/locales/es-ES/calendar.json
+++ b/locales/es-ES/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "Registrado",
"events.view.hosting": "Organizando",
"events.view.scrims": "Scrims",
+ "events.view.team": "",
"events.view.saved": "Guardados",
"events.view.organization": "Organización",
"events.empty": "No hay eventos en esta categoría",
diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json
index 2fc34a1d1..e3be2b89e 100644
--- a/locales/es-ES/common.json
+++ b/locales/es-ES/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} ha invitado a tu grupo en {{tournamentName}}",
"notifications.title.TO_LIKE_ACCEPTED": "Invitación de grupo aceptada",
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Inicio de Sesión Cancelado",
"auth.errors.failed": "Error al Iniciar Sesión",
"auth.errors.discordPermissions": "Para tu perfil de sendou.ink, el sitio necesita acceso al nombre, avatar y conexiones sociales de tu perfil de Discord.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.",
"build.private": "Privado",
"or": "O",
+ "inviteLink": "",
"yes": "Sí",
"no": "No",
"leaderboard.tabs.players": "",
diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json
index 3a06c99f8..1233622a7 100644
--- a/locales/es-ES/forms.json
+++ b/locales/es-ES/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "Mensaje",
"labels.scrimRequestStartTime": "Hora de inicio",
"bottomTexts.scrimRequestStartTime": "Selecciona una hora dentro del rango de tiempo de la publicación",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "La fecha no puede ser en el pasado",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "La fecha es demasiado temprana",
"errors.dateTooLate": "La fecha es demasiado tarde",
"errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro",
diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json
index 43a78b935..55d944244 100644
--- a/locales/es-ES/front.json
+++ b/locales/es-ES/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Buscando scrim",
"sideNav.scrimRequestPending": "Solicitud pendiente",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "Menú",
"mobileNav.friends": "Amigos",
"mobileNav.you": "Tú",
diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/es-ES/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/es-ES/team.json b/locales/es-ES/team.json
index 4ccfbf1da..84c50023e 100644
--- a/locales/es-ES/team.json
+++ b/locales/es-ES/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Abandonar",
"actionButtons.editTeam": "Editar equipo",
"actionButtons.manageRoster": "Gestionar miembros",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Eliminar equipo",
"actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil",
"actionButtons.deleteTeam.banner": "Eliminar banner",
diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json
index a658e54bc..00271f63a 100644
--- a/locales/es-ES/tournament.json
+++ b/locales/es-ES/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Llenar equipo",
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}",
"pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.",
- "pre.roster.addFriend.header": "Añadir amigos",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Borrar miembro",
- "pre.roster.delete.header": "Miembro que quieres borrar",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Escoger grupo de mapas",
"pre.pool.banned": "Prohibidos",
"pre.pool.tiebreaker.short": "Desempate",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "Para este evento",
"staff.editOrganization": "Editar organización",
"actions.addSub": "Añadir sub",
- "actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json
index 9f4655cdb..41967c393 100644
--- a/locales/es-US/calendar.json
+++ b/locales/es-US/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "Registrado",
"events.view.hosting": "Organizando",
"events.view.scrims": "Scrims",
+ "events.view.team": "",
"events.view.saved": "Guardados",
"events.view.organization": "Organización",
"events.empty": "No hay eventos en esta categoría",
diff --git a/locales/es-US/common.json b/locales/es-US/common.json
index 6ba4902e5..f529281e9 100644
--- a/locales/es-US/common.json
+++ b/locales/es-US/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} ha invitado a tu grupo en {{tournamentName}}",
"notifications.title.TO_LIKE_ACCEPTED": "Invitación de grupo aceptada",
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Ingreso cancelado",
"auth.errors.failed": "Ingreso fallido",
"auth.errors.discordPermissions": "Para tu perfil en sendou.ink, el sitio requiere acceso a tu nombre en Discord, avatar, y redes sociales.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.",
"build.private": "Privado",
"or": "O",
+ "inviteLink": "",
"yes": "Sí",
"no": "No",
"leaderboard.tabs.players": "",
diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json
index 459297dc4..1b8883402 100644
--- a/locales/es-US/forms.json
+++ b/locales/es-US/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "Mensaje",
"labels.scrimRequestStartTime": "Hora de inicio",
"bottomTexts.scrimRequestStartTime": "Selecciona una hora dentro del rango de tiempo de la publicación",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "La fecha no puede ser en el pasado",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "La fecha es demasiado temprana",
"errors.dateTooLate": "La fecha es demasiado tarde",
"errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro",
diff --git a/locales/es-US/front.json b/locales/es-US/front.json
index efb962de2..68993ce2f 100644
--- a/locales/es-US/front.json
+++ b/locales/es-US/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Buscando scrim",
"sideNav.scrimRequestPending": "Solicitud pendiente",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "Menú",
"mobileNav.friends": "Amigos",
"mobileNav.you": "Tú",
diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/es-US/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/es-US/team.json b/locales/es-US/team.json
index f03a74656..4528de9d9 100644
--- a/locales/es-US/team.json
+++ b/locales/es-US/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Abandonar",
"actionButtons.editTeam": "Editar Equipo",
"actionButtons.manageRoster": "Gestionar miembros",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Eliminar Equipo",
"actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil",
"actionButtons.deleteTeam.banner": "Eliminar banner",
diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json
index c21641bf5..bc8f2858e 100644
--- a/locales/es-US/tournament.json
+++ b/locales/es-US/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Llenar equipo",
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}",
"pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.",
- "pre.roster.addFriend.header": "Añadir amigos",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Borrar miembro",
- "pre.roster.delete.header": "Miembro que quieres borrar",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Escoger grupo de escenarios",
"pre.pool.banned": "Prohibidos",
"pre.pool.tiebreaker.short": "Desempate",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "Para este evento",
"staff.editOrganization": "Editar organización",
"actions.addSub": "Añadir sub",
- "actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json
index 8fbce9bd7..9d5fad56d 100644
--- a/locales/fr-CA/calendar.json
+++ b/locales/fr-CA/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json
index 795687d72..f725c62cf 100644
--- a/locales/fr-CA/common.json
+++ b/locales/fr-CA/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Connexion abandonnée",
"auth.errors.failed": "Connexion échouée",
"auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privé",
"or": "Ou",
+ "inviteLink": "",
"yes": "Oui",
"no": "Non",
"leaderboard.tabs.players": "",
diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json
index d9c951b84..7cf260a4b 100644
--- a/locales/fr-CA/forms.json
+++ b/locales/fr-CA/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json
index df82d5263..50d180a95 100644
--- a/locales/fr-CA/front.json
+++ b/locales/fr-CA/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/fr-CA/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/fr-CA/team.json b/locales/fr-CA/team.json
index 2e6bdab4d..41530bc02 100644
--- a/locales/fr-CA/team.json
+++ b/locales/fr-CA/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Quitter",
"actionButtons.editTeam": "Modifier l'équipe",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Supprimer l'équipe",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json
index 9de3b4e3c..584380ef8 100644
--- a/locales/fr-CA/tournament.json
+++ b/locales/fr-CA/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Remplir la liste",
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Effacer membre",
- "pre.roster.delete.header": "Membre à effacer",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Sélection de stage",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Ajouter remplaçant",
- "actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
"actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",
diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json
index 8fbce9bd7..9d5fad56d 100644
--- a/locales/fr-EU/calendar.json
+++ b/locales/fr-EU/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json
index b448e6979..a832c75d7 100644
--- a/locales/fr-EU/common.json
+++ b/locales/fr-EU/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Connexion abandonnée",
"auth.errors.failed": "Connexion échouée",
"auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privé",
"or": "Ou",
+ "inviteLink": "",
"yes": "Oui",
"no": "Non",
"leaderboard.tabs.players": "",
diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json
index 90f2f257b..e3e091e3b 100644
--- a/locales/fr-EU/forms.json
+++ b/locales/fr-EU/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json
index faaec0026..f7194efda 100644
--- a/locales/fr-EU/front.json
+++ b/locales/fr-EU/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/fr-EU/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/fr-EU/team.json b/locales/fr-EU/team.json
index 0e64ac75a..a240a8335 100644
--- a/locales/fr-EU/team.json
+++ b/locales/fr-EU/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Quitter",
"actionButtons.editTeam": "Modifier l'équipe",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Supprimer l'équipe",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json
index 8ba5e4c73..2f83aeb47 100644
--- a/locales/fr-EU/tournament.json
+++ b/locales/fr-EU/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Remplir la liste",
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Effacer membre",
- "pre.roster.delete.header": "Membre à effacer",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Sélection de stage",
"pre.pool.banned": "Bannis",
"pre.pool.tiebreaker.short": "Manche décisive",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Ajouter remplaçant",
- "actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
"actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",
diff --git a/locales/he/calendar.json b/locales/he/calendar.json
index da78a0b78..50f7c8457 100644
--- a/locales/he/calendar.json
+++ b/locales/he/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/he/common.json b/locales/he/common.json
index e1079807a..a28d18b2f 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "הכניסה בוטלה",
"auth.errors.failed": "הכניסה נכשלה",
"auth.errors.discordPermissions": "עבור פרופיל sendou.ink שלך, האתר זקוק לגישה לשם, הפרופיל והקשרים החברתיים של פרופיל ה-Discord שלך.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "פרטי",
"or": "או",
+ "inviteLink": "",
"yes": "כן",
"no": "לא",
"leaderboard.tabs.players": "",
diff --git a/locales/he/forms.json b/locales/he/forms.json
index 1a5885194..e9548f971 100644
--- a/locales/he/forms.json
+++ b/locales/he/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/he/front.json b/locales/he/front.json
index df82d5263..50d180a95 100644
--- a/locales/he/front.json
+++ b/locales/he/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/he/schedule.json b/locales/he/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/he/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/he/team.json b/locales/he/team.json
index be7f271ae..327171e6e 100644
--- a/locales/he/team.json
+++ b/locales/he/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "לעזוב",
"actionButtons.editTeam": "עריכת צוות",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "מחיקת צוות",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/he/tournament.json b/locales/he/tournament.json
index 3555e5576..1f44bc75c 100644
--- a/locales/he/tournament.json
+++ b/locales/he/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "מלא צוות",
"pre.roster.footer": "לפחות {{atLeastCount}} חברי צוות נדרשים כדי להשתתף. גודל הצוות המרבי הוא {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "מחקו חבר צוות",
- "pre.roster.delete.header": "חבר צוות למחיקה",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "בחרו מאגר מפות",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "הוסיפו ממלא מקום",
- "actions.shareLink": "שתפו קישור הזמנה להוספת חברי צוות: {{inviteLink}}",
"actions.sub.prompt_one": "אתם עדיין יכולים להוסיף {{count}} ממלא מקום לצוות שלכם",
"actions.sub.prompt_two": "",
"actions.sub.prompt_other": "אתם עדיין יכולים להוסיף {{count}} ממלאי מקום לצוות שלכם",
diff --git a/locales/it/calendar.json b/locales/it/calendar.json
index fb89f677b..5f2187a38 100644
--- a/locales/it/calendar.json
+++ b/locales/it/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/it/common.json b/locales/it/common.json
index 9604f1b35..6125a6114 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Accesso cancellato",
"auth.errors.failed": "Accesso fallito",
"auth.errors.discordPermissions": "Per il tuo profilo di sendou.ink, il sito ha bisogno di accesso al nome utente, avatar e connessioni social del tuo profilo Discord.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privato",
"or": "O",
+ "inviteLink": "",
"yes": "Sì",
"no": "No",
"leaderboard.tabs.players": "",
diff --git a/locales/it/forms.json b/locales/it/forms.json
index afc04ae48..5ac452fd8 100644
--- a/locales/it/forms.json
+++ b/locales/it/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/it/front.json b/locales/it/front.json
index 2f1b62a1d..159266a35 100644
--- a/locales/it/front.json
+++ b/locales/it/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/it/schedule.json b/locales/it/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/it/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/it/team.json b/locales/it/team.json
index bdebb8191..a8d6605a7 100644
--- a/locales/it/team.json
+++ b/locales/it/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Lascia",
"actionButtons.editTeam": "Modifica team",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Delete team",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/it/tournament.json b/locales/it/tournament.json
index 3c6e54276..cc87be634 100644
--- a/locales/it/tournament.json
+++ b/locales/it/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Riempi roster",
"pre.roster.footer": "Sono necessari almeno {{atLeastCount}} membri per partecipare. La dimensione massima del roster è {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Elimina membro",
- "pre.roster.delete.header": "Membro da eliminare",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Scegli pool mappe",
"pre.pool.banned": "Banneta",
"pre.pool.tiebreaker.short": "Spareggio",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Aggiungi sub",
- "actions.shareLink": "Condividi il tuo link d'invito per aggiungere membri: {{inviteLink}}",
"actions.sub.prompt_one": "Puoi ancora aggiungere {{count}} sub al tuo roster",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Puoi ancora aggiungere {{count}} sub al tuo roster",
diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json
index 2f70e6b11..f52c6dfc6 100644
--- a/locales/ja/calendar.json
+++ b/locales/ja/calendar.json
@@ -81,6 +81,7 @@
"events.view.registered": "参加",
"events.view.hosting": "運営",
"events.view.scrims": "対抗戦",
+ "events.view.team": "",
"events.view.saved": "保存済み",
"events.view.organization": "",
"events.empty": "イベントはありません",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index e57a72e53..77c7c44c2 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}}があなたのグループを{{tournamentName}}に招待しました",
"notifications.title.TO_LIKE_ACCEPTED": "招待が承諾されました",
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}}が{{tournamentName}}への招待が承諾されました",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "ログインを中断しました",
"auth.errors.failed": "ログインに失敗しました",
"auth.errors.discordPermissions": "sendou.ink プロファイルを作成するには、Discord 名、アバター、そしてSNSの連携が必要です。",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "非公開",
"or": "または",
+ "inviteLink": "",
"yes": "はい",
"no": "いいえ",
"leaderboard.tabs.players": "",
diff --git a/locales/ja/forms.json b/locales/ja/forms.json
index 138b164be..397f79646 100644
--- a/locales/ja/forms.json
+++ b/locales/ja/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/ja/front.json b/locales/ja/front.json
index df82d5263..50d180a95 100644
--- a/locales/ja/front.json
+++ b/locales/ja/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/ja/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/ja/team.json b/locales/ja/team.json
index 967acf2cb..15a08796d 100644
--- a/locales/ja/team.json
+++ b/locales/ja/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "チームを抜ける",
"actionButtons.editTeam": "チームを編集",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "チームを削除",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json
index 030b0d28b..7bee31700 100644
--- a/locales/ja/tournament.json
+++ b/locales/ja/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "参加プレイヤーを登録",
"pre.roster.footer": "少なくとも {{atLeastCount}} 人の参加が必要です。最大メンバー数は {{maxCount}} です。",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "メンバーを削除する",
- "pre.roster.delete.header": "削除するメンバー",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "マッププールを選択する",
"pre.pool.banned": "禁止",
"pre.pool.tiebreaker.short": "タイブレイカー",
@@ -151,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "サブを追加",
- "actions.shareLink": "メンバー招待リンクをシェアする: {{inviteLink}}",
"actions.sub.prompt_zero": "メンバーが上限に達しているので、これ以上サブを追加することができません",
"actions.finalize": "",
"actions.finalize.button": "",
diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json
index aad8656c7..43aebe643 100644
--- a/locales/ko/calendar.json
+++ b/locales/ko/calendar.json
@@ -79,6 +79,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 951475ac9..e7bb1c6bb 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "로그인 중단됨",
"auth.errors.failed": "로그인 실패",
"auth.errors.discordPermissions": "sendou.ink 프로필을 위해 디스코드 프로필의 이름, 아바타와 연락처에 대한 접근이 필요합니다.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Private",
"or": "또는",
+ "inviteLink": "",
"yes": "네",
"no": "아니오",
"leaderboard.tabs.players": "",
diff --git a/locales/ko/forms.json b/locales/ko/forms.json
index ce8bb30c3..abceebdc2 100644
--- a/locales/ko/forms.json
+++ b/locales/ko/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/ko/front.json b/locales/ko/front.json
index df82d5263..50d180a95 100644
--- a/locales/ko/front.json
+++ b/locales/ko/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/ko/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/ko/team.json b/locales/ko/team.json
index 7e13669ec..59642bfe4 100644
--- a/locales/ko/team.json
+++ b/locales/ko/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "",
"actionButtons.editTeam": "",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json
index bef3bf695..4f1f4f42d 100644
--- a/locales/ko/tournament.json
+++ b/locales/ko/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "",
"pre.roster.footer": "",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "",
- "pre.roster.delete.header": "",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -151,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "",
- "actions.shareLink": "",
"actions.sub.prompt_zero": "",
"actions.finalize": "",
"actions.finalize.button": "",
diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json
index 15dca99f4..db7ea02e6 100644
--- a/locales/nl/calendar.json
+++ b/locales/nl/calendar.json
@@ -83,6 +83,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index a1c12c26e..814697afb 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "",
"auth.errors.failed": "",
"auth.errors.discordPermissions": "",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
+ "inviteLink": "",
"yes": "",
"no": "",
"leaderboard.tabs.players": "",
diff --git a/locales/nl/forms.json b/locales/nl/forms.json
index 883a2cc2f..dfa189efb 100644
--- a/locales/nl/forms.json
+++ b/locales/nl/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/nl/front.json b/locales/nl/front.json
index df82d5263..50d180a95 100644
--- a/locales/nl/front.json
+++ b/locales/nl/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/nl/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/nl/team.json b/locales/nl/team.json
index 7e13669ec..59642bfe4 100644
--- a/locales/nl/team.json
+++ b/locales/nl/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "",
"actionButtons.editTeam": "",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json
index 0e768460f..5f6e1e63c 100644
--- a/locales/nl/tournament.json
+++ b/locales/nl/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "",
"pre.roster.footer": "",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "",
- "pre.roster.delete.header": "",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -153,7 +157,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "",
- "actions.shareLink": "",
"actions.sub.prompt_one": "",
"actions.sub.prompt_other": "",
"actions.sub.prompt_zero": "",
diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json
index 592196284..e2427f359 100644
--- a/locales/pl/calendar.json
+++ b/locales/pl/calendar.json
@@ -87,6 +87,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 09eb92c4f..b83b8be41 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Logowanie przerwane",
"auth.errors.failed": "Logowanie nieudane",
"auth.errors.discordPermissions": "Do twojego profilu sendou.ink, ta strona potrzebuje dostęp do twojej nazwy, avataru i połączeń konta Discord.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
+ "inviteLink": "",
"yes": "",
"no": "",
"leaderboard.tabs.players": "",
diff --git a/locales/pl/forms.json b/locales/pl/forms.json
index 39ffdb336..21afa2ec0 100644
--- a/locales/pl/forms.json
+++ b/locales/pl/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/pl/front.json b/locales/pl/front.json
index df82d5263..50d180a95 100644
--- a/locales/pl/front.json
+++ b/locales/pl/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/pl/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/pl/team.json b/locales/pl/team.json
index 4fb714e1d..b1a20f22c 100644
--- a/locales/pl/team.json
+++ b/locales/pl/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Opuść",
"actionButtons.editTeam": "Edytuj Drużynę",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Usuń drużynę",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json
index 45d245417..33fa7c80b 100644
--- a/locales/pl/tournament.json
+++ b/locales/pl/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "",
"pre.roster.footer": "",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "",
- "pre.roster.delete.header": "",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -155,7 +159,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "",
- "actions.shareLink": "",
"actions.sub.prompt_one": "",
"actions.sub.prompt_few": "",
"actions.sub.prompt_many": "",
diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json
index 7002feace..29fe5df65 100644
--- a/locales/pt-BR/calendar.json
+++ b/locales/pt-BR/calendar.json
@@ -85,6 +85,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json
index 84beda5f6..961ed2a25 100644
--- a/locales/pt-BR/common.json
+++ b/locales/pt-BR/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Login Abortado",
"auth.errors.failed": "Login Falhou",
"auth.errors.discordPermissions": "Para o seu perfil do sendou.ink, o site precisa de acesso ao nome do perfil do seu Discord, incluindo também o avatar e conexões sociais.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privada",
"or": "Ou",
+ "inviteLink": "",
"yes": "Sim",
"no": "Não",
"leaderboard.tabs.players": "",
diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json
index dbf506ea9..53f897d78 100644
--- a/locales/pt-BR/forms.json
+++ b/locales/pt-BR/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json
index df82d5263..50d180a95 100644
--- a/locales/pt-BR/front.json
+++ b/locales/pt-BR/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/pt-BR/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/pt-BR/team.json b/locales/pt-BR/team.json
index e33749aec..09c1f38be 100644
--- a/locales/pt-BR/team.json
+++ b/locales/pt-BR/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Sair",
"actionButtons.editTeam": "Editar Time",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Apagar Time",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json
index c44589c13..36ae832d7 100644
--- a/locales/pt-BR/tournament.json
+++ b/locales/pt-BR/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Preencher lista",
"pre.roster.footer": "Pelo menos {{atLeastCount}} membros são necessários para participar. O número máximo da lista de participantes é de {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Excluir membro",
- "pre.roster.delete.header": "Membro a ser excluído",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Escolher seleção de mapas",
"pre.pool.banned": "Banido",
"pre.pool.tiebreaker.short": "Desempate",
@@ -154,7 +158,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Adicionar substituto(a)",
- "actions.shareLink": "Compartilhe seu link de convite para adicionar membros: {{inviteLink}}",
"actions.sub.prompt_one": "Você ainda pode adicionar {{count}} substituto(a) à sua lista",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Você ainda pode adicionar {{count}} substitutos(as) à sua lista",
diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json
index 0f5995c84..60a916e65 100644
--- a/locales/ru/calendar.json
+++ b/locales/ru/calendar.json
@@ -87,6 +87,7 @@
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
+ "events.view.team": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index a4955321b..11ae04e12 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "",
"notifications.title.TO_LIKE_ACCEPTED": "",
"notifications.text.TO_LIKE_ACCEPTED": "",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Вход отменён",
"auth.errors.failed": "Ошибка входа",
"auth.errors.discordPermissions": "Для вашего профиля на sendou.ink странице нужен доступ к вашему имени, аватару и привязанным аккаунтам соц. сетей в Discord.",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Приватный",
"or": "Или",
+ "inviteLink": "",
"yes": "Да",
"no": "Нет",
"leaderboard.tabs.players": "",
diff --git a/locales/ru/forms.json b/locales/ru/forms.json
index f25e5b880..c177c11c7 100644
--- a/locales/ru/forms.json
+++ b/locales/ru/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "",
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.dateTooFarInFuture": "",
diff --git a/locales/ru/front.json b/locales/ru/front.json
index 50622da2d..d81054318 100644
--- a/locales/ru/front.json
+++ b/locales/ru/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",
diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/ru/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/ru/team.json b/locales/ru/team.json
index b3e3f031a..d8a6cddf4 100644
--- a/locales/ru/team.json
+++ b/locales/ru/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Покинуть",
"actionButtons.editTeam": "Редактировать команду",
"actionButtons.manageRoster": "",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "Удалить команду",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",
diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json
index 4e7e5bff4..85823b75b 100644
--- a/locales/ru/tournament.json
+++ b/locales/ru/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "Заполните состав",
"pre.roster.footer": "Необходимый минимум игроков для данного турнира: {{atLeastCount}}. Максимальное количество игроков в составе: {{maxCount}}",
"pre.roster.footer.noSubs": "",
- "pre.roster.addFriend.header": "",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "Удалить участника",
- "pre.roster.delete.header": "Участник для удаления",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "Выберите пул арен",
"pre.pool.banned": "Запрещено",
"pre.pool.tiebreaker.short": "Тайбрейк",
@@ -155,7 +159,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Добавить запасного",
- "actions.shareLink": "Ссылка приглашения в команду: {{inviteLink}}",
"actions.sub.prompt_one": "Вы ещё можете добавить {{count}} запасного",
"actions.sub.prompt_few": "",
"actions.sub.prompt_many": "",
diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json
index 635c20b35..c78f710ba 100644
--- a/locales/zh/calendar.json
+++ b/locales/zh/calendar.json
@@ -81,6 +81,7 @@
"events.view.registered": "已报名",
"events.view.hosting": "我主办的",
"events.view.scrims": "对抗战",
+ "events.view.team": "",
"events.view.saved": "已保存",
"events.view.organization": "组织",
"events.empty": "该分类下暂无赛事。",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index c254e8204..293936c0d 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -108,6 +108,10 @@
"notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} 在赛事【{{tournamentName}}】中邀请了您的小组",
"notifications.title.TO_LIKE_ACCEPTED": "小组邀请已接受",
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} 在赛事【{{tournamentName}}】中接受了您的小组邀请",
+ "notifications.title.TEAM_EVENT_ADDED": "",
+ "notifications.text.TEAM_EVENT_ADDED": "",
+ "notifications.title.SCHEDULE_TEAM_REMINDER": "",
+ "notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "登录中止",
"auth.errors.failed": "登录失败",
"auth.errors.discordPermissions": "为了完善您的sendou.ink个人资料,网站需要获取您的 Discord 名字、头像和社交链接。",
@@ -358,6 +362,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "私人",
"or": "或",
+ "inviteLink": "",
"yes": "是",
"no": "否",
"leaderboard.tabs.players": "",
diff --git a/locales/zh/forms.json b/locales/zh/forms.json
index 8d9b2901f..81cff2877 100644
--- a/locales/zh/forms.json
+++ b/locales/zh/forms.json
@@ -127,7 +127,18 @@
"labels.scrimRequestMessage": "消息",
"labels.scrimRequestStartTime": "开始时间",
"bottomTexts.scrimRequestStartTime": "请在招募帖的时间范围内选择一个时间",
+ "labels.duration": "",
+ "options.duration.30m": "",
+ "options.duration.1h": "",
+ "options.duration.1h30m": "",
+ "options.duration.2h": "",
+ "options.duration.2h30m": "",
+ "options.duration.3h": "",
+ "options.duration.4h": "",
+ "options.duration.5h": "",
+ "options.duration.6h": "",
"errors.dateInPast": "日期不能早于当前时间",
+ "errors.dateTooFarAway": "",
"errors.dateTooEarly": "日期过早",
"errors.dateTooLate": "日期过晚",
"errors.dateTooFarInFuture": "日期不能晚于当前时间超过 2 周",
diff --git a/locales/zh/front.json b/locales/zh/front.json
index 5bec39084..9f16b6f82 100644
--- a/locales/zh/front.json
+++ b/locales/zh/front.json
@@ -28,6 +28,8 @@
"sideNav.scrimVs": "对战 {{opponent}}",
"sideNav.lookingForScrim": "寻找对抗战",
"sideNav.scrimRequestPending": "请求待处理",
+ "sideNav.scheduleNudge": "",
+ "sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "菜单",
"mobileNav.friends": "好友",
"mobileNav.you": "你",
diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json
new file mode 100644
index 000000000..deed72853
--- /dev/null
+++ b/locales/zh/schedule.json
@@ -0,0 +1,47 @@
+{
+ "commitment.scrim": "",
+ "editor.addTime": "",
+ "editor.copyLastWeek": "",
+ "editor.earlier": "",
+ "editor.editDay": "",
+ "editor.later": "",
+ "editor.notFilled": "",
+ "editor.note": "",
+ "editor.saved": "",
+ "editor.saveWeek": "",
+ "editor.title": "",
+ "editor.timesInYourTimezone": "",
+ "editor.visibility": "",
+ "events.title": "",
+ "events.add": "",
+ "events.addDialogTitle": "",
+ "events.membersWillSee": "",
+ "events.none": "",
+ "events.delete": "",
+ "events.deleteConfirm": "",
+ "friends.availabilityOf": "",
+ "registration.title": "",
+ "registration.estimated": "",
+ "registration.friends": "",
+ "registration.beyondHorizon": "",
+ "registration.summary.available": "",
+ "registration.summary.partial": "",
+ "registration.summary.out": "",
+ "registration.summary.unknown": "",
+ "team.canPlay": "",
+ "team.currentWeek": "",
+ "team.hidden": "",
+ "team.nextWeek": "",
+ "team.noSchedule": "",
+ "team.notAvailable": "",
+ "team.noWindows": "",
+ "team.weekHeading": "",
+ "team.withSub": "",
+ "picker.title": "",
+ "picker.free": "",
+ "picker.noSchedule": "",
+ "picker.andOthers": "",
+ "picker.legend.full": "",
+ "picker.legend.oneShort": "",
+ "scrims.availableOfRoster": ""
+}
diff --git a/locales/zh/team.json b/locales/zh/team.json
index 995cb0e57..29dc92f92 100644
--- a/locales/zh/team.json
+++ b/locales/zh/team.json
@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "退出",
"actionButtons.editTeam": "编辑队伍",
"actionButtons.manageRoster": "管理队员",
+ "actionButtons.schedule": "",
"actionButtons.deleteTeam": "删除队伍",
"actionButtons.deleteTeam.profilePicture": "移除队徽",
"actionButtons.deleteTeam.banner": "移除横幅",
diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json
index b9b53fff4..80fa2f64f 100644
--- a/locales/zh/tournament.json
+++ b/locales/zh/tournament.json
@@ -57,11 +57,15 @@
"pre.roster.header": "填写阵容",
"pre.roster.footer": "至少需要 {{atLeastCount}} 名成员才能参赛。最大阵容人数为 {{maxCount}} 人。",
"pre.roster.footer.noSubs": "赛制为 {{format}}。不允许替补。",
- "pre.roster.addFriend.header": "添加好友",
- "pre.roster.addFriend.others": "",
- "pre.roster.delete.button": "删除成员",
- "pre.roster.delete.header": "要删除的成员",
"pre.roster.ignWarning": "",
+ "pre.roster.quickAdd": "",
+ "pre.roster.quickAdd.pickup": "",
+ "pre.roster.quickAdd.addAll": "",
+ "pre.roster.quickAdd.addAll.confirm": "",
+ "pre.roster.addMembers": "",
+ "pre.roster.emptySlot": "",
+ "pre.roster.emptySlot.optional": "",
+ "pre.roster.remove.confirm": "",
"pre.pool.header": "选择场地池",
"pre.pool.banned": "已禁用",
"pre.pool.tiebreaker.short": "决胜局场地",
@@ -152,7 +156,6 @@
"staff.divider.addedForEvent": "仅限此赛事",
"staff.editOrganization": "编辑组织",
"actions.addSub": "添加替补",
- "actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}",
"actions.sub.prompt": "您仍可以向阵容中添加 {{count}} 名替补",
"actions.sub.prompt_zero": "您的阵容已满,无法添加更多替补",
"actions.finalize": "正在结束赛事",
diff --git a/migrations/20260822034109-availability.ts b/migrations/20260822034109-availability.ts
new file mode 100644
index 000000000..facbc6381
--- /dev/null
+++ b/migrations/20260822034109-availability.ts
@@ -0,0 +1,85 @@
+import { type Kysely, sql } from "kysely";
+
+/** Weekly availability users report for their teammates and friends, and the team events that block it */
+export async function up(db: Kysely): Promise {
+ await db.transaction().execute(async (trx) => {
+ await trx.schema
+ .createTable("AvailabilityWeek")
+ .addColumn("id", "integer", (col) => col.primaryKey())
+ .addColumn("userId", "integer", (col) =>
+ col.notNull().references("User.id").onDelete("cascade"),
+ )
+ .addColumn("weekStartsAt", "integer", (col) => col.notNull())
+ .addColumn("timezone", "text", (col) => col.notNull())
+ .addColumn("createdAt", "integer", (col) =>
+ col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
+ )
+ .addColumn("updatedAt", "integer", (col) =>
+ col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
+ )
+ .addUniqueConstraint("availability_week_user_id_week_starts_at", [
+ "userId",
+ "weekStartsAt",
+ ])
+ // every table in this schema is strict
+ .modifyEnd(sql`strict`)
+ .execute();
+
+ await trx.schema
+ .createTable("AvailabilitySlot")
+ .addColumn("id", "integer", (col) => col.primaryKey())
+ .addColumn("availabilityWeekId", "integer", (col) =>
+ col.notNull().references("AvailabilityWeek.id").onDelete("cascade"),
+ )
+ .addColumn("startsAt", "integer", (col) => col.notNull())
+ .addColumn("endsAt", "integer", (col) => col.notNull())
+ .modifyEnd(sql`strict`)
+ .execute();
+
+ await trx.schema
+ .createIndex("availability_slot_availability_week_id")
+ .on("AvailabilitySlot")
+ .column("availabilityWeekId")
+ .execute();
+
+ await trx.schema
+ .createTable("AvailabilityDayNote")
+ .addColumn("availabilityWeekId", "integer", (col) =>
+ col.notNull().references("AvailabilityWeek.id").onDelete("cascade"),
+ )
+ .addColumn("date", "text", (col) => col.notNull())
+ .addColumn("text", "text", (col) => col.notNull())
+ .addPrimaryKeyConstraint("availability_day_note_pk", [
+ "availabilityWeekId",
+ "date",
+ ])
+ .modifyEnd(sql`strict`)
+ .execute();
+
+ await trx.schema
+ .createTable("TeamEvent")
+ .addColumn("id", "integer", (col) => col.primaryKey())
+ .addColumn("teamId", "integer", (col) =>
+ col.notNull().references("AllTeam.id").onDelete("cascade"),
+ )
+ // the event belongs to the team, so it outlives its author's account,
+ // the way every other authored row of the schema does
+ .addColumn("authorId", "integer", (col) =>
+ col.references("User.id").onDelete("set null"),
+ )
+ .addColumn("name", "text", (col) => col.notNull())
+ .addColumn("startsAt", "integer", (col) => col.notNull())
+ .addColumn("endsAt", "integer", (col) => col.notNull())
+ .addColumn("createdAt", "integer", (col) =>
+ col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
+ )
+ .modifyEnd(sql`strict`)
+ .execute();
+
+ await trx.schema
+ .createIndex("team_event_team_id_starts_at")
+ .on("TeamEvent")
+ .columns(["teamId", "startsAt"])
+ .execute();
+ });
+}
diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts
index 23462c694..54e184c75 100644
--- a/scripts/benchmark-db/cases.ts
+++ b/scripts/benchmark-db/cases.ts
@@ -1,9 +1,11 @@
+import { subDays } from "date-fns";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
import * as ApiRepository from "~/features/api/ApiRepository.server";
import * as ArtRepository from "~/features/art/ArtRepository.server";
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
import * as LogInLinkRepository from "~/features/auth/LogInLinkRepository.server";
+import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
@@ -158,6 +160,66 @@ export function buildCases(fx: Fixtures): {
LogInLinkRepository.findValidByCode(code),
);
+ // AvailabilityRepository
+ add(
+ "AvailabilityRepository.findAllWeeksByUserIds",
+ both(fx.manyUserIds, fx.availabilityWindow),
+ ([userIds, window]) =>
+ AvailabilityRepository.findAllWeeksByUserIds({
+ userIds,
+ startsAt: window.startsAt,
+ endsAt: window.endsAt,
+ }),
+ );
+ add(
+ "AvailabilityRepository.hasReportedWeek",
+ both(fx.heavyUser, fx.availabilityWindow),
+ ([user, window]) =>
+ AvailabilityRepository.hasReportedWeek({
+ userId: user.id,
+ weekStartsAt: window.weekStartsAt,
+ }),
+ );
+ add(
+ "AvailabilityRepository.findWeekReminderUserIds",
+ fx.availabilityWindow,
+ (window) =>
+ AvailabilityRepository.findWeekReminderUserIds(window.weekStartsAt),
+ );
+ add(
+ "AvailabilityRepository.findAllTeamEventsByUserIds",
+ both(fx.manyUserIds, fx.availabilityWindow),
+ ([userIds, window]) =>
+ AvailabilityRepository.findAllTeamEventsByUserIds({
+ userIds,
+ startsAt: window.startsAt,
+ endsAt: window.endsAt,
+ }),
+ );
+ add(
+ "AvailabilityRepository.findTeamEventsByTeamId",
+ both(fx.heavyTeam, fx.availabilityWindow),
+ ([team, window]) =>
+ AvailabilityRepository.findTeamEventsByTeamId({
+ teamId: team.id,
+ startsAt: window.startsAt,
+ endsAt: window.endsAt,
+ }),
+ );
+ add(
+ "AvailabilityRepository.findAllUpcomingTeamEventsByUserId",
+ both(fx.heavyTeam, fx.availabilityWindow),
+ ([team, window]) =>
+ AvailabilityRepository.findAllUpcomingTeamEventsByUserId({
+ userId: team.memberUserId,
+ startsAt: window.startsAt,
+ endsAt: window.endsAt,
+ }),
+ );
+ add("AvailabilityRepository.findTeamEventById", fx.teamEventId, (id) =>
+ AvailabilityRepository.findTeamEventById(id),
+ );
+
// BadgeRepository
addStatic("BadgeRepository.findAll", () => BadgeRepository.findAll());
add("BadgeRepository.findById", fx.heavyBadgeId, (badgeId) =>
@@ -625,6 +687,16 @@ export function buildCases(fx: Fixtures): {
add("ScrimPostRepository.findUserScrims", fx.scrimUserIds, (userIds) =>
ScrimPostRepository.findUserScrims(userIds[0]),
);
+ add(
+ "ScrimPostRepository.findAllAcceptedByUserIds",
+ both(fx.scrimUserIds, fx.scrimWindow),
+ ([userIds, window]) =>
+ ScrimPostRepository.findAllAcceptedByUserIds({
+ userIds,
+ startsAt: dateToDatabaseTimestamp(window.startTime),
+ endsAt: dateToDatabaseTimestamp(window.endTime),
+ }),
+ );
// GroupMatchContinueVoteRepository
add(
@@ -1028,9 +1100,15 @@ export function buildCases(fx: Fixtures): {
org.memberUserId,
),
);
+ addStatic("TournamentOrganizationRepository.findAllSeries", () =>
+ TournamentOrganizationRepository.findAllSeries(),
+ );
addStatic(
- "TournamentOrganizationRepository.findAllSeriesWithTierHistory",
- () => TournamentOrganizationRepository.findAllSeriesWithTierHistory(),
+ "TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts",
+ () =>
+ TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts({
+ startedAfter: dateToDatabaseTimestamp(subDays(new Date(), 90)),
+ }),
);
// SavedCalendarEventRepository
@@ -1229,6 +1307,16 @@ export function buildCases(fx: Fixtures): {
fx.tournamentTeamPair,
(teamIds) => TournamentTeamRepository.findMapPoolsByTeamIds(teamIds),
);
+ add(
+ "TournamentTeamRepository.findAllRegistrationsByUserIds",
+ both(fx.manyUserIds, fx.availabilityWindow),
+ ([userIds, window]) =>
+ TournamentTeamRepository.findAllRegistrationsByUserIds({
+ userIds,
+ startsAt: window.startsAt,
+ endsAt: window.endsAt,
+ }),
+ );
add(
"TournamentTeamRepository.isOrganizerAddedMember",
both(fx.heavyTournamentTeamId, fx.heavyUser),
diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts
index 1cc8d857f..beb65af01 100644
--- a/scripts/benchmark-db/fixtures.ts
+++ b/scripts/benchmark-db/fixtures.ts
@@ -1,7 +1,8 @@
-import { sub } from "date-fns";
+import { addWeeks, sub } from "date-fns";
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
+import * as Availability from "~/features/availability/core/Availability";
import * as ChatRepository from "~/features/chat/ChatRepository.server";
import type { ChatRoomType } from "~/features/chat/chat-types";
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
@@ -60,6 +61,13 @@ export interface Fixtures {
calendarAuthorId: number | null;
calendarWindow: { startTime: Date; endTime: Date } | null;
scrimWindow: { startTime: Date; endTime: Date } | null;
+ /** The horizon availability reads cover: the current week's start, and the current-plus-next week as a range. */
+ availabilityWindow: {
+ weekStartsAt: number;
+ startsAt: number;
+ endsAt: number;
+ } | null;
+ teamEventId: number | null;
heavyScrimPostId: number | null;
scrimUserIds: number[] | null;
heavyOrg: {
@@ -174,6 +182,8 @@ export async function resolveFixtures(): Promise {
calendarAuthorId: await resolveCalendarAuthorId(),
calendarWindow: await resolveCalendarWindow(),
scrimWindow: await resolveScrimWindow(),
+ availabilityWindow: resolveAvailabilityWindow(),
+ teamEventId: await resolveTeamEventId(),
heavyScrimPostId,
scrimUserIds: await resolveScrimUserIds(heavyScrimPostId, heavyUser),
heavyOrg: await resolveHeavyOrg(),
@@ -1423,6 +1433,27 @@ async function resolveScannerIngestSendouq() {
};
}
+function resolveAvailabilityWindow() {
+ const current = Availability.weekRange(new Date(), "UTC");
+
+ return {
+ weekStartsAt: current.startsAt,
+ startsAt: current.startsAt,
+ endsAt: Availability.weekRange(addWeeks(new Date(), 1), "UTC").endsAt,
+ };
+}
+
+async function resolveTeamEventId() {
+ const row = await db
+ .selectFrom("TeamEvent")
+ .select("id")
+ .orderBy("startsAt", "desc")
+ .limit(1)
+ .executeTakeFirst();
+
+ return row?.id ?? null;
+}
+
async function resolveCastedTournamentId() {
const row = await db
.selectFrom("Tournament")
diff --git a/vite.config.ts b/vite.config.ts
index 38a4ceeff..a1abedd41 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -15,6 +15,26 @@ export default defineConfig((config) => {
},
},
plugins: [
+ {
+ // Vite dev serves everything with no-cache, so the browser revalidates
+ // the woff2 on every font re-resolution — any mutation (e.g. an
+ // intent-prefetch link mounting) then flashes fallback fonts across the
+ // whole page while the 304 round-trips. Fonts effectively never change,
+ // so dev caches them hard, matching how the production build serves them.
+ name: "cache-fonts-in-dev",
+ apply: "serve",
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (req.url?.includes("/fonts/") && req.url.includes(".woff2")) {
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=31536000, immutable",
+ );
+ }
+ next();
+ });
+ },
+ },
{
// Wraps CSS modules in a @layer so utility classes always win and, more
// generally, so that the more specific of two modules styling the same