mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 05:06:15 -05:00
Auto-cancel overlapping pending scrim posts/requests on booking
Closes #3150
This commit is contained in:
@@ -6,6 +6,7 @@ import type { SendouButtonProps } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { FormMessage } from "./FormMessage";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
|
||||
interface ChildProps {
|
||||
@@ -17,6 +18,7 @@ export function FormWithConfirm({
|
||||
fields,
|
||||
children,
|
||||
dialogHeading,
|
||||
description,
|
||||
submitButtonText,
|
||||
action,
|
||||
submitButtonTestId = "submit-button",
|
||||
@@ -31,6 +33,8 @@ export function FormWithConfirm({
|
||||
)[];
|
||||
children?: React.ReactElement<ChildProps>;
|
||||
dialogHeading: string;
|
||||
/** Optional explanatory text shown below the heading in the confirm dialog */
|
||||
description?: React.ReactNode;
|
||||
submitButtonText?: string;
|
||||
action?: string;
|
||||
submitButtonTestId?: string;
|
||||
@@ -96,6 +100,9 @@ export function FormWithConfirm({
|
||||
>
|
||||
<div className="stack md">
|
||||
<h2 className="text-md text-center">{dialogHeading}</h2>
|
||||
{description ? (
|
||||
<FormMessage type="info">{description}</FormMessage>
|
||||
) : null}
|
||||
<div className="stack horizontal md justify-center mt-2">
|
||||
<SubmitButton
|
||||
form={id}
|
||||
|
||||
@@ -29,6 +29,7 @@ const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
|
||||
SCRIM_SCHEDULED: "high",
|
||||
SCRIM_CANCELED: "high",
|
||||
SCRIM_STARTING_SOON: "high",
|
||||
SCRIM_AUTO_DELETED: "normal",
|
||||
COMMISSIONS_CLOSED: "normal",
|
||||
FRIEND_REQUEST_RECEIVED: "normal",
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@ export type Notification =
|
||||
"SCRIM_STARTING_SOON",
|
||||
{ id: number; opponentTeamName: string }
|
||||
>
|
||||
| NotificationItem<"SCRIM_AUTO_DELETED", { at: number }>
|
||||
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>
|
||||
| NotificationItem<"FRIEND_REQUEST_RECEIVED", { senderUsername: string }>
|
||||
| NotificationItem<
|
||||
|
||||
@@ -43,6 +43,7 @@ export const notificationNavIcon = (type: Notification["type"]) => {
|
||||
case "SCRIM_SCHEDULED":
|
||||
case "SCRIM_CANCELED":
|
||||
case "SCRIM_STARTING_SOON":
|
||||
case "SCRIM_AUTO_DELETED":
|
||||
return "scrims";
|
||||
case "FRIEND_REQUEST_RECEIVED":
|
||||
return "sendou_love";
|
||||
@@ -85,7 +86,8 @@ export const notificationLink = (notification: Notification) => {
|
||||
case "TO_TEST_CREATED":
|
||||
case "TO_CHECK_IN_OPENED":
|
||||
return tournamentRegisterPage(notification.meta.tournamentId);
|
||||
case "SCRIM_NEW_REQUEST": {
|
||||
case "SCRIM_NEW_REQUEST":
|
||||
case "SCRIM_AUTO_DELETED": {
|
||||
return scrimsPage();
|
||||
}
|
||||
case "SCRIM_CANCELED":
|
||||
|
||||
206
app/features/scrims/ScrimPostRepository.server.test.ts
Normal file
206
app/features/scrims/ScrimPostRepository.server.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { add, sub } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as ScrimPostRepository from "./ScrimPostRepository.server";
|
||||
|
||||
const BOOKED_AT = add(new Date(), { hours: 10 });
|
||||
|
||||
const dbTs = (date: Date) => dateToDatabaseTimestamp(date);
|
||||
|
||||
const WINDOW = {
|
||||
startTime: dbTs(sub(BOOKED_AT, { hours: 1 })),
|
||||
endTime: dbTs(add(BOOKED_AT, { hours: 1 })),
|
||||
};
|
||||
|
||||
function insertPost({
|
||||
at,
|
||||
rangeEnd = null,
|
||||
users,
|
||||
}: {
|
||||
at: Date;
|
||||
rangeEnd?: Date | null;
|
||||
users: Array<{ userId: number; isOwner: 0 | 1 }>;
|
||||
}) {
|
||||
return ScrimPostRepository.insert({
|
||||
at: dbTs(at),
|
||||
rangeEnd: rangeEnd ? dbTs(rangeEnd) : null,
|
||||
maxDiv: null,
|
||||
minDiv: null,
|
||||
teamId: null,
|
||||
text: null,
|
||||
maps: null,
|
||||
mapsTournamentId: null,
|
||||
users,
|
||||
visibility: null,
|
||||
managedByAnyone: false,
|
||||
isScheduledForFuture: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("findPendingOverlapsForUsers", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(5);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns a specific-time pending post in window with its member ids", async () => {
|
||||
const postId = await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [
|
||||
{ userId: 1, isOwner: 1 },
|
||||
{ userId: 2, isOwner: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const { posts } = await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(posts).toHaveLength(1);
|
||||
expect(posts[0]!.id).toBe(postId);
|
||||
expect(posts[0]!.memberIds.sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("returns a ranged post whose interval overlaps the window even if its start is outside", async () => {
|
||||
const postId = await insertPost({
|
||||
at: sub(BOOKED_AT, { hours: 2 }),
|
||||
rangeEnd: BOOKED_AT,
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
|
||||
const { posts } = await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(posts.map((p) => p.id)).toEqual([postId]);
|
||||
});
|
||||
|
||||
test("does not return a ranged post whose interval does not overlap the window", async () => {
|
||||
await insertPost({
|
||||
at: sub(BOOKED_AT, { hours: 5 }),
|
||||
rangeEnd: sub(BOOKED_AT, { hours: 3 }),
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
|
||||
const { posts } = await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("excludes the just-booked post even when it overlaps", async () => {
|
||||
const postId = await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
|
||||
const { posts } = await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1],
|
||||
...WINDOW,
|
||||
excludePostId: postId,
|
||||
});
|
||||
|
||||
expect(posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("does not return posts that involve none of the given users", async () => {
|
||||
await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [{ userId: 3, isOwner: 1 }],
|
||||
});
|
||||
|
||||
const { posts } = await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1, 2],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("excludes already-accepted (booked) posts", async () => {
|
||||
const postId = await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: postId,
|
||||
teamId: null,
|
||||
message: null,
|
||||
at: dbTs(BOOKED_AT),
|
||||
users: [{ userId: 3, isOwner: 1 }],
|
||||
});
|
||||
const post = await ScrimPostRepository.findById(postId);
|
||||
await ScrimPostRepository.acceptRequest(post!.requests[0]!.id);
|
||||
|
||||
const { posts, requestIds } =
|
||||
await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1, 3],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(posts).toHaveLength(0);
|
||||
expect(requestIds).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns pending request ids whose effective time falls in the window", async () => {
|
||||
const postId = await insertPost({
|
||||
at: add(BOOKED_AT, { hours: 3 }),
|
||||
users: [{ userId: 3, isOwner: 1 }],
|
||||
});
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: postId,
|
||||
teamId: null,
|
||||
message: null,
|
||||
at: dbTs(BOOKED_AT),
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
const post = await ScrimPostRepository.findById(postId);
|
||||
const requestId = post!.requests[0]!.id;
|
||||
|
||||
const { posts, requestIds } =
|
||||
await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(posts).toHaveLength(0);
|
||||
expect(requestIds).toEqual([requestId]);
|
||||
});
|
||||
|
||||
test("does not return pending requests whose effective time is outside the window", async () => {
|
||||
const postId = await insertPost({
|
||||
at: add(BOOKED_AT, { hours: 3 }),
|
||||
users: [{ userId: 3, isOwner: 1 }],
|
||||
});
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: postId,
|
||||
teamId: null,
|
||||
message: null,
|
||||
at: dbTs(add(BOOKED_AT, { hours: 3 })),
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
|
||||
const { requestIds } =
|
||||
await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: [1],
|
||||
...WINDOW,
|
||||
excludePostId: -1,
|
||||
});
|
||||
|
||||
expect(requestIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -437,6 +437,104 @@ export async function findAcceptedScrimsBetweenTwoTimestamps({
|
||||
return rows.map(mapDBRowToScrimPost).filter((post) => Scrim.isAccepted(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds pending (unaccepted, uncanceled, future) scrim posts and requests
|
||||
* involving any of the given users whose time overlaps [startTime, endTime].
|
||||
* Used to auto-clean conflicting availability when a scrim is scheduled.
|
||||
*
|
||||
* @returns posts (with their member ids, for notifying) and request ids
|
||||
* (deleted silently) that should be removed
|
||||
*/
|
||||
export async function findPendingOverlapsForUsers({
|
||||
userIds,
|
||||
startTime,
|
||||
endTime,
|
||||
excludePostId,
|
||||
}: {
|
||||
userIds: number[];
|
||||
/** window start, database timestamp (seconds) */
|
||||
startTime: number;
|
||||
/** window end, database timestamp (seconds) */
|
||||
endTime: number;
|
||||
excludePostId: number;
|
||||
}): Promise<{
|
||||
posts: Array<{ id: number; at: number; memberIds: number[] }>;
|
||||
requestIds: number[];
|
||||
}> {
|
||||
if (userIds.length === 0) {
|
||||
return { posts: [], requestIds: [] };
|
||||
}
|
||||
|
||||
const now = dateToDatabaseTimestamp(new Date());
|
||||
|
||||
const rows = await baseFindQuery
|
||||
.where("ScrimPost.canceledAt", "is", null)
|
||||
.where("ScrimPost.at", ">=", now)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("ScrimPostUser")
|
||||
.select("ScrimPostUser.scrimPostId")
|
||||
.whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostUser.userId", "in", userIds),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("ScrimPostRequest")
|
||||
.innerJoin(
|
||||
"ScrimPostRequestUser",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
"ScrimPostRequest.id",
|
||||
)
|
||||
.select("ScrimPostRequest.scrimPostId")
|
||||
.whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostRequestUser.userId", "in", userIds),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
const userIdSet = new Set(userIds);
|
||||
|
||||
const posts: Array<{ id: number; at: number; memberIds: number[] }> = [];
|
||||
const requestIds: number[] = [];
|
||||
|
||||
for (const post of rows
|
||||
.map(mapDBRowToScrimPost)
|
||||
.filter((post) => !Scrim.isAccepted(post))) {
|
||||
if (post.id === excludePostId) continue;
|
||||
|
||||
const postInvolvesUser = post.users.some((u) => userIdSet.has(u.id));
|
||||
const postIntervalOverlaps =
|
||||
post.at <= endTime && (post.rangeEnd ?? post.at) >= startTime;
|
||||
if (postInvolvesUser && postIntervalOverlaps) {
|
||||
posts.push({
|
||||
id: post.id,
|
||||
at: post.at,
|
||||
memberIds: post.users.map((u) => u.id),
|
||||
});
|
||||
}
|
||||
|
||||
for (const request of post.requests) {
|
||||
if (request.isAccepted) continue;
|
||||
const effectiveAt = request.at ?? post.at;
|
||||
const requestInvolvesUser = request.users.some((u) =>
|
||||
userIdSet.has(u.id),
|
||||
);
|
||||
if (
|
||||
requestInvolvesUser &&
|
||||
effectiveAt >= startTime &&
|
||||
effectiveAt <= endTime
|
||||
) {
|
||||
requestIds.push(request.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { posts, requestIds };
|
||||
}
|
||||
|
||||
export type SidebarScrim = {
|
||||
id: number;
|
||||
at: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { add } from "date-fns";
|
||||
import { add, sub } from "date-fns";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
actionError,
|
||||
errorToast,
|
||||
@@ -24,6 +25,7 @@ import { assertUnreachable } from "~/utils/types";
|
||||
import { navIconUrl, scrimPage, scrimsPage } from "~/utils/urls";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas";
|
||||
import { generateTimeOptions } from "../scrims-utils";
|
||||
import { usersListForPost } from "./scrims.new.server";
|
||||
@@ -174,6 +176,46 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (fullPost) {
|
||||
try {
|
||||
const bookedAt = databaseTimestampToDate(
|
||||
Scrim.getStartTime(fullPost),
|
||||
);
|
||||
const startTime = dateToDatabaseTimestamp(
|
||||
sub(bookedAt, { hours: SCRIM.AUTO_CANCEL_WINDOW_HOURS }),
|
||||
);
|
||||
const endTime = dateToDatabaseTimestamp(
|
||||
add(bookedAt, { hours: SCRIM.AUTO_CANCEL_WINDOW_HOURS }),
|
||||
);
|
||||
|
||||
const { posts, requestIds } =
|
||||
await ScrimPostRepository.findPendingOverlapsForUsers({
|
||||
userIds: Scrim.participantIdsListFromAccepted(fullPost),
|
||||
startTime,
|
||||
endTime,
|
||||
excludePostId: post.id,
|
||||
});
|
||||
|
||||
for (const requestId of requestIds) {
|
||||
await ScrimPostRepository.deleteRequest(requestId);
|
||||
}
|
||||
|
||||
for (const removed of posts) {
|
||||
await ScrimPostRepository.del(removed.id);
|
||||
notify({
|
||||
userIds: removed.memberIds,
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SCRIM_AUTO_DELETED",
|
||||
meta: { at: removed.at },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to auto-cancel overlapping scrims", error);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "CANCEL_REQUEST": {
|
||||
|
||||
@@ -524,6 +524,7 @@ export function ScrimRequestCard({
|
||||
dialogHeading={t("scrims:acceptModal.title", {
|
||||
groupName: teamName,
|
||||
})}
|
||||
description={t("scrims:autoCancelInfo")}
|
||||
fields={[
|
||||
["scrimPostRequestId", request.id],
|
||||
["_action", "ACCEPT_REQUEST"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
@@ -77,6 +78,7 @@ export function ScrimRequestModal({
|
||||
<FormField name="at" options={timeOptions} />
|
||||
) : null}
|
||||
<FormField name="message" />
|
||||
<FormMessage type="info">{t("scrims:autoCancelInfo")}</FormMessage>
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
|
||||
@@ -20,6 +20,7 @@ export const SCRIM = {
|
||||
REQUEST_MESSAGE_MAX_LENGTH: 200,
|
||||
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
|
||||
ROOM_LINK_FRESHNESS_MINUTES: 30,
|
||||
AUTO_CANCEL_WINDOW_HOURS: 1,
|
||||
};
|
||||
|
||||
export const SCRIM_TRACKING_AUTO_LOCK_HOURS = 4;
|
||||
|
||||
@@ -126,6 +126,48 @@ test.describe("Scrims", () => {
|
||||
await expect(page.getByText("Scheduled scrim")).toBeVisible();
|
||||
});
|
||||
|
||||
test("auto-cancels overlapping pending scrims when a scrim is booked", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seed(page, "NO_SCRIMS");
|
||||
await impersonate(page);
|
||||
|
||||
const bookedAt = new Date();
|
||||
bookedAt.setDate(bookedAt.getDate() + 1);
|
||||
bookedAt.setHours(18, 0, 0, 0);
|
||||
|
||||
const overlappingAt = new Date(bookedAt);
|
||||
overlappingAt.setMinutes(30); // within ±1h of the booked time
|
||||
|
||||
const farAwayAt = new Date(bookedAt);
|
||||
farAwayAt.setHours(22, 0, 0, 0); // outside the ±1h window
|
||||
|
||||
await createScrimPost(page, bookedAt, "Booked post");
|
||||
await createScrimPost(page, overlappingAt, "Overlapping post");
|
||||
await createScrimPost(page, farAwayAt, "Far away post");
|
||||
|
||||
// NZAP requests the earliest (soon-to-be-booked) post
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await navigate({ page, url: scrimsPage() });
|
||||
await page.getByTestId("available-scrims-tab").click();
|
||||
await page.getByTestId("request-scrim-button").first().click();
|
||||
await selectUser({ labelName: "User 2", page, userName: "5" });
|
||||
await selectUser({ labelName: "User 3", page, userName: "6" });
|
||||
await selectUser({ labelName: "User 4", page, userName: "7" });
|
||||
await submit(page);
|
||||
|
||||
// Author accepts the request, booking the scrim
|
||||
await impersonate(page);
|
||||
await navigate({ page, url: scrimsPage() });
|
||||
await page.getByTestId("confirm-modal-trigger-button").first().click();
|
||||
await submit(page, "confirm-button");
|
||||
|
||||
// The overlapping pending post is auto-removed, the far away one survives
|
||||
await page.getByRole("tab", { name: /Owned/ }).click();
|
||||
await expect(page.getByText("Far away post")).toBeVisible();
|
||||
await expect(page.getByText("Overlapping post")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("cancels a scrim and shows canceled status", async ({ page }) => {
|
||||
await seed(page);
|
||||
await impersonate(page, ADMIN_ID);
|
||||
@@ -356,6 +398,17 @@ test.describe("Scrims", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function createScrimPost(page: Page, at: Date, text: string) {
|
||||
await navigate({ page, url: newScrimPostPage() });
|
||||
|
||||
const form = createFormHelpers(page, scrimsNewFormSchema);
|
||||
|
||||
await form.setDateTime("at", at);
|
||||
await form.fill("postText", text);
|
||||
|
||||
await submit(page);
|
||||
}
|
||||
|
||||
async function reportScrimMapWinner(page: Page, winner: "ALPHA" | "BRAVO") {
|
||||
const testId = winner === "ALPHA" ? "winner-radio-1" : "winner-radio-2";
|
||||
await expect(
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "Neuen Scrim-Post erstellen",
|
||||
"forms.with.title": "Mit",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "The scrim vs. {{opponentTeamName}} was canceled",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "Scrim Starting Soon",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "Your scrim vs. {{opponentTeamName}} is starting soon",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "Scrim Post Removed",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "Your scrim post was automatically removed because you booked a scrim around that time (± 1 hour)",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "Commissions Closed",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "If your commissions are still open, please re-enable them",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "Friend Request",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "Accept the request to scrim by {{groupName}} & reject others (if any)?",
|
||||
"acceptModal.prevented": "Ask the person who posted the scrim to accept this request",
|
||||
"acceptModal.confirmFor": "Confirm for {{time}}",
|
||||
"autoCancelInfo": "Once a scrim is booked, any other posts and requests within ±1 hour that share players are automatically removed to avoid double-booking.",
|
||||
"postModal.footer": "Post created {{time}}",
|
||||
"forms.title": "Creating a new scrim post",
|
||||
"forms.with.title": "With",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "El scrim empieza pronto",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "Comisiones Cerradas",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "Si tus comisiones siguen abiertas, por favor vuelve a activarlas",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "¿Aceptar la petición de scrim de {{groupName}} y rechazar las demás (si las hay)?",
|
||||
"acceptModal.prevented": "Pide a la persona que publicó el scrim que acepte esta petición",
|
||||
"acceptModal.confirmFor": "Confirmar para: {{time}}",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "Publicación creada a las {{time}}",
|
||||
"forms.title": "Creando una nueva publicación de scrim",
|
||||
"forms.with.title": "Con",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "Créer un nouveau post",
|
||||
"forms.with.title": "Avec",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"notifications.text.SCRIM_CANCELED": "",
|
||||
"notifications.title.SCRIM_STARTING_SOON": "",
|
||||
"notifications.text.SCRIM_STARTING_SOON": "",
|
||||
"notifications.title.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.text.SCRIM_AUTO_DELETED": "",
|
||||
"notifications.title.COMMISSIONS_CLOSED": "",
|
||||
"notifications.text.COMMISSIONS_CLOSED": "",
|
||||
"notifications.title.FRIEND_REQUEST_RECEIVED": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"acceptModal.title": "",
|
||||
"acceptModal.prevented": "",
|
||||
"acceptModal.confirmFor": "",
|
||||
"autoCancelInfo": "",
|
||||
"postModal.footer": "",
|
||||
"forms.title": "",
|
||||
"forms.with.title": "",
|
||||
|
||||
Reference in New Issue
Block a user