Auto-cancel overlapping pending scrim posts/requests on booking
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

Closes #3150
This commit is contained in:
Kalle
2026-06-15 17:20:55 +03:00
parent c8ba0f52d2
commit 40ac14eb68
43 changed files with 464 additions and 2 deletions

View File

@@ -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}

View File

@@ -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",
};

View File

@@ -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<

View File

@@ -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":

View 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);
});
});

View File

@@ -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;

View File

@@ -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": {

View File

@@ -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"],

View File

@@ -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>

View File

@@ -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;

View File

@@ -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(

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "Neuen Scrim-Post erstellen",
"forms.with.title": "Mit",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "Créer un nouveau post",
"forms.with.title": "Avec",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"acceptModal.title": "",
"acceptModal.prevented": "",
"acceptModal.confirmFor": "",
"autoCancelInfo": "",
"postModal.footer": "",
"forms.title": "",
"forms.with.title": "",