+
{streamedMatch.stream.viewerCount}
diff --git a/app/features/sendouq/PrivateUserNoteRepository.server.ts b/app/features/sendouq/PrivateUserNoteRepository.server.ts
new file mode 100644
index 000000000..86349a1ae
--- /dev/null
+++ b/app/features/sendouq/PrivateUserNoteRepository.server.ts
@@ -0,0 +1,61 @@
+import { db } from "~/db/sql";
+import type { TablesInsertable } from "~/db/tables";
+import { databaseTimestampNow } from "~/utils/dates";
+
+export function byAuthorUserId(
+ authorId: number,
+ /** Which users to get notes for, if omitted all notes for author are returned */
+ targetUserIds: number[] = [],
+) {
+ let query = db
+ .selectFrom("PrivateUserNote")
+ .select([
+ "PrivateUserNote.sentiment",
+ "PrivateUserNote.targetId as targetUserId",
+ "PrivateUserNote.text",
+ "PrivateUserNote.updatedAt",
+ ])
+ .where("authorId", "=", authorId);
+
+ const targetUsersWithoutAuthor = targetUserIds.filter(
+ (id) => id !== authorId,
+ );
+ if (targetUsersWithoutAuthor.length > 0) {
+ query = query.where("targetId", "in", targetUsersWithoutAuthor);
+ }
+
+ return query.execute();
+}
+
+export function upsert(args: TablesInsertable["PrivateUserNote"]) {
+ return db
+ .insertInto("PrivateUserNote")
+ .values({
+ authorId: args.authorId,
+ targetId: args.targetId,
+ sentiment: args.sentiment,
+ text: args.text,
+ })
+ .onConflict((oc) =>
+ oc.columns(["authorId", "targetId"]).doUpdateSet({
+ sentiment: args.sentiment,
+ text: args.text,
+ updatedAt: databaseTimestampNow(),
+ }),
+ )
+ .execute();
+}
+
+export function del({
+ authorId,
+ targetId,
+}: {
+ authorId: number;
+ targetId: number;
+}) {
+ return db
+ .deleteFrom("PrivateUserNote")
+ .where("authorId", "=", authorId)
+ .where("targetId", "=", targetId)
+ .execute();
+}
diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts
deleted file mode 100644
index ea0ccfa56..000000000
--- a/app/features/sendouq/QRepository.server.ts
+++ /dev/null
@@ -1,409 +0,0 @@
-import { sub } from "date-fns";
-import { type NotNull, sql } from "kysely";
-import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
-import { db } from "~/db/sql";
-import type {
- Tables,
- TablesInsertable,
- UserMapModePreferences,
-} from "~/db/tables";
-import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
-import { IS_E2E_TEST_RUN } from "~/utils/e2e";
-import { shortNanoid } from "~/utils/id";
-import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
-import { userIsBanned } from "../ban/core/banned.server";
-import type { LookingGroupWithInviteCode } from "./q-types";
-
-export function mapModePreferencesByGroupId(groupId: number) {
- return db
- .selectFrom("GroupMember")
- .innerJoin("User", "User.id", "GroupMember.userId")
- .select(["User.id as userId", "User.mapModePreferences as preferences"])
- .where("GroupMember.groupId", "=", groupId)
- .where("User.mapModePreferences", "is not", null)
- .$narrowType<{ preferences: NotNull }>()
- .execute();
-}
-
-// groups visible for longer to make development easier
-const SECONDS_TILL_STALE =
- process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
-
-export async function findLookingGroups({
- minGroupSize,
- maxGroupSize,
- ownGroupId,
- includeChatCode = false,
- includeMapModePreferences = false,
- loggedInUserId,
-}: {
- minGroupSize?: number;
- maxGroupSize?: number;
- ownGroupId?: number;
- includeChatCode?: boolean;
- includeMapModePreferences?: boolean;
- loggedInUserId?: number;
-}): Promise
{
- const rows = await db
- .selectFrom("Group")
- .leftJoin("GroupMatch", (join) =>
- join.on((eb) =>
- eb.or([
- eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
- eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
- ]),
- ),
- )
- .select((eb) => [
- "Group.id",
- "Group.createdAt",
- "Group.chatCode",
- "Group.inviteCode",
- jsonArrayFrom(
- eb
- .selectFrom("GroupMember")
- .innerJoin("User", "User.id", "GroupMember.userId")
- .leftJoin("PlusTier", "PlusTier.userId", "GroupMember.userId")
- .select((arrayEb) => [
- ...COMMON_USER_FIELDS,
- "User.qWeaponPool as weapons",
- "PlusTier.tier as plusTier",
- "GroupMember.note",
- "GroupMember.role",
- "User.languages",
- "User.vc",
- "User.noScreen",
- jsonObjectFrom(
- eb
- .selectFrom("PrivateUserNote")
- .select([
- "PrivateUserNote.sentiment",
- "PrivateUserNote.text",
- "PrivateUserNote.updatedAt",
- ])
- .where("authorId", "=", loggedInUserId ?? -1)
- .where("targetId", "=", arrayEb.ref("User.id")),
- ).as("privateNote"),
- sql<
- string | null
- >`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as(
- "chatNameColor",
- ),
- ])
- .where("GroupMember.groupId", "=", eb.ref("Group.id"))
- .groupBy("GroupMember.userId"),
- ).as("members"),
- ])
- .$if(includeMapModePreferences, (qb) =>
- qb.select((eb) =>
- jsonArrayFrom(
- eb
- .selectFrom("GroupMember")
- .innerJoin("User", "User.id", "GroupMember.userId")
- .select("User.mapModePreferences")
- .where("GroupMember.groupId", "=", eb.ref("Group.id"))
- .where("User.mapModePreferences", "is not", null),
- ).as("mapModePreferences"),
- ),
- )
- .where("Group.status", "=", "ACTIVE")
- .where("GroupMatch.id", "is", null)
- .where((eb) =>
- eb.or([
- eb(
- "Group.latestActionAt",
- ">",
- sql`(unixepoch() - ${SECONDS_TILL_STALE})`,
- ),
- eb("Group.id", "=", ownGroupId ?? -1),
- ]),
- )
- .execute();
-
- // TODO: a bit weird we filter chatCode here but not inviteCode and do some logic about filtering
- return rows
- .map((row) => {
- return {
- ...row,
- chatCode: includeChatCode ? row.chatCode : undefined,
- mapModePreferences: row.mapModePreferences?.map(
- (c) => c.mapModePreferences,
- ) as NonNullable[],
- members: row.members.map((member) => {
- return {
- ...member,
- languages: member.languages ? member.languages.split(",") : [],
- } as LookingGroupWithInviteCode["members"][number];
- }),
- };
- })
- .filter((group) => {
- if (group.id === ownGroupId) return true;
- if (maxGroupSize && group.members.length > maxGroupSize) return false;
- if (minGroupSize && group.members.length < minGroupSize) return false;
-
- return true;
- });
-}
-
-export async function findActiveGroupMembers() {
- return db
- .selectFrom("GroupMember")
- .innerJoin("Group", "Group.id", "GroupMember.groupId")
- .select("GroupMember.userId")
- .where("Group.status", "!=", "INACTIVE")
- .execute();
-}
-
-type CreateGroupArgs = {
- status: Exclude;
- userId: number;
-};
-export function createGroup(args: CreateGroupArgs) {
- return db.transaction().execute(async (trx) => {
- const createdGroup = await trx
- .insertInto("Group")
- .values({
- inviteCode: shortNanoid(),
- chatCode: shortNanoid(),
- status: args.status,
- })
- .returning("id")
- .executeTakeFirstOrThrow();
-
- await trx
- .insertInto("GroupMember")
- .values({
- groupId: createdGroup.id,
- userId: args.userId,
- role: "OWNER",
- })
- .execute();
-
- return createdGroup;
- });
-}
-
-type CreateGroupFromPreviousGroupArgs = {
- previousGroupId: number;
- members: {
- id: number;
- role: Tables["GroupMember"]["role"];
- }[];
-};
-export async function createGroupFromPrevious(
- args: CreateGroupFromPreviousGroupArgs,
-) {
- return db.transaction().execute(async (trx) => {
- const createdGroup = await trx
- .insertInto("Group")
- .columns(["teamId", "chatCode", "inviteCode", "status"])
- .expression((eb) =>
- eb
- .selectFrom("Group")
- .select((eb) => [
- "Group.teamId",
- "Group.chatCode",
- eb.val(shortNanoid()).as("inviteCode"),
- eb.val("PREPARING").as("status"),
- ])
- .where("Group.id", "=", args.previousGroupId),
- )
- .returning("id")
- .executeTakeFirstOrThrow();
-
- await trx
- .insertInto("GroupMember")
- .values(
- args.members.map((member) => ({
- groupId: createdGroup.id,
- userId: member.id,
- role: member.role,
- })),
- )
- .execute();
-
- return createdGroup;
- });
-}
-
-export function rechallenge({
- likerGroupId,
- targetGroupId,
-}: {
- likerGroupId: number;
- targetGroupId: number;
-}) {
- return db
- .updateTable("GroupLike")
- .set({ isRechallenge: 1 })
- .where("likerGroupId", "=", likerGroupId)
- .where("targetGroupId", "=", targetGroupId)
- .execute();
-}
-
-export function upsertPrivateUserNote(
- args: TablesInsertable["PrivateUserNote"],
-) {
- return db
- .insertInto("PrivateUserNote")
- .values({
- authorId: args.authorId,
- targetId: args.targetId,
- sentiment: args.sentiment,
- text: args.text,
- })
- .onConflict((oc) =>
- oc.columns(["authorId", "targetId"]).doUpdateSet({
- sentiment: args.sentiment,
- text: args.text,
- updatedAt: dateToDatabaseTimestamp(new Date()),
- }),
- )
- .execute();
-}
-
-export function deletePrivateUserNote({
- authorId,
- targetId,
-}: {
- authorId: number;
- targetId: number;
-}) {
- return db
- .deleteFrom("PrivateUserNote")
- .where("authorId", "=", authorId)
- .where("targetId", "=", targetId)
- .execute();
-}
-
-/**
- * Retrieves information about users who have trusted the specified user,
- * including their associated teams and explicit trust relationships. Banned users are excluded.
- */
-export async function usersThatTrusted(userId: number) {
- const teams = await db
- .selectFrom("TeamMemberWithSecondary")
- .innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
- .select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"])
- .where("userId", "=", userId)
- .execute();
-
- const rows = await db
- .selectFrom("TeamMemberWithSecondary")
- .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
- .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
- .select([
- ...COMMON_USER_FIELDS,
- "User.inGameName",
- "TeamMemberWithSecondary.teamId",
- ])
- .where(
- "TeamMemberWithSecondary.teamId",
- "in",
- teams.map((t) => t.id),
- )
- .union((eb) =>
- eb
- .selectFrom("TrustRelationship")
- .innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
- .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
- .select([
- ...COMMON_USER_FIELDS,
- "User.inGameName",
- sql`null`.as("teamId"),
- ])
- .where("TrustRelationship.trustReceiverUserId", "=", userId),
- )
- .execute();
-
- const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id));
-
- const teamMemberIds = rowsWithoutBanned
- .filter((row) => row.teamId)
- .map((row) => row.id);
-
- // we want user to show twice if member of two different teams
- // but we don't want a user from the team to show in teamless section
- const deduplicatedRows = rowsWithoutBanned.filter(
- (row) => row.teamId || !teamMemberIds.includes(row.id),
- );
-
- // done here at not sql just because it was easier to do here ignoring case
- deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username));
-
- return {
- teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam),
- trusters: deduplicatedRows,
- };
-}
-
-/** Update the timestamp of the trust relationship, delaying its automatic deletion */
-export async function refreshTrust({
- trustGiverUserId,
- trustReceiverUserId,
-}: {
- trustGiverUserId: number;
- trustReceiverUserId: number;
-}) {
- return db
- .updateTable("TrustRelationship")
- .set({ lastUsedAt: databaseTimestampNow() })
- .where("trustGiverUserId", "=", trustGiverUserId)
- .where("trustReceiverUserId", "=", trustReceiverUserId)
- .execute();
-}
-
-export async function deleteOldTrust() {
- const twoMonthsAgo = sub(new Date(), { months: 2 });
-
- return db
- .deleteFrom("TrustRelationship")
- .where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo))
- .executeTakeFirst();
-}
-
-export async function setOldGroupsAsInactive() {
- const oneHourAgo = sub(new Date(), { hours: 1 });
-
- return db.transaction().execute(async (trx) => {
- const groupsToSetInactive = await trx
- .selectFrom("Group")
- .leftJoin("GroupMatch", (join) =>
- join.on((eb) =>
- eb.or([
- eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
- eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
- ]),
- ),
- )
- .select(["Group.id"])
- .where("status", "!=", "INACTIVE")
- .where("GroupMatch.id", "is", null)
- .where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo))
- .execute();
-
- return trx
- .updateTable("Group")
- .set({ status: "INACTIVE" })
- .where(
- "Group.id",
- "in",
- groupsToSetInactive.map((g) => g.id),
- )
- .executeTakeFirst();
- });
-}
-
-export async function mapModePreferencesBySeasonNth(seasonNth: number) {
- return db
- .selectFrom("Skill")
- .innerJoin("User", "User.id", "Skill.userId")
- .select("User.mapModePreferences")
- .where("Skill.season", "=", seasonNth)
- .where("Skill.userId", "is not", null)
- .where("User.mapModePreferences", "is not", null)
- .groupBy("Skill.userId")
- .$narrowType<{ mapModePreferences: UserMapModePreferences }>()
- .execute();
-}
diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts
new file mode 100644
index 000000000..352bcb26a
--- /dev/null
+++ b/app/features/sendouq/SQGroupRepository.server.ts
@@ -0,0 +1,696 @@
+import { sub } from "date-fns";
+import { type NotNull, sql, type Transaction } from "kysely";
+import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
+import { db } from "~/db/sql";
+import type { DB, Tables, UserMapModePreferences } from "~/db/tables";
+import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
+import { shortNanoid } from "~/utils/id";
+import invariant from "~/utils/invariant";
+import {
+ COMMON_USER_FIELDS,
+ userChatNameColorForJson,
+} from "~/utils/kysely.server";
+import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
+import { userIsBanned } from "../ban/core/banned.server";
+import { FULL_GROUP_SIZE } from "./q-constants";
+import { SendouQError } from "./q-utils.server";
+
+export function mapModePreferencesByGroupId(groupId: number) {
+ return db
+ .selectFrom("GroupMember")
+ .innerJoin("User", "User.id", "GroupMember.userId")
+ .select(["User.id as userId", "User.mapModePreferences as preferences"])
+ .where("GroupMember.groupId", "=", groupId)
+ .where("User.mapModePreferences", "is not", null)
+ .$narrowType<{ preferences: NotNull }>()
+ .execute();
+}
+
+export async function findCurrentGroups() {
+ type SendouQMemberObject = {
+ id: Tables["User"]["id"];
+ username: Tables["User"]["username"];
+ discordId: Tables["User"]["discordId"];
+ discordAvatar: Tables["User"]["discordAvatar"];
+ customUrl: Tables["User"]["customUrl"];
+ mapModePreferences: Tables["User"]["mapModePreferences"];
+ noScreen: Tables["User"]["noScreen"];
+ languages: Tables["User"]["languages"];
+ vc: Tables["User"]["vc"];
+ role: Tables["GroupMember"]["role"];
+ note: Tables["GroupMember"]["note"];
+ weapons: Tables["User"]["qWeaponPool"];
+ chatNameColor: string | null;
+ plusTier: Tables["PlusTier"]["tier"] | null;
+ };
+
+ return db
+ .selectFrom("Group")
+ .innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
+ .innerJoin("User", "User.id", "GroupMember.userId")
+ .leftJoin("PlusTier", "PlusTier.userId", "User.id")
+ .leftJoin("GroupMatch", (join) =>
+ join.on((eb) =>
+ eb.or([
+ eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
+ eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
+ ]),
+ ),
+ )
+ .select(({ fn, eb }) => [
+ "Group.id",
+ "Group.chatCode",
+ "Group.inviteCode",
+ "Group.latestActionAt",
+ "Group.chatCode",
+ "Group.inviteCode",
+ "Group.status",
+ "GroupMatch.id as matchId",
+ fn
+ .agg("json_group_array", [
+ jsonBuildObject({
+ id: eb.ref("User.id"),
+ username: eb.ref("User.username"),
+ discordId: eb.ref("User.discordId"),
+ discordAvatar: eb.ref("User.discordAvatar"),
+ customUrl: eb.ref("User.customUrl"),
+ mapModePreferences: eb.ref("User.mapModePreferences"),
+ noScreen: eb.ref("User.noScreen"),
+ role: eb.ref("GroupMember.role"),
+ note: eb.ref("GroupMember.note"),
+ weapons: eb.ref("User.qWeaponPool"),
+ languages: eb.ref("User.languages"),
+ plusTier: eb.ref("PlusTier.tier"),
+ vc: eb.ref("User.vc"),
+ chatNameColor: userChatNameColorForJson,
+ }),
+ ])
+ .$castTo()
+ .as("members"),
+ ])
+ .where((eb) =>
+ eb.or([
+ eb("Group.status", "=", "ACTIVE"),
+ eb("Group.status", "=", "PREPARING"),
+ ]),
+ )
+ .groupBy("Group.id")
+ .execute();
+}
+
+export async function findActiveGroupMembers() {
+ return db
+ .selectFrom("GroupMember")
+ .innerJoin("Group", "Group.id", "GroupMember.groupId")
+ .select("GroupMember.userId")
+ .where("Group.status", "!=", "INACTIVE")
+ .execute();
+}
+
+type CreateGroupArgs = {
+ status: Exclude;
+ userId: number;
+};
+export function createGroup(args: CreateGroupArgs) {
+ return db.transaction().execute(async (trx) => {
+ const createdGroup = await trx
+ .insertInto("Group")
+ .values({
+ inviteCode: shortNanoid(),
+ chatCode: shortNanoid(),
+ status: args.status,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ await trx
+ .insertInto("GroupMember")
+ .values({
+ groupId: createdGroup.id,
+ userId: args.userId,
+ role: "OWNER",
+ })
+ .execute();
+
+ if (!(await isGroupCorrect(createdGroup.id, trx))) {
+ throw new SendouQError("Group has a member in multiple groups");
+ }
+
+ return createdGroup;
+ });
+}
+
+type CreateGroupFromPreviousGroupArgs = {
+ previousGroupId: number;
+ members: {
+ id: number;
+ role: Tables["GroupMember"]["role"];
+ }[];
+};
+export async function createGroupFromPrevious(
+ args: CreateGroupFromPreviousGroupArgs,
+) {
+ return db.transaction().execute(async (trx) => {
+ const createdGroup = await trx
+ .insertInto("Group")
+ .columns(["teamId", "chatCode", "inviteCode", "status"])
+ .expression((eb) =>
+ eb
+ .selectFrom("Group")
+ .select((eb) => [
+ "Group.teamId",
+ "Group.chatCode",
+ eb.val(shortNanoid()).as("inviteCode"),
+ eb.val("PREPARING").as("status"),
+ ])
+ .where("Group.id", "=", args.previousGroupId),
+ )
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ await trx
+ .insertInto("GroupMember")
+ .values(
+ args.members.map((member) => ({
+ groupId: createdGroup.id,
+ userId: member.id,
+ role: member.role,
+ })),
+ )
+ .execute();
+
+ if (!(await isGroupCorrect(createdGroup.id, trx))) {
+ throw new SendouQError(
+ "Group has too many members or member in multiple groups",
+ );
+ }
+
+ return createdGroup;
+ });
+}
+
+function deleteLikesByGroupId(groupId: number, trx: Transaction) {
+ return trx
+ .deleteFrom("GroupLike")
+ .where((eb) =>
+ eb.or([
+ eb("GroupLike.likerGroupId", "=", groupId),
+ eb("GroupLike.targetGroupId", "=", groupId),
+ ]),
+ )
+ .execute();
+}
+
+export function morphGroups({
+ survivingGroupId,
+ otherGroupId,
+}: {
+ survivingGroupId: number;
+ otherGroupId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ // reset chat code so previous messages are not visible
+ await trx
+ .updateTable("Group")
+ .set({ chatCode: shortNanoid() })
+ .where("Group.id", "=", survivingGroupId)
+ .execute();
+
+ const otherGroupMembers = await trx
+ .selectFrom("GroupMember")
+ .select(["GroupMember.userId", "GroupMember.role"])
+ .where("GroupMember.groupId", "=", otherGroupId)
+ .execute();
+
+ for (const member of otherGroupMembers) {
+ const oldRole = otherGroupMembers.find(
+ (m) => m.userId === member.userId,
+ )?.role;
+ invariant(oldRole, "Member lacking a role");
+
+ await trx
+ .updateTable("GroupMember")
+ .set({
+ role:
+ oldRole === "OWNER"
+ ? "MANAGER"
+ : oldRole === "MANAGER"
+ ? "MANAGER"
+ : "REGULAR",
+ groupId: survivingGroupId,
+ })
+ .where("GroupMember.groupId", "=", otherGroupId)
+ .where("GroupMember.userId", "=", member.userId)
+ .execute();
+ }
+
+ await deleteLikesByGroupId(survivingGroupId, trx);
+ await refreshGroup(survivingGroupId, trx);
+
+ await trx
+ .deleteFrom("Group")
+ .where("Group.id", "=", otherGroupId)
+ .execute();
+
+ if (!(await isGroupCorrect(survivingGroupId, trx))) {
+ throw new SendouQError(
+ "Group has too many members or member in multiple groups",
+ );
+ }
+ });
+}
+
+/** Check that the group has at most FULL_GROUP_SIZE members and each member is only in this group */
+async function isGroupCorrect(
+ groupId: number,
+ trx: Transaction,
+): Promise {
+ const members = await trx
+ .selectFrom("GroupMember")
+ .select("GroupMember.userId")
+ .where("GroupMember.groupId", "=", groupId)
+ .execute();
+
+ if (members.length > FULL_GROUP_SIZE) {
+ return false;
+ }
+
+ for (const member of members) {
+ const otherGroup = await trx
+ .selectFrom("GroupMember")
+ .innerJoin("Group", "Group.id", "GroupMember.groupId")
+ .select(["Group.id"])
+ .where("GroupMember.userId", "=", member.userId)
+ .where("Group.status", "!=", "INACTIVE")
+ .where("GroupMember.groupId", "!=", groupId)
+ .executeTakeFirst();
+
+ if (otherGroup) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+export function addMember(
+ groupId: number,
+ {
+ userId,
+ role = "REGULAR",
+ }: {
+ userId: number;
+ role?: Tables["GroupMember"]["role"];
+ },
+) {
+ return db.transaction().execute(async (trx) => {
+ await trx
+ .insertInto("GroupMember")
+ .values({
+ groupId,
+ userId,
+ role,
+ })
+ .execute();
+
+ await deleteLikesByGroupId(groupId, trx);
+
+ if (!(await isGroupCorrect(groupId, trx))) {
+ throw new SendouQError(
+ "Group has too many members or member in multiple groups",
+ );
+ }
+ });
+}
+
+export async function allLikesByGroupId(groupId: number) {
+ const rows = await db
+ .selectFrom("GroupLike")
+ .select([
+ "GroupLike.likerGroupId",
+ "GroupLike.targetGroupId",
+ "GroupLike.isRechallenge",
+ ])
+ .where((eb) =>
+ eb.or([
+ eb("GroupLike.likerGroupId", "=", groupId),
+ eb("GroupLike.targetGroupId", "=", groupId),
+ ]),
+ )
+ .execute();
+
+ return {
+ given: rows
+ .filter((row) => row.likerGroupId === groupId)
+ .map((row) => ({
+ groupId: row.targetGroupId,
+ isRechallenge: row.isRechallenge,
+ })),
+ received: rows
+ .filter((row) => row.targetGroupId === groupId)
+ .map((row) => ({
+ groupId: row.likerGroupId,
+ isRechallenge: row.isRechallenge,
+ })),
+ };
+}
+
+export function rechallenge({
+ likerGroupId,
+ targetGroupId,
+}: {
+ likerGroupId: number;
+ targetGroupId: number;
+}) {
+ return db
+ .updateTable("GroupLike")
+ .set({ isRechallenge: 1 })
+ .where("likerGroupId", "=", likerGroupId)
+ .where("targetGroupId", "=", targetGroupId)
+ .execute();
+}
+
+/**
+ * Retrieves information about users who have trusted the specified user,
+ * including their associated teams and explicit trust relationships. Banned users are excluded.
+ */
+export async function usersThatTrusted(userId: number) {
+ const teams = await db
+ .selectFrom("TeamMemberWithSecondary")
+ .innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
+ .select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"])
+ .where("userId", "=", userId)
+ .execute();
+
+ const rows = await db
+ .selectFrom("TeamMemberWithSecondary")
+ .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
+ .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
+ .select([
+ ...COMMON_USER_FIELDS,
+ "User.inGameName",
+ "TeamMemberWithSecondary.teamId",
+ ])
+ .where(
+ "TeamMemberWithSecondary.teamId",
+ "in",
+ teams.map((t) => t.id),
+ )
+ .union((eb) =>
+ eb
+ .selectFrom("TrustRelationship")
+ .innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
+ .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
+ .select([
+ ...COMMON_USER_FIELDS,
+ "User.inGameName",
+ sql`null`.as("teamId"),
+ ])
+ .where("TrustRelationship.trustReceiverUserId", "=", userId),
+ )
+ .execute();
+
+ const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id));
+
+ const teamMemberIds = rowsWithoutBanned
+ .filter((row) => row.teamId)
+ .map((row) => row.id);
+
+ // we want user to show twice if member of two different teams
+ // but we don't want a user from the team to show in teamless section
+ const deduplicatedRows = rowsWithoutBanned.filter(
+ (row) => row.teamId || !teamMemberIds.includes(row.id),
+ );
+
+ // done here at not sql just because it was easier to do here ignoring case
+ deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username));
+
+ return {
+ teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam),
+ trusters: deduplicatedRows,
+ };
+}
+
+/** Update the timestamp of the trust relationship, delaying its automatic deletion */
+export async function refreshTrust({
+ trustGiverUserId,
+ trustReceiverUserId,
+}: {
+ trustGiverUserId: number;
+ trustReceiverUserId: number;
+}) {
+ return db
+ .updateTable("TrustRelationship")
+ .set({ lastUsedAt: databaseTimestampNow() })
+ .where("trustGiverUserId", "=", trustGiverUserId)
+ .where("trustReceiverUserId", "=", trustReceiverUserId)
+ .execute();
+}
+
+export async function deleteOldTrust() {
+ const twoMonthsAgo = sub(new Date(), { months: 2 });
+
+ return db
+ .deleteFrom("TrustRelationship")
+ .where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo))
+ .executeTakeFirst();
+}
+
+export async function setOldGroupsAsInactive() {
+ const oneHourAgo = sub(new Date(), { hours: 1 });
+
+ return db.transaction().execute(async (trx) => {
+ const groupsToSetInactive = await trx
+ .selectFrom("Group")
+ .leftJoin("GroupMatch", (join) =>
+ join.on((eb) =>
+ eb.or([
+ eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
+ eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
+ ]),
+ ),
+ )
+ .select(["Group.id"])
+ .where("status", "!=", "INACTIVE")
+ .where("GroupMatch.id", "is", null)
+ .where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo))
+ .execute();
+
+ return trx
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where(
+ "Group.id",
+ "in",
+ groupsToSetInactive.map((g) => g.id),
+ )
+ .executeTakeFirst();
+ });
+}
+
+export async function mapModePreferencesBySeasonNth(seasonNth: number) {
+ return db
+ .selectFrom("Skill")
+ .innerJoin("User", "User.id", "Skill.userId")
+ .select("User.mapModePreferences")
+ .where("Skill.season", "=", seasonNth)
+ .where("Skill.userId", "is not", null)
+ .where("User.mapModePreferences", "is not", null)
+ .groupBy("Skill.userId")
+ .$narrowType<{ mapModePreferences: UserMapModePreferences }>()
+ .execute();
+}
+
+export async function findRecentlyFinishedMatches() {
+ const twoHoursAgo = sub(new Date(), { hours: 2 });
+
+ const rows = await db
+ .selectFrom("GroupMatch")
+ .select((eb) => [
+ jsonArrayFrom(
+ eb
+ .selectFrom("GroupMember")
+ .select("GroupMember.userId")
+ .whereRef("GroupMember.groupId", "=", "GroupMatch.alphaGroupId"),
+ ).as("groupAlphaMemberIds"),
+ jsonArrayFrom(
+ eb
+ .selectFrom("GroupMember")
+ .select("GroupMember.userId")
+ .whereRef("GroupMember.groupId", "=", "GroupMatch.bravoGroupId"),
+ ).as("groupBravoMemberIds"),
+ ])
+ .where("GroupMatch.reportedAt", "is not", null)
+ .where("GroupMatch.reportedAt", ">", dateToDatabaseTimestamp(twoHoursAgo))
+ .execute();
+
+ return rows.map((row) => ({
+ groupAlphaMemberIds: row.groupAlphaMemberIds.map((m) => m.userId),
+ groupBravoMemberIds: row.groupBravoMemberIds.map((m) => m.userId),
+ }));
+}
+
+export function addLike({
+ likerGroupId,
+ targetGroupId,
+}: {
+ likerGroupId: number;
+ targetGroupId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ try {
+ await trx
+ .insertInto("GroupLike")
+ .values({ likerGroupId, targetGroupId })
+ .onConflict((oc) =>
+ oc.columns(["likerGroupId", "targetGroupId"]).doNothing(),
+ )
+ .execute();
+ } catch (error) {
+ if (errorIsSqliteForeignKeyConstraintFailure(error)) {
+ throw new SendouQError(error.message);
+ }
+ throw error;
+ }
+
+ await refreshGroup(likerGroupId, trx);
+ });
+}
+
+export function deleteLike({
+ likerGroupId,
+ targetGroupId,
+}: {
+ likerGroupId: number;
+ targetGroupId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ await trx
+ .deleteFrom("GroupLike")
+ .where("likerGroupId", "=", likerGroupId)
+ .where("targetGroupId", "=", targetGroupId)
+ .execute();
+
+ await refreshGroup(likerGroupId, trx);
+ });
+}
+
+export function leaveGroup(userId: number) {
+ return db.transaction().execute(async (trx) => {
+ const userGroup = await trx
+ .selectFrom("GroupMember")
+ .innerJoin("Group", "Group.id", "GroupMember.groupId")
+ .select(["Group.id", "GroupMember.role"])
+ .where("userId", "=", userId)
+ .where("Group.status", "!=", "INACTIVE")
+ .executeTakeFirstOrThrow();
+
+ await trx
+ .deleteFrom("GroupMember")
+ .where("userId", "=", userId)
+ .where("GroupMember.groupId", "=", userGroup.id)
+ .execute();
+
+ const remainingMembers = await trx
+ .selectFrom("GroupMember")
+ .select(["userId", "role"])
+ .where("groupId", "=", userGroup.id)
+ .execute();
+
+ if (remainingMembers.length === 0) {
+ await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute();
+ return;
+ }
+
+ if (userGroup.role === "OWNER") {
+ const newOwner =
+ remainingMembers.find((m) => m.role === "MANAGER") ??
+ remainingMembers[0];
+
+ await trx
+ .updateTable("GroupMember")
+ .set({ role: "OWNER" })
+ .where("userId", "=", newOwner.userId)
+ .where("groupId", "=", userGroup.id)
+ .execute();
+ }
+
+ const match = await trx
+ .selectFrom("GroupMatch")
+ .select(["GroupMatch.id"])
+ .where((eb) =>
+ eb.or([
+ eb("alphaGroupId", "=", userGroup.id),
+ eb("bravoGroupId", "=", userGroup.id),
+ ]),
+ )
+ .executeTakeFirst();
+
+ if (match) {
+ throw new SendouQError("Can't leave group when already in a match");
+ }
+ });
+}
+
+export function refreshGroup(groupId: number, trx?: Transaction) {
+ return (trx ?? db)
+ .updateTable("Group")
+ .set({ latestActionAt: databaseTimestampNow() })
+ .where("Group.id", "=", groupId)
+ .execute();
+}
+
+export function updateMemberNote({
+ groupId,
+ userId,
+ value,
+}: {
+ groupId: number;
+ userId: number;
+ value: string | null;
+}) {
+ return db.transaction().execute(async (trx) => {
+ await trx
+ .updateTable("GroupMember")
+ .set({ note: value })
+ .where("groupId", "=", groupId)
+ .where("userId", "=", userId)
+ .execute();
+
+ await refreshGroup(groupId, trx);
+ });
+}
+
+export function updateMemberRole({
+ userId,
+ groupId,
+ role,
+}: {
+ userId: number;
+ groupId: number;
+ role: Tables["GroupMember"]["role"];
+}) {
+ if (role === "OWNER") {
+ throw new Error("Can't set role to OWNER with this function");
+ }
+
+ return db.transaction().execute(async (trx) => {
+ await trx
+ .updateTable("GroupMember")
+ .set({ role })
+ .where("userId", "=", userId)
+ .where("groupId", "=", groupId)
+ .execute();
+
+ await refreshGroup(groupId, trx);
+ });
+}
+
+export function setPreparingGroupAsActive(groupId: number) {
+ return db
+ .updateTable("Group")
+ .set({ status: "ACTIVE", latestActionAt: databaseTimestampNow() })
+ .where("id", "=", groupId)
+ .where("status", "=", "PREPARING")
+ .execute();
+}
diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts
index e99ee7940..e9e9eee3f 100644
--- a/app/features/sendouq/actions/q.looking.server.ts
+++ b/app/features/sendouq/actions/q.looking.server.ts
@@ -3,41 +3,20 @@ import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
-import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import {
createMatchMemento,
matchMapList,
} from "~/features/sendouq-match/core/match.server";
-import invariant from "~/utils/invariant";
-import { logger } from "~/utils/logger";
-import {
- errorToast,
- errorToastIfFalsy,
- parseRequestPayload,
-} from "~/utils/remix.server";
-import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
+import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
+import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
-import { membersNeededForFull } from "../core/groups.server";
-import { FULL_GROUP_SIZE } from "../q-constants";
+import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
import { lookingSchema } from "../q-schemas.server";
-import { addLike } from "../queries/addLike.server";
-import { addManagerRole } from "../queries/addManagerRole.server";
-import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server";
-import { createMatch } from "../queries/createMatch.server";
-import { deleteLike } from "../queries/deleteLike.server";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { groupHasMatch } from "../queries/groupHasMatch.server";
-import { groupSize } from "../queries/groupSize.server";
-import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
-import { leaveGroup } from "../queries/leaveGroup.server";
-import { likeExists } from "../queries/likeExists.server";
-import { morphGroups } from "../queries/morphGroups.server";
-import { refreshGroup } from "../queries/refreshGroup.server";
-import { removeManagerRole } from "../queries/removeManagerRole.server";
-import { updateNote } from "../queries/updateNote.server";
+import { SendouQError } from "../q-utils.server";
// this function doesn't throw normally because we are assuming
// if there is a validation error the user saw stale data
@@ -48,362 +27,278 @@ export const action: ActionFunction = async ({ request }) => {
request,
schema: lookingSchema,
});
- const currentGroup = findCurrentGroupByUserId(user.id);
+ const currentGroup = SendouQ.findOwnGroup(user.id);
if (!currentGroup) return null;
- // this throws because there should normally be no way user loses ownership by the action of some other user
- const validateIsGroupOwner = () =>
- errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner");
- const isGroupManager = () =>
- currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
+ try {
+ // this throws because there should normally be no way user loses ownership by the action of some other user
+ const validateIsGroupOwner = () =>
+ errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner");
+ const isGroupManager = () =>
+ currentGroup.usersRole === "MANAGER" ||
+ currentGroup.usersRole === "OWNER";
- switch (data._action) {
- case "LIKE": {
- if (!isGroupManager()) return null;
+ switch (data._action) {
+ case "LIKE": {
+ if (!isGroupManager()) return null;
- try {
- addLike({
+ await SQGroupRepository.addLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
- } catch (e) {
- // the group disbanded before we could like it
- if (errorIsSqliteForeignKeyConstraintFailure(e)) return null;
- throw e;
- }
- refreshGroup(currentGroup.id);
-
- const targetChatCode = chatCodeByGroupId(data.targetGroupId);
- if (targetChatCode) {
- ChatSystemMessage.send({
- room: targetChatCode,
- type: "LIKE_RECEIVED",
- revalidateOnly: true,
- });
- }
-
- break;
- }
- case "RECHALLENGE": {
- if (!isGroupManager()) return null;
-
- await QRepository.rechallenge({
- likerGroupId: currentGroup.id,
- targetGroupId: data.targetGroupId,
- });
-
- const targetChatCode = chatCodeByGroupId(data.targetGroupId);
- if (targetChatCode) {
- ChatSystemMessage.send({
- room: targetChatCode,
- type: "LIKE_RECEIVED",
- revalidateOnly: true,
- });
- }
- break;
- }
- case "UNLIKE": {
- if (!isGroupManager()) return null;
-
- deleteLike({
- likerGroupId: currentGroup.id,
- targetGroupId: data.targetGroupId,
- });
- refreshGroup(currentGroup.id);
-
- break;
- }
- case "GROUP_UP": {
- if (!isGroupManager()) return null;
- if (
- !likeExists({
- targetGroupId: currentGroup.id,
- likerGroupId: data.targetGroupId,
- })
- ) {
- return null;
- }
-
- const lookingGroups = await QRepository.findLookingGroups({
- maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
- ownGroupId: currentGroup.id,
- includeChatCode: true,
- });
-
- const ourGroup = lookingGroups.find(
- (group) => group.id === currentGroup.id,
- );
- if (!ourGroup) return null;
- const theirGroup = lookingGroups.find(
- (group) => group.id === data.targetGroupId,
- );
- if (!theirGroup) return null;
-
- const { id: survivingGroupId } = groupAfterMorph({
- liker: "THEM",
- ourGroup,
- theirGroup,
- });
-
- const otherGroup =
- ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
-
- invariant(ourGroup.members, "our group has no members");
- invariant(otherGroup.members, "other group has no members");
-
- morphGroups({
- survivingGroupId,
- otherGroupId: otherGroup.id,
- newMembers: otherGroup.members.map((m) => m.id),
- });
- refreshGroup(survivingGroupId);
-
- if (ourGroup.chatCode && theirGroup.chatCode) {
- ChatSystemMessage.send([
- {
- room: ourGroup.chatCode,
- type: "NEW_GROUP",
+ const targetChatCode = SendouQ.findUncensoredGroupById(
+ data.targetGroupId,
+ )?.chatCode;
+ if (targetChatCode) {
+ ChatSystemMessage.send({
+ room: targetChatCode,
+ type: "LIKE_RECEIVED",
revalidateOnly: true,
- },
- {
- room: theirGroup.chatCode,
- type: "NEW_GROUP",
- revalidateOnly: true,
- },
- ]);
+ });
+ }
+
+ break;
}
+ case "RECHALLENGE": {
+ if (!isGroupManager()) return null;
- break;
- }
- case "MATCH_UP_RECHALLENGE":
- case "MATCH_UP": {
- if (!isGroupManager()) return null;
- if (
- !likeExists({
- targetGroupId: currentGroup.id,
- likerGroupId: data.targetGroupId,
- })
- ) {
- return null;
- }
-
- const lookingGroups = await QRepository.findLookingGroups({
- minGroupSize: FULL_GROUP_SIZE,
- ownGroupId: currentGroup.id,
- includeChatCode: true,
- });
-
- const ourGroup = lookingGroups.find(
- (group) => group.id === currentGroup.id,
- );
- if (!ourGroup) return null;
- const theirGroup = lookingGroups.find(
- (group) => group.id === data.targetGroupId,
- );
- if (!theirGroup) return null;
-
- errorToastIfFalsy(
- ourGroup.members.length === FULL_GROUP_SIZE,
- "Our group is not full",
- );
- errorToastIfFalsy(
- theirGroup.members.length === FULL_GROUP_SIZE,
- "Their group is not full",
- );
-
- errorToastIfFalsy(
- !groupHasMatch(ourGroup.id),
- "Our group already has a match",
- );
- errorToastIfFalsy(
- !groupHasMatch(theirGroup.id),
- "Their group already has a match",
- );
-
- const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId(
- ourGroup.id,
- );
- const theirGroupPreferences =
- await QRepository.mapModePreferencesByGroupId(theirGroup.id);
- const mapList = await matchMapList(
- {
- id: ourGroup.id,
- preferences: ourGroupPreferences,
- },
- {
- id: theirGroup.id,
- preferences: theirGroupPreferences,
- ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
- },
- );
-
- const memberInManyGroups = verifyNoMemberInTwoGroups(
- [...ourGroup.members, ...theirGroup.members],
- lookingGroups,
- );
- if (memberInManyGroups) {
- logger.error("User in two groups preventing match creation", {
- userId: memberInManyGroups.id,
+ await SQGroupRepository.rechallenge({
+ likerGroupId: currentGroup.id,
+ targetGroupId: data.targetGroupId,
});
- errorToast(
- `${memberInManyGroups.username} is in two groups so match can't be started`,
+ const targetChatCode = SendouQ.findUncensoredGroupById(
+ data.targetGroupId,
+ )?.chatCode;
+ if (targetChatCode) {
+ ChatSystemMessage.send({
+ room: targetChatCode,
+ type: "LIKE_RECEIVED",
+ revalidateOnly: true,
+ });
+ }
+ break;
+ }
+ case "UNLIKE": {
+ if (!isGroupManager()) return null;
+
+ await SQGroupRepository.deleteLike({
+ likerGroupId: currentGroup.id,
+ targetGroupId: data.targetGroupId,
+ });
+
+ break;
+ }
+ case "GROUP_UP": {
+ if (!isGroupManager()) return null;
+
+ const allLikes = await SQGroupRepository.allLikesByGroupId(
+ data.targetGroupId,
);
- }
+ if (!allLikes.given.some((like) => like.groupId === currentGroup.id)) {
+ return null;
+ }
- const createdMatch = createMatch({
- alphaGroupId: ourGroup.id,
- bravoGroupId: theirGroup.id,
- mapList,
- memento: createMatchMemento({
- own: { group: ourGroup, preferences: ourGroupPreferences },
- their: { group: theirGroup, preferences: theirGroupPreferences },
- mapList,
- }),
- });
+ const ourGroup = SendouQ.findOwnGroup(user.id);
+ const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
+ if (!ourGroup || !theirGroup) return null;
- if (ourGroup.chatCode && theirGroup.chatCode) {
- ChatSystemMessage.send([
- {
- room: ourGroup.chatCode,
- type: "MATCH_STARTED",
- revalidateOnly: true,
- },
- {
- room: theirGroup.chatCode,
- type: "MATCH_STARTED",
- revalidateOnly: true,
- },
- ]);
- }
-
- notify({
- userIds: [
- ...ourGroup.members.map((m) => m.id),
- ...theirGroup.members.map((m) => m.id),
- ],
- defaultSeenUserIds: [user.id],
- notification: {
- type: "SQ_NEW_MATCH",
- meta: {
- matchId: createdMatch.id,
- },
- },
- });
-
- throw redirect(sendouQMatchPage(createdMatch.id));
- }
- case "GIVE_MANAGER": {
- validateIsGroupOwner();
-
- addManagerRole({
- groupId: currentGroup.id,
- userId: data.userId,
- });
- refreshGroup(currentGroup.id);
-
- break;
- }
- case "REMOVE_MANAGER": {
- validateIsGroupOwner();
-
- removeManagerRole({
- groupId: currentGroup.id,
- userId: data.userId,
- });
- refreshGroup(currentGroup.id);
-
- break;
- }
- case "LEAVE_GROUP": {
- errorToastIfFalsy(
- !currentGroup.matchId,
- "Can't leave group while in a match",
- );
- let newOwnerId: number | null = null;
- if (currentGroup.role === "OWNER") {
- newOwnerId = groupSuccessorOwner(currentGroup.id);
- }
-
- leaveGroup({
- groupId: currentGroup.id,
- userId: user.id,
- newOwnerId,
- wasOwner: currentGroup.role === "OWNER",
- });
-
- const targetChatCode = chatCodeByGroupId(currentGroup.id);
- if (targetChatCode) {
- ChatSystemMessage.send({
- room: targetChatCode,
- type: "USER_LEFT",
- context: { name: user.username },
+ const { id: survivingGroupId } = groupAfterMorph({
+ liker: "THEM",
+ ourGroup,
+ theirGroup,
});
+
+ const otherGroup =
+ ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
+
+ await SQGroupRepository.morphGroups({
+ survivingGroupId,
+ otherGroupId: otherGroup.id,
+ });
+
+ await refreshSendouQInstance();
+
+ if (ourGroup.chatCode && theirGroup.chatCode) {
+ ChatSystemMessage.send([
+ {
+ room: ourGroup.chatCode,
+ type: "NEW_GROUP",
+ revalidateOnly: true,
+ },
+ {
+ room: theirGroup.chatCode,
+ type: "NEW_GROUP",
+ revalidateOnly: true,
+ },
+ ]);
+ }
+
+ break;
}
+ case "MATCH_UP_RECHALLENGE":
+ case "MATCH_UP": {
+ if (!isGroupManager()) return null;
- throw redirect(SENDOUQ_PAGE);
- }
- case "KICK_FROM_GROUP": {
- validateIsGroupOwner();
- errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
+ const ourGroup = SendouQ.findOwnGroup(user.id);
+ const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
+ if (!ourGroup || !theirGroup) return null;
- leaveGroup({
- groupId: currentGroup.id,
- userId: data.userId,
- newOwnerId: null,
- wasOwner: false,
- });
+ const ourGroupPreferences =
+ await SQGroupRepository.mapModePreferencesByGroupId(ourGroup.id);
+ const theirGroupPreferences =
+ await SQGroupRepository.mapModePreferencesByGroupId(theirGroup.id);
+ const mapList = await matchMapList(
+ {
+ id: ourGroup.id,
+ preferences: ourGroupPreferences,
+ },
+ {
+ id: theirGroup.id,
+ preferences: theirGroupPreferences,
+ ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
+ },
+ );
- break;
- }
- case "REFRESH_GROUP": {
- refreshGroup(currentGroup.id);
+ const createdMatch = await SQMatchRepository.create({
+ alphaGroupId: ourGroup.id,
+ bravoGroupId: theirGroup.id,
+ mapList,
+ memento: createMatchMemento({
+ own: { group: ourGroup, preferences: ourGroupPreferences },
+ their: { group: theirGroup, preferences: theirGroupPreferences },
+ mapList,
+ }),
+ });
- break;
- }
- case "UPDATE_NOTE": {
- updateNote({
- note: data.value,
- groupId: currentGroup.id,
- userId: user.id,
- });
- refreshGroup(currentGroup.id);
+ await refreshSendouQInstance();
- break;
- }
- case "DELETE_PRIVATE_USER_NOTE": {
- await QRepository.deletePrivateUserNote({
- authorId: user.id,
- targetId: data.targetId,
- });
+ if (ourGroup.chatCode && theirGroup.chatCode) {
+ ChatSystemMessage.send([
+ {
+ room: ourGroup.chatCode,
+ type: "MATCH_STARTED",
+ revalidateOnly: true,
+ },
+ {
+ room: theirGroup.chatCode,
+ type: "MATCH_STARTED",
+ revalidateOnly: true,
+ },
+ ]);
+ }
- break;
+ notify({
+ userIds: [
+ ...ourGroup.members.map((m) => m.id),
+ ...theirGroup.members.map((m) => m.id),
+ ],
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SQ_NEW_MATCH",
+ meta: {
+ matchId: createdMatch.id,
+ },
+ },
+ });
+
+ throw redirect(sendouQMatchPage(createdMatch.id));
+ }
+ case "GIVE_MANAGER": {
+ validateIsGroupOwner();
+
+ await SQGroupRepository.updateMemberRole({
+ groupId: currentGroup.id,
+ userId: data.userId,
+ role: "MANAGER",
+ });
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "REMOVE_MANAGER": {
+ validateIsGroupOwner();
+
+ await SQGroupRepository.updateMemberRole({
+ groupId: currentGroup.id,
+ userId: data.userId,
+ role: "REGULAR",
+ });
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "LEAVE_GROUP": {
+ await SQGroupRepository.leaveGroup(user.id);
+
+ await refreshSendouQInstance();
+
+ const targetChatCode = SendouQ.findUncensoredGroupById(
+ currentGroup.id,
+ )?.chatCode;
+ if (targetChatCode) {
+ ChatSystemMessage.send({
+ room: targetChatCode,
+ type: "USER_LEFT",
+ context: { name: user.username },
+ });
+ }
+
+ throw redirect(SENDOUQ_PAGE);
+ }
+ case "KICK_FROM_GROUP": {
+ validateIsGroupOwner();
+ errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
+
+ await SQGroupRepository.leaveGroup(data.userId);
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "REFRESH_GROUP": {
+ await SQGroupRepository.refreshGroup(currentGroup.id);
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "UPDATE_NOTE": {
+ await SQGroupRepository.updateMemberNote({
+ groupId: currentGroup.id,
+ userId: user.id,
+ value: data.value,
+ });
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "DELETE_PRIVATE_USER_NOTE": {
+ await PrivateUserNoteRepository.del({
+ authorId: user.id,
+ targetId: data.targetId,
+ });
+
+ break;
+ }
+ default: {
+ assertUnreachable(data);
+ }
}
- default: {
- assertUnreachable(data);
+
+ return null;
+ } catch (error) {
+ // some errors are expected to happen, for example they might request two groups at the same time
+ // then after morphing one group the other request fails because the group no longer exists
+ // return null causes loaders to run and they see the fresh state again instead of error page
+ if (error instanceof SendouQError) {
+ return null;
}
+
+ throw error;
}
-
- return null;
};
-
-/** Sanity check that no member is in two groups due to a bug or race condition.
- *
- * @returns null if no member is in two groups, otherwise return the problematic member
- */
-function verifyNoMemberInTwoGroups(
- members: LookingGroupWithInviteCode["members"],
- allGroups: LookingGroupWithInviteCode[],
-) {
- for (const member of members) {
- if (
- allGroups.filter((group) => group.members.some((m) => m.id === member.id))
- .length > 1
- ) {
- return member;
- }
- }
-
- return null;
-}
diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts
index 8146c624e..37a7e2ec7 100644
--- a/app/features/sendouq/actions/q.preparing.server.ts
+++ b/app/features/sendouq/actions/q.preparing.server.ts
@@ -3,19 +3,12 @@ import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { notify } from "~/features/notifications/core/notify.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
-import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
-import invariant from "~/utils/invariant";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
-import { hasGroupManagerPerms } from "../core/groups";
-import { FULL_GROUP_SIZE } from "../q-constants";
+import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import { preparingSchema } from "../q-schemas.server";
-import { addMember } from "../queries/addMember.server";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { refreshGroup } from "../queries/refreshGroup.server";
-import { setGroupAsActive } from "../queries/setGroupAsActive.server";
export type SendouQPreparingAction = typeof action;
@@ -26,10 +19,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
schema: preparingSchema,
});
- const currentGroup = findCurrentGroupByUserId(user.id);
- errorToastIfFalsy(currentGroup, "No group found");
+ const ownGroup = SendouQ.findOwnGroup(user.id);
+ errorToastIfFalsy(ownGroup, "No group found");
- if (!hasGroupManagerPerms(currentGroup.role)) {
+ // no perms, possibly just lost them so no more graceful degradation
+ if (ownGroup.usersRole === "REGULAR") {
return null;
}
@@ -38,48 +32,37 @@ export const action = async ({ request }: ActionFunctionArgs) => {
switch (data._action) {
case "JOIN_QUEUE": {
- if (currentGroup.status !== "PREPARING") {
- return null;
- }
+ await SQGroupRepository.setPreparingGroupAsActive(ownGroup.id);
- setGroupAsActive(currentGroup.id);
- refreshGroup(currentGroup.id);
+ await refreshSendouQInstance();
return redirect(SENDOUQ_LOOKING_PAGE);
}
case "ADD_TRUSTED": {
- const available = await QRepository.findActiveGroupMembers();
+ const available = await SQGroupRepository.findActiveGroupMembers();
if (available.some(({ userId }) => userId === data.id)) {
return { error: "taken" } as const;
}
errorToastIfFalsy(
- (await QRepository.usersThatTrusted(user.id)).trusters.some(
+ (await SQGroupRepository.usersThatTrusted(user.id)).trusters.some(
(trusterUser) => trusterUser.id === data.id,
),
"Not trusted",
);
- const ownGroupWithMembers = await QMatchRepository.findGroupById({
- groupId: currentGroup.id,
- });
- invariant(ownGroupWithMembers, "No own group found");
- errorToastIfFalsy(
- ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
- "Group is full",
- );
-
- addMember({
- groupId: currentGroup.id,
+ await SQGroupRepository.addMember(ownGroup.id, {
userId: data.id,
role: "MANAGER",
});
- await QRepository.refreshTrust({
+ await SQGroupRepository.refreshTrust({
trustGiverUserId: data.id,
trustReceiverUserId: user.id,
});
+ await refreshSendouQInstance();
+
notify({
userIds: [data.id],
notification: {
diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts
index 3b089c6f4..b2f9fcc79 100644
--- a/app/features/sendouq/actions/q.server.ts
+++ b/app/features/sendouq/actions/q.server.ts
@@ -1,11 +1,10 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
-import { sql } from "~/db/sql";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import * as Seasons from "~/features/mmr/core/Seasons";
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
@@ -16,13 +15,10 @@ import {
SENDOUQ_PREPARING_PAGE,
SUSPENDED_PAGE,
} from "~/utils/urls";
-import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
+import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
import { frontPageSchema } from "../q-schemas.server";
import { userCanJoinQueueAt } from "../q-utils";
-import { addMember } from "../queries/addMember.server";
-import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
@@ -35,11 +31,13 @@ export const action: ActionFunction = async ({ request }) => {
case "JOIN_QUEUE": {
await validateCanJoinQ(user);
- await QRepository.createGroup({
+ await SQGroupRepository.createGroup({
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
userId: user.id,
});
+ await refreshSendouQInstance();
+
return redirect(
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE,
);
@@ -53,34 +51,28 @@ export const action: ActionFunction = async ({ request }) => {
);
const groupInvitedTo =
- code && user ? findGroupByInviteCode(code) : undefined;
+ code && user ? SendouQ.findGroupByInviteCode(code) : undefined;
errorToastIfFalsy(
groupInvitedTo,
"Invite code doesn't match any active team",
);
- errorToastIfFalsy(
- groupInvitedTo.members.length < FULL_GROUP_SIZE,
- "Team is full",
- );
- sql.transaction(() => {
- addMember({
- groupId: groupInvitedTo.id,
- userId: user.id,
- role: "MANAGER",
+ await SQGroupRepository.addMember(groupInvitedTo.id, {
+ userId: user.id,
+ role: "MANAGER",
+ });
+
+ if (data._action === "JOIN_TEAM_WITH_TRUST") {
+ const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
+ invariant(owner, "Owner not found");
+
+ giveTrust({
+ trustGiverUserId: user.id,
+ trustReceiverUserId: owner.id,
});
- deleteLikesByGroupId(groupInvitedTo.id);
+ }
- if (data._action === "JOIN_TEAM_WITH_TRUST") {
- const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
- invariant(owner, "Owner not found");
-
- giveTrust({
- trustGiverUserId: user.id,
- trustReceiverUserId: owner.id,
- });
- }
- })();
+ await refreshSendouQInstance();
return redirect(
groupInvitedTo.status === "PREPARING"
@@ -132,6 +124,5 @@ async function validateCanJoinQ(user: { id: number; discordId: string }) {
const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW";
errorToastIfFalsy(Seasons.current(), "Season is not active");
- errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group");
errorToastIfFalsy(canJoinQueue, "Can't join queue right now");
}
diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css
new file mode 100644
index 000000000..6728570c4
--- /dev/null
+++ b/app/features/sendouq/components/GroupCard.module.css
@@ -0,0 +1,169 @@
+.group {
+ background-color: var(--bg-lighter-solid);
+ width: 100%;
+ border-radius: var(--rounded);
+ padding: var(--s-2-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+ position: relative;
+ color: var(--text);
+}
+
+.noScreen {
+ background-color: var(--theme-error-transparent);
+ border-radius: 100%;
+ padding: var(--s-1);
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+}
+
+.member {
+ display: flex;
+ gap: var(--s-2);
+ align-items: center;
+ background-color: var(--bg-darker);
+ border-radius: var(--rounded);
+ font-size: var(--fonts-xsm);
+ font-weight: var(--semi-bold);
+ padding-inline-end: var(--s-2-5);
+}
+
+.name {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ max-width: 7.5rem;
+ font-size: var(--fonts-xs);
+ color: var(--text);
+}
+
+.avatar {
+ min-width: 36px;
+}
+
+.avatarPositive {
+ outline: 2px solid var(--theme-success-transparent);
+}
+
+.avatarNeutral {
+ outline: 2px solid var(--theme-warning-transparent);
+}
+
+.avatarNegative {
+ outline: 2px solid var(--theme-error-transparent);
+}
+
+.tier {
+ margin-inline-start: auto;
+}
+
+.tierPlaceholder {
+ min-width: 26.58px;
+}
+
+.extraInfo {
+ font-size: var(--fonts-xs);
+ background-color: var(--bg-darker);
+ border-radius: var(--rounded);
+ padding: var(--s-0-5) var(--s-1-5);
+ width: max-content;
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ font-weight: var(--semi-bold);
+ min-height: 24px;
+}
+
+.extraInfoButton {
+ font-size: var(--fonts-xs);
+ background-color: var(--bg-darker);
+ border-radius: var(--rounded);
+ padding: var(--s-0-5) var(--s-1-5);
+ width: max-content;
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ font-weight: var(--semi-bold);
+ color: var(--text);
+ border: none;
+ min-height: 24px;
+}
+
+.addNoteButton {
+ border: none;
+ padding: 0 var(--s-1-5);
+ color: var(--body);
+ font-size: var(--fonts-xxs);
+ font-weight: var(--semi-bold);
+ background-color: var(--bg-darker);
+ white-space: nowrap;
+}
+
+.addNoteButtonEdit > svg {
+ color: var(--theme);
+}
+
+.addNoteButton > svg {
+ width: 14px;
+ margin-inline-end: var(--s-1);
+}
+
+.noteTextarea {
+ height: 4rem !important;
+}
+
+.futureMatchMode {
+ border-radius: 100%;
+ background-color: var(--bg-lightest);
+ height: 30px;
+ width: 30px;
+ display: grid;
+ place-items: center;
+ padding: var(--s-1-5);
+}
+
+.vcIcon {
+ height: 15px;
+ stroke-width: 2;
+}
+
+.star {
+ min-width: 18px;
+ max-width: 18px;
+ color: var(--theme-secondary);
+ stroke-width: 2;
+}
+
+.starInactive {
+ color: var(--text-lighter);
+}
+
+.displayTier {
+ display: flex;
+ gap: var(--s-1);
+ align-items: center;
+ position: absolute;
+ border-radius: var(--rounded);
+ background-color: var(--bg-darker);
+ padding: var(--s-0-5) var(--s-2-5);
+ font-size: var(--fonts-xs);
+ font-weight: var(--semi-bold);
+ bottom: -36px;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.popoverButton {
+ background-color: transparent;
+ color: var(--text-lighter);
+ font-size: var(--fonts-xs);
+ padding: 0;
+ border: none;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ font-weight: var(--bold);
+ height: 19.8281px;
+}
diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
index dc54782f0..ad1d650c4 100644
--- a/app/features/sendouq/components/GroupCard.tsx
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -17,14 +17,13 @@ import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { TrashIcon } from "~/components/icons/Trash";
import { SubmitButton } from "~/components/SubmitButton";
-import type { ParsedMemento, Tables } from "~/db/tables";
+import type { ParsedMemento } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { languagesUnified } from "~/modules/i18n/config";
-import type { ModeShort } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { databaseTimestampToDate } from "~/utils/dates";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
@@ -36,35 +35,37 @@ import {
tierImageUrl,
userPage,
} from "~/utils/urls";
+import type {
+ SQGroup,
+ SQGroupMember,
+ SQMatchGroup,
+ SQMatchGroupMember,
+ SQOwnGroup,
+} from "../core/SendouQ.server";
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
-import type { LookingGroup } from "../q-types";
+import { resolveFutureMatchModes } from "../q-utils";
+import styles from "./GroupCard.module.css";
export function GroupCard({
group,
action,
- ownRole,
- ownGroup = false,
- isExpired = false,
displayOnly = false,
hideVc = false,
hideWeapons = false,
hideNote: _hidenote = false,
- enableKicking,
showAddNote,
showNote = false,
+ ownGroup,
}: {
- group: Omit;
+ group: SQGroup | SQOwnGroup | SQMatchGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
- ownRole?: Tables["GroupMember"]["role"] | "PREVIEWER";
- ownGroup?: boolean;
- isExpired?: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
hideWeapons?: SqlBool;
hideNote?: boolean;
- enableKicking?: boolean;
showAddNote?: SqlBool;
showNote?: boolean;
+ ownGroup?: SQOwnGroup;
}) {
const { t } = useTranslation(["q"]);
const user = useUser();
@@ -76,18 +77,27 @@ export function GroupCard({
group.members.length === FULL_GROUP_SIZE ||
_hidenote;
+ const isOwnGroup = group.id === ownGroup?.id;
+
+ const futureMatchModes = ownGroup
+ ? resolveFutureMatchModes({
+ ownGroup,
+ theirGroup: group,
+ })
+ : null;
+
+ const enableKicking = group.usersRole === "OWNER" && !displayOnly;
+
return (
-
-
+
+
{group.members ? (
{group.members.map((member) => {
return (
) : null}
- {group.futureMatchModes && !group.members ? (
+ {futureMatchModes && !group.members ? (
- {group.futureMatchModes.map((mode) => {
+ {futureMatchModes.map((mode) => {
return (
-
- {group.isNoScreen ? (
-
+ {group.noScreen ? (
+
) : null}
- {group.tier && !displayOnly ? (
+ {group.tier &&
+ (!group.members || group.members.length === FULL_GROUP_SIZE) ? (
@@ -152,37 +157,46 @@ export function GroupCard({
) : null}
- {group.tier && displayOnly ? (
-
+ {group.tier && displayOnly && !group.members ? (
+
{group.tier.name}
{group.tier.isPlus ? "+" : ""}
) : null}
- {group.tierRange?.range ? (
+ {group.tierRange ? (
-
- (-{group.tierRange.diff})
-
+ {group.tierRange.diff[0] ? (
+
+ ({group.tierRange.diff[0]})
+
+ ) : null}
-
- {t("q:looking.range.or")}
-
- }
- >
- {t("q:looking.range.or.explanation")}
-
+ {/** in preview mode they don't see full group tiers (because they don't have a group to compare against) so it is a "true range" */}
+ {group.tierRange.diff[0] ? (
+
+ {t("q:looking.range.or")}
+
+ }
+ >
+ {t("q:looking.range.or.explanation")}
+
+ ) : (
+ "—"
+ )}
-
- (+{group.tierRange.diff})
-
+ {group.tierRange.diff[1] ? (
+
+ (+{group.tierRange.diff[1]})
+
+ ) : null}
{group.isReplay ? (
@@ -196,8 +210,8 @@ export function GroupCard({
) : null}
{action &&
- (ownRole === "OWNER" || ownRole === "MANAGER") &&
- !isExpired ? (
+ (ownGroup?.usersRole === "OWNER" ||
+ ownGroup?.usersRole === "MANAGER") ? (
) : null}
- {!group.isRechallenge &&
- group.rechallengeMatchModes &&
- (ownRole === "OWNER" || ownRole === "MANAGER") &&
- !isExpired ? (
-
- ) : null}
);
}
function GroupCardContainer({
- ownGroup,
+ isOwnGroup,
groupId,
children,
}: {
- ownGroup: boolean;
+ isOwnGroup: boolean;
groupId: number;
children: React.ReactNode;
}) {
// we don't want it to animate
- if (ownGroup) return <>{children}>;
+ if (isOwnGroup) return <>{children}>;
return
{children} ;
}
@@ -258,7 +263,7 @@ function GroupMember({
showAddNote,
showNote,
}: {
- member: NonNullable
[number];
+ member: SQGroupMember | SQMatchGroupMember;
showActions: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
@@ -273,8 +278,8 @@ function GroupMember({
const { formatDateTime } = useTimeFormat();
return (
-
-
+
+
{showNote && member.privateNote ? (
@@ -319,7 +326,7 @@ function GroupMember({
) : (
)}
-
+
{member.inGameName ? (
<>
@@ -346,12 +353,12 @@ function GroupMember({
{member.vc && !hideVc ? (
-
+
) : null}
{member.plusTier ? (
-
+
{member.plusTier}
@@ -359,7 +366,7 @@ function GroupMember({
{member.friendCode ? (
+
FC
}
@@ -371,8 +378,8 @@ function GroupMember({
}
- className={clsx("q__group-member__add-note-button", {
- "q__group-member__add-note-button__edit": member.privateNote,
+ className={clsx(styles.addNoteButton, {
+ [styles.addNoteButtonEdit]: member.privateNote,
})}
>
{member.privateNote
@@ -382,7 +389,7 @@ function GroupMember({
) : null}
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
-
+
{member.weapons?.map((weapon) => {
return (
setValue(e.target.value)}
rows={2}
- className="q__group-member__note-textarea mt-1"
+ className={`${styles.noteTextarea} mt-1`}
name="value"
ref={textareaRef}
/>
@@ -515,36 +522,6 @@ function AddPrivateNoteForm({
);
}
-function RechallengeForm({
- modes,
- targetGroupId,
-}: {
- modes: ModeShort[];
- targetGroupId: number;
-}) {
- const { t } = useTranslation(["q"]);
- const fetcher = useFetcher();
-
- return (
-
-
-
- {t("q:looking.groups.actions.rechallenge")}
-
- {modes.map((mode) => (
-
- ))}
-
-
-
- );
-}
-
function DeletePrivateNoteForm({
targetId,
name,
@@ -622,7 +599,7 @@ function MemberSkillDifference({
▼
);
return (
-
+
{symbol}
{Math.abs(skillDifference.spDiff)}SP
@@ -631,7 +608,7 @@ function MemberSkillDifference({
if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) {
return (
-
+
{t("q:looking.sp.calculated")}: {" "}
{skillDifference.newSp ? <>{skillDifference.newSp}SP> : null}
@@ -639,7 +616,7 @@ function MemberSkillDifference({
}
return (
-
+
{t("q:looking.sp.calculating")} (
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
@@ -651,7 +628,7 @@ function MemberRoleManager({
displayOnly,
enableKicking,
}: {
- member: NonNullable
[number];
+ member: Pick;
displayOnly?: boolean;
enableKicking?: boolean;
}) {
@@ -669,8 +646,8 @@ function MemberRoleManager({
variant="minimal"
icon={
}
@@ -728,7 +705,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
if (skill === "CALCULATING") {
return (
-
+
@@ -736,7 +713,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
path={tierImageUrl("CALCULATING")}
alt=""
height={32.965}
- className="q__group-member__tier__placeholder"
+ className={styles.tierPlaceholder}
/>
}
@@ -750,7 +727,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
}
return (
-
+
@@ -785,7 +762,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
function VoiceChatInfo({
member,
}: {
- member: NonNullable[number];
+ member: Pick;
}) {
const user = useUser();
const { t } = useTranslation(["q"]);
@@ -830,7 +807,7 @@ function VoiceChatInfo({
}
+ icon={ }
/>
}
>
diff --git a/app/features/sendouq/components/MemberAdder.module.css b/app/features/sendouq/components/MemberAdder.module.css
new file mode 100644
index 000000000..7223ee1f4
--- /dev/null
+++ b/app/features/sendouq/components/MemberAdder.module.css
@@ -0,0 +1,4 @@
+.input {
+ --input-width: 11rem;
+ width: 11rem;
+}
diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx
index f511043e3..64e58df95 100644
--- a/app/features/sendouq/components/MemberAdder.tsx
+++ b/app/features/sendouq/components/MemberAdder.tsx
@@ -14,6 +14,7 @@ import {
sendouQInviteLink,
} from "~/utils/urls";
import type { SendouQPreparingAction } from "../actions/q.preparing.server";
+import styles from "./MemberAdder.module.css";
export function MemberAdder({
inviteCode,
@@ -56,7 +57,7 @@ export function MemberAdder({
value={inviteLink}
readOnly
id="invite"
- className="q__member-adder__input"
+ className={styles.input}
/>
- );
+ return ;
}
const trustersNotInGroup = trusters.filter(
@@ -132,7 +126,7 @@ function TrusterDropdown({
onChange={(e) =>
setTruster(e.target.value ? Number(e.target.value) : undefined)
}
- className="q__member-adder__input"
+ className={styles.input}
>
{t("q:looking.groups.adder.select")}
{teams?.map((team) => {
diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts
new file mode 100644
index 000000000..103755c58
--- /dev/null
+++ b/app/features/sendouq/core/SendouQ.server.test.ts
@@ -0,0 +1,922 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { db } from "~/db/sql";
+import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
+import { dbInsertUsers, dbReset } from "~/utils/Test";
+import * as SQGroupRepository from "../SQGroupRepository.server";
+import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
+
+const { mockSeasonCurrentOrPrevious } = vi.hoisted(() => ({
+ mockSeasonCurrentOrPrevious: vi.fn(() => ({
+ nth: 1,
+ starts: new Date("2023-01-01"),
+ ends: new Date("2030-12-31"),
+ })),
+}));
+
+vi.mock("~/features/mmr/core/Seasons", () => ({
+ currentOrPrevious: mockSeasonCurrentOrPrevious,
+}));
+
+const createGroup = async (
+ userIds: number[],
+ options: {
+ status?: "PREPARING" | "ACTIVE";
+ inviteCode?: string;
+ } = {},
+) => {
+ const { status = "ACTIVE", inviteCode } = options;
+
+ const groupResult = await SQGroupRepository.createGroup({
+ status,
+ userId: userIds[0],
+ });
+
+ if (inviteCode) {
+ await db
+ .updateTable("Group")
+ .set({ inviteCode })
+ .where("id", "=", groupResult.id)
+ .execute();
+ }
+
+ for (let i = 1; i < userIds.length; i++) {
+ await SQGroupRepository.addMember(groupResult.id, {
+ userId: userIds[i],
+ role: "REGULAR",
+ });
+ }
+
+ return groupResult.id;
+};
+
+const createMatch = async (
+ alphaGroupId: number,
+ bravoGroupId: number,
+ options: { reportedAt?: number } = {},
+) => {
+ const { reportedAt = Date.now() } = options;
+
+ await db
+ .insertInto("GroupMatch")
+ .values({
+ alphaGroupId,
+ bravoGroupId,
+ reportedAt,
+ })
+ .execute();
+};
+
+const createPrivateNote = async (
+ authorId: number,
+ targetId: number,
+ sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE",
+ text = "test note",
+) => {
+ await PrivateUserNoteRepository.upsert({
+ authorId,
+ targetId,
+ sentiment,
+ text,
+ });
+};
+
+const insertSkill = async (userId: number, ordinal: number, season = 1) => {
+ await db
+ .insertInto("Skill")
+ .values({
+ userId,
+ season,
+ mu: 25,
+ sigma: 8.333,
+ ordinal,
+ matchesCount: 10,
+ })
+ .execute();
+};
+
+describe("SendouQ", () => {
+ describe("currentViewByUserId", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(4);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns 'default' when user not in any group", async () => {
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("default");
+ });
+
+ test("returns 'preparing' when user in PREPARING group", async () => {
+ await createGroup([1], { status: "PREPARING" });
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("preparing");
+ });
+
+ test("returns 'match' when user in ACTIVE group with matchId", async () => {
+ const groupId1 = await createGroup([1]);
+ const groupId2 = await createGroup([2]);
+
+ await createMatch(groupId1, groupId2);
+
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("match");
+ });
+
+ test("returns 'looking' when user in ACTIVE group without matchId", async () => {
+ await createGroup([1], { status: "ACTIVE" });
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("looking");
+ });
+ });
+
+ describe("findOwnGroup", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(8);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns group when user is a member", async () => {
+ await createGroup([1, 2, 3]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(1);
+
+ expect(group).toBeDefined();
+ expect(group?.members.some((m) => m.id === 1)).toBe(true);
+ });
+
+ test("returns undefined when user not in any group", async () => {
+ await createGroup([1, 2, 3]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(4);
+
+ expect(group).toBeUndefined();
+ });
+
+ test("returns group with correct role when user is OWNER", async () => {
+ await createGroup([1, 2]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(1);
+
+ expect(group).toBeDefined();
+ const member = group?.members.find((m) => m.id === 1);
+ expect(member?.role).toBe("OWNER");
+ });
+
+ test("returns group with correct role when user is REGULAR member", async () => {
+ await createGroup([1, 2]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(2);
+
+ expect(group).toBeDefined();
+ const member = group?.members.find((m) => m.id === 2);
+ expect(member?.role).toBe("REGULAR");
+ });
+
+ test("returns correct group when multiple groups exist", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3, 4]);
+ await createGroup([5, 6]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(5);
+
+ expect(group).toBeDefined();
+ expect(group?.members.some((m) => m.id === 5)).toBe(true);
+ expect(group?.members.some((m) => m.id === 1)).toBe(false);
+ });
+ });
+
+ describe("findGroupByInviteCode", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(4);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns group when invite code is valid", async () => {
+ await createGroup([1], { inviteCode: "ABC123" });
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findGroupByInviteCode("ABC123");
+
+ expect(group).toBeDefined();
+ expect(group?.inviteCode).toBe("ABC123");
+ });
+
+ test("returns undefined when invite code is invalid", async () => {
+ await createGroup([1], { inviteCode: "ABC123" });
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findGroupByInviteCode("INVALID");
+
+ expect(group).toBeUndefined();
+ });
+
+ test("returns correct group when multiple groups exist", async () => {
+ await createGroup([1], { inviteCode: "CODE1" });
+ await createGroup([2], { inviteCode: "CODE2" });
+ await createGroup([3], { inviteCode: "CODE3" });
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findGroupByInviteCode("CODE2");
+
+ expect(group).toBeDefined();
+ expect(group?.members[0].id).toBe(2);
+ });
+ });
+
+ describe("previewGroups", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(12);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns empty array when no groups exist", async () => {
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toEqual([]);
+ });
+
+ test("censors members for full groups", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toBeUndefined();
+ });
+
+ test("shows members for partial groups", async () => {
+ await createGroup([1, 2]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toBeDefined();
+ expect(groups[0].members).toHaveLength(2);
+ });
+
+ test("attaches private notes to members", async () => {
+ await createGroup([1, 2]);
+ await createPrivateNote(3, 2, "POSITIVE", "Great player");
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(3);
+ const groups = SendouQ.previewGroups(3, notes);
+
+ expect(groups).toHaveLength(1);
+ const member = groups[0].members?.find((m) => m.id === 2);
+ expect(member?.privateNote).toBeDefined();
+ expect(member?.privateNote?.sentiment).toBe("POSITIVE");
+ });
+
+ test("removes inviteCode and chatCode from all groups", async () => {
+ await createGroup([1, 2], { inviteCode: "CODE1" });
+ await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" });
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(2);
+ for (const group of groups) {
+ expect(group).not.toHaveProperty("inviteCode");
+ expect(group).not.toHaveProperty("chatCode");
+ }
+ });
+
+ test("applies correct censoring for mix of full and partial groups", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3, 4, 5, 6]);
+ await createGroup([7, 8, 9]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(3);
+
+ const partialGroups = groups.filter((g) => g.members !== undefined);
+ const fullGroups = groups.filter((g) => g.members === undefined);
+
+ expect(partialGroups).toHaveLength(2);
+ expect(fullGroups).toHaveLength(1);
+ });
+
+ test("censors tier and sets tier range for full groups", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ const fullGroup = groups.find((g) => g.members === undefined);
+ const partialGroup = groups.find((g) => g.members !== undefined);
+
+ expect(fullGroup?.tier).toBeNull();
+ expect(fullGroup?.tierRange).toBeDefined();
+
+ expect(fullGroup?.tierRange?.range[0].name).toBe("IRON");
+ expect(fullGroup?.tierRange?.range[1].name).toBe("LEVIATHAN");
+
+ expect(partialGroup?.tier).toBeDefined();
+ expect(partialGroup?.tierRange).toBeNull();
+ });
+
+ describe("tier sorting", () => {
+ test("sorts full groups by tier when viewer has a tier", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 500);
+ await insertSkill(3, 500);
+ await insertSkill(4, 500);
+ await insertSkill(5, 500);
+ await insertSkill(6, 2000);
+ await insertSkill(7, 2000);
+ await insertSkill(8, 2000);
+ await insertSkill(9, 2000);
+
+ const group1Id = await createGroup([2, 3, 4, 5]);
+ const group2Id = await createGroup([6, 7, 8, 9]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(2);
+ expect(groups[0].id).toBe(group1Id);
+ expect(groups[1].id).toBe(group2Id);
+ });
+
+ test("sorts partial groups by tier relative to viewer", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 500);
+ await insertSkill(3, 2000);
+ await insertSkill(4, 1050);
+
+ await createGroup([4]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(3);
+ expect(groups[0].members![0].id).toBe(4);
+ expect(groups[1].members![0].id).toBe(2);
+ expect(groups[2].members![0].id).toBe(3);
+ });
+
+ test("full groups are sorted last regardless of tier", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1100);
+ await insertSkill(3, 1100);
+ await insertSkill(4, 1100);
+ await insertSkill(5, 1100);
+ await insertSkill(6, 500);
+
+ const fullGroupId = await createGroup([2, 3, 4, 5]);
+ const partialGroupId = await createGroup([6]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(2);
+ expect(groups[0].id).toBe(partialGroupId);
+ expect(groups[1].id).toBe(fullGroupId);
+ });
+
+ test("sorts by sentiment first, then tier within same sentiment", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 500);
+ await insertSkill(3, 2000);
+ await insertSkill(4, 1050);
+
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createPrivateNote(1, 2, "POSITIVE");
+ await createPrivateNote(1, 3, "NEGATIVE");
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(3);
+ expect(groups[0].members![0].id).toBe(2);
+ expect(groups[1].members![0].id).toBe(4);
+ expect(groups[2].members![0].id).toBe(3);
+ });
+
+ test("handles viewer without skill gracefully", async () => {
+ await insertSkill(2, 500);
+ await insertSkill(3, 2000);
+
+ await createGroup([2]);
+ await createGroup([3]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(1, notes);
+
+ expect(groups).toHaveLength(2);
+ });
+ });
+ });
+
+ describe("lookingGroups", () => {
+ describe("filtering", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(20);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns empty array when user not in a group", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(5);
+ const groups = SendouQ.lookingGroups(5, notes);
+
+ expect(groups).toEqual([]);
+ });
+
+ test("only returns ACTIVE groups", async () => {
+ await createGroup([1]);
+ await createGroup([2], { status: "PREPARING" });
+ const group3 = await createGroup([3]);
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "=", group3)
+ .execute();
+ await createGroup([4], { status: "ACTIVE" });
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members![0].id).toBe(4);
+ });
+
+ test("only returns groups without matchId", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+
+ await createMatch(1, 2);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members![0].id).toBe(3);
+ });
+
+ test("excludes own group from results", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3, 4]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members?.some((m) => m.id === 1)).toBe(false);
+ });
+
+ test("own group size 4 only shows size 4 groups", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8, 9, 10]);
+ await createGroup([11, 12, 13, 14]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toBeUndefined();
+ });
+
+ test("own group size 3 only shows size 1 groups", async () => {
+ await createGroup([1, 2, 3]);
+ await createGroup([4]);
+ await createGroup([5, 6]);
+ await createGroup([7, 8, 9]);
+ await createGroup([10, 11, 12, 13]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toHaveLength(1);
+ expect(groups[0].members![0].id).toBe(4);
+ });
+
+ test("own group size 2 shows size 1 and 2 groups", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3]);
+ await createGroup([4, 5]);
+ await createGroup([6, 7, 8]);
+ await createGroup([9, 10, 11, 12]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(2);
+ const groupSizes = groups.map((g) => g.members!.length);
+ expect(groupSizes).toContain(1);
+ expect(groupSizes).toContain(2);
+ });
+
+ test("own group size 1 shows size 1, 2, and 3 groups", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3, 4]);
+ await createGroup([5, 6, 7]);
+ await createGroup([8, 9, 10, 11]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(3);
+ const groupSizes = groups.map((g) => g.members!.length);
+ expect(groupSizes).toContain(1);
+ expect(groupSizes).toContain(2);
+ expect(groupSizes).toContain(3);
+ });
+ });
+
+ describe("replay detection", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(12);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("marks group as replay when 3+ members overlap", async () => {
+ const group1 = await createGroup([1, 2, 3, 4]);
+ const group2 = await createGroup([5, 6, 7, 8]);
+
+ await createMatch(group1, group2);
+
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "in", [group1, group2])
+ .execute();
+
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6, 7, 8]);
+ await createGroup([9, 10, 11, 12]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const replayGroup = groups.find((g) => g.members === undefined);
+ expect(replayGroup?.isReplay).toBe(true);
+ });
+
+ test("does not mark as replay when less than 3 members overlap", async () => {
+ const group1 = await createGroup([1, 2, 3, 4]);
+ const group2 = await createGroup([5, 6, 7, 8]);
+
+ await createMatch(group1, group2);
+
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "in", [group1, group2])
+ .execute();
+
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6, 9, 10]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ for (const group of groups) {
+ expect(group.isReplay).toBe(false);
+ }
+ });
+
+ test("all groups have isReplay false when no recent matches", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ for (const group of groups) {
+ expect(group.isReplay).toBe(false);
+ }
+ });
+
+ test("non-full groups do not have isReplay even with 3+ overlapping members", async () => {
+ const group1 = await createGroup([1, 2, 3, 4]);
+ const group2 = await createGroup([5, 6, 7, 8]);
+
+ await createMatch(group1, group2);
+
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "in", [group1, group2])
+ .execute();
+
+ await createGroup([1]);
+ await createGroup([5, 6, 7]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const partialGroup = groups.find((g) =>
+ g.members?.some((m) => m.id === 5),
+ );
+ expect(partialGroup?.isReplay).toBe(false);
+ });
+ });
+
+ describe("censoring", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(12);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("full groups have members undefined", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6, 7, 8]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const fullGroup = groups.find((g) => g.members === undefined);
+ expect(fullGroup).toBeDefined();
+ });
+
+ test("partial groups have members visible", async () => {
+ await createGroup([1]);
+ await createGroup([2, 3]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const partialGroup = groups.find((g) => g.members?.length === 2);
+ expect(partialGroup).toBeDefined();
+ expect(partialGroup?.members).toHaveLength(2);
+ });
+
+ test("inviteCode and chatCode removed from all groups", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3, 4, 5, 6]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ for (const group of groups) {
+ expect(group).not.toHaveProperty("inviteCode");
+ expect(group).not.toHaveProperty("chatCode");
+ }
+ });
+ });
+
+ describe("private note sorting", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(8);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("users with positive note sorted first", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8]);
+
+ await createPrivateNote(1, 5, "POSITIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(5);
+ });
+
+ test("users with negative note sorted last", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8]);
+
+ await createPrivateNote(1, 5, "NEGATIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[groups.length - 1].members![0].id).toBe(5);
+ });
+
+ test("group with both negative and positive sentiment sorted last", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8]);
+
+ await createPrivateNote(1, 6, "POSITIVE");
+ await createPrivateNote(1, 7, "NEGATIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe(
+ true,
+ );
+ });
+ });
+
+ describe("skill-based sorting", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(10);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("sentiment still takes priority over skill", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 500);
+ await insertSkill(4, 2000);
+
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([4]);
+
+ await createPrivateNote(1, 4, "POSITIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(4);
+ });
+
+ test("groups with closer skill sorted first within same sentiment", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1050);
+ await insertSkill(3, 500);
+ await insertSkill(4, 2000);
+
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(2);
+ });
+
+ test("full groups sorted by average skill", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1000);
+ await insertSkill(3, 1000);
+ await insertSkill(4, 1000);
+ await insertSkill(5, 1100);
+ await insertSkill(6, 1100);
+ await insertSkill(7, 1100);
+ await insertSkill(8, 1100);
+ await insertSkill(9, 500);
+ await insertSkill(10, 500);
+
+ await createGroup([1, 2, 3, 4]);
+ const closerGroup = await createGroup([5, 6, 7, 8]);
+ await createGroup([9, 10]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups.length).toBeGreaterThan(0);
+ expect(groups[0].id).toBe(closerGroup);
+ });
+
+ test("newer groups sorted first when skill is equal", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1000);
+ await insertSkill(3, 1000);
+
+ const group1Id = await createGroup([2]);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ const group2Id = await createGroup([3]);
+
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
+ await db
+ .updateTable("Group")
+ .set({ latestActionAt: currentTimeInSeconds - 100 })
+ .where("id", "=", group1Id)
+ .execute();
+
+ await db
+ .updateTable("Group")
+ .set({ latestActionAt: currentTimeInSeconds - 50 })
+ .where("id", "=", group2Id)
+ .execute();
+
+ await createGroup([1]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(3);
+ expect(groups[1].members![0].id).toBe(2);
+ });
+ });
+ });
+});
diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts
new file mode 100644
index 000000000..4c650a796
--- /dev/null
+++ b/app/features/sendouq/core/SendouQ.server.ts
@@ -0,0 +1,556 @@
+import { isWithinInterval, sub } from "date-fns";
+import * as R from "remeda";
+import type { DBBoolean, ParsedMemento, Tables } from "~/db/tables";
+import type { AuthenticatedUser } from "~/features/auth/core/user.server";
+import * as Seasons from "~/features/mmr/core/Seasons";
+import { defaultOrdinal } from "~/features/mmr/mmr-utils";
+import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server";
+import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
+import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
+import { modesShort } from "~/modules/in-game-lists/modes";
+import type { ModeShort } from "~/modules/in-game-lists/types";
+import { databaseTimestampToDate } from "~/utils/dates";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
+import type { SerializeFrom } from "~/utils/remix";
+import { FULL_GROUP_SIZE } from "../q-constants";
+import type { TierRange } from "../q-types";
+import { getTierIndex } from "../q-utils.server";
+import { tierDifferenceToRangeOrExact } from "./groups.server";
+
+type DBGroupRow = Awaited<
+ ReturnType
+>[number];
+type DBPrivateNoteRow = Awaited<
+ ReturnType
+>[number];
+type DBRecentlyFinishedMatchRow = Awaited<
+ ReturnType
+>[number];
+type DBMatch = NonNullable<
+ Awaited>
+>;
+
+export type SQUncensoredGroup = SerializeFrom<
+ (typeof SendouQClass.prototype.groups)[number]
+>;
+export type SQGroup = SerializeFrom<
+ ReturnType[number]
+>;
+export type SQOwnGroup = SerializeFrom<
+ NonNullable>
+>;
+export type SQMatch = SerializeFrom>;
+export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
+export type SQGroupMember = NonNullable[number];
+export type SQMatchGroupMember = SQMatchGroup["members"][number];
+
+const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
+const SECONDS_TILL_STALE =
+ process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
+
+class SendouQClass {
+ groups;
+ #recentMatches;
+ #isAccurateTiers;
+ #userSkills;
+ #intervals;
+ /** Array of user IDs currently in the queue */
+ usersInQueue;
+
+ constructor(
+ groups: DBGroupRow[],
+ recentMatches: DBRecentlyFinishedMatchRow[],
+ ) {
+ const season = Seasons.currentOrPrevious();
+ const {
+ intervals,
+ userSkills: calculatedUserSkills,
+ isAccurateTiers,
+ } = userSkills(season!.nth);
+
+ this.#recentMatches = recentMatches;
+ this.#isAccurateTiers = isAccurateTiers;
+ this.#userSkills = calculatedUserSkills;
+ this.#intervals = intervals;
+ this.usersInQueue = groups.flatMap((group) =>
+ group.members.map((member) => member.id),
+ );
+ this.groups = groups.map((group) => ({
+ ...group,
+ noScreen: this.#groupNoScreen(group),
+ modePreferences: this.#groupModePreferences(group),
+ tier: this.#groupTier(group) as TieredSkill["tier"] | null,
+ tierRange: null as TierRange | null,
+ skillDifference:
+ undefined as ParsedMemento["groups"][number]["skillDifference"],
+ isReplay: false,
+ usersRole: null as Tables["GroupMember"]["role"] | null,
+ members: group.members.map((member) => {
+ const skill = calculatedUserSkills[String(member.id)];
+
+ return {
+ ...member,
+ privateNote: null as DBPrivateNoteRow | null,
+ languages: member.languages?.split(",") || [],
+ skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
+ mapModePreferences: undefined,
+ noScreen: undefined,
+ friendCode: null as string | null,
+ inGameName: null as string | null,
+ skillDifference:
+ undefined as ParsedMemento["users"][number]["skillDifference"],
+ };
+ }),
+ }));
+ }
+
+ /**
+ * Determines the current view state for a user based on their group status.
+ */
+ currentViewByUserId(
+ /** The ID of the logged in user */
+ userId: number,
+ ) {
+ const ownGroup = this.findOwnGroup(userId);
+
+ if (!ownGroup) return "default";
+ if (ownGroup.status === "PREPARING") return "preparing";
+ if (ownGroup.matchId) return "match";
+
+ return "looking";
+ }
+
+ /**
+ * Finds the group that a user belongs to.
+ * @returns The user's group with their role, or undefined if not in a group
+ */
+ findOwnGroup(userId: number) {
+ const result = this.groups.find((group) =>
+ group.members.some((member) => member.id === userId),
+ );
+ if (!result) return;
+
+ const member = result.members.find((m) => m.id === userId)!;
+
+ return {
+ ...result,
+ usersRole: member.role,
+ };
+ }
+
+ /**
+ * Finds a group by its ID without censoring sensitive data.
+ * @returns The uncensored group, or undefined if not found
+ */
+ findUncensoredGroupById(groupId: number) {
+ return this.groups.find((group) => group.id === groupId);
+ }
+
+ /**
+ * Finds a group by its invite code.
+ * @returns The group with matching invite code, or undefined if not found
+ */
+ findGroupByInviteCode(inviteCode: string) {
+ return this.groups.find((group) => group.inviteCode === inviteCode);
+ }
+
+ /**
+ * Maps a database match to a format with appropriate censoring based on user permissions.
+ * Includes private notes for team members and censors sensitive data for non-participants.
+ * @returns The mapped match with censored data based on user permissions
+ */
+ mapMatch(
+ /** The database match object to map */
+ match: DBMatch,
+ /** The authenticated user viewing the match (if any) */
+ user?: AuthenticatedUser,
+ /** Array of private user notes to include */
+ notes: DBPrivateNoteRow[] = [],
+ ) {
+ const isTeamAlphaMember = match.groupAlpha.members.some(
+ (m) => m.id === user?.id,
+ );
+ const isTeamBravoMember = match.groupBravo.members.some(
+ (m) => m.id === user?.id,
+ );
+ const isMatchInsider =
+ isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF");
+ const happenedInLastMonth = isWithinInterval(
+ databaseTimestampToDate(match.createdAt),
+ {
+ start: sub(new Date(), { months: 1 }),
+ end: new Date(),
+ },
+ );
+
+ const matchGroupCensorer = (
+ group: DBMatch["groupAlpha"] | DBMatch["groupBravo"],
+ isTeamMember: boolean,
+ ) => {
+ return {
+ ...group,
+ isReplay: false,
+ tierRange: null as TierRange | null,
+ chatCode: isTeamMember ? group.chatCode : undefined,
+ noScreen: this.#groupNoScreen(group),
+ tier: match.memento?.groups[group.id]?.tier,
+ skillDifference: match.memento?.groups[group.id]?.skillDifference,
+ modePreferences: this.#groupModePreferences(group),
+ usersRole: null as Tables["GroupMember"]["role"] | null,
+ members: group.members.map((member) => {
+ return {
+ ...member,
+ skill: match.memento?.users[member.id]?.skill,
+ privateNote: null as DBPrivateNoteRow | null,
+ skillDifference: match.memento?.users[member.id]?.skillDifference,
+ noScreen: undefined,
+ languages: member.languages?.split(",") || [],
+ friendCode:
+ isMatchInsider && happenedInLastMonth
+ ? member.friendCode
+ : undefined,
+ };
+ }),
+ };
+ };
+
+ return {
+ ...match,
+ chatCode: isMatchInsider ? match.chatCode : undefined,
+ groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(
+ matchGroupCensorer(match.groupAlpha, isTeamAlphaMember),
+ ),
+ groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(
+ matchGroupCensorer(match.groupBravo, isTeamBravoMember),
+ ),
+ };
+ }
+
+ /**
+ * Returns all groups with wide tier ranges for preview purposes. Full groups being preview always show the full range (IRON-LEVIATHAN)
+ * @returns Array of censored groups with preview tier ranges
+ */
+ previewGroups(
+ /** The ID of the user viewing the preview */
+ userId: number,
+ /** Array of private user notes to include */
+ notes: DBPrivateNoteRow[],
+ ) {
+ const usersTier = this.#getUserTier(userId);
+ return this.groups
+ .map(this.#getAddMemberPrivateNoteMapper(notes))
+ .sort(this.#getSkillAndNoteSortComparator(usersTier))
+ .map((group) => this.#addPreviewTierRange(group))
+ .map((group) => this.#censorGroup(group));
+ }
+
+ /**
+ * Returns groups that are available for matchmaking for a specific user based on their current group size.
+ * Filters groups based on member count compatibility, activity status, and excludes stale groups.
+ * Results are sorted by sentiment (notes), tier difference, and activity.
+ * @returns Array of compatible groups sorted by relevance, or empty array if user has no group
+ */
+ lookingGroups(
+ /** The ID of the user looking for groups */
+ userId: number,
+ /** Array of private user notes to include */
+ notes: DBPrivateNoteRow[] = [],
+ ) {
+ const ownGroup = this.findOwnGroup(userId);
+ if (!ownGroup) return [];
+
+ const currentMemberCountOptions =
+ ownGroup.members.length === 4
+ ? [4]
+ : ownGroup.members.length === 3
+ ? [1]
+ : ownGroup.members.length === 2
+ ? [1, 2]
+ : [1, 2, 3];
+
+ const staleThreshold = sub(new Date(), { seconds: SECONDS_TILL_STALE });
+ return this.groups
+ .filter((group) => {
+ const groupLastAction = databaseTimestampToDate(group.latestActionAt);
+ return (
+ group.status === "ACTIVE" &&
+ !group.matchId &&
+ group.id !== ownGroup.id &&
+ currentMemberCountOptions.includes(group.members.length) &&
+ groupLastAction >= staleThreshold
+ );
+ })
+ .map(this.#getGroupReplayMapper(userId))
+ .map(this.#getAddTierRangeMapper(ownGroup.tier))
+ .map(this.#getAddMemberPrivateNoteMapper(notes))
+ .sort(this.#getSkillAndNoteSortComparator(ownGroup.tier))
+ .map((group) => this.#censorGroup(group));
+ }
+
+ #getGroupReplayMapper(userId: number) {
+ const recentOpponents = this.#recentMatches.flatMap((match) => {
+ if (match.groupAlphaMemberIds.includes(userId)) {
+ return [match.groupBravoMemberIds];
+ }
+
+ if (match.groupBravoMemberIds.includes(userId)) {
+ return [match.groupAlphaMemberIds];
+ }
+
+ return [];
+ });
+
+ return (group: T) => {
+ if (recentOpponents.length === 0) return group;
+ if (!this.#groupIsFull(group)) return group;
+
+ const isReplay = recentOpponents.some((opponentIds) => {
+ const duplicateCount =
+ R.countBy(opponentIds, (id) =>
+ group.members.some((m) => m.id === id) ? "match" : "no-match",
+ ).match ?? 0;
+
+ return duplicateCount >= 3;
+ });
+
+ return {
+ ...group,
+ isReplay,
+ };
+ };
+ }
+
+ #getAddTierRangeMapper(ownTier?: TieredSkill["tier"] | null) {
+ return (group: T) => {
+ if (!this.#groupIsFull(group)) {
+ return group;
+ }
+
+ const tierRangeOrExact = tierDifferenceToRangeOrExact({
+ ourTier: ownTier ?? FALLBACK_TIER,
+ theirTier: group.tier ?? FALLBACK_TIER,
+ hasLeviathan: this.#isAccurateTiers,
+ });
+
+ if (tierRangeOrExact.type === "exact") {
+ return group;
+ }
+
+ return {
+ ...group,
+ tierRange: R.omit(tierRangeOrExact, ["type"]),
+ tier: null,
+ };
+ };
+ }
+
+ #addPreviewTierRange(group: T) {
+ if (!this.#groupIsFull(group)) {
+ return group;
+ }
+
+ return {
+ ...group,
+ tierRange: {
+ type: "range" as const,
+ range: [
+ { name: "IRON", isPlus: false } as TieredSkill["tier"],
+ { name: "LEVIATHAN", isPlus: true } as TieredSkill["tier"],
+ ],
+ diff: 0,
+ },
+ tier: null,
+ };
+ }
+
+ #censorGroup(
+ group: T,
+ ): Omit & {
+ members: T["members"] | undefined;
+ } {
+ const baseGroup = R.omit(group, ["inviteCode", "chatCode", "members"]);
+
+ if (this.#groupIsFull(group)) {
+ return {
+ ...baseGroup,
+ members: undefined,
+ };
+ }
+
+ return {
+ ...baseGroup,
+ members: group.members,
+ };
+ }
+
+ #getUserTier(userId: number): TieredSkill["tier"] | null {
+ const skill = this.#userSkills[String(userId)];
+ if (!skill || skill.approximate) {
+ return null;
+ }
+ return skill.tier;
+ }
+
+ #getAddMemberPrivateNoteMapper(notes: DBPrivateNoteRow[]) {
+ return (group: T) => {
+ const membersWithNotes = group.members.map((member) => {
+ const note = notes.find((n) => n.targetUserId === member.id);
+ return {
+ ...member,
+ privateNote: note ?? null,
+ };
+ });
+
+ return {
+ ...group,
+ members: membersWithNotes,
+ };
+ };
+ }
+
+ #getSkillAndNoteSortComparator(ownTier?: TieredSkill["tier"] | null) {
+ return <
+ T extends {
+ members: { privateNote: DBPrivateNoteRow | null }[];
+ tierRange: TierRange | null;
+ tier: TieredSkill["tier"] | null;
+ latestActionAt: number;
+ },
+ >(
+ a: T,
+ b: T,
+ ) => {
+ const aIsFull = this.#groupIsFull(a);
+ const bIsFull = this.#groupIsFull(b);
+
+ if (aIsFull !== bIsFull) {
+ return aIsFull ? 1 : -1;
+ }
+
+ const getGroupSentimentScore = (group: T) => {
+ const hasNegative = group.members.some(
+ (m) => m.privateNote?.sentiment === "NEGATIVE",
+ );
+ const hasPositive = group.members.some(
+ (m) => m.privateNote?.sentiment === "POSITIVE",
+ );
+
+ if (hasNegative) return -1;
+ if (hasPositive) return 1;
+ return 0;
+ };
+
+ const scoreA = getGroupSentimentScore(a);
+ const scoreB = getGroupSentimentScore(b);
+
+ if (scoreA !== scoreB) {
+ return scoreB - scoreA;
+ }
+
+ if (a.tierRange && b.tierRange) {
+ if (a.tierRange.diff[1] !== b.tierRange.diff[1]) {
+ return a.tierRange.diff[1] - b.tierRange.diff[1];
+ }
+ }
+
+ const ownTierIndex = getTierIndex(ownTier, this.#isAccurateTiers);
+ if (typeof ownTierIndex === "number") {
+ const diffA = Math.abs(
+ ownTierIndex - (getTierIndex(a.tier, this.#isAccurateTiers) ?? 999),
+ );
+ const diffB = Math.abs(
+ ownTierIndex - (getTierIndex(b.tier, this.#isAccurateTiers) ?? 999),
+ );
+ if (diffA !== diffB) {
+ return diffA - diffB;
+ }
+ }
+
+ return b.latestActionAt - a.latestActionAt;
+ };
+ }
+
+ #groupNoScreen(group: { members: { noScreen: DBBoolean }[] }) {
+ return this.#groupIsFull(group)
+ ? group.members.some((member) => member.noScreen)
+ : null;
+ }
+
+ #groupModePreferences(
+ group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"],
+ ): ModeShort[] {
+ const modePreferences: ModeShort[] = [];
+
+ for (const mode of modesShort) {
+ let score = 0;
+ for (const member of group.members) {
+ const userModePreferences = member.mapModePreferences?.modes;
+ if (!userModePreferences) continue;
+
+ if (
+ userModePreferences.some(
+ (p) => p.mode === mode && p.preference === "PREFER",
+ )
+ ) {
+ score += 1;
+ } else if (
+ userModePreferences.some(
+ (p) => p.mode === mode && p.preference === "AVOID",
+ )
+ ) {
+ score -= 1;
+ }
+ }
+
+ if (score > 0) {
+ modePreferences.push(mode);
+ }
+ }
+
+ // reasonable default
+ if (modePreferences.length === 0) {
+ return ["SZ"];
+ }
+
+ return modePreferences;
+ }
+
+ #groupIsFull(group: { members: unknown[] }) {
+ return group.members.length === FULL_GROUP_SIZE;
+ }
+
+ #groupTier(
+ group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"],
+ ): TieredSkill["tier"] | undefined {
+ if (!group.members) return;
+
+ const skills = group.members.map(
+ (m) => this.#userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
+ );
+
+ const averageOrdinal =
+ skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
+
+ return (
+ this.#intervals.find(
+ (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
+ ) ?? { isPlus: false, name: "IRON" }
+ );
+ }
+}
+
+const groups = await SQGroupRepository.findCurrentGroups();
+const recentMatches = await SQGroupRepository.findRecentlyFinishedMatches();
+/** Global instance of the SendouQ manager. Manages all active groups and matchmaking state. */
+export let SendouQ = new SendouQClass(groups, recentMatches);
+
+/**
+ * Refreshes the global SendouQ instance with the latest data from the database.
+ * Should be called after any database changes that affect groups or matches.
+ */
+export async function refreshSendouQInstance() {
+ const groups = await SQGroupRepository.findCurrentGroups();
+ const recentMatches = await SQGroupRepository.findRecentlyFinishedMatches();
+ SendouQ = new SendouQClass(groups, recentMatches);
+}
diff --git a/app/features/sendouq/core/default-maps.server.ts b/app/features/sendouq/core/default-maps.server.ts
index 1b0f9501e..de14686ae 100644
--- a/app/features/sendouq/core/default-maps.server.ts
+++ b/app/features/sendouq/core/default-maps.server.ts
@@ -3,8 +3,8 @@ import * as MapList from "~/features/map-list-generator/core/MapList";
import * as Seasons from "~/features/mmr/core/Seasons";
import { modesShort } from "~/modules/in-game-lists/modes";
import { logger } from "~/utils/logger";
-import * as QRepository from "../QRepository.server";
import { SENDOUQ_BEST_OF } from "../q-constants";
+import * as SQGroupRepository from "../SQGroupRepository.server";
let cachedDefaults: Map | null = null;
@@ -59,7 +59,7 @@ async function calculateSeasonDefaultMaps(
seasonNth: number,
): Promise> {
const activeUsersWithPreferences =
- await QRepository.mapModePreferencesBySeasonNth(seasonNth);
+ await SQGroupRepository.mapModePreferencesBySeasonNth(seasonNth);
const mapModeCounts = new Map();
diff --git a/app/features/sendouq/core/groups.server.test.ts b/app/features/sendouq/core/groups.server.test.ts
index 91e4d4aa5..46abbcf60 100644
--- a/app/features/sendouq/core/groups.server.test.ts
+++ b/app/features/sendouq/core/groups.server.test.ts
@@ -6,7 +6,7 @@ const paramsToExpected = new Map<
Parameters[0]["ourTier"],
Parameters[0]["theirTier"],
],
- ReturnType["tier"]
+ ReturnType
>()
// exact
.set(
@@ -14,7 +14,7 @@ const paramsToExpected = new Map<
{ isPlus: false, name: "GOLD" },
{ isPlus: false, name: "GOLD" },
],
- { isPlus: false, name: "GOLD" },
+ { type: "exact", diff: 0, tier: { isPlus: false, name: "GOLD" } },
)
// 1 place difference
.set(
@@ -22,10 +22,14 @@ const paramsToExpected = new Map<
{ isPlus: false, name: "GOLD" },
{ isPlus: true, name: "GOLD" },
],
- [
- { isPlus: true, name: "SILVER" },
- { isPlus: true, name: "GOLD" },
- ],
+ {
+ type: "range",
+ diff: [-1, 1],
+ range: [
+ { isPlus: true, name: "SILVER" },
+ { isPlus: true, name: "GOLD" },
+ ],
+ },
)
// 2 places difference
.set(
@@ -33,10 +37,14 @@ const paramsToExpected = new Map<
{ isPlus: false, name: "GOLD" },
{ isPlus: false, name: "PLATINUM" },
],
- [
- { isPlus: false, name: "SILVER" },
- { isPlus: false, name: "PLATINUM" },
- ],
+ {
+ type: "range",
+ diff: [-2, 2],
+ range: [
+ { isPlus: false, name: "SILVER" },
+ { isPlus: false, name: "PLATINUM" },
+ ],
+ },
)
// too high, has to be exact
.set(
@@ -44,7 +52,7 @@ const paramsToExpected = new Map<
{ isPlus: true, name: "LEVIATHAN" },
{ isPlus: false, name: "LEVIATHAN" },
],
- { isPlus: false, name: "LEVIATHAN" },
+ { type: "exact", diff: 1, tier: { isPlus: false, name: "LEVIATHAN" } },
)
// too low, has to be exact
.set(
@@ -52,7 +60,7 @@ const paramsToExpected = new Map<
{ isPlus: false, name: "IRON" },
{ isPlus: true, name: "IRON" },
],
- { isPlus: true, name: "IRON" },
+ { type: "exact", diff: 1, tier: { isPlus: true, name: "IRON" } },
)
// not max rank but still too high
.set(
@@ -60,7 +68,7 @@ const paramsToExpected = new Map<
{ isPlus: false, name: "LEVIATHAN" },
{ isPlus: false, name: "DIAMOND" },
],
- { isPlus: false, name: "DIAMOND" },
+ { type: "exact", diff: 2, tier: { isPlus: false, name: "DIAMOND" } },
);
describe("tierDifferenceToRangeOrExact()", () => {
@@ -70,7 +78,7 @@ describe("tierDifferenceToRangeOrExact()", () => {
ourTier: input[0],
theirTier: input[1],
hasLeviathan: true,
- }).tier;
+ });
expect(result).toEqual(expected);
});
}
@@ -80,7 +88,11 @@ describe("tierDifferenceToRangeOrExact()", () => {
ourTier: { isPlus: true, name: "DIAMOND" },
theirTier: { isPlus: false, name: "DIAMOND" },
hasLeviathan: false,
- }).tier;
- expect(result).toEqual({ isPlus: false, name: "DIAMOND" });
+ });
+ expect(result).toEqual({
+ type: "exact",
+ diff: 1,
+ tier: { isPlus: false, name: "DIAMOND" },
+ });
});
});
diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts
index d661b323a..a1d8fa520 100644
--- a/app/features/sendouq/core/groups.server.ts
+++ b/app/features/sendouq/core/groups.server.ts
@@ -1,509 +1,7 @@
-import type { Tables } from "~/db/tables";
import { TIERS } from "~/features/mmr/mmr-constants";
-import { defaultOrdinal } from "~/features/mmr/mmr-utils";
-import type {
- SkillTierInterval,
- TieredSkill,
-} from "~/features/mmr/tiered.server";
-import { mapModePreferencesToModeList } from "~/features/sendouq-match/core/match.server";
-import { modesShort } from "~/modules/in-game-lists/modes";
-import { databaseTimestampToDate } from "~/utils/dates";
+import type { TieredSkill } from "~/features/mmr/tiered.server";
import invariant from "~/utils/invariant";
-import { FULL_GROUP_SIZE } from "../q-constants";
-import type {
- DividedGroups,
- DividedGroupsUncensored,
- GroupExpiryStatus,
- LookingGroup,
- LookingGroupWithInviteCode,
-} from "../q-types";
-import type { RecentMatchPlayer } from "../queries/findRecentMatchPlayersByUserId.server";
-
-export function divideGroups({
- groups,
- ownGroupId,
- likes,
-}: {
- groups: LookingGroupWithInviteCode[];
- ownGroupId?: number;
- likes: Pick<
- Tables["GroupLike"],
- "likerGroupId" | "targetGroupId" | "isRechallenge"
- >[];
-}): DividedGroupsUncensored {
- let own: LookingGroupWithInviteCode | undefined;
- const neutral: LookingGroupWithInviteCode[] = [];
- const likesReceived: LookingGroupWithInviteCode[] = [];
-
- const unneutralGroupIds = new Set();
- for (const like of likes) {
- for (const group of groups) {
- if (group.id === ownGroupId) continue;
-
- // handles edge case where they liked each other
- // right after each other so the group didn't morph
- // so instead it will look so that the group liked us
- // and there is the option to morph
- if (unneutralGroupIds.has(group.id)) continue;
-
- if (like.likerGroupId === group.id) {
- likesReceived.push(group);
- if (like.isRechallenge) {
- group.isRechallenge = true;
- }
-
- unneutralGroupIds.add(group.id);
- break;
- }
- if (like.targetGroupId === group.id) {
- group.isLiked = true;
- if (like.isRechallenge) {
- group.isRechallenge = true;
- }
- }
- }
- }
-
- for (const group of groups) {
- if (group.id === ownGroupId) {
- own = group;
- continue;
- }
-
- if (unneutralGroupIds.has(group.id)) continue;
-
- neutral.push(group);
- }
-
- return {
- own,
- neutral,
- likesReceived,
- };
-}
-
-export function addNoScreenIndicator(
- groups: DividedGroupsUncensored,
-): DividedGroupsUncensored {
- const ownGroupFull = groups.own?.members.length === FULL_GROUP_SIZE;
- const ownGroupNoScreen = groups.own?.members.some((m) => m.noScreen);
-
- const addNoScreenIndicatorIfNeeded = (group: LookingGroupWithInviteCode) => {
- const theirGroupNoScreen = group.members.some((m) => m.noScreen);
-
- return {
- ...group,
- isNoScreen: ownGroupFull && (ownGroupNoScreen || theirGroupNoScreen),
- members: group.members.map((m) => ({ ...m, noScreen: undefined })),
- };
- };
-
- return {
- own: groups.own
- ? {
- ...groups.own,
- members: groups.own.members.map((m) => ({
- ...m,
- noScreen: undefined,
- })),
- }
- : undefined,
- likesReceived: groups.likesReceived.map(addNoScreenIndicatorIfNeeded),
- neutral: groups.neutral.map(addNoScreenIndicatorIfNeeded),
- };
-}
-
-const MIN_PLAYERS_FOR_REPLAY = 3;
-export function addReplayIndicator({
- groups,
- recentMatchPlayers,
- userId,
-}: {
- groups: DividedGroupsUncensored;
- recentMatchPlayers: RecentMatchPlayer[];
- userId: number;
-}): DividedGroupsUncensored {
- if (!recentMatchPlayers.length) return groups;
-
- const ownGroupId = recentMatchPlayers.find(
- (u) => u.userId === userId,
- )?.groupId;
- invariant(ownGroupId, "own group not found");
- const otherGroupId = recentMatchPlayers.find(
- (u) => u.groupId !== ownGroupId,
- )?.groupId;
- invariant(otherGroupId, "other group not found");
-
- const opponentPlayers = recentMatchPlayers
- .filter((u) => u.groupId === otherGroupId)
- .map((p) => p.userId);
-
- const addReplayIndicatorIfNeeded = (group: LookingGroupWithInviteCode) => {
- const samePlayersCount = group.members.reduce(
- (acc, cur) => (opponentPlayers.includes(cur.id) ? acc + 1 : acc),
- 0,
- );
-
- return { ...group, isReplay: samePlayersCount >= MIN_PLAYERS_FOR_REPLAY };
- };
-
- return {
- own: groups.own,
- likesReceived: groups.likesReceived.map(addReplayIndicatorIfNeeded),
- neutral: groups.neutral.map(addReplayIndicatorIfNeeded),
- };
-}
-
-export function addFutureMatchModes(
- groups: DividedGroupsUncensored,
-): DividedGroupsUncensored {
- const ownModePreferences =
- groups.own?.mapModePreferences?.map((p) => p.modes) ?? [];
-
- const combinedMatchModes = (group: LookingGroupWithInviteCode) => {
- const theirModePreferences = group.mapModePreferences?.map((p) => p.modes);
- if (!theirModePreferences) return;
-
- return mapModePreferencesToModeList(
- ownModePreferences,
- theirModePreferences,
- ).sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b));
- };
-
- const oneGroupMatchModes = (group: LookingGroupWithInviteCode) => {
- const modePreferences = group.mapModePreferences?.map((p) => p.modes);
- if (!modePreferences) return;
-
- return mapModePreferencesToModeList(modePreferences, []).sort(
- (a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
- );
- };
-
- const removeRechallengeIfIdentical = (group: LookingGroupWithInviteCode) => {
- if (!group.futureMatchModes || !group.rechallengeMatchModes) return group;
-
- return {
- ...group,
- rechallengeMatchModes:
- group.futureMatchModes.length === group.rechallengeMatchModes.length &&
- group.futureMatchModes.every(
- (m, i) => m === group.rechallengeMatchModes![i],
- )
- ? undefined
- : group.rechallengeMatchModes,
- };
- };
-
- return {
- own: groups.own,
- likesReceived: groups.likesReceived.map((g) => ({
- ...g,
- futureMatchModes:
- g.isRechallenge && groups.own
- ? oneGroupMatchModes(groups.own)
- : combinedMatchModes(g),
- })),
- neutral: groups.neutral
- .map((g) => ({
- ...g,
- futureMatchModes: g.isRechallenge
- ? oneGroupMatchModes(g)
- : combinedMatchModes(g),
- rechallengeMatchModes: g.isLiked ? oneGroupMatchModes(g) : undefined,
- }))
- .map(removeRechallengeIfIdentical),
- };
-}
-
-const censorGroupFully = ({
- inviteCode: _inviteCode,
- mapModePreferences: _mapModePreferences,
- ...group
-}: LookingGroupWithInviteCode): LookingGroup => ({
- ...group,
- members: undefined,
-});
-const censorGroupPartly = ({
- inviteCode: _inviteCode,
- mapModePreferences: _mapModePreferences,
- ...group
-}: LookingGroupWithInviteCode): LookingGroup => group;
-export function censorGroups({
- groups,
- showInviteCode,
-}: {
- groups: DividedGroupsUncensored;
- showInviteCode: boolean;
-}): DividedGroups {
- return {
- own:
- showInviteCode || !groups.own
- ? groups.own
- : censorGroupPartly(groups.own),
- neutral: groups.neutral.map((g) =>
- g.members.length === FULL_GROUP_SIZE
- ? censorGroupFully(g)
- : censorGroupPartly(g),
- ),
- likesReceived: groups.likesReceived.map((g) =>
- g.members.length === FULL_GROUP_SIZE
- ? censorGroupFully(g)
- : censorGroupPartly(g),
- ),
- };
-}
-
-export function sortGroupsBySkillAndSentiment({
- groups,
- userSkills,
- intervals,
- userId,
-}: {
- groups: DividedGroups;
- userSkills: Record;
- intervals: SkillTierInterval[];
- userId?: number;
-}): DividedGroups {
- const ownGroupTier = () => {
- if (groups.own?.tier?.name) return groups.own.tier.name;
- if (groups.own) {
- return resolveGroupSkill({
- group: groups.own as LookingGroupWithInviteCode,
- userSkills,
- intervals,
- })?.name;
- }
-
- // preview mode, BRONZE as some kind of sensible defaults for unranked folks
- return userSkills[String(userId)]?.tier?.name ?? "BRONZE";
- };
- const ownGroupTierIndex = TIERS.findIndex((t) => t.name === ownGroupTier());
-
- const tierDiff = (otherGroupTierName?: string) => {
- if (!otherGroupTierName) return 10;
-
- const otherGroupTierIndex = TIERS.findIndex(
- (t) => t.name === otherGroupTierName,
- );
-
- return Math.abs(ownGroupTierIndex - otherGroupTierIndex);
- };
-
- const groupSentiment = (group: LookingGroup) => {
- if (group.members?.some((m) => m.privateNote?.sentiment === "NEGATIVE")) {
- return "NEGATIVE";
- }
-
- if (group.members?.some((m) => m.privateNote?.sentiment === "POSITIVE")) {
- return "POSITIVE";
- }
-
- return "NEUTRAL";
- };
-
- return {
- ...groups,
- neutral: groups.neutral.sort((a, b) => {
- const aSentiment = groupSentiment(a);
- const bSentiment = groupSentiment(b);
-
- if (aSentiment !== bSentiment) {
- if (aSentiment === "NEGATIVE") return 1;
- if (bSentiment === "NEGATIVE") return -1;
- if (aSentiment === "POSITIVE") return -1;
- if (bSentiment === "POSITIVE") return 1;
- }
-
- const aDiff = a.tierRange?.diff ?? 0;
- const bDiff = b.tierRange?.diff ?? 0;
-
- if (aDiff || bDiff) {
- return aDiff - bDiff;
- }
-
- const aTier =
- a.tier?.name ??
- resolveGroupSkill({
- group: a as LookingGroupWithInviteCode,
- userSkills,
- intervals,
- })?.name;
- const bTier =
- b.tier?.name ??
- resolveGroupSkill({
- group: b as LookingGroupWithInviteCode,
- userSkills,
- intervals,
- })?.name;
-
- const aTierDiff = tierDiff(aTier);
- const bTierDiff = tierDiff(bTier);
-
- // if same tier difference, show newer groups first
- if (aTierDiff === bTierDiff) {
- return b.createdAt - a.createdAt;
- }
-
- // show groups with smaller tier difference first
- return aTierDiff - bTierDiff;
- }),
- };
-}
-
-export function addSkillsToGroups({
- groups,
- userSkills,
- intervals,
-}: {
- groups: DividedGroupsUncensored;
- userSkills: Record;
- intervals: SkillTierInterval[];
-}): DividedGroupsUncensored {
- const addSkill = (group: LookingGroupWithInviteCode) => ({
- ...group,
- members: group.members?.map((m) => {
- const skill = userSkills[String(m.id)];
-
- return {
- ...m,
- skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
- };
- }),
- tier:
- group.members.length === FULL_GROUP_SIZE
- ? resolveGroupSkill({ group, userSkills, intervals })
- : undefined,
- });
-
- return {
- own: groups.own ? addSkill(groups.own) : undefined,
- neutral: groups.neutral.map(addSkill),
- likesReceived: groups.likesReceived.map(addSkill),
- };
-}
-
-const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
-export function addSkillRangeToGroups({
- groups,
- hasLeviathan,
- isPreview,
-}: {
- groups: DividedGroups;
- hasLeviathan: boolean;
- isPreview: boolean;
-}): DividedGroups {
- const addRange = (group: LookingGroup) => {
- if (group.members && group.members.length !== FULL_GROUP_SIZE) return group;
-
- if (isPreview) {
- return {
- ...group,
- tierRange: {
- range: [
- { name: "IRON", isPlus: false },
- { name: "LEVIATHAN", isPlus: true },
- ] as [TieredSkill["tier"], TieredSkill["tier"]],
- diff: 0,
- },
- tier: undefined,
- };
- }
-
- const range = tierDifferenceToRangeOrExact({
- ourTier: groups.own?.tier ?? FALLBACK_TIER,
- theirTier: group.tier ?? FALLBACK_TIER,
- hasLeviathan,
- });
-
- if (!Array.isArray(range.tier)) {
- return {
- ...group,
- tierRange: { diff: range.diff },
- };
- }
-
- return {
- ...group,
- tierRange: { range: range.tier, diff: range.diff },
- tier: undefined,
- };
- };
-
- return {
- own: groups.own,
- neutral: groups.neutral.map(addRange),
- likesReceived: groups.likesReceived.map(addRange),
- };
-}
-
-export function membersNeededForFull(currentSize: number) {
- return FULL_GROUP_SIZE - currentSize;
-}
-
-function resolveGroupSkill({
- group,
- userSkills,
- intervals,
-}: {
- group: LookingGroupWithInviteCode;
- userSkills: Record;
- intervals: SkillTierInterval[];
-}): TieredSkill["tier"] | undefined {
- if (!group.members) return;
-
- const skills = group.members.map(
- (m) => userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
- );
-
- const averageOrdinal =
- skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
-
- return (
- intervals.find(
- (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
- ) ?? { isPlus: false, name: "IRON" }
- );
-}
-
-export function groupExpiryStatus(
- group?: Pick,
-): null | GroupExpiryStatus {
- if (!group) return null;
-
- // group expires in 30min without actions performed
- const groupExpiresAt =
- databaseTimestampToDate(group.latestActionAt).getTime() + 30 * 60 * 1000;
-
- const now = Date.now();
-
- if (now > groupExpiresAt) {
- return "EXPIRED";
- }
-
- const tenMinutesFromNow = now + 10 * 60 * 1000;
-
- if (tenMinutesFromNow > groupExpiresAt) {
- return "EXPIRING_SOON";
- }
-
- return null;
-}
-
-export function censorGroupsIfOwnExpired({
- groups,
- ownGroupExpiryStatus,
-}: {
- groups: DividedGroups;
- ownGroupExpiryStatus: GroupExpiryStatus | null;
-}): DividedGroups {
- if (ownGroupExpiryStatus !== "EXPIRED") return groups;
-
- return {
- own: groups.own,
- likesReceived: [],
- neutral: [],
- };
-}
+import type { TierDifference } from "../q-types";
const allTiersOrdered = TIERS.flatMap((tier) => [
{ name: tier.name, isPlus: true },
@@ -517,12 +15,9 @@ export function tierDifferenceToRangeOrExact({
ourTier: TieredSkill["tier"];
theirTier: TieredSkill["tier"];
hasLeviathan: boolean;
-}): {
- diff: number;
- tier: TieredSkill["tier"] | [TieredSkill["tier"], TieredSkill["tier"]];
-} {
+}): TierDifference {
if (ourTier.name === theirTier.name && ourTier.isPlus === theirTier.isPlus) {
- return { diff: 0, tier: structuredClone(ourTier) };
+ return { type: "exact", diff: 0, tier: structuredClone(ourTier) };
}
const tiers = hasLeviathan
@@ -544,14 +39,15 @@ export function tierDifferenceToRangeOrExact({
const upperBound = tier1Idx + idxDiff;
if (lowerBound < 0 || upperBound >= tiers.length) {
- return { diff: idxDiff, tier: structuredClone(theirTier) };
+ return { type: "exact", diff: idxDiff, tier: structuredClone(theirTier) };
}
const lowerTier = tiers[lowerBound];
const upperTier = tiers[upperBound];
return {
- diff: idxDiff,
- tier: [structuredClone(lowerTier), structuredClone(upperTier)],
+ type: "range",
+ diff: [-idxDiff, idxDiff],
+ range: [structuredClone(lowerTier), structuredClone(upperTier)],
};
}
diff --git a/app/features/sendouq/core/groups.ts b/app/features/sendouq/core/groups.ts
index d1b1d42df..62a6d5c2c 100644
--- a/app/features/sendouq/core/groups.ts
+++ b/app/features/sendouq/core/groups.ts
@@ -1,5 +1,6 @@
-import type { Tables } from "~/db/tables";
-import type { LookingGroup } from "../q-types";
+import { databaseTimestampToDate } from "~/utils/dates";
+import type { GroupExpiryStatus } from "../q-types";
+import type { SQGroup } from "./SendouQ.server";
// logic is that team who is bigger decides the settings
// but if groups are the same size then the one who liked
@@ -9,8 +10,8 @@ export function groupAfterMorph({
theirGroup,
liker,
}: {
- ourGroup: LookingGroup;
- theirGroup: LookingGroup;
+ ourGroup: SQGroup;
+ theirGroup: SQGroup;
liker: "US" | "THEM";
}) {
const ourMembers = ourGroup.members ?? [];
@@ -31,6 +32,24 @@ export function groupAfterMorph({
return ourGroup;
}
-export function hasGroupManagerPerms(role: Tables["GroupMember"]["role"]) {
- return role === "OWNER" || role === "MANAGER";
+export function groupExpiryStatus(
+ latestActionAt: number,
+): GroupExpiryStatus | null {
+ // group expires in 30min without actions performed
+ const groupExpiresAt =
+ databaseTimestampToDate(latestActionAt).getTime() + 30 * 60 * 1000;
+
+ const now = Date.now();
+
+ if (now > groupExpiresAt) {
+ return "EXPIRED";
+ }
+
+ const tenMinutesFromNow = now + 10 * 60 * 1000;
+
+ if (tenMinutesFromNow > groupExpiresAt) {
+ return "EXPIRING_SOON";
+ }
+
+ return null;
}
diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts
index 5a70c8b59..2d1d6b633 100644
--- a/app/features/sendouq/loaders/q.looking.server.ts
+++ b/app/features/sendouq/loaders/q.looking.server.ts
@@ -1,131 +1,49 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
-import { redirect } from "@remix-run/node";
-import { getUser } from "~/features/auth/core/user.server";
-import * as Seasons from "~/features/mmr/core/Seasons";
-import { userSkills } from "~/features/mmr/tiered.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import { requireUser } from "~/features/auth/core/user.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
-import invariant from "~/utils/invariant";
-import { hasGroupManagerPerms } from "../core/groups";
-import {
- addFutureMatchModes,
- addNoScreenIndicator,
- addReplayIndicator,
- addSkillRangeToGroups,
- addSkillsToGroups,
- censorGroups,
- censorGroupsIfOwnExpired,
- divideGroups,
- groupExpiryStatus,
- membersNeededForFull,
- sortGroupsBySkillAndSentiment,
-} from "../core/groups.server";
-import { FULL_GROUP_SIZE } from "../q-constants";
-import { groupRedirectLocationByCurrentLocation } from "../q-utils";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { findLikes } from "../queries/findLikes";
-import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server";
-import { groupSize } from "../queries/groupSize.server";
+import { groupExpiryStatus } from "../core/groups";
+import { SendouQ } from "../core/SendouQ.server";
+import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
+import { sqRedirectIfNeeded } from "../q-utils.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
- const user = await getUser(request);
+ const user = await requireUser(request);
- const isPreview = Boolean(
+ const isPreview =
new URL(request.url).searchParams.get("preview") === "true" &&
- user?.roles.includes("SUPPORTER"),
+ user.roles.includes("SUPPORTER");
+
+ const privateNotes = await PrivateUserNoteRepository.byAuthorUserId(
+ user.id,
+ SendouQ.usersInQueue,
);
- const currentGroup =
- user && !isPreview ? findCurrentGroupByUserId(user.id) : undefined;
- const redirectLocation = isPreview
- ? undefined
- : groupRedirectLocationByCurrentLocation({
- group: currentGroup,
- currentLocation: "looking",
- });
+ const groups = isPreview
+ ? SendouQ.previewGroups(user.id, privateNotes)
+ : SendouQ.lookingGroups(user.id, privateNotes);
+ const ownGroup = SendouQ.findOwnGroup(user.id);
- if (redirectLocation) {
- throw redirect(redirectLocation);
+ if (!isPreview) {
+ sqRedirectIfNeeded({
+ ownGroup,
+ currentLocation: "looking",
+ });
}
- invariant(currentGroup || isPreview, "currentGroup is undefined");
-
- const currentGroupSize = currentGroup ? groupSize(currentGroup.id) : 1;
- const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
-
- const dividedGroups = divideGroups({
- groups: await QRepository.findLookingGroups({
- maxGroupSize:
- groupIsFull || isPreview
- ? undefined
- : membersNeededForFull(currentGroupSize),
- minGroupSize: groupIsFull && !isPreview ? FULL_GROUP_SIZE : undefined,
- ownGroupId: currentGroup?.id,
- includeMapModePreferences: Boolean(groupIsFull || isPreview),
- loggedInUserId: user?.id,
- }),
- ownGroupId: currentGroup?.id,
- likes: currentGroup ? findLikes(currentGroup.id) : [],
- });
-
- const season = Seasons.currentOrPrevious();
-
- const {
- intervals,
- userSkills: calculatedUserSkills,
- isAccurateTiers,
- } = userSkills(season!.nth);
- const groupsWithSkills = addSkillsToGroups({
- groups: dividedGroups,
- intervals,
- userSkills: calculatedUserSkills,
- });
-
- const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills);
-
- const groupsWithNoScreenIndicator = addNoScreenIndicator(
- groupsWithFutureMatchModes,
- );
-
- const groupsWithReplayIndicator = groupIsFull
- ? addReplayIndicator({
- groups: groupsWithNoScreenIndicator,
- recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id),
- userId: user!.id,
- })
- : groupsWithNoScreenIndicator;
-
- const censoredGroups = censorGroups({
- groups: groupsWithReplayIndicator,
- showInviteCode: currentGroup
- ? hasGroupManagerPerms(currentGroup.role) && !groupIsFull
- : false,
- });
-
- const rangedGroups = addSkillRangeToGroups({
- groups: censoredGroups,
- hasLeviathan: isAccurateTiers,
- isPreview,
- });
-
- const sortedGroups = sortGroupsBySkillAndSentiment({
- groups: rangedGroups,
- intervals,
- userSkills: calculatedUserSkills,
- userId: user?.id,
- });
-
- const expiryStatus = groupExpiryStatus(currentGroup);
-
return {
- groups: censorGroupsIfOwnExpired({
- groups: sortedGroups,
- ownGroupExpiryStatus: expiryStatus,
- }),
- role: currentGroup ? currentGroup.role : ("PREVIEWER" as const),
- chatCode: currentGroup?.chatCode,
+ groups:
+ ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
+ ? []
+ : groups,
+ ownGroup,
+ likes: ownGroup
+ ? await SQGroupRepository.allLikesByGroupId(ownGroup.id)
+ : {
+ given: [],
+ received: [],
+ },
lastUpdated: Date.now(),
streamsCount: (await cachedStreams()).length,
- expiryStatus: groupExpiryStatus(currentGroup),
};
};
diff --git a/app/features/sendouq/loaders/q.preparing.server.ts b/app/features/sendouq/loaders/q.preparing.server.ts
index 306c21dcb..af7c5393d 100644
--- a/app/features/sendouq/loaders/q.preparing.server.ts
+++ b/app/features/sendouq/loaders/q.preparing.server.ts
@@ -1,30 +1,20 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
-import { redirect } from "@remix-run/node";
-import { getUser } from "~/features/auth/core/user.server";
-import invariant from "~/utils/invariant";
-import { groupRedirectLocationByCurrentLocation } from "../q-utils";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { findPreparingGroup } from "../queries/findPreparingGroup.server";
+import { requireUserId } from "~/features/auth/core/user.server";
+import { SendouQ } from "../core/SendouQ.server";
+import { sqRedirectIfNeeded } from "../q-utils.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
- const user = await getUser(request);
+ const user = await requireUserId(request);
- const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
- const redirectLocation = groupRedirectLocationByCurrentLocation({
- group: currentGroup,
+ const ownGroup = SendouQ.findOwnGroup(user.id);
+
+ sqRedirectIfNeeded({
+ ownGroup,
currentLocation: "preparing",
});
- if (redirectLocation) {
- throw redirect(redirectLocation);
- }
-
- const ownGroup = findPreparingGroup(currentGroup!.id);
- invariant(ownGroup, "No own group found");
-
return {
lastUpdated: Date.now(),
- group: ownGroup,
- role: currentGroup!.role,
+ group: ownGroup!,
};
};
diff --git a/app/features/sendouq/loaders/q.server.ts b/app/features/sendouq/loaders/q.server.ts
index a525a54ec..7e58f26ce 100644
--- a/app/features/sendouq/loaders/q.server.ts
+++ b/app/features/sendouq/loaders/q.server.ts
@@ -1,12 +1,10 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
-import { redirect } from "@remix-run/node";
import { getUserId } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as UserRepository from "~/features/user-page/UserRepository.server";
+import { SendouQ } from "../core/SendouQ.server";
import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
-import { groupRedirectLocationByCurrentLocation } from "../q-utils";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
+import { sqRedirectIfNeeded } from "../q-utils.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
@@ -15,16 +13,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
JOIN_CODE_SEARCH_PARAM_KEY,
);
- const redirectLocation = groupRedirectLocationByCurrentLocation({
- group: user ? findCurrentGroupByUserId(user.id) : undefined,
+ const ownGroup = user ? SendouQ.findOwnGroup(user.id) : undefined;
+
+ sqRedirectIfNeeded({
+ ownGroup,
currentLocation: "default",
});
- if (redirectLocation) {
- throw redirect(`${redirectLocation}${code ? "?joining=true" : ""}`);
- }
-
- const groupInvitedTo = code && user ? findGroupByInviteCode(code) : undefined;
+ const groupInvitedTo =
+ code && user ? SendouQ.findGroupByInviteCode(code) : undefined;
const season = Seasons.current();
const upcomingSeason = !season ? Seasons.next() : undefined;
diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts
index 8d59c2215..8dafa796f 100644
--- a/app/features/sendouq/q-types.ts
+++ b/app/features/sendouq/q-types.ts
@@ -1,66 +1,16 @@
-import type { ParsedMemento, QWeaponPool, Tables } from "~/db/tables";
-import type { ModeShort } from "~/modules/in-game-lists/types";
import type { TieredSkill } from "../mmr/tiered.server";
-import type { GroupForMatch } from "../sendouq-match/QMatchRepository.server";
-
-export type LookingGroup = {
- id: number;
- createdAt: Tables["Group"]["createdAt"];
- tier?: TieredSkill["tier"];
- tierRange?: {
- range?: [TieredSkill["tier"], TieredSkill["tier"]];
- diff: number;
- };
- isReplay?: boolean;
- isNoScreen?: boolean;
- isLiked?: boolean;
- isRechallenge?: boolean;
- team?: GroupForMatch["team"];
- chatCode?: Tables["Group"]["chatCode"];
- mapModePreferences?: Array>;
- futureMatchModes?: Array;
- rechallengeMatchModes?: Array;
- skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
- members?: {
- id: number;
- discordId: string;
- username: string;
- discordAvatar: string | null;
- noScreen?: number;
- customUrl?: Tables["User"]["customUrl"];
- plusTier?: Tables["PlusTier"]["tier"];
- role: Tables["GroupMember"]["role"];
- note?: Tables["GroupMember"]["note"];
- weapons?: QWeaponPool[];
- skill?: TieredSkill | "CALCULATING";
- vc?: Tables["User"]["vc"];
- inGameName?: Tables["User"]["inGameName"];
- languages: string[];
- chatNameColor: string | null;
- skillDifference?: ParsedMemento["users"][number]["skillDifference"];
- friendCode?: string;
- privateNote: Pick<
- Tables["PrivateUserNote"],
- "sentiment" | "text" | "updatedAt"
- > | null;
- }[];
-};
-
-export type LookingGroupWithInviteCode = LookingGroup & {
- inviteCode: Tables["Group"]["inviteCode"];
- members: NonNullable;
-};
-
-export interface DividedGroups {
- own?: LookingGroup | LookingGroupWithInviteCode;
- neutral: LookingGroup[];
- likesReceived: LookingGroup[];
-}
-
-export interface DividedGroupsUncensored {
- own?: LookingGroupWithInviteCode;
- neutral: LookingGroupWithInviteCode[];
- likesReceived: LookingGroupWithInviteCode[];
-}
export type GroupExpiryStatus = "EXPIRING_SOON" | "EXPIRED";
+
+export type TierDifference =
+ | { type: "exact"; diff: number; tier: TieredSkill["tier"] }
+ | {
+ type: "range";
+ diff: [number, number];
+ range: [TieredSkill["tier"], TieredSkill["tier"]];
+ };
+
+export type TierRange = Omit<
+ Extract,
+ "type"
+>;
diff --git a/app/features/sendouq/q-utils.server.ts b/app/features/sendouq/q-utils.server.ts
new file mode 100644
index 000000000..d648eb7b7
--- /dev/null
+++ b/app/features/sendouq/q-utils.server.ts
@@ -0,0 +1,72 @@
+import { redirect } from "@remix-run/node";
+import { TIERS } from "~/features/mmr/mmr-constants";
+import type { TieredSkill } from "~/features/mmr/tiered.server";
+import {
+ SENDOUQ_LOOKING_PAGE,
+ SENDOUQ_PAGE,
+ SENDOUQ_PREPARING_PAGE,
+ sendouQMatchPage,
+} from "~/utils/urls";
+import type { SQOwnGroup } from "./core/SendouQ.server";
+
+/** Error class for SendouQ (expected) errors */
+export class SendouQError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SendouQError";
+ }
+}
+
+function groupRedirectLocation(group?: SQOwnGroup) {
+ if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE;
+ if (group?.matchId) return sendouQMatchPage(group.matchId);
+ if (group) return SENDOUQ_LOOKING_PAGE;
+
+ return SENDOUQ_PAGE;
+}
+
+/** User needs to be on certain page depending on their SendouQ group status. This functions throws a `Redirect` if they are trying to load the wrong page. */
+export function sqRedirectIfNeeded({
+ ownGroup,
+ currentLocation,
+}: {
+ ownGroup?: SQOwnGroup;
+ currentLocation: "default" | "preparing" | "looking" | "match";
+}) {
+ const newLocation = groupRedirectLocation(ownGroup);
+
+ // we are already in the correct location, don't redirect
+ if (currentLocation === "default" && newLocation === SENDOUQ_PAGE) return;
+ if (currentLocation === "preparing" && newLocation === SENDOUQ_PREPARING_PAGE)
+ return;
+ if (currentLocation === "looking" && newLocation === SENDOUQ_LOOKING_PAGE)
+ return;
+ if (currentLocation === "match" && newLocation.includes("match")) return;
+
+ throw redirect(newLocation);
+}
+
+const allTiersOrdered = TIERS.flatMap((t) => [
+ { name: t.name, isPlus: true },
+ { name: t.name, isPlus: false },
+]).reverse();
+const allTiersOrderedWithLeviathan = allTiersOrdered.filter(
+ (t) => t.name !== "LEVIATHAN",
+);
+
+export function getTierIndex(
+ tier: TieredSkill["tier"] | null | undefined,
+ isAccurateTiers: boolean,
+) {
+ if (!tier) return null;
+
+ const tiers = isAccurateTiers
+ ? allTiersOrdered
+ : allTiersOrderedWithLeviathan;
+
+ const index = tiers.findIndex(
+ (t) => t.name === tier.name && t.isPlus === tier.isPlus,
+ );
+
+ return index === -1 ? null : index;
+}
diff --git a/app/features/sendouq/q-utils.ts b/app/features/sendouq/q-utils.ts
index 6a079e701..06c52f015 100644
--- a/app/features/sendouq/q-utils.ts
+++ b/app/features/sendouq/q-utils.ts
@@ -1,75 +1,7 @@
-import type { Tables } from "~/db/tables";
-import { rankedModesShort } from "~/modules/in-game-lists/modes";
-import { stageIds } from "~/modules/in-game-lists/stage-ids";
+import { modesShort } from "~/modules/in-game-lists/modes";
import { databaseTimestampToDate } from "~/utils/dates";
-import {
- SENDOUQ_LOOKING_PAGE,
- SENDOUQ_PAGE,
- SENDOUQ_PREPARING_PAGE,
- sendouQMatchPage,
-} from "~/utils/urls";
import { accountCreatedInTheLastSixMonths } from "~/utils/users";
-import type { MapPool } from "../map-list-generator/core/map-pool";
-import { SENDOUQ } from "./q-constants";
-
-function groupRedirectLocation(
- group?: Pick & { matchId?: number },
-) {
- if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE;
- if (group?.matchId) return sendouQMatchPage(group.matchId);
- if (group) return SENDOUQ_LOOKING_PAGE;
-
- return SENDOUQ_PAGE;
-}
-
-export function groupRedirectLocationByCurrentLocation({
- group,
- currentLocation,
-}: {
- group?: Pick & { matchId?: number };
- currentLocation: "default" | "preparing" | "looking" | "match";
-}) {
- const newLocation = groupRedirectLocation(group);
-
- // we are already in the correct location, don't redirect
- if (currentLocation === "default" && newLocation === SENDOUQ_PAGE) return;
- if (currentLocation === "preparing" && newLocation === SENDOUQ_PREPARING_PAGE)
- return;
- if (currentLocation === "looking" && newLocation === SENDOUQ_LOOKING_PAGE)
- return;
- if (currentLocation === "match" && newLocation.includes("match")) return;
-
- return newLocation;
-}
-
-export function mapPoolOk(mapPool: MapPool) {
- for (const modeShort of rankedModesShort) {
- if (
- modeShort === "SZ" &&
- mapPool.countMapsByMode(modeShort) !== SENDOUQ.SZ_MAP_COUNT
- ) {
- return false;
- }
-
- if (
- modeShort !== "SZ" &&
- mapPool.countMapsByMode(modeShort) !== SENDOUQ.OTHER_MODE_MAP_COUNT
- ) {
- return false;
- }
- }
-
- for (const stageId of stageIds) {
- if (
- mapPool.stageModePairs.filter((pair) => pair.stageId === stageId).length >
- SENDOUQ.MAX_STAGE_REPEAT_COUNT
- ) {
- return false;
- }
- }
-
- return true;
-}
+import type { SQGroup } from "./core/SendouQ.server";
export function userCanJoinQueueAt(
user: { id: number; discordId: string },
@@ -94,3 +26,25 @@ export function userCanJoinQueueAt(
return canJoinQueueAt;
}
+
+export function resolveFutureMatchModes({
+ ownGroup,
+ theirGroup,
+}: {
+ ownGroup: Pick;
+ theirGroup: Pick;
+}) {
+ const ourModes = ownGroup.modePreferences;
+ const theirModes = theirGroup.modePreferences;
+
+ const overlap = ourModes.filter((mode) => theirModes.includes(mode));
+ if (overlap.length > 0) {
+ return overlap;
+ }
+
+ const union = modesShort.filter(
+ (mode) => ourModes.includes(mode) || theirModes.includes(mode),
+ );
+
+ return union;
+}
diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css
deleted file mode 100644
index 163333462..000000000
--- a/app/features/sendouq/q.css
+++ /dev/null
@@ -1,560 +0,0 @@
-.twf {
- width: 1.5rem;
- height: 1.5rem;
-}
-
-.q__clocks-container {
- display: flex;
- gap: var(--s-2);
-}
-
-.q__clock {
- font-size: var(--fonts-sm);
- font-weight: var(--bold);
- color: var(--color-text-high);
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- flex: 1 1 0;
- line-height: 1.3;
-}
-
-.q__clock-country {
- color: var(--color-text) !important;
- white-space: nowrap;
- font-size: var(--fonts-xs);
-}
-
-.q__front-page-link {
- background-color: var(--color-bg-high);
- border-radius: var(--rounded-sm);
- padding: var(--s-2);
- font-size: var(--fonts-sm);
- color: var(--color-text);
- font-weight: var(--bold);
- display: flex;
- align-items: center;
- gap: var(--s-2-5);
- transition: background-color 0.2s;
-}
-
-.q__front-page-link:hover {
- background-color: var(--color-bg-higher);
-}
-
-.q__front-page-link__sub-text {
- color: var(--color-text-high);
- font-size: var(--fonts-xxs);
- font-weight: var(--body);
-}
-
-.q__tab-button {
- width: 100px;
- background-color: var(--color-bg-high);
- color: var(--color-text);
- font-size: var(--fonts-sm);
- border-color: transparent;
- gap: var(--s-2);
- position: relative;
-}
-
-.q__tab-button__icon {
- width: 14px;
-}
-
-.q__tab-button__badge {
- font-size: var(--fonts-xxs);
- display: grid;
- place-items: center;
- background-color: var(--color-accent);
- position: absolute;
- top: -4px;
- right: -1px;
- border-radius: 100%;
- width: 17px;
- height: 17px;
- color: var(--color-text-inverse);
-}
-
-.q__top-container {
- display: flex;
- gap: var(--s-3);
- max-width: 500px;
-}
-
-.q__top-container__divider {
- min-width: 3px;
- background-color: var(--color-border);
- height: 100%;
- border-radius: var(--rounded);
-}
-
-.q__chat-container {
- top: var(--sticky-top);
- position: sticky;
-}
-
-.q__chat-messages-container {
- height: 350px;
-}
-
-.q__groups-container {
- display: grid;
- justify-content: center;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: var(--s-7);
-}
-
-.q__groups-container__mobile {
- grid-template-columns: 1fr;
- min-width: 300px;
- margin: 0 auto;
-}
-
-.q__groups-inner-container {
- max-width: 94vw;
-}
-
-.q__column-header {
- text-align: center;
- font-size: var(--fonts-xxs);
- font-weight: var(--semi-bold);
- text-transform: uppercase;
- color: var(--color-text-accent);
- display: flex;
- align-items: center;
-}
-
-.q__column-header::before,
-.q__column-header::after {
- flex: 1;
- content: "";
- padding: 2px;
- background-color: var(--color-bg-high);
- margin: 5px;
- border-radius: var(--rounded);
-}
-
-.q__group {
- background-color: var(--color-bg-high);
- width: 100%;
- border-radius: var(--rounded);
- padding: var(--s-2-5);
- display: flex;
- flex-direction: column;
- gap: var(--s-4);
- position: relative;
- color: var(--color-text);
-}
-
-.q__group__no-screen {
- background-color: var(--color-error);
- border-radius: 100%;
- padding: var(--s-1);
- width: 30px;
- height: 30px;
- display: grid;
- place-items: center;
-}
-
-.q__group__display-only {
- height: 100%;
- padding-block-end: var(--s-10);
-}
-
-.q__group-member {
- display: flex;
- gap: var(--s-2);
- align-items: center;
- background-color: var(--color-bg);
- border-radius: var(--rounded);
- font-size: var(--fonts-xsm);
- font-weight: var(--semi-bold);
- padding-inline-end: var(--s-2-5);
-}
-
-.q__group-member__name {
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- max-width: 7.5rem;
- font-size: var(--fonts-xs);
- color: var(--color-text);
-}
-
-.q__group-member__avatar {
- min-width: 36px;
-}
-
-.q__group-member__avatar__POSITIVE {
- outline: 2px solid var(--color-success-high);
-}
-
-.q__group-member__avatar__NEUTRAL {
- outline: 2px solid var(--color-warning-high);
-}
-
-.q__group-member__avatar__NEGATIVE {
- outline: 2px solid var(--color-error);
-}
-
-.q__group-member__tier {
- margin-inline-start: auto;
-}
-
-.q__group-member__tier__placeholder {
- min-width: 26.58px;
-}
-
-.q__group-member__extra-info {
- font-size: var(--fonts-xs);
- background-color: var(--color-bg);
- border-radius: var(--rounded);
- padding: var(--s-0-5) var(--s-1-5);
- width: max-content;
- display: flex;
- align-items: center;
- gap: var(--s-1);
- font-weight: var(--semi-bold);
- min-height: 24px;
-}
-
-.q__group-member__extra-info-button {
- font-size: var(--fonts-xs);
- background-color: var(--color-bg);
- border-radius: var(--rounded);
- padding: var(--s-0-5) var(--s-1-5);
- width: max-content;
- display: flex;
- align-items: center;
- gap: var(--s-1);
- font-weight: var(--semi-bold);
- color: var(--color-text);
- border: none;
- min-height: 24px;
-}
-
-.q__group-member__add-note-button {
- border: none;
- padding: 0 var(--s-1-5);
- color: var(--body);
- font-size: var(--fonts-xxs);
- font-weight: var(--semi-bold);
- background-color: var(--color-bg);
- white-space: nowrap;
-}
-
-.q__group-member__add-note-button__edit > svg {
- color: var(--color-accent);
-}
-
-.q__group-member__add-note-button > svg {
- width: 14px;
- margin-inline-end: var(--s-1);
-}
-
-.q__group-member__note-textarea {
- height: 4rem !important;
-}
-
-.q__group__future-match-mode {
- border-radius: 100%;
- background-color: var(--color-bg-higher);
- height: 30px;
- width: 30px;
- display: grid;
- place-items: center;
- padding: var(--s-1-5);
-}
-
-.q__group__future-match-mode__rechallenge {
- outline: 2px solid var(--color-accent);
-}
-
-.q__group-member-weapons {
- display: flex;
- gap: var(--s-1);
- margin-block-start: -2px;
-}
-
-.q__group-member-weapon {
- background-color: var(--color-bg);
- border-radius: 100%;
- padding: var(--s-1);
- overflow: visible;
-}
-
-.q__group-member-vc-icon {
- height: 15px;
- stroke-width: 2;
-}
-
-.q__group-member__star {
- min-width: 18px;
- max-width: 18px;
- color: var(--color-text-accent);
- stroke-width: 2;
-}
-
-.q__group-member__star__inactive {
- color: var(--color-text-high);
-}
-
-.q__group__display-group-tier {
- display: flex;
- gap: var(--s-1);
- align-items: center;
- position: absolute;
- border-radius: var(--rounded);
- background-color: var(--color-bg);
- padding: var(--s-0-5) var(--s-2-5);
- font-size: var(--fonts-xs);
- font-weight: var(--semi-bold);
- bottom: -36px;
- left: 50%;
- transform: translate(-50%, -50%);
-}
-
-.q__group__or-popover-button {
- background-color: transparent;
- color: var(--color-text-high);
- font-size: var(--fonts-xs);
- padding: 0;
- border: none;
- text-decoration: underline;
- text-decoration-style: dotted;
- font-weight: var(--bold);
- height: 19.8281px;
-}
-
-.q__member-adder__input {
- --input-width: 11rem;
- width: 11rem;
-}
-
-.q-preparing__card-container {
- min-width: 250px;
- margin: 0 auto;
-}
-
-.q-match__stage-popover-button {
- background-color: transparent;
- color: var(--color-text-high);
- font-size: var(--fonts-xs);
- padding: 0;
- border: none;
- text-decoration: underline;
- text-decoration-style: dotted;
- font-weight: var(--body);
- height: 19.8281px;
-}
-
-.q-match__stage-popover-button:focus {
- outline: none;
- color: var(--color-accent);
-}
-
-.q-match__mode-popover-button {
- background-color: transparent;
- padding: 0;
- border: none;
-}
-
-.q-match__mode-popover-button:focus {
- outline: none;
-}
-
-.q-match__join-discord-section {
- border-left: 4px solid var(--color-accent);
- padding-inline-start: var(--s-4);
- font-size: var(--fonts-sm);
- color: var(--color-text-high);
- margin-block-start: var(--s-1);
-}
-
-.q-match__join-discord-section__highlighted {
- font-size: var(--fonts-md);
- letter-spacing: 1px;
- color: var(--color-text);
- font-weight: var(--semi-bold);
-}
-
-.q-match__container {
- /** Push footer down to avoid it "flashing" when the score reporter animates */
- padding-bottom: 14rem;
-}
-
-.q-match__header {
- line-height: 1.2;
-}
-
-.q-match__teams-container {
- display: grid;
- grid-template-columns: 1fr;
- gap: var(--s-8);
-}
-
-.q-match__map-list-chat-container {
- display: grid;
- grid-template-columns: 2fr 1fr 2fr;
- place-items: center;
- gap: var(--s-4);
-}
-
-.q-match__report__user-name-container {
- display: flex;
- gap: var(--s-2);
- width: 175px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.q-match__report-section {
- display: grid;
- grid-template-columns: max-content 1fr;
- row-gap: var(--s-2);
- column-gap: var(--s-2-5);
- align-items: center;
- font-size: var(--fonts-xs);
-}
-
-.q-match__pool-pass-container {
- display: flex;
- gap: var(--s-2);
- flex-direction: column;
- max-width: max-content;
-}
-
-.q-match__sentiment-emoji {
- width: 12px;
-}
-
-.q-match__chat-container {
- align-self: flex-start;
- top: var(--sticky-top);
- position: sticky;
- width: 100%;
-}
-
-.q-match__bottom-mid-section {
- display: flex;
- flex-direction: column;
- align-self: flex-start;
- top: var(--sticky-top);
- position: sticky;
-}
-
-.q-match__info__header {
- text-transform: uppercase;
- color: var(--color-text-high);
- font-size: var(--fonts-xs);
- line-height: 1.1;
-}
-
-.q-match__info__value {
- font-size: var(--fonts-xl);
- font-weight: var(--semi-bold);
- letter-spacing: 1px;
-}
-
-.q-match__screen-legality svg {
- width: 24px;
-}
-
-.q-match__screen-legality .alert {
- padding-block: var(--s-1);
- padding-inline: var(--s-2-5);
-}
-
-.q-match__screen-legality__button {
- width: 100%;
-}
-
-.q-match__screen-legality__button:focus-visible {
- outline: none !important;
-}
-
-.q-match__screen-legality__button:focus-visible .alert {
- background-color: var(--color-bg-high);
-}
-
-@media screen and (min-width: 640px) {
- .q-match__teams-container {
- grid-template-columns: 1fr 1fr;
- }
-}
-
-.q-stream__stream__user-container {
- font-size: var(--fonts-xs);
- display: flex;
- gap: var(--s-2);
- align-items: center;
- font-weight: var(--semi-bold);
- color: var(--color-text);
-}
-
-.q-stream__stream__team-name {
- color: var(--color-accent);
-}
-
-.q-stream__stream__viewer-count {
- font-size: var(--fonts-xs);
- display: flex;
- gap: var(--s-2);
- align-items: center;
- margin-block-start: -5px;
- color: var(--color-text-high);
-}
-
-.q-stream__stream__viewer-count > svg {
- width: 0.75rem;
-}
-
-.q-stream__info-circle {
- border-radius: 100%;
- background-color: var(--color-bg-high);
- padding: var(--s-1);
-}
-
-.q-info__container {
- display: flex;
- flex-direction: column;
- gap: var(--s-4);
- font-size: var(--fonts-md);
-}
-
-.q-info__container h2 {
- font-size: var(--fonts-lg);
- margin-block-end: var(--s-2);
- color: var(--color-text-accent);
-}
-
-.q-info__container h3 {
- font-size: var(--fonts-md);
- margin-block-end: var(--s-3);
-}
-
-.q-info__container p {
- margin-block: var(--s-2);
-}
-
-.q-info__table-of-contents ul {
- padding-left: 0;
-}
-
-.q-info__table-of-contents li {
- list-style: none;
- font-size: var(--fonts-sm);
- font-weight: var(--semi-bold);
-}
-
-.q-info__table-of-contents li:has(button) {
- margin-block-start: var(--s-2);
-}
-
-.q-info__table-of-contents li:not(:has(button)) {
- margin-inline-start: var(--s-2);
- font-size: var(--fonts-xs);
-}
diff --git a/app/features/sendouq/queries/addLike.server.ts b/app/features/sendouq/queries/addLike.server.ts
deleted file mode 100644
index 065983374..000000000
--- a/app/features/sendouq/queries/addLike.server.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- insert into "GroupLike" ("likerGroupId", "targetGroupId")
- values (@likerGroupId, @targetGroupId)
- on conflict ("likerGroupId", "targetGroupId") do nothing
-`);
-
-export function addLike({
- likerGroupId,
- targetGroupId,
-}: {
- likerGroupId: number;
- targetGroupId: number;
-}) {
- stm.run({ likerGroupId, targetGroupId });
-}
diff --git a/app/features/sendouq/queries/addManagerRole.server.ts b/app/features/sendouq/queries/addManagerRole.server.ts
deleted file mode 100644
index a9fdc0aa3..000000000
--- a/app/features/sendouq/queries/addManagerRole.server.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- update "GroupMember"
- set "role" = 'MANAGER'
- where "userId" = @userId
- and "groupId" = @groupId
-`);
-
-export function addManagerRole({
- userId,
- groupId,
-}: {
- userId: number;
- groupId: number;
-}) {
- stm.run({ userId, groupId });
-}
diff --git a/app/features/sendouq/queries/addMember.server.ts b/app/features/sendouq/queries/addMember.server.ts
deleted file mode 100644
index 6615feb81..000000000
--- a/app/features/sendouq/queries/addMember.server.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/* sql */ `
- insert into "GroupMember" (
- "groupId",
- "userId",
- "role"
- ) values (
- @groupId,
- @userId,
- @role
- )
-`);
-
-export function addMember({
- groupId,
- userId,
- role = "REGULAR",
-}: {
- groupId: number;
- userId: number;
- role?: Tables["GroupMember"]["role"];
-}) {
- stm.run({ groupId, userId, role });
-}
diff --git a/app/features/sendouq/queries/chatCodeByGroupId.server.ts b/app/features/sendouq/queries/chatCodeByGroupId.server.ts
deleted file mode 100644
index 1882e503c..000000000
--- a/app/features/sendouq/queries/chatCodeByGroupId.server.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- select
- "chatCode"
- from "Group"
- where "id" = @id
-`);
-
-export function chatCodeByGroupId(id: number) {
- return (stm.get({ id }) as any)?.chatCode as string | undefined;
-}
diff --git a/app/features/sendouq/queries/createMatch.server.ts b/app/features/sendouq/queries/createMatch.server.ts
deleted file mode 100644
index 0289f81fc..000000000
--- a/app/features/sendouq/queries/createMatch.server.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { sql } from "~/db/sql";
-import type { ParsedMemento, Tables } from "~/db/tables";
-import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
-import { shortNanoid } from "~/utils/id";
-import { syncGroupTeamId } from "./syncGroupTeamId.server";
-
-const createMatchStm = sql.prepare(/* sql */ `
- insert into "GroupMatch" (
- "alphaGroupId",
- "bravoGroupId",
- "chatCode",
- "memento"
- ) values (
- @alphaGroupId,
- @bravoGroupId,
- @chatCode,
- @memento
- )
- returning *
-`);
-
-const createMatchMapStm = sql.prepare(/* sql */ `
- insert into "GroupMatchMap" (
- "matchId",
- "index",
- "mode",
- "stageId",
- "source"
- ) values (
- @matchId,
- @index,
- @mode,
- @stageId,
- @source
- )
-`);
-
-export const createMatch = sql.transaction(
- ({
- alphaGroupId,
- bravoGroupId,
- mapList,
- memento,
- }: {
- alphaGroupId: number;
- bravoGroupId: number;
- mapList: TournamentMapListMap[];
- memento: ParsedMemento;
- }) => {
- const match = createMatchStm.get({
- alphaGroupId,
- bravoGroupId,
- chatCode: shortNanoid(),
- memento: JSON.stringify(memento),
- }) as Tables["GroupMatch"];
-
- for (const [i, { mode, source, stageId }] of mapList.entries()) {
- createMatchMapStm.run({
- matchId: match.id,
- index: i,
- mode,
- stageId,
- source: String(source),
- });
- }
-
- syncGroupTeamId(alphaGroupId);
- syncGroupTeamId(bravoGroupId);
-
- return match;
- },
-);
diff --git a/app/features/sendouq/queries/deleteLike.server.ts b/app/features/sendouq/queries/deleteLike.server.ts
deleted file mode 100644
index 3d586a0ec..000000000
--- a/app/features/sendouq/queries/deleteLike.server.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- delete from "GroupLike"
- where "likerGroupId" = @likerGroupId
- and "targetGroupId" = @targetGroupId
-`);
-
-export function deleteLike({
- likerGroupId,
- targetGroupId,
-}: {
- likerGroupId: number;
- targetGroupId: number;
-}) {
- stm.run({ likerGroupId, targetGroupId });
-}
diff --git a/app/features/sendouq/queries/deleteLikesByGroupId.server.ts b/app/features/sendouq/queries/deleteLikesByGroupId.server.ts
deleted file mode 100644
index dee288cb0..000000000
--- a/app/features/sendouq/queries/deleteLikesByGroupId.server.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- delete from "GroupLike"
- where "likerGroupId" = @groupId
- or "targetGroupId" = @groupId
-`);
-
-export function deleteLikesByGroupId(groupId: number) {
- stm.run({ groupId });
-}
diff --git a/app/features/sendouq/queries/findCurrentGroupByUserId.server.ts b/app/features/sendouq/queries/findCurrentGroupByUserId.server.ts
deleted file mode 100644
index 39e3d16c3..000000000
--- a/app/features/sendouq/queries/findCurrentGroupByUserId.server.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-import invariant from "~/utils/invariant";
-
-const stm = sql.prepare(/* sql */ `
- select
- "Group"."id",
- "Group"."status",
- "Group"."latestActionAt",
- "Group"."chatCode",
- "GroupMatch"."id" as "matchId",
- "GroupMember"."role"
- from
- "Group"
- left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
- left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
- or "GroupMatch"."bravoGroupId" = "Group"."id"
- where
- "Group"."status" != 'INACTIVE'
- and "GroupMember"."userId" = @userId
-`);
-
-type ActiveGroup = Pick<
- Tables["Group"],
- "id" | "status" | "latestActionAt" | "chatCode"
-> & {
- matchId?: number;
- role: Tables["GroupMember"]["role"];
-};
-
-export function findCurrentGroupByUserId(
- userId: number,
-): ActiveGroup | undefined {
- const groups = stm.all({ userId }) as any;
-
- invariant(groups.length <= 1, "User can't be in more than one group");
-
- return groups[0];
-}
diff --git a/app/features/sendouq/queries/findGroupByInviteCode.server.ts b/app/features/sendouq/queries/findGroupByInviteCode.server.ts
deleted file mode 100644
index 4c1e1d1a7..000000000
--- a/app/features/sendouq/queries/findGroupByInviteCode.server.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-import { parseDBJsonArray } from "~/utils/sql";
-
-const stm = sql.prepare(/* sql */ `
- select
- "Group"."id",
- "Group"."status",
- json_group_array(
- json_object(
- 'id', "User"."id",
- 'username', "User"."username",
- 'role', "GroupMember"."role"
- )
- ) as "members"
- from
- "Group"
- left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
- left join "User" on "User"."id" = "GroupMember"."userId"
- where
- "Group"."inviteCode" = @inviteCode
- and "Group"."status" != 'INACTIVE'
- group by "Group"."id"
-`);
-
-export function findGroupByInviteCode(inviteCode: string): {
- id: number;
- status: Tables["Group"]["status"];
- members: {
- id: number;
- username: string;
- role: Tables["GroupMember"]["role"];
- }[];
-} | null {
- const row = stm.get({ inviteCode }) as any;
- if (!row) return null;
-
- return {
- id: row.id,
- status: row.status,
- members: parseDBJsonArray(row.members),
- };
-}
diff --git a/app/features/sendouq/queries/findLikes.ts b/app/features/sendouq/queries/findLikes.ts
deleted file mode 100644
index 9d6b1ff59..000000000
--- a/app/features/sendouq/queries/findLikes.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/* sql */ `
- select
- "GroupLike"."likerGroupId",
- "GroupLike"."targetGroupId",
- "GroupLike"."isRechallenge"
- from
- "GroupLike"
- where
- "GroupLike"."likerGroupId" = @groupId
- or "GroupLike"."targetGroupId" = @groupId
- order by
- "GroupLike"."createdAt" desc
-`);
-
-export function findLikes(
- groupId: number,
-): Pick<
- Tables["GroupLike"],
- "likerGroupId" | "targetGroupId" | "isRechallenge"
->[] {
- return stm.all({ groupId }) as any;
-}
diff --git a/app/features/sendouq/queries/findPreparingGroup.server.ts b/app/features/sendouq/queries/findPreparingGroup.server.ts
deleted file mode 100644
index 7946fd3df..000000000
--- a/app/features/sendouq/queries/findPreparingGroup.server.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { sql } from "~/db/sql";
-import { parseDBJsonArray } from "~/utils/sql";
-import type { LookingGroupWithInviteCode } from "../q-types";
-
-const stm = sql.prepare(/* sql */ `
- with "q1" as (
- select
- "Group"."id",
- "Group"."createdAt",
- "Group"."inviteCode",
- "User"."id" as "userId",
- "User"."discordId",
- "User"."username",
- "User"."discordAvatar",
- "User"."qWeaponPool",
- "GroupMember"."role",
- "GroupMember"."note"
- from
- "Group"
- left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
- left join "User" on "User"."id" = "GroupMember"."userId"
- left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
- or "GroupMatch"."bravoGroupId" = "Group"."id"
- where
- "Group"."id" = @ownGroupId
- and "Group"."status" = 'PREPARING'
- )
- select
- "q1"."id",
- "q1"."inviteCode",
- "q1"."createdAt",
- json_group_array(
- json_object(
- 'id', "q1"."userId",
- 'discordId', "q1"."discordId",
- 'username', "q1"."username",
- 'discordAvatar', "q1"."discordAvatar",
- 'role', "q1"."role",
- 'note', "q1"."note",
- 'qWeaponPool', "q1"."qWeaponPool"
- )
- ) as "members"
- from "q1"
- group by "q1"."id"
- order by "q1"."createdAt" desc
-`);
-
-export function findPreparingGroup(
- ownGroupId: number,
-): LookingGroupWithInviteCode {
- const row = stm.get({ ownGroupId }) as any;
-
- return {
- id: row.id,
- createdAt: row.createdAt,
- chatCode: null,
- inviteCode: row.inviteCode,
- members: parseDBJsonArray(row.members).map((member: any) => {
- const weapons = member.qWeaponPool ? JSON.parse(member.qWeaponPool) : [];
-
- return {
- ...member,
- weapons: weapons.length > 0 ? weapons : undefined,
- };
- }),
- };
-}
diff --git a/app/features/sendouq/queries/findRecentMatchPlayersByUserId.server.ts b/app/features/sendouq/queries/findRecentMatchPlayersByUserId.server.ts
deleted file mode 100644
index ffcccfb40..000000000
--- a/app/features/sendouq/queries/findRecentMatchPlayersByUserId.server.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/* sql*/ `
- with "MostRecentGroupMatch" as (
- select
- "GroupMatch".*
- from "GroupMember"
- left join "Group" on "Group"."id" = "GroupMember"."groupId"
- inner join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
- or "GroupMatch"."bravoGroupId" = "Group"."id"
- where
- "GroupMember"."userId" = @userId
- order by "GroupMatch"."createdAt" desc
- limit 1
- )
- select
- "GroupMember"."groupId",
- "GroupMember"."userId"
- from "MostRecentGroupMatch"
- left join "Group" on "Group"."id" = "MostRecentGroupMatch"."alphaGroupId"
- or "Group"."id" = "MostRecentGroupMatch"."bravoGroupId"
- left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
- where
- "MostRecentGroupMatch"."createdAt" > unixepoch() - 60 * 60 * 2
-`);
-
-export type RecentMatchPlayer = Pick<
- Tables["GroupMember"],
- "groupId" | "userId"
->;
-
-export function findRecentMatchPlayersByUserId(userId: number) {
- return stm.all({ userId }) as Array;
-}
diff --git a/app/features/sendouq/queries/groupHasMatch.server.ts b/app/features/sendouq/queries/groupHasMatch.server.ts
deleted file mode 100644
index aa10cf504..000000000
--- a/app/features/sendouq/queries/groupHasMatch.server.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- select 1
- from "GroupMatch"
- where
- "alphaGroupId" = @groupId
- or "bravoGroupId" = @groupId
-`);
-
-export function groupHasMatch(groupId: number) {
- return Boolean(stm.get({ groupId }));
-}
diff --git a/app/features/sendouq/queries/groupSize.server.ts b/app/features/sendouq/queries/groupSize.server.ts
deleted file mode 100644
index 1eb9f455c..000000000
--- a/app/features/sendouq/queries/groupSize.server.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- select
- count(*) as "count"
- from
- "GroupMember"
- where
- "GroupMember"."groupId" = @groupId
-`);
-
-export function groupSize(groupId: number) {
- return (stm.get({ groupId }) as any).count as number;
-}
diff --git a/app/features/sendouq/queries/groupSuccessorOwner.ts b/app/features/sendouq/queries/groupSuccessorOwner.ts
deleted file mode 100644
index 13328e503..000000000
--- a/app/features/sendouq/queries/groupSuccessorOwner.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/* sql */ `
- select
- "GroupMember"."userId",
- "GroupMember"."role"
- from "GroupMember"
- where "GroupMember"."groupId" = @groupId
- and "GroupMember"."role" != 'OWNER'
- order by "GroupMember"."createdAt" asc
-`);
-
-export const groupSuccessorOwner = (groupId: number) => {
- const rows = stm.all({ groupId }) as Array<
- Pick
- >;
-
- if (rows.length === 0) {
- return null;
- }
-
- const manager = rows.find((r) => r.role === "MANAGER");
- if (manager) return manager.userId;
-
- return rows[0].userId;
-};
diff --git a/app/features/sendouq/queries/leaveGroup.server.ts b/app/features/sendouq/queries/leaveGroup.server.ts
deleted file mode 100644
index cb85baf34..000000000
--- a/app/features/sendouq/queries/leaveGroup.server.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { sql } from "~/db/sql";
-
-const makeMemberOwnerStm = sql.prepare(/* sql */ `
- update "GroupMember"
- set "role" = 'OWNER'
- where "GroupMember"."groupId" = @groupId
- and "GroupMember"."userId" = @userId
-`);
-
-const deleteGroupMemberStm = sql.prepare(/* sql */ `
- delete from "GroupMember"
- where "GroupMember"."groupId" = @groupId
- and "GroupMember"."userId" = @userId
-`);
-
-const deleteGroupStm = sql.prepare(/* sql */ `
- delete from "Group"
- where "Group"."id" = @groupId
-`);
-
-export const leaveGroup = sql.transaction(
- ({
- groupId,
- userId,
- newOwnerId,
- wasOwner,
- }: {
- groupId: number;
- userId: number;
- newOwnerId: number | null;
- wasOwner: boolean;
- }) => {
- if (!wasOwner) {
- deleteGroupMemberStm.run({ groupId, userId });
- return;
- }
-
- if (newOwnerId) {
- makeMemberOwnerStm.run({ groupId, userId: newOwnerId });
- deleteGroupMemberStm.run({ groupId, userId });
- } else {
- deleteGroupStm.run({ groupId });
- }
- },
-);
diff --git a/app/features/sendouq/queries/likeExists.server.ts b/app/features/sendouq/queries/likeExists.server.ts
deleted file mode 100644
index 675b580b6..000000000
--- a/app/features/sendouq/queries/likeExists.server.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- select 1 from "GroupLike"
- where
- "GroupLike"."likerGroupId" = @likerGroupId
- and "GroupLike"."targetGroupId" = @targetGroupId
-`);
-
-export function likeExists({
- likerGroupId,
- targetGroupId,
-}: {
- likerGroupId: number;
- targetGroupId: number;
-}) {
- return Boolean(stm.get({ likerGroupId, targetGroupId }));
-}
diff --git a/app/features/sendouq/queries/morphGroups.server.ts b/app/features/sendouq/queries/morphGroups.server.ts
deleted file mode 100644
index 3b73ea3c5..000000000
--- a/app/features/sendouq/queries/morphGroups.server.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-import { shortNanoid } from "~/utils/id";
-import { deleteLikesByGroupId } from "./deleteLikesByGroupId.server";
-
-const findToBeDeletedGroupNonRegularsStm = sql.prepare(/* sql */ `
- select "userId"
- from "GroupMember"
- where "groupId" = @groupId
- and "role" != 'REGULAR'
-`);
-
-const deleteGroupStm = sql.prepare(/* sql */ `
- delete from "Group"
- where "Group"."id" = @groupId
-`);
-
-const updateGroupMemberStm = sql.prepare(/* sql */ `
- update "GroupMember"
- set "role" = @role,
- "groupId" = @newGroupId
- where "groupId" = @oldGroupId
- and "userId" = @userId
-`);
-
-const updateGroupStm = sql.prepare(/* sql */ `
- update "Group"
- set "chatCode" = @chatCode
- where "id" = @groupId
-`);
-
-export const morphGroups = sql.transaction(
- ({
- survivingGroupId,
- otherGroupId,
- newMembers,
- }: {
- survivingGroupId: number;
- otherGroupId: number;
- newMembers: number[];
- }) => {
- const toBeDeletedGroupNonRegulars = findToBeDeletedGroupNonRegularsStm
- .all({ groupId: otherGroupId })
- .map((row: any) => row.userId) as Array;
-
- deleteLikesByGroupId(survivingGroupId);
-
- // reset chat code so previous messages are not visible
- updateGroupStm.run({
- groupId: survivingGroupId,
- chatCode: shortNanoid(),
- });
-
- for (const userId of newMembers) {
- const role: Tables["GroupMember"]["role"] =
- toBeDeletedGroupNonRegulars.includes(userId) ? "MANAGER" : "REGULAR";
- updateGroupMemberStm.run({
- newGroupId: survivingGroupId,
- oldGroupId: otherGroupId,
- userId,
- role,
- });
- }
-
- deleteGroupStm.run({ groupId: otherGroupId });
- },
-);
diff --git a/app/features/sendouq/queries/refreshGroup.server.ts b/app/features/sendouq/queries/refreshGroup.server.ts
deleted file mode 100644
index 44a155f30..000000000
--- a/app/features/sendouq/queries/refreshGroup.server.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { sql } from "~/db/sql";
-import { dateToDatabaseTimestamp } from "~/utils/dates";
-
-const stm = sql.prepare(/* sql */ `
- update "Group"
- set 'latestActionAt' = @latestActionAt
- where "Group"."id" = @groupId
-`);
-
-export function refreshGroup(groupId: number) {
- stm.run({ latestActionAt: dateToDatabaseTimestamp(new Date()), groupId });
-}
diff --git a/app/features/sendouq/queries/removeManagerRole.server.ts b/app/features/sendouq/queries/removeManagerRole.server.ts
deleted file mode 100644
index 8b96d2c93..000000000
--- a/app/features/sendouq/queries/removeManagerRole.server.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { sql } from "~/db/sql";
-
-const stm = sql.prepare(/* sql */ `
- update "GroupMember"
- set "role" = 'REGULAR'
- where "userId" = @userId
- and "groupId" = @groupId
-`);
-
-export function removeManagerRole({
- userId,
- groupId,
-}: {
- userId: number;
- groupId: number;
-}) {
- stm.run({ userId, groupId });
-}
diff --git a/app/features/sendouq/queries/seasonMatchesByUserId.server.ts b/app/features/sendouq/queries/seasonMatchesByUserId.server.ts
deleted file mode 100644
index 36a9da1ee..000000000
--- a/app/features/sendouq/queries/seasonMatchesByUserId.server.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import { add } from "date-fns";
-import { sql } from "~/db/sql";
-import type { ParsedMemento, Tables } from "~/db/tables";
-import * as Seasons from "~/features/mmr/core/Seasons";
-import { MATCHES_PER_SEASONS_PAGE } from "~/features/user-page/user-page-constants";
-import type { MainWeaponId } from "~/modules/in-game-lists/types";
-import { dateToDatabaseTimestamp } from "~/utils/dates";
-import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
-
-const stm = sql.prepare(/* sql */ `
- with "q1" as (
- select
- "GroupMatch"."id",
- "GroupMatch"."alphaGroupId",
- "GroupMatch"."bravoGroupId",
- "GroupMatch"."createdAt",
- "GroupMatch"."memento",
- (select exists (select 1 from "Skill" where "Skill"."groupMatchId" = "GroupMatch"."id")) as "isLocked"
- from "GroupMember"
- inner join "Group" on "GroupMember"."groupId" = "Group"."id"
- inner join "GroupMatch" on
- (
- "GroupMatch"."alphaGroupId" = "Group"."id" or
- "GroupMatch"."bravoGroupId" = "Group"."id"
- )
- and "GroupMatch"."createdAt" between @starts and @ends
- where
- "GroupMember"."userId" = @userId
- order by "GroupMatch"."id" desc
- limit ${MATCHES_PER_SEASONS_PAGE}
- offset ${MATCHES_PER_SEASONS_PAGE} * (@page - 1)
- ),
- "q2" as (
- select
- "q1".*,
- json_group_array(
- "GroupMatchMap"."winnerGroupId"
- ) as "winnerGroupIds"
- from
- "q1"
- left join "GroupMatchMap" on "q1"."id" = "GroupMatchMap"."matchId"
- group by "q1"."id"
- ), "q3" as (
- select
- "q2".*,
- json_group_array(
- json_object(
- 'id', "User"."id",
- 'username', "User"."username",
- 'discordId', "User"."discordId",
- 'discordAvatar', "User"."discordAvatar"
- )
- ) as "groupAlphaMembers"
- from "q2"
- left join "Group" on "q2"."alphaGroupId" = "Group"."id"
- left join "GroupMember" on "Group"."id" = "GroupMember"."groupId"
- left join "User" on "GroupMember"."userId" = "User"."id"
- group by "q2"."id"
- )
- select
- "q3".*,
- json_group_array(
- json_object(
- 'id', "User"."id",
- 'username', "User"."username",
- 'discordId', "User"."discordId",
- 'discordAvatar', "User"."discordAvatar"
- )
- ) as "groupBravoMembers"
- from "q3"
- left join "Group" on "q3"."bravoGroupId" = "Group"."id"
- left join "GroupMember" on "Group"."id" = "GroupMember"."groupId"
- left join "User" on "GroupMember"."userId" = "User"."id"
- group by "q3"."id"
- order by "q3"."id" desc
-`);
-
-const weaponsStm = sql.prepare(/* sql */ `
- with "q1" as (
- select
- "ReportedWeapon"."userId",
- "ReportedWeapon"."weaponSplId",
- count(*) as "count"
- from
- "GroupMatch"
- left join "GroupMatchMap" on "GroupMatch"."id" = "GroupMatchMap"."matchId"
- left join "ReportedWeapon" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
- where "GroupMatch"."id" = @id
- group by "ReportedWeapon"."userId", "ReportedWeapon"."weaponSplId"
- order by "count" desc
- )
- select
- "q1"."userId",
- "q1"."weaponSplId"
- from "q1"
- group by "q1"."userId"
-`);
-
-interface SeasonMatchByUserId {
- id: Tables["GroupMatch"]["id"];
- alphaGroupId: Tables["GroupMatch"]["alphaGroupId"];
- bravoGroupId: Tables["GroupMatch"]["bravoGroupId"];
- winnerGroupIds: Array;
- createdAt: Tables["GroupMatch"]["createdAt"];
- isLocked: number;
- spDiff: number | null;
- groupAlphaMembers: Array<{
- id: Tables["User"]["id"];
- username: Tables["User"]["username"];
- discordId: Tables["User"]["discordId"];
- discordAvatar: Tables["User"]["discordAvatar"];
- weaponSplId?: MainWeaponId;
- }>;
- groupBravoMembers: Array<{
- id: Tables["User"]["id"];
- username: Tables["User"]["username"];
- discordId: Tables["User"]["discordId"];
- discordAvatar: Tables["User"]["discordAvatar"];
- weaponSplId?: MainWeaponId;
- }>;
-}
-
-export function seasonMatchesByUserId({
- userId,
- season,
- page,
-}: {
- userId: number;
- season: number;
- page: number;
-}): SeasonMatchByUserId[] {
- const { starts, ends } = Seasons.nthToDateRange(season);
-
- const rows = stm.all({
- userId,
- starts: dateToDatabaseTimestamp(starts),
- // sets can still start a bit after season ends
- // no season can start within 7 days of another
- ends: dateToDatabaseTimestamp(add(ends, { days: 7 })),
- page,
- }) as any;
-
- return rows.map((row: any) => {
- const weapons = weaponsStm.all({ id: row.id }) as any;
-
- const skillDiff = row.memento
- ? (JSON.parse(row.memento) as ParsedMemento).users[userId]
- ?.skillDifference
- : null;
-
- return {
- ...row,
- spDiff: skillDiff?.calculated ? skillDiff.spDiff : null,
- winnerGroupIds: parseDBArray(row.winnerGroupIds),
- groupAlphaMembers: parseDBJsonArray(row.groupAlphaMembers).map(
- (member: any) => ({
- ...member,
- weaponSplId: weapons.find((w: any) => w.userId === member.id)
- ?.weaponSplId,
- }),
- ),
- groupBravoMembers: parseDBJsonArray(row.groupBravoMembers).map(
- (member: any) => ({
- ...member,
- weaponSplId: weapons.find((w: any) => w.userId === member.id)
- ?.weaponSplId,
- }),
- ),
- };
- });
-}
-
-const pagesStm = sql.prepare(/* sql */ `
- select
- count(*) as "count"
- from
- "GroupMember"
- inner join "Group" on "GroupMember"."groupId" = "Group"."id"
- inner join "GroupMatch" on
- (
- "GroupMatch"."alphaGroupId" = "Group"."id" or
- "GroupMatch"."bravoGroupId" = "Group"."id"
- )
- and "GroupMatch"."createdAt" between @starts and @ends
- where
- "GroupMember"."userId" = @userId
-`);
-
-export function seasonMatchesByUserIdPagesCount({
- userId,
- season,
-}: {
- userId: number;
- season: number;
-}): number {
- const { starts, ends } = Seasons.nthToDateRange(season);
-
- const row = pagesStm.get({
- userId,
- starts: dateToDatabaseTimestamp(starts),
- // see above
- ends: dateToDatabaseTimestamp(add(ends, { days: 7 })),
- }) as any;
-
- return Math.ceil((row.count as number) / MATCHES_PER_SEASONS_PAGE);
-}
diff --git a/app/features/sendouq/queries/setGroupAsActive.server.ts b/app/features/sendouq/queries/setGroupAsActive.server.ts
deleted file mode 100644
index 46a4fd36c..000000000
--- a/app/features/sendouq/queries/setGroupAsActive.server.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/* sql */ `
- update "Group"
- set "status" = 'ACTIVE'
- where "id" = @groupId
-`);
-
-export function setGroupAsActive(groupId: Tables["Group"]["id"]) {
- stm.run({ groupId });
-}
diff --git a/app/features/sendouq/queries/syncGroupTeamId.server.ts b/app/features/sendouq/queries/syncGroupTeamId.server.ts
deleted file mode 100644
index 15b23b151..000000000
--- a/app/features/sendouq/queries/syncGroupTeamId.server.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { sql } from "~/db/sql";
-
-const memberTeamIdsStm = sql.prepare(/* sql */ `
- select "TeamMemberWithSecondary"."teamId"
- from "GroupMember"
- left join "TeamMemberWithSecondary" on "TeamMemberWithSecondary"."userId" = "GroupMember"."userId"
- where "groupId" = @groupId
-`);
-
-const updateStm = sql.prepare(/* sql */ `
- update "Group"
- set "teamId" = @teamId
- where "id" = @groupId
-`);
-
-export function syncGroupTeamId(groupId: number) {
- const teamIds = memberTeamIdsStm
- .all({ groupId })
- .map((row: any) => row.teamId);
-
- const counts = new Map();
-
- // note if there are multiple teams with 4 members we just choose one of them
- for (const teamId of teamIds) {
- const newCount = (counts.get(teamId) ?? 0) + 1;
- if (newCount === 4) {
- return updateStm.run({ groupId, teamId });
- }
-
- counts.set(teamId, newCount);
- }
-
- return updateStm.run({ groupId, teamId: null });
-}
diff --git a/app/features/sendouq/queries/updateNote.server.ts b/app/features/sendouq/queries/updateNote.server.ts
deleted file mode 100644
index 79d32b528..000000000
--- a/app/features/sendouq/queries/updateNote.server.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/* sql */ `
- update "GroupMember"
- set "note" = @note
- where "groupId" = @groupId and "userId" = @userId
-`);
-
-export function updateNote(args: {
- note: Tables["GroupMember"]["note"];
- groupId: Tables["GroupMember"]["groupId"];
- userId: Tables["GroupMember"]["userId"];
-}) {
- stm.run(args);
-}
diff --git a/app/features/sendouq/routes/q.info.module.css b/app/features/sendouq/routes/q.info.module.css
new file mode 100644
index 000000000..185497317
--- /dev/null
+++ b/app/features/sendouq/routes/q.info.module.css
@@ -0,0 +1,40 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+ font-size: var(--fonts-md);
+}
+
+.container h2 {
+ font-size: var(--fonts-lg);
+ margin-block-end: var(--s-2);
+ color: var(--theme-secondary);
+}
+
+.container h3 {
+ font-size: var(--fonts-md);
+ margin-block-end: var(--s-3);
+}
+
+.container p {
+ margin-block: var(--s-2);
+}
+
+.tableOfContents ul {
+ padding-left: 0;
+}
+
+.tableOfContents li {
+ list-style: none;
+ font-size: var(--fonts-sm);
+ font-weight: var(--semi-bold);
+}
+
+.tableOfContents li:has(button) {
+ margin-block-start: var(--s-2);
+}
+
+.tableOfContents li:not(:has(button)) {
+ margin-inline-start: var(--s-2);
+ font-size: var(--fonts-xs);
+}
diff --git a/app/features/sendouq/routes/q.info.tsx b/app/features/sendouq/routes/q.info.tsx
index b76b2b263..800001dc3 100644
--- a/app/features/sendouq/routes/q.info.tsx
+++ b/app/features/sendouq/routes/q.info.tsx
@@ -1,5 +1,11 @@
+import type { MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
+import { SendouButton } from "~/components/elements/Button";
+import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
+import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
+import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "~/features/mmr/mmr-constants";
+import { metaTags } from "~/utils/remix";
import {
CALENDAR_PAGE,
FAQ_PAGE,
@@ -8,13 +14,7 @@ import {
SENDOUQ_SETTINGS_PAGE,
TIERS_PAGE,
} from "~/utils/urls";
-import "../q.css";
-import type { MetaFunction } from "@remix-run/node";
-import { SendouButton } from "~/components/elements/Button";
-import { Image } from "~/components/Image";
-import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
-import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "~/features/mmr/mmr-constants";
-import { metaTags } from "~/utils/remix";
+import styles from "./q.info.module.css";
export const meta: MetaFunction = (args) => {
return metaTags({
@@ -26,7 +26,7 @@ export const meta: MetaFunction = (args) => {
export default function SendouQInfoPage() {
return (
-
+
@@ -53,7 +53,7 @@ function TableOfContents() {
};
return (
-
+
Table of contents
diff --git a/app/features/sendouq/routes/q.looking.module.css b/app/features/sendouq/routes/q.looking.module.css
new file mode 100644
index 000000000..1ade2773a
--- /dev/null
+++ b/app/features/sendouq/routes/q.looking.module.css
@@ -0,0 +1,45 @@
+.chatContainer {
+ top: var(--sticky-top);
+ position: sticky;
+}
+
+.messagesContainer {
+ height: 350px;
+}
+
+.container {
+ display: grid;
+ justify-content: center;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--s-7);
+}
+
+.containerMobile {
+ grid-template-columns: 1fr;
+ min-width: 300px;
+ margin: 0 auto;
+}
+
+.innerContainer {
+ max-width: 94vw;
+}
+
+.header {
+ text-align: center;
+ font-size: var(--fonts-xxs);
+ font-weight: var(--semi-bold);
+ text-transform: uppercase;
+ color: var(--theme);
+ display: flex;
+ align-items: center;
+}
+
+.header::before,
+.header::after {
+ flex: 1;
+ content: "";
+ padding: 2px;
+ background-color: var(--theme-transparent);
+ margin: 5px;
+ border-radius: var(--rounded);
+}
diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts
index a2269a1ac..1bcf93c40 100644
--- a/app/features/sendouq/routes/q.looking.test.ts
+++ b/app/features/sendouq/routes/q.looking.test.ts
@@ -1,20 +1,13 @@
-import type { SerializeFrom } from "@remix-run/server-runtime";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import type { UserMapModePreferences } from "~/db/tables";
-import type { matchSchema } from "~/features/sendouq-match/q-match-schemas";
-import { action as rawMatchAction } from "~/features/sendouq-match/routes/q.match.$id";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import invariant from "~/utils/invariant";
-import {
- dbInsertUsers,
- dbReset,
- wrappedAction,
- wrappedLoader,
-} from "~/utils/Test";
+import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
+import { refreshSendouQInstance } from "../core/SendouQ.server";
import type { lookingSchema } from "../q-schemas.server";
-import { loader, action as rawLookingAction } from "./q.looking";
+import { action as rawLookingAction } from "./q.looking";
const createGroup = async (userIds: number[]) => {
const group = await db
@@ -103,6 +96,7 @@ const findMatch = () =>
describe("SendouQ match creation", () => {
beforeEach(async () => {
await prepareGroups();
+ await refreshSendouQInstance();
});
afterEach(() => {
@@ -177,98 +171,3 @@ describe("SendouQ match creation", () => {
expect(modePreferences?.SZ?.some((p) => !p.preference)).toBe(true);
});
});
-
-describe("Private user note sorting", () => {
- beforeEach(async () => {
- await dbInsertUsers(8);
-
- await createGroup([1]);
- await createGroup([2]);
- await createGroup([3]);
- await createGroup([4]);
- await createGroup([5]);
- await createGroup([6, 7]);
- await createGroup([8]);
-
- await db
- .insertInto("GroupMatch")
- .values({ alphaGroupId: 2, bravoGroupId: 3 })
- .execute();
- });
-
- afterEach(() => {
- dbReset();
- });
-
- const lookingLoader = wrappedLoader>({
- loader,
- });
- const matchAction = wrappedAction({
- action: rawMatchAction,
- });
-
- const matchActionParams = { id: "1" };
-
- test("users with positive note sorted first", async () => {
- await matchAction(
- {
- _action: "ADD_PRIVATE_USER_NOTE",
- targetId: 5,
- sentiment: "POSITIVE",
- comment: "test",
- },
- { user: "admin", params: matchActionParams },
- );
-
- const data = await lookingLoader({ user: "admin" });
-
- expect(data.groups.neutral[0].members![0].id).toBe(5);
- });
-
- test("users with negative note sorted last", async () => {
- await matchAction(
- {
- _action: "ADD_PRIVATE_USER_NOTE",
- targetId: 5,
- sentiment: "NEGATIVE",
- comment: "test",
- },
- { user: "admin", params: matchActionParams },
- );
-
- const data = await lookingLoader({ user: "admin" });
-
- expect(
- data.groups.neutral[data.groups.neutral.length - 1].members![0].id,
- ).toBe(5);
- });
-
- test("group with both negative and positive sentiment sorted last", async () => {
- await matchAction(
- {
- _action: "ADD_PRIVATE_USER_NOTE",
- targetId: 6,
- sentiment: "POSITIVE",
- comment: "test",
- },
- { user: "admin", params: matchActionParams },
- );
- await matchAction(
- {
- _action: "ADD_PRIVATE_USER_NOTE",
- targetId: 7,
- sentiment: "NEGATIVE",
- comment: "test",
- },
- { user: "admin", params: matchActionParams },
- );
-
- const data = await lookingLoader({ user: "admin" });
-
- expect(
- data.groups.neutral[data.groups.neutral.length - 1].members?.some(
- (m) => m.id === 6,
- ),
- ).toBe(true);
- });
-});
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index 766ef325c..8a40456ea 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -14,6 +14,7 @@ import {
} from "~/components/elements/Tabs";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
+import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useChat } from "~/features/chat/chat-hooks";
@@ -35,12 +36,12 @@ import { action } from "../actions/q.looking.server";
import { GroupCard } from "../components/GroupCard";
import { GroupLeaver } from "../components/GroupLeaver";
import { MemberAdder } from "../components/MemberAdder";
+import { groupExpiryStatus } from "../core/groups";
import { loader } from "../loaders/q.looking.server";
import { FULL_GROUP_SIZE } from "../q-constants";
-import type { LookingGroupWithInviteCode } from "../q-types";
export { action, loader };
-import "../q.css";
+import styles from "./q.looking.module.css";
export const handle: SendouRouteHandle = {
i18n: ["user", "q"],
@@ -58,7 +59,20 @@ export const meta: MetaFunction = (args) => {
});
};
-export default function QLookingPage() {
+export default function QLookingShell() {
+ const isMounted = useIsMounted();
+
+ if (!isMounted)
+ return (
+
+
+
+ );
+
+ return ;
+}
+
+function QLookingPage() {
const { t } = useTranslation(["q"]);
const user = useUser();
const data = useLoaderData();
@@ -68,14 +82,14 @@ export default function QLookingPage() {
const wasTryingToJoinAnotherTeam = searchParams.get("joining") === "true";
const showGoToSettingPrompt = () => {
- if (!data.groups.own) return false;
+ if (!data.ownGroup) return false;
- const isAlone = data.groups.own.members!.length === 1;
+ const isAlone = data.ownGroup.members.length === 1;
const hasWeaponPool = Boolean(
- data.groups.own.members!.find((m) => m.id === user?.id)?.weapons,
+ data.ownGroup.members.find((m) => m.id === user?.id)?.weapons,
);
const hasVCStatus =
- (data.groups.own.members!.find((m) => m.id === user?.id)?.languages ?? [])
+ (data.ownGroup.members.find((m) => m.id === user?.id)?.languages ?? [])
.length > 0;
return isAlone && (!hasWeaponPool || !hasVCStatus);
@@ -104,7 +118,11 @@ function InfoText() {
const fetcher = useFetcher();
const { formatTime } = useTimeFormat();
- if (data.expiryStatus === "EXPIRED") {
+ const expiryStatus = data.ownGroup
+ ? groupExpiryStatus(data.ownGroup.latestActionAt)
+ : null;
+
+ if (expiryStatus === "EXPIRED") {
return (
{
return Object.fromEntries(
- (data.groups.own?.members ?? []).map((m) => [m.id, m]),
+ (data.ownGroup?.members ?? []).map((m) => [m.id, m]),
);
}, [data]);
const rooms = React.useMemo(() => {
- return data.chatCode
+ return data.ownGroup?.chatCode
? [
{
- code: data.chatCode,
+ code: data.ownGroup.chatCode,
label: "Group",
},
]
: [];
- }, [data.chatCode]);
+ }, [data.ownGroup?.chatCode]);
const onNewMessage = React.useCallback(() => {
setUnseenMessages((msg) => msg + 1);
@@ -235,10 +253,9 @@ function Groups() {
const isMobile = width < 750;
const isFullGroup =
- data.groups.own && data.groups.own.members!.length === FULL_GROUP_SIZE;
- const ownGroup = data.groups.own as LookingGroupWithInviteCode | undefined;
+ data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
- const renderChat = data.groups.own && data.groups.own.members!.length > 1;
+ const showChat = data.ownGroup && data.ownGroup.members.length > 1;
const invitedGroupsDesktop = (
@@ -249,17 +266,18 @@ function Groups() {
: "q:looking.columns.invited",
)}
- {data.groups.neutral
- .filter((group) => group.isLiked)
+ {data.groups
+ .filter((group) =>
+ data.likes.given.some((like) => like.groupId === group.id),
+ )
.map((group) => {
return (
);
})}
@@ -268,13 +286,13 @@ function Groups() {
const chatElement = (
- {renderChat ? (
+ {showChat ? (
<>
);
- const ownGroupElement = ownGroup ? (
+ const ownGroupElement = data.ownGroup ? (
- {!renderChat && (
+ {!showChat && (
{t("q:looking.columns.myGroup")}
)}
-
- {ownGroup?.inviteCode ? (
+
+ {data.ownGroup.inviteCode ? (
m.id)}
+ inviteCode={data.ownGroup.inviteCode}
+ groupMemberIds={data.ownGroup.members.map((m) => m.id)}
/>
) : null}
{!isMobile ? invitedGroupsDesktop : null}
) : null;
+ const neutralGroups = data.groups.filter(
+ (group) =>
+ !data.likes.given.some((like) => like.groupId === group.id) &&
+ !data.likes.received.some((like) => like.groupId === group.id),
+ );
+ const groupsReceivedLikesFrom = data.groups.filter((group) =>
+ data.likes.received.some((like) => like.groupId === group.id),
+ );
+
// no animations needed if liking group on mobile as they stay in place
- const flipKey = `${data.groups.neutral
- .map((g) => `${g.id}-${isMobile ? true : g.isLiked}`)
- .join(":")};${data.groups.likesReceived.map((g) => g.id).join(":")}`;
+ const flipKey = `${neutralGroups
+ .map(
+ (g) =>
+ `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.groupId === g.id)}`,
+ )
+ .join(":")};${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
return (
{!isMobile ? (
- {data.groups.own && (
-
+ {data.ownGroup && (
+
{t("q:looking.columns.myGroup")}
)}
- {renderChat && (
+ {showChat && (
{t("q:looking.columns.chat")}
)}
{ownGroupElement}
- {data.chatCode && (
+ {data.ownGroup?.chatCode && (
{chatElement}
)}
) : null}
-
+
-
+
{t("q:looking.columns.groups")}
{isMobile && (
{t(
isFullGroup
@@ -358,12 +388,12 @@ function Groups() {
)}
)}
- {isMobile && data.groups.own && (
-
+ {isMobile && data.ownGroup && (
+
{t("q:looking.columns.myGroup")}
)}
- {isMobile && renderChat && (
+ {isMobile && showChat && (
{t("q:looking.columns.chat")}
@@ -372,30 +402,45 @@ function Groups() {
{t("q:looking.columns.available")}
- {data.groups.neutral
- .filter((group) => isMobile || !group.isLiked)
- .map((group) => {
- return (
-
- );
- })}
+ {(isMobile
+ ? data.groups.filter(
+ (group) =>
+ !data.likes.received.some(
+ (like) => like.groupId === group.id,
+ ),
+ )
+ : neutralGroups
+ ).map((group) => {
+ return (
+ like.groupId === group.id,
+ )
+ ? "UNLIKE"
+ : "LIKE"
+ }
+ showNote
+ ownGroup={data.ownGroup}
+ />
+ );
+ })}
- {!data.groups.own ?
: null}
- {data.groups.likesReceived.map((group) => {
+ {!data.ownGroup ?
: null}
+ {groupsReceivedLikesFrom.map((group) => {
+ const like = data.likes.received.find(
+ (l) => l.groupId === group.id,
+ )!;
+
const action = () => {
if (!isFullGroup) return "GROUP_UP";
- if (group.isRechallenge) return "MATCH_UP_RECHALLENGE";
+ if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
return "MATCH_UP";
};
@@ -404,9 +449,8 @@ function Groups() {
key={group.id}
group={group}
action={action()}
- ownRole={data.role}
- isExpired={data.expiryStatus === "EXPIRED"}
showNote
+ ownGroup={data.ownGroup}
/>
);
})}
@@ -425,12 +469,16 @@ function Groups() {
: "q:looking.columns.invitations",
)}
- {!data.groups.own ?
: null}
- {data.groups.likesReceived.map((group) => {
+ {!data.ownGroup ?
: null}
+ {groupsReceivedLikesFrom.map((group) => {
+ const like = data.likes.received.find(
+ (l) => l.groupId === group.id,
+ )!;
+
const action = () => {
if (!isFullGroup) return "GROUP_UP";
- if (group.isRechallenge) return "MATCH_UP_RECHALLENGE";
+ if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
return "MATCH_UP";
};
@@ -439,9 +487,8 @@ function Groups() {
key={group.id}
group={group}
action={action()}
- ownRole={data.role}
- isExpired={data.expiryStatus === "EXPIRED"}
showNote
+ ownGroup={data.ownGroup}
/>
);
})}
@@ -459,7 +506,7 @@ function ColumnHeader({ children }: { children: React.ReactNode }) {
if (isMobile) return null;
- return
{children}
;
+ return
{children}
;
}
function JoinQueuePrompt() {
diff --git a/app/features/sendouq/routes/q.module.css b/app/features/sendouq/routes/q.module.css
new file mode 100644
index 000000000..a4c65f527
--- /dev/null
+++ b/app/features/sendouq/routes/q.module.css
@@ -0,0 +1,45 @@
+.clocksContainer {
+ display: flex;
+ gap: var(--s-2);
+}
+
+.clock {
+ font-size: var(--fonts-sm);
+ font-weight: var(--bold);
+ color: var(--text-lighter);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ flex: 1 1 0;
+ line-height: 1.3;
+}
+
+.clockCountry {
+ color: var(--text) !important;
+ white-space: nowrap;
+ font-size: var(--fonts-xs);
+}
+
+.frontPageLink {
+ background-color: var(--bg-lighter);
+ border-radius: var(--rounded-sm);
+ padding: var(--s-2);
+ font-size: var(--fonts-sm);
+ color: var(--text);
+ font-weight: var(--bold);
+ display: flex;
+ align-items: center;
+ gap: var(--s-2-5);
+ transition: background-color 0.2s;
+}
+
+.frontPageLink:hover {
+ background-color: var(--theme-transparent);
+}
+
+.linkSubText {
+ color: var(--text-lighter);
+ font-size: var(--fonts-xxs);
+ font-weight: var(--body);
+}
diff --git a/app/features/sendouq/routes/q.preparing.module.css b/app/features/sendouq/routes/q.preparing.module.css
new file mode 100644
index 000000000..fcbad7662
--- /dev/null
+++ b/app/features/sendouq/routes/q.preparing.module.css
@@ -0,0 +1,4 @@
+.cardContainer {
+ min-width: 250px;
+ margin: 0 auto;
+}
diff --git a/app/features/sendouq/routes/q.preparing.tsx b/app/features/sendouq/routes/q.preparing.tsx
index 3d72a2624..565abedb3 100644
--- a/app/features/sendouq/routes/q.preparing.tsx
+++ b/app/features/sendouq/routes/q.preparing.tsx
@@ -11,12 +11,11 @@ import { action } from "../actions/q.preparing.server";
import { GroupCard } from "../components/GroupCard";
import { GroupLeaver } from "../components/GroupLeaver";
import { MemberAdder } from "../components/MemberAdder";
-import { hasGroupManagerPerms } from "../core/groups";
import { loader } from "../loaders/q.preparing.server";
import { FULL_GROUP_SIZE } from "../q-constants";
export { loader, action };
-import "../q.css";
+import styles from "./q.preparing.module.css";
export const handle: SendouRouteHandle = {
i18n: ["q", "user"],
@@ -42,17 +41,12 @@ export default function QPreparingPage() {
return (
-
-
+
+
{data.group.members.length < FULL_GROUP_SIZE &&
- hasGroupManagerPerms(data.role) ? (
+ (data.group.usersRole === "OWNER" ||
+ data.group.usersRole === "MANAGER") ? (
m.id)}
diff --git a/app/features/sendouq/routes/q.tsx b/app/features/sendouq/routes/q.tsx
index 4d0dc8548..642608ec1 100644
--- a/app/features/sendouq/routes/q.tsx
+++ b/app/features/sendouq/routes/q.tsx
@@ -43,7 +43,7 @@ import { FULL_GROUP_SIZE } from "../q-constants";
import { userCanJoinQueueAt } from "../q-utils";
export { loader, action };
-import "../q.css";
+import styles from "./q.module.css";
export const handle: SendouRouteHandle = {
i18n: ["q"],
@@ -208,11 +208,11 @@ function Clocks() {
useAutoRerender();
return (
-
+
{countries.map((country) => {
return (
-
-
+
+
{t(`q:front.cities.${country.city}`)}
@@ -392,11 +392,11 @@ function QLink({
subText: string;
}) {
return (
-
+
{title}
-
{subText}
+
{subText}
);
diff --git a/app/features/sendouq/routes/trusters.ts b/app/features/sendouq/routes/trusters.ts
index 3e7e9061a..4148f0fac 100644
--- a/app/features/sendouq/routes/trusters.ts
+++ b/app/features/sendouq/routes/trusters.ts
@@ -1,6 +1,6 @@
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
export type TrustersLoaderData = SerializeFrom
;
@@ -8,6 +8,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const { id: userId } = await requireUserId(request);
return {
- trusters: await QRepository.usersThatTrusted(userId),
+ trusters: await SQGroupRepository.usersThatTrusted(userId),
};
};
diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts
index 642ef61db..4a2990db0 100644
--- a/app/features/team/TeamRepository.server.ts
+++ b/app/features/team/TeamRepository.server.ts
@@ -126,10 +126,6 @@ export function findResultPlacementsById(teamId: number) {
.execute();
}
-export type FindResultsById = NonNullable<
- Awaited>
->;
-
/**
* Retrieves tournament results for a given team by its ID.
*/
diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts
index 098eabf3c..f43d83411 100644
--- a/app/features/tier-list-maker/hooks/useTierList.ts
+++ b/app/features/tier-list-maker/hooks/useTierList.ts
@@ -454,7 +454,7 @@ export function useTierList() {
const TIER_SEARCH_PARAM_NAME = "state";
-export function useSearchParamTiersState() {
+function useSearchParamTiersState() {
const [initialSearchParams] = useSearchParams();
const [tiers, setTiers] = React.useState(() => {
const param = initialSearchParams.get(TIER_SEARCH_PARAM_NAME);
diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx
index e281212b1..c116ce042 100644
--- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx
+++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx
@@ -86,7 +86,7 @@ export function TeamRosterInputs({
);
}
-export function TeamRoster({
+function TeamRoster({
team,
bothTeamsHaveActiveRosters,
presentational,
@@ -241,7 +241,7 @@ export function TeamRoster({
);
}
-export function TeamRosterHeader({
+function TeamRosterHeader({
idx,
team,
tournamentId,
@@ -337,7 +337,7 @@ function WinnerRadio({
);
}
-export function PointInput({
+function PointInput({
value,
onChange,
presentational,
diff --git a/app/features/tournament-bracket/core/Bracket/index.ts b/app/features/tournament-bracket/core/Bracket/index.ts
index 55c4e907b..b6944aa6d 100644
--- a/app/features/tournament-bracket/core/Bracket/index.ts
+++ b/app/features/tournament-bracket/core/Bracket/index.ts
@@ -7,10 +7,6 @@ import { SwissBracket } from "./SwissBracket";
export type { CreateBracketArgs, Standing, TeamTrackRecord } from "./Bracket";
export { Bracket } from "./Bracket";
-export { DoubleEliminationBracket } from "./DoubleEliminationBracket";
-export { RoundRobinBracket } from "./RoundRobinBracket";
-export { SingleEliminationBracket } from "./SingleEliminationBracket";
-export { SwissBracket } from "./SwissBracket";
export function createBracket(
args: CreateBracketArgs,
diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-bracket/core/mapList.server.ts
index 760227823..d58edf40c 100644
--- a/app/features/tournament-bracket/core/mapList.server.ts
+++ b/app/features/tournament-bracket/core/mapList.server.ts
@@ -119,7 +119,7 @@ function resolveBannedByTeamId(
return;
}
-export function resolveFreshTeamPickedMapList(
+function resolveFreshTeamPickedMapList(
args: ResolveCurrentMapListArgs & {
mapPickingStyle: Exclude;
},
diff --git a/app/features/tournament-bracket/core/rounds.ts b/app/features/tournament-bracket/core/rounds.ts
index f368c1d94..c7ed696b9 100644
--- a/app/features/tournament-bracket/core/rounds.ts
+++ b/app/features/tournament-bracket/core/rounds.ts
@@ -108,7 +108,7 @@ export function getRounds(args: {
}
// adjusting losers bracket round numbers to start from 1, can sometimes start with 2 if byes are certain way
-export function adjustRoundNumbers(rounds: T[]) {
+function adjustRoundNumbers(rounds: T[]) {
if (rounds.at(0)?.number === 1) {
return rounds;
}
diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts
index 3db577133..f2cd6309a 100644
--- a/app/features/tournament-bracket/core/tests/mocks.ts
+++ b/app/features/tournament-bracket/core/tests/mocks.ts
@@ -1,534 +1,5 @@
-import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { TournamentData } from "../Tournament.server";
-export const FOUR_TEAMS_RR = (): TournamentManagerDataSet => ({
- stage: [
- {
- id: 0,
- tournament_id: 1,
- name: "Groups stage",
- type: "round_robin",
- number: 1,
- settings: {
- groupCount: 1,
- roundRobinMode: "simple",
- size: 4,
- seedOrdering: ["groups.seed_optimized"],
- },
- },
- ],
- group: [
- {
- id: 0,
- stage_id: 0,
- number: 1,
- },
- ],
- round: [
- {
- id: 0,
- number: 1,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 1,
- number: 2,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 2,
- number: 3,
- stage_id: 0,
- group_id: 0,
- },
- ],
- match: [
- {
- id: 0,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 0,
- status: 2,
- opponent1: {
- id: 0,
- position: 1,
- },
- opponent2: {
- id: 3,
- position: 4,
- },
- },
- {
- id: 1,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 0,
- status: 2,
- opponent1: {
- id: 2,
- position: 3,
- },
- opponent2: {
- id: 1,
- position: 2,
- },
- },
- {
- id: 2,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 1,
- status: 2,
- opponent1: {
- id: 1,
- position: 2,
- },
- opponent2: {
- id: 3,
- position: 4,
- },
- },
- {
- id: 3,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 1,
- status: 2,
- opponent1: {
- id: 0,
- position: 1,
- },
- opponent2: {
- id: 2,
- position: 3,
- },
- },
- {
- id: 4,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 2,
- status: 2,
- opponent1: {
- id: 2,
- position: 3,
- },
- opponent2: {
- id: 3,
- position: 4,
- },
- },
- {
- id: 5,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 2,
- status: 2,
- opponent1: {
- id: 1,
- position: 2,
- },
- opponent2: {
- id: 0,
- position: 1,
- },
- },
- ],
-});
-
-export const FIVE_TEAMS_RR = (): TournamentManagerDataSet => ({
- stage: [
- {
- id: 0,
- tournament_id: 3,
- name: "Groups stage",
- type: "round_robin",
- number: 1,
- settings: {
- groupCount: 1,
- seedOrdering: ["groups.seed_optimized"],
- roundRobinMode: "simple",
- size: 5,
- },
- },
- ],
- group: [
- {
- id: 0,
- stage_id: 0,
- number: 1,
- },
- ],
- round: [
- {
- id: 0,
- number: 1,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 1,
- number: 2,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 2,
- number: 3,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 3,
- number: 4,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 4,
- number: 5,
- stage_id: 0,
- group_id: 0,
- },
- ],
- match: [
- {
- id: 0,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 0,
- status: 2,
- opponent1: {
- id: 4,
- position: 5,
- },
- opponent2: {
- id: 1,
- position: 2,
- },
- },
- {
- id: 2,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 1,
- status: 2,
- opponent1: {
- id: 0,
- position: 1,
- },
- opponent2: {
- id: 2,
- position: 3,
- },
- },
- {
- id: 3,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 1,
- status: 2,
- opponent1: {
- id: 4,
- position: 5,
- },
- opponent2: {
- id: 3,
- position: 4,
- },
- },
- {
- id: 4,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 2,
- status: 2,
- opponent1: {
- id: 1,
- position: 2,
- },
- opponent2: {
- id: 3,
- position: 4,
- },
- },
- {
- id: 5,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 2,
- status: 2,
- opponent1: {
- id: 0,
- position: 1,
- },
- opponent2: {
- id: 4,
- position: 5,
- },
- },
- {
- id: 6,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 3,
- status: 2,
- opponent1: {
- id: 2,
- position: 3,
- },
- opponent2: {
- id: 4,
- position: 5,
- },
- },
- {
- id: 7,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 3,
- status: 2,
- opponent1: {
- id: 1,
- position: 2,
- },
- opponent2: {
- id: 0,
- position: 1,
- },
- },
- {
- id: 8,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 4,
- status: 2,
- opponent1: {
- id: 3,
- position: 4,
- },
- opponent2: {
- id: 0,
- position: 1,
- },
- },
- {
- id: 9,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 4,
- status: 2,
- opponent1: {
- id: 2,
- position: 3,
- },
- opponent2: {
- id: 1,
- position: 2,
- },
- },
- {
- id: 1,
- number: 2,
- stage_id: 0,
- group_id: 0,
- round_id: 0,
- status: 2,
- opponent1: {
- id: 3,
- position: 4,
- },
- opponent2: {
- id: 2,
- position: 3,
- },
- },
- ],
-});
-
-export const SIX_TEAMS_TWO_GROUPS_RR = (): TournamentManagerDataSet => ({
- stage: [
- {
- id: 0,
- tournament_id: 3,
- name: "Groups stage",
- type: "round_robin",
- number: 1,
- settings: {
- groupCount: 2,
- seedOrdering: ["groups.seed_optimized"],
- roundRobinMode: "simple",
- size: 6,
- },
- },
- ],
- group: [
- {
- id: 0,
- stage_id: 0,
- number: 1,
- },
- {
- id: 1,
- stage_id: 0,
- number: 2,
- },
- ],
- round: [
- {
- id: 0,
- number: 1,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 1,
- number: 2,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 2,
- number: 3,
- stage_id: 0,
- group_id: 0,
- },
- {
- id: 3,
- number: 1,
- stage_id: 0,
- group_id: 1,
- },
- {
- id: 4,
- number: 2,
- stage_id: 0,
- group_id: 1,
- },
- {
- id: 5,
- number: 3,
- stage_id: 0,
- group_id: 1,
- },
- ],
- match: [
- {
- id: 0,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 0,
- status: 2,
- opponent1: {
- id: 4,
- position: 5,
- },
- opponent2: {
- id: 3,
- position: 4,
- },
- },
- {
- id: 1,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 1,
- status: 2,
- opponent1: {
- id: 0,
- position: 1,
- },
- opponent2: {
- id: 4,
- position: 5,
- },
- },
- {
- id: 2,
- number: 1,
- stage_id: 0,
- group_id: 0,
- round_id: 2,
- status: 2,
- opponent1: {
- id: 3,
- position: 4,
- },
- opponent2: {
- id: 0,
- position: 1,
- },
- },
- {
- id: 3,
- number: 1,
- stage_id: 0,
- group_id: 1,
- round_id: 3,
- status: 2,
- opponent1: {
- id: 5,
- position: 6,
- },
- opponent2: {
- id: 2,
- position: 3,
- },
- },
- {
- id: 4,
- number: 1,
- stage_id: 0,
- group_id: 1,
- round_id: 4,
- status: 2,
- opponent1: {
- id: 1,
- position: 2,
- },
- opponent2: {
- id: 5,
- position: 6,
- },
- },
- {
- id: 5,
- number: 1,
- stage_id: 0,
- group_id: 1,
- round_id: 5,
- status: 2,
- opponent1: {
- id: 2,
- position: 3,
- },
- opponent2: {
- id: 1,
- position: 2,
- },
- },
- ],
-});
-
export const PADDLING_POOL_257 = () =>
({
data: {
diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts
index c0d35d959..81a8f6931 100644
--- a/app/features/tournament-bracket/core/tests/test-utils.ts
+++ b/app/features/tournament-bracket/core/tests/test-utils.ts
@@ -103,55 +103,6 @@ export const testTournament = ({
});
};
-export const adjustResults = (
- data: TournamentManagerDataSet,
- adjustedArr: Array<{
- ids: [number, number];
- score: [number, number];
- points?: [number, number];
- }>,
-): TournamentManagerDataSet => {
- return {
- ...data,
- match: data.match.map((match, idx) => {
- const adjusted = adjustedArr[idx];
- if (!adjusted) throw new Error(`No adjusted result for match ${idx}`);
-
- if (adjusted.ids[0] !== match.opponent1!.id) {
- throw new Error("Adjusted match opponent1 id does not match");
- }
-
- if (adjusted.ids[1] !== match.opponent2!.id) {
- throw new Error("Adjusted match opponent2 id does not match");
- }
-
- return {
- ...match,
- opponent1: {
- ...match.opponent1!,
- score: adjusted.score[0],
- result: adjusted.score[0] > adjusted.score[1] ? "win" : "loss",
- totalPoints: adjusted.points
- ? adjusted.points[0]
- : adjusted.score[0] > adjusted.score[1]
- ? 100
- : 0,
- },
- opponent2: {
- ...match.opponent2!,
- score: adjusted.score[1],
- result: adjusted.score[1] > adjusted.score[0] ? "win" : "loss",
- totalPoints: adjusted.points
- ? adjusted.points[1]
- : adjusted.score[1] > adjusted.score[0]
- ? 100
- : 0,
- },
- };
- }),
- };
-};
-
const DEFAULT_PROGRESSION_ARGS = {
requiresCheckIn: false,
settings: {},
diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts
index 5451edf79..12ded212d 100644
--- a/app/features/tournament-bracket/tournament-bracket-utils.ts
+++ b/app/features/tournament-bracket/tournament-bracket-utils.ts
@@ -2,14 +2,11 @@ import type { TFunction } from "i18next";
import * as R from "remeda";
import type { TournamentRoundMaps } from "~/db/tables";
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
-import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
-import { sourceTypes } from "~/modules/tournament-map-list-generator/constants";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { logger } from "~/utils/logger";
import { seededRandom } from "~/utils/random";
import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server";
-import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
import type { Standing } from "./core/Bracket";
import type { Tournament } from "./core/Tournament";
import type { TournamentDataTeam } from "./core/Tournament.server";
@@ -83,23 +80,6 @@ export function mapCountPlayedInSetWithCertainty({
return scoreSum + (Math.ceil(bestOf / 2) - maxScore);
}
-export function checkSourceIsValid({
- source,
- match,
-}: {
- source: string;
- match: NonNullable;
-}) {
- if (sourceTypes.includes(source as any)) return true;
-
- const asTeamId = Number(source);
-
- if (match.opponentOne?.id === asTeamId) return true;
- if (match.opponentTwo?.id === asTeamId) return true;
-
- return false;
-}
-
export function fillWithNullTillPowerOfTwo(arr: T[]) {
const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length));
const nullsToAdd = nextPowerOfTwo - arr.length;
@@ -107,59 +87,6 @@ export function fillWithNullTillPowerOfTwo(arr: T[]) {
return [...arr, ...new Array(nullsToAdd).fill(null)];
}
-export function everyMatchIsOver(
- bracket: Pick,
-) {
- // winners, losers & grand finals+bracket reset are all different stages
- const isDoubleElimination =
- R.unique(bracket.match.map((match) => match.group_id)).length === 3;
-
- // tournament didn't start yet
- if (bracket.match.length === 0) return false;
-
- let lastWinner = -1;
- for (const [i, match] of bracket.match.entries()) {
- // special case - bracket reset might not be played depending on who wins in the grands
- const isLast = i === bracket.match.length - 1;
- if (isLast && lastWinner === 1 && isDoubleElimination) {
- continue;
- }
- // BYE
- if (match.opponent1 === null || match.opponent2 === null) {
- continue;
- }
- if (
- match.opponent1?.result !== "win" &&
- match.opponent2?.result !== "win"
- ) {
- return false;
- }
-
- lastWinner = match.opponent1?.result === "win" ? 1 : 2;
- }
-
- return true;
-}
-
-export function everyBracketOver(tournament: TournamentManagerDataSet) {
- const stageIds = tournament.stage.map((stage) => stage.id);
-
- for (const stageId of stageIds) {
- const matches = tournament.match.filter(
- (match) => match.stage_id === stageId,
- );
-
- if (!everyMatchIsOver({ match: matches })) {
- return false;
- }
- }
-
- return true;
-}
-
-export const bracketHasStarted = (bracket: TournamentManagerDataSet) =>
- bracket.stage[0] && bracket.stage[0].id !== 0;
-
export function matchIsLocked({
tournament,
matchId,
diff --git a/app/features/tournament-organization/actions/org.$slug.edit.server.ts b/app/features/tournament-organization/actions/org.$slug.edit.server.ts
index 5e7ac359f..05a75f8c9 100644
--- a/app/features/tournament-organization/actions/org.$slug.edit.server.ts
+++ b/app/features/tournament-organization/actions/org.$slug.edit.server.ts
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
-import i18next from "~/modules/i18n/i18next.server";
+import { i18next } from "~/modules/i18n/i18next.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { valueArrayToDBFormat } from "~/utils/form";
import { actionError, parseRequestPayload } from "~/utils/remix.server";
diff --git a/app/features/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts
index 8d45a9bb9..34c0f296b 100644
--- a/app/features/tournament-organization/tournament-organization-schemas.ts
+++ b/app/features/tournament-organization/tournament-organization-schemas.ts
@@ -113,12 +113,12 @@ export const banUserActionSchema = z.object({
),
});
-export const unbanUserActionSchema = z.object({
+const unbanUserActionSchema = z.object({
_action: _action("UNBAN_USER"),
userId: id,
});
-export const updateIsEstablishedActionSchema = z.object({
+const updateIsEstablishedActionSchema = z.object({
_action: _action("UPDATE_IS_ESTABLISHED"),
isEstablished: z.boolean(),
});
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts
index 932841253..b0ebd616b 100644
--- a/app/features/tournament/TournamentRepository.server.ts
+++ b/app/features/tournament/TournamentRepository.server.ts
@@ -573,40 +573,6 @@ export function topThreeResultsByTournamentId(tournamentId: number) {
.execute();
}
-export async function findCastTwitchAccountsByTournamentId(
- tournamentId: number,
-) {
- const result = await db
- .selectFrom("Tournament")
- .select("castTwitchAccounts")
- .where("id", "=", tournamentId)
- .executeTakeFirst();
-
- if (!result) return null;
-
- return result.castTwitchAccounts;
-}
-
-export function checkedInTournamentTeamsByBracket({
- tournamentId,
- bracketIdx,
-}: {
- tournamentId: number;
- bracketIdx: number;
-}) {
- return db
- .selectFrom("TournamentTeamCheckIn")
- .innerJoin(
- "TournamentTeam",
- "TournamentTeamCheckIn.tournamentTeamId",
- "TournamentTeam.id",
- )
- .select(["TournamentTeamCheckIn.tournamentTeamId"])
- .where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx)
- .where("TournamentTeam.tournamentId", "=", tournamentId)
- .execute();
-}
-
export async function friendCodesByTournamentId(tournamentId: number) {
const values = await db
.selectFrom("TournamentTeam")
diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts
index f4488c933..6f2f745aa 100644
--- a/app/features/tournament/actions/to.$id.register.server.ts
+++ b/app/features/tournament/actions/to.$id.register.server.ts
@@ -3,7 +3,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { notify } from "~/features/notifications/core/notify.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
@@ -254,7 +254,7 @@ export const action: ActionFunction = async ({ request, params }) => {
);
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(
- (await QRepository.usersThatTrusted(user.id)).trusters.some(
+ (await SQGroupRepository.usersThatTrusted(user.id)).trusters.some(
(trusterPlayer) => trusterPlayer.id === data.userId,
),
"No trust given from this user",
@@ -280,7 +280,7 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
}),
});
- await QRepository.refreshTrust({
+ await SQGroupRepository.refreshTrust({
trustGiverUserId: data.userId,
trustReceiverUserId: user.id,
});
diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts
index 025c3ee33..b9a2dd66e 100644
--- a/app/features/tournament/loaders/to.$id.register.server.ts
+++ b/app/features/tournament/loaders/to.$id.register.server.ts
@@ -1,6 +1,6 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { findMapPoolByTeamId } from "~/features/tournament-bracket/queries/findMapPoolByTeamId.server";
import { parseParams } from "~/utils/remix.server";
@@ -29,7 +29,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return {
mapPool: findMapPoolByTeamId(ownTournamentTeam.id),
- trusterPlayers: await QRepository.usersThatTrusted(user.id),
+ trusterPlayers: await SQGroupRepository.usersThatTrusted(user.id),
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
};
};
diff --git a/app/features/tournament/queries/findByIdentifier.server.ts b/app/features/tournament/queries/findByIdentifier.server.ts
deleted file mode 100644
index c5b5597ca..000000000
--- a/app/features/tournament/queries/findByIdentifier.server.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-
-const stm = sql.prepare(/*sql*/ `
-select
- "Tournament"."id",
- "Tournament"."mapPickingStyle",
- "Tournament"."settings",
- "CalendarEvent"."id" as "eventId",
- "CalendarEvent"."name",
- "CalendarEvent"."description",
- "CalendarEvent"."bracketUrl",
- "CalendarEvent"."authorId",
- "CalendarEventDate"."startTime",
- "User"."username",
- "User"."discordId"
- from "Tournament"
- left join "CalendarEvent" on "Tournament"."id" = "CalendarEvent"."tournamentId"
- left join "User" on "CalendarEvent"."authorId" = "User"."id"
- left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
- where "Tournament"."id" = @identifier
- group by "CalendarEvent"."id"
-`);
-
-type FindByIdentifierRow = (Pick<
- Tables["CalendarEvent"],
- "bracketUrl" | "name" | "description" | "authorId"
-> &
- Pick &
- Pick &
- Pick) & {
- eventId: Tables["CalendarEvent"]["id"];
-} & { settings: string };
-
-export function findByIdentifier(identifier: string | number) {
- const rows = stm.all({ identifier }) as FindByIdentifierRow[];
- if (rows.length === 0) return null;
-
- const tournament = { ...rows[0], startTime: resolveEarliestStartTime(rows) };
-
- const { discordId, username, ...rest } = tournament;
-
- return {
- ...rest,
- settings: JSON.parse(
- tournament.settings,
- ) as Tables["Tournament"]["settings"],
- author: {
- discordId,
- username,
- },
- };
-}
-
-function resolveEarliestStartTime(
- rows: Pick[],
-) {
- return Math.min(...rows.map((row) => row.startTime));
-}
diff --git a/app/features/tournament/queries/findMapPoolsByTournamentId.server.ts b/app/features/tournament/queries/findMapPoolsByTournamentId.server.ts
deleted file mode 100644
index 05f9185cc..000000000
--- a/app/features/tournament/queries/findMapPoolsByTournamentId.server.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { sql } from "~/db/sql";
-import type { Tables } from "~/db/tables";
-import { parseDBArray } from "~/utils/sql";
-
-const stm = sql.prepare(/*sql*/ `
- select
- "MapPoolMap"."tournamentTeamId",
- json_group_array(
- json_object(
- 'stageId', "MapPoolMap"."stageId",
- 'mode', "MapPoolMap"."mode"
- )
- ) as "mapPool"
- from "TournamentTeam"
- inner join "MapPoolMap" on "MapPoolMap"."tournamentTeamId" = "TournamentTeam"."id"
- where
- "TournamentTeam"."tournamentId" = @tournamentId
- group by "TournamentTeam"."id"
-`);
-
-interface FindMapPoolsByTournamentIdItem {
- tournamentTeamId: number;
- mapPool: Array>;
-}
-
-export function findMapPoolsByTournamentId(
- tournamentId: number,
-): FindMapPoolsByTournamentIdItem[] {
- const rows = stm.all({ tournamentId }) as any[];
-
- return rows.map((row) => {
- return {
- tournamentTeamId: row.tournamentTeamId,
- mapPool: parseDBArray(row.mapPool),
- };
- });
-}
diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts
index 8a579ee28..5956935d1 100644
--- a/app/features/tournament/tournament-schemas.server.ts
+++ b/app/features/tournament/tournament-schemas.server.ts
@@ -14,7 +14,7 @@ import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.ser
import { USER } from "../user-page/user-page-constants";
import { TOURNAMENT } from "./tournament-constants";
-export const teamName = safeStringSchema({
+const teamName = safeStringSchema({
max: TOURNAMENT.TEAM_NAME_MAX_LENGTH,
});
diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts
index 5f3265af8..b381ebc80 100644
--- a/app/features/tournament/tournament-utils.ts
+++ b/app/features/tournament/tournament-utils.ts
@@ -12,7 +12,6 @@ import type { ParsedBracket } from "../tournament-bracket/core/Progression";
import * as Progression from "../tournament-bracket/core/Progression";
import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament";
import type { TournamentData } from "../tournament-bracket/core/Tournament.server";
-import type { PlayedSet } from "./core/sets.server";
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
const mapPickingStyleToModeRecord = {
@@ -58,16 +57,6 @@ export function isOneModeTournamentOf(
: null;
}
-export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
- if (round.round === "grand_finals") return "bracket.grand_finals";
- if (round.round === "bracket_reset") {
- return "bracket.grand_finals.bracket_reset";
- }
- if (round.round === "finals") return `bracket.${round.type}.finals` as const;
-
- return `bracket.${round.type}` as const;
-}
-
export type CounterPickValidationStatus =
| "PICKING"
| "VALID"
diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts
index 087257515..563761b7f 100644
--- a/app/features/user-page/UserRepository.server.ts
+++ b/app/features/user-page/UserRepository.server.ts
@@ -305,14 +305,6 @@ export function findByFriendCode(friendCode: string) {
.execute();
}
-export function findBannedStatusByUserId(userId: number) {
- return db
- .selectFrom("User")
- .select(["User.banned", "User.bannedReason"])
- .where("User.id", "=", userId)
- .executeTakeFirst();
-}
-
export async function findSubDefaultsByUserId(userId: number) {
const user = await db
.selectFrom("User")
diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts
index aa8f3aba7..b116842b2 100644
--- a/app/features/user-page/loaders/u.$identifier.seasons.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts
@@ -8,7 +8,7 @@ import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/season
import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server";
import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server";
import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server";
-import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
+import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
@@ -62,19 +62,19 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
tier,
isAccurateTiers,
results: {
- value: await QMatchRepository.seasonResultsByUserId({
+ value: await SQMatchRepository.seasonResultsByUserId({
season,
userId: user.id,
page,
}),
currentPage: page,
- pages: await QMatchRepository.seasonResultPagesByUserId({
+ pages: await SQMatchRepository.seasonResultPagesByUserId({
season,
userId: user.id,
}),
},
canceled: loggedInUser?.roles.includes("STAFF")
- ? await QMatchRepository.seasonCanceledMatchesByUserId({
+ ? await SQMatchRepository.seasonCanceledMatchesByUserId({
season,
userId: user.id,
})
diff --git a/app/features/user-page/routes/short.$customUrl.ts b/app/features/user-page/routes/short.$customUrl.ts
index be95af885..c38000292 100644
--- a/app/features/user-page/routes/short.$customUrl.ts
+++ b/app/features/user-page/routes/short.$customUrl.ts
@@ -1,5 +1,4 @@
-import type { LoaderFunction } from "@remix-run/node";
-import { redirect } from "react-router-dom";
+import { type LoaderFunction, redirect } from "@remix-run/node";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isSupporter } from "~/modules/permissions/utils";
import { userPage } from "~/utils/urls";
diff --git a/app/features/user-page/routes/u.$identifier.builds.new.tsx b/app/features/user-page/routes/u.$identifier.builds.new.tsx
index c10bbc093..ccefd7652 100644
--- a/app/features/user-page/routes/u.$identifier.builds.new.tsx
+++ b/app/features/user-page/routes/u.$identifier.builds.new.tsx
@@ -23,7 +23,7 @@ import type { GearType } from "~/db/tables";
import {
validatedBuildFromSearchParams,
validatedWeaponIdFromSearchParams,
-} from "~/features/build-analyzer";
+} from "~/features/build-analyzer/core/utils";
import { BUILD } from "~/features/builds/builds-constants";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import type {
diff --git a/app/features/user-page/routes/u.$identifier.seasons.tsx b/app/features/user-page/routes/u.$identifier.seasons.tsx
index 8e3a6153a..1be0b7ba9 100644
--- a/app/features/user-page/routes/u.$identifier.seasons.tsx
+++ b/app/features/user-page/routes/u.$identifier.seasons.tsx
@@ -39,7 +39,7 @@ import { ordinalToSp } from "~/features/mmr/mmr-utils";
import type {
SeasonGroupMatch,
SeasonTournamentResult,
-} from "~/features/sendouq-match/QMatchRepository.server";
+} from "~/features/sendouq-match/SQMatchRepository.server";
import { useWeaponUsage } from "~/hooks/swr";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts
index fe955192c..63d9039d1 100644
--- a/app/features/user-page/user-page-schemas.ts
+++ b/app/features/user-page/user-page-schemas.ts
@@ -149,7 +149,7 @@ export const addModNoteSchema = z.object({
value: z.string().trim().min(1).max(USER.MOD_NOTE_MAX_LENGTH),
});
-export const deleteModNoteSchema = z.object({
+const deleteModNoteSchema = z.object({
_action: _action("DELETE_MOD_NOTE"),
noteId: id,
});
diff --git a/app/features/vods/vods-schemas.ts b/app/features/vods/vods-schemas.ts
index a5d7cf001..4b02ef339 100644
--- a/app/features/vods/vods-schemas.ts
+++ b/app/features/vods/vods-schemas.ts
@@ -15,7 +15,7 @@ import { extractYoutubeIdFromVideoUrl } from "./vods-utils";
export const HOURS_MINUTES_SECONDS_REGEX = /^(\d{1,2}:)?\d{1,2}:\d{2}$/;
-export const videoMatchSchema = z.object({
+const videoMatchSchema = z.object({
startsAt: z.string().regex(HOURS_MINUTES_SECONDS_REGEX, {
message: "Invalid time format. Use HH:MM:SS or MM:SS",
}),
diff --git a/app/features/vods/vods-types.ts b/app/features/vods/vods-types.ts
index bfbdefd1b..869717383 100644
--- a/app/features/vods/vods-types.ts
+++ b/app/features/vods/vods-types.ts
@@ -1,14 +1,10 @@
import type { z } from "zod/v4";
import type { Tables } from "~/db/tables";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
-import type { videoMatchSchema, videoSchema } from "./vods-schemas";
-
-export type VideoBeingAddedPartial = Partial;
+import type { videoSchema } from "./vods-schemas";
export type VideoBeingAdded = z.infer;
-export type VideoMatchBeingAdded = z.infer;
-
export interface Vod {
id: Tables["Video"]["id"];
pov?:
diff --git a/app/hooks/useOnClickOutside.ts b/app/hooks/useOnClickOutside.ts
deleted file mode 100644
index c7095e69b..000000000
--- a/app/hooks/useOnClickOutside.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as React from "react";
-
-/** @link https://usehooks.com/useOnClickOutside/ */
-export function useOnClickOutside(
- ref: React.RefObject,
- handler: (event: MouseEvent | TouchEvent) => void,
-) {
- React.useEffect(() => {
- const listener = (event: MouseEvent | TouchEvent) => {
- if (!ref.current || ref.current.contains(event.target as Node)) {
- return;
- }
- handler(event);
- };
- document.addEventListener("mousedown", listener);
- document.addEventListener("touchstart", listener);
- return () => {
- document.removeEventListener("mousedown", listener);
- document.removeEventListener("touchstart", listener);
- };
- }, [ref, handler]);
-}
diff --git a/app/hooks/useRootLoaderData.ts b/app/hooks/useRootLoaderData.ts
deleted file mode 100644
index 70574bb8f..000000000
--- a/app/hooks/useRootLoaderData.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { useMatches } from "@remix-run/react";
-import type { RootLoaderData } from "~/root";
-import invariant from "~/utils/invariant";
-
-export function useRootLoaderData() {
- const [root] = useMatches();
- invariant(root);
-
- return root.data as RootLoaderData;
-}
diff --git a/app/modules/brackets-manager/helpers.ts b/app/modules/brackets-manager/helpers.ts
index e856ae160..ee00d77ed 100644
--- a/app/modules/brackets-manager/helpers.ts
+++ b/app/modules/brackets-manager/helpers.ts
@@ -1,5 +1,4 @@
import type {
- GrandFinalType,
GroupType,
Match,
MatchResults,
@@ -13,7 +12,6 @@ import type {
} from "~/modules/brackets-model";
import { Status } from "~/modules/brackets-model";
-import invariant from "~/utils/invariant";
import { ordering } from "./ordering";
import type {
Database,
@@ -23,7 +21,6 @@ import type {
Nullable,
ParitySplit,
ParticipantSlot,
- Scores,
Side,
} from "./types";
@@ -68,7 +65,7 @@ export function makeRoundRobinMatches(
*
* @param participants The participants to distribute.
*/
-export function makeRoundRobinDistribution(participants: T[]): [T, T][][] {
+function makeRoundRobinDistribution(participants: T[]): [T, T][][] {
const n = participants.length;
const n1 = n % 2 === 0 ? n : n + 1;
const roundCount = n1 - 1;
@@ -248,7 +245,7 @@ export function normalizeIds(data: Database): Database {
*
* @param elements A list of elements with IDs.
*/
-export function makeNormalizedIdMapping(elements: { id: number }[]): IdMapping {
+function makeNormalizedIdMapping(elements: { id: number }[]): IdMapping {
let currentId = 0;
return elements.reduce(
@@ -261,24 +258,6 @@ export function makeNormalizedIdMapping(elements: { id: number }[]): IdMapping {
) as IdMapping;
}
-/**
- * Apply a normalizing mapping to a participant.
- *
- * @param participant The participant.
- * @param mapping The mapping of IDs.
- */
-export function normalizeParticipant(
- participant: ParticipantResult | null,
- mapping: IdMapping,
-): ParticipantResult | null {
- if (participant === null) return null;
-
- return {
- ...participant,
- id: participant.id !== null ? mapping[participant.id] : null,
- };
-}
-
/**
* Sets the size of an array with a placeholder if the size is bigger.
*
@@ -286,11 +265,7 @@ export function normalizeParticipant(
* @param length The new length.
* @param placeholder A placeholder to use to fill the empty space.
*/
-export function setArraySize(
- array: T[],
- length: number,
- placeholder: T,
-): T[] {
+function setArraySize(array: T[], length: number, placeholder: T): T[] {
return Array.from(Array(length), (_, i) => array[i] || placeholder);
}
@@ -306,15 +281,6 @@ export function makePairs(array: T[]): [T, T][] {
.filter((v): v is [T, T] => v.length === 2);
}
-/**
- * Ensures that a list of elements has an even size.
- *
- * @param array A list of elements.
- */
-export function ensureEvenSized(array: T[]): void {
- if (array.length % 2 === 1) throw Error("Array size must be even.");
-}
-
/**
* Ensures there are no duplicates in a list of elements.
*
@@ -334,16 +300,6 @@ export function ensureNoDuplicates(array: Nullable[]): void {
throw new Error("The seeding has a duplicate participant.");
}
-/**
- * Ensures that two lists of elements have the same size.
- *
- * @param left The first list of elements.
- * @param right The second list of elements.
- */
-export function ensureEquallySized(left: T[], right: T[]): void {
- if (left.length !== right.length) throw Error("Arrays' size must be equal.");
-}
-
/**
* Fixes the seeding by enlarging it if it's not complete.
*
@@ -394,41 +350,6 @@ export function ensureValidSize(
);
}
-/**
- * Ensures that a match scores aren't tied.
- *
- * @param scores Two numbers which are scores.
- */
-export function ensureNotTied(scores: [number, number]): void {
- if (scores[0] === scores[1])
- throw Error(`${scores[0]} and ${scores[1]} are tied. It cannot be.`);
-}
-
-/**
- * Converts a TBD to a BYE.
- *
- * @param slot The slot to convert.
- */
-export function convertTBDtoBYE(slot: ParticipantSlot): ParticipantSlot {
- if (slot === null) return null; // Already a BYE.
- if (slot?.id === null) return null; // It's a TBD: make it a BYE.
-
- return slot; // It's a determined participant.
-}
-
-/**
- * Converts a participant slot to a result stored in storage.
- *
- * @param slot A participant slot.
- */
-export function toResult(slot: ParticipantSlot): ParticipantSlot {
- return (
- slot && {
- id: slot.id,
- }
- );
-}
-
/**
* Converts a participant slot to a result stored in storage, with the position the participant is coming from.
*
@@ -443,28 +364,6 @@ export function toResultWithPosition(slot: ParticipantSlot): ParticipantSlot {
);
}
-/**
- * Returns the winner of a match.
- *
- * @param match The match.
- */
-export function getWinner(match: MatchResults): ParticipantSlot {
- const winnerSide = getMatchResult(match);
- if (!winnerSide) return null;
- return match[winnerSide];
-}
-
-/**
- * Returns the loser of a match.
- *
- * @param match The match.
- */
-export function getLoser(match: MatchResults): ParticipantSlot {
- const winnerSide = getMatchResult(match);
- if (!winnerSide) return null;
- return match[getOtherSide(winnerSide)];
-}
-
/**
* Returns the pre-computed winner for a match because of BYEs.
*
@@ -536,46 +435,12 @@ export function getMatchResult(match: MatchResults): Side | null {
return winner;
}
-/**
- * Finds a position in a list of matches.
- *
- * @param matches A list of matches to search into.
- * @param position The position to find.
- */
-export function findPosition(
- matches: Match[],
- position: number,
-): ParticipantSlot {
- for (const match of matches) {
- if (match.opponent1?.position === position) return match.opponent1;
-
- if (match.opponent2?.position === position) return match.opponent2;
- }
-
- return null;
-}
-
-/**
- * Checks if a participant is involved in a given match.
- *
- * @param match A match.
- * @param participantId ID of a participant.
- */
-export function isParticipantInMatch(
- match: MatchResults,
- participantId: number,
-): boolean {
- return [match.opponent1, match.opponent2].some(
- (m) => m?.id === participantId,
- );
-}
-
/**
* Gets the side where the winner of the given match will go in the next match.
*
* @param matchNumber Number of the match.
*/
-export function getSide(matchNumber: number): Side {
+function getSide(matchNumber: number): Side {
return matchNumber % 2 === 1 ? "opponent1" : "opponent2";
}
@@ -593,7 +458,7 @@ export function getOtherSide(side: Side): Side {
*
* @param match Partial match results.
*/
-export function isMatchStarted(match: DeepPartial): boolean {
+function isMatchStarted(match: DeepPartial): boolean {
return (
match.opponent1?.score !== undefined || match.opponent2?.score !== undefined
);
@@ -604,7 +469,7 @@ export function isMatchStarted(match: DeepPartial): boolean {
*
* @param match Partial match results.
*/
-export function isMatchCompleted(match: DeepPartial): boolean {
+function isMatchCompleted(match: DeepPartial): boolean {
return isMatchByeCompleted(match) || isMatchResultCompleted(match);
}
@@ -613,9 +478,7 @@ export function isMatchCompleted(match: DeepPartial): boolean {
*
* @param match Partial match results.
*/
-export function isMatchResultCompleted(
- match: DeepPartial,
-): boolean {
+function isMatchResultCompleted(match: DeepPartial): boolean {
return isMatchWinCompleted(match);
}
@@ -624,7 +487,7 @@ export function isMatchResultCompleted(
*
* @param match Partial match results.
*/
-export function isMatchWinCompleted(match: DeepPartial): boolean {
+function isMatchWinCompleted(match: DeepPartial): boolean {
return (
match.opponent1?.result === "win" ||
match.opponent2?.result === "win" ||
@@ -657,15 +520,6 @@ export function isMatchUpdateLocked(match: MatchResults): boolean {
return match.status === Status.Locked || match.status === Status.Waiting;
}
-/**
- * Checks if a match's participants can't be updated.
- *
- * @param match The match to check.
- */
-export function isMatchParticipantLocked(match: MatchResults): boolean {
- return match.status >= Status.Running;
-}
-
/**
* Indicates whether a match has at least one BYE or not.
*
@@ -785,7 +639,7 @@ export function resetMatchResults(stored: MatchResults): void {
* @param stored A reference to what will be updated in the storage.
* @param match Input of the update.
*/
-export function setExtraFields(
+function setExtraFields(
stored: MatchResults,
match: DeepPartial,
): void {
@@ -837,7 +691,7 @@ export function setExtraFields(
* @param match The match to get the opponent from.
* @param side The side where to get the opponent from.
*/
-export function getOpponentId(match: MatchResults, side: Side): number | null {
+function getOpponentId(match: MatchResults, side: Side): number | null {
const opponent = match[side];
return opponent?.id ?? null;
}
@@ -855,58 +709,6 @@ export function getOriginPosition(match: Match, side: Side): number {
return matchNumber;
}
-/**
- * Returns every loser in a list of matches.
- *
- * @param participants The list of participants.
- * @param matches A list of matches to get losers of.
- */
-export function getLosers(matches: Match[]): number[][] {
- const losers: number[][] = [];
-
- let currentRound: number | null = null;
- let roundIndex = -1;
-
- for (const match of matches) {
- if (match.round_id !== currentRound) {
- currentRound = match.round_id;
- roundIndex++;
- losers[roundIndex] = [];
- }
-
- const loser = getLoser(match);
- if (loser === null) continue;
-
- invariant(loser.id, "Loser id not found");
- losers[roundIndex].push(loser.id);
- }
-
- return losers;
-}
-
-/**
- * Returns the decisive match of a Grand Final.
- *
- * @param type The type of Grand Final.
- * @param matches The matches in the Grand Final.
- */
-export function getGrandFinalDecisiveMatch(
- type: GrandFinalType,
- matches: Match[],
-): Match {
- if (type === "simple") return matches[0];
-
- if (type === "double") {
- const result = getMatchResult(matches[0]);
-
- if (result === "opponent2") return matches[1];
-
- return matches[0];
- }
-
- throw Error("The Grand Final is disabled.");
-}
-
/**
* Gets the side the winner of the current match will go to in the next match.
*
@@ -1005,7 +807,7 @@ export function resetNextOpponent(nextMatch: Match, nextSide: Side): void {
* @param stored A reference to what will be updated in the storage.
* @param match Input of the update.
*/
-export function handleOpponentsInversion(
+function handleOpponentsInversion(
stored: MatchResults,
match: DeepPartial,
): void {
@@ -1033,7 +835,7 @@ export function handleOpponentsInversion(
*
* @param match A match to update.
*/
-export function invertOpponents(match: DeepPartial): void {
+function invertOpponents(match: DeepPartial): void {
[match.opponent1, match.opponent2] = [match.opponent2, match.opponent1];
}
@@ -1044,7 +846,7 @@ export function invertOpponents(match: DeepPartial): void {
* @param match Input of the update.
* @returns `true` if the status of the match changed, `false` otherwise.
*/
-export function setScores(
+function setScores(
stored: MatchResults,
match: DeepPartial,
): boolean {
@@ -1073,7 +875,7 @@ export function setScores(
* @param stored A reference to what will be updated in the storage.
* @param match Input of the update.
*/
-export function setCompleted(
+function setCompleted(
stored: MatchResults,
match: DeepPartial,
): void {
@@ -1097,7 +899,7 @@ export function setCompleted(
* @param check A result to check in each opponent.
* @param change A result to set in each other opponent if `check` is correct.
*/
-export function setResults(
+function setResults(
stored: MatchResults,
match: DeepPartial,
check: Result,
@@ -1128,84 +930,17 @@ export function setResults(
}
}
-/**
- * Converts a list of matches to a seeding.
- *
- * @param matches The input matches.
- */
-export function convertMatchesToSeeding(matches: Match[]): ParticipantSlot[] {
- const flattened = ([] as ParticipantSlot[]).concat(
- ...matches.map((match) => [match.opponent1, match.opponent2]),
- );
- return sortSeeding(flattened);
-}
-
-/**
- * Converts a list of slots to an input seeding.
- *
- * @param slots The slots to convert.
- */
-export function convertSlotsToSeeding(slots: ParticipantSlot[]): Seeding {
- return slots.map((slot) => {
- if (slot === null || slot.id === null) return null; // BYE or TBD.
- return slot.id; // Let's return the ID instead of the name to be sure we keep the same reference.
- });
-}
-
-/**
- * Sorts the seeding with the BYEs in the correct position.
- *
- * @param slots A list of slots to sort.
- */
-export function sortSeeding(slots: ParticipantSlot[]): ParticipantSlot[] {
- const withoutByes = slots.filter((v) => v !== null);
-
- // a and b are not null because of the filter.
- // The slots are from seeding slots, thus they have a position.
- withoutByes.sort((a, b) => a.position! - b.position!);
-
- if (withoutByes.length === slots.length) return withoutByes;
-
- // Same for v and position.
- const placed = Object.fromEntries(
- withoutByes.map((v) => [v.position! - 1, v]),
- );
- const sortedWithByes = Array.from(
- { length: slots.length },
- (_, i) => placed[i] || null,
- );
-
- return sortedWithByes;
-}
-
/**
* Returns only the non null elements.
*
* @param array The array to process.
*/
-export function getNonNull(array: Nullable[]): T[] {
+function getNonNull(array: Nullable[]): T[] {
// Use a TS type guard to exclude null from the resulting type.
const nonNull = array.filter((element): element is T => element !== null);
return nonNull;
}
-/**
- * Returns a list of objects which have unique values of a specific key.
- *
- * @param array The array to process.
- * @param key The key to filter by.
- */
-export function uniqueBy(array: T[], key: (obj: T) => unknown): T[] {
- const seen = new Set();
- return array.filter((item) => {
- const value = key(item);
- if (!value) return true;
- if (seen.has(value)) return false;
- seen.add(value);
- return true;
- });
-}
-
/**
* Makes the transition to a major round for duels of the previous round. The duel count is divided by 2.
*
@@ -1253,28 +988,6 @@ export function transitionToMinor(
return currentDuels;
}
-/**
- * Returns a parent match results based on its child games scores.
- *
- * @param storedParent The parent match stored in the database.
- * @param scores The scores of the match child games.
- */
-export function getParentMatchResults(
- storedParent: Match,
- scores: Scores,
-): Pick {
- return {
- opponent1: {
- id: storedParent.opponent1?.id ?? null,
- score: scores.opponent1,
- },
- opponent2: {
- id: storedParent.opponent2?.id ?? null,
- score: scores.opponent2,
- },
- };
-}
-
/**
* Gets the values which need to be updated in a match when it's updated on insertion.
*
@@ -1314,81 +1027,6 @@ export function getUpdatedMatchResults(
};
}
-/**
- * Gets the default list of seeds for a round's matches.
- *
- * @param inLoserBracket Whether the match is in the loser bracket.
- * @param roundNumber The number of the current round.
- * @param roundCountLB The count of rounds in loser bracket.
- * @param matchCount The count of matches in the round.
- */
-export function getSeeds(
- inLoserBracket: boolean,
- roundNumber: number,
- roundCountLB: number,
- matchCount: number,
-): number[] {
- const seedCount = getSeedCount(
- inLoserBracket,
- roundNumber,
- roundCountLB,
- matchCount,
- );
- return Array.from(Array(seedCount), (_, i) => i + 1);
-}
-
-/**
- * Gets the number of seeds for a round's matches.
- *
- * @param inLoserBracket Whether the match is in the loser bracket.
- * @param roundNumber The number of the current round.
- * @param roundCountLB The count of rounds in loser bracket.
- * @param matchCount The count of matches in the round.
- */
-export function getSeedCount(
- inLoserBracket: boolean,
- roundNumber: number,
- roundCountLB: number,
- matchCount: number,
-): number {
- ensureOrderingSupported(inLoserBracket, roundNumber, roundCountLB);
-
- return roundNumber === 1
- ? matchCount * 2 // Two per match for upper or lower bracket round 1.
- : matchCount; // One per match for loser bracket minor rounds.
-}
-
-/**
- * Throws if the ordering is not supported on the given round number.
- *
- * @param inLoserBracket Whether the match is in the loser bracket.
- * @param roundNumber The number of the round.
- * @param roundCountLB The count of rounds in loser bracket.
- */
-export function ensureOrderingSupported(
- inLoserBracket: boolean,
- roundNumber: number,
- roundCountLB: number,
-): void {
- if (
- inLoserBracket &&
- !isOrderingSupportedLoserBracket(roundNumber, roundCountLB)
- )
- throw Error("This round does not support ordering.");
-
- if (!inLoserBracket && !isOrderingSupportedUpperBracket(roundNumber))
- throw Error("This round does not support ordering.");
-}
-
-/**
- * Indicates whether the ordering is supported in upper bracket, given the round number.
- *
- * @param roundNumber The number of the round.
- */
-export function isOrderingSupportedUpperBracket(roundNumber: number): boolean {
- return roundNumber === 1;
-}
-
/**
* Indicates whether the ordering is supported in loser bracket, given the round number.
*
@@ -1466,7 +1104,7 @@ export function findLoserMatchNumber(
* @param participantCount The number of participants in a stage.
* @param roundNumber Number of the round.
*/
-export function getLoserRoundMatchCount(
+function getLoserRoundMatchCount(
participantCount: number,
roundNumber: number,
): number {
@@ -1482,7 +1120,7 @@ export function getLoserRoundMatchCount(
* @param participantCount The number of participants in a stage.
* @param roundNumber Number of the round.
*/
-export function getLoserRoundLoserCount(
+function getLoserRoundLoserCount(
participantCount: number,
roundNumber: number,
): number {
@@ -1508,16 +1146,6 @@ export function getLoserOrdering(
return seedOrdering[orderingIndex];
}
-/**
- * Returns the number of rounds a lower bracket has given the number of participants in a double elimination stage.
- *
- * @param participantCount The number of participants in the stage.
- */
-export function getLowerBracketRoundCount(participantCount: number): number {
- const roundPairCount = getRoundPairCount(participantCount);
- return roundPairCount * 2;
-}
-
/**
* Returns the match number of the corresponding match in the next round by dividing by two.
*
@@ -1532,19 +1160,10 @@ export function getDiagonalMatchNumber(matchNumber: number): number {
*
* @param input The input number.
*/
-export function getNearestPowerOfTwo(input: number): number {
+function getNearestPowerOfTwo(input: number): number {
return 2 ** Math.ceil(Math.log2(input));
}
-/**
- * Returns the minimum score a participant must have to win a Best Of X series match.
- *
- * @param x The count of child games in the series.
- */
-export function minScoreToWinBestOfX(x: number): number {
- return (x + 1) / 2;
-}
-
/**
* Checks if a stage is a round-robin stage.
*
@@ -1558,26 +1177,6 @@ export function isSwiss(stage: Stage): boolean {
return stage.type === "swiss";
}
-/**
- * Throws if a stage is round-robin.
- *
- * @param stage The stage to check.
- */
-export function ensureNotRoundRobin(stage: Stage): void {
- const inRoundRobin = isRoundRobin(stage);
- if (inRoundRobin)
- throw Error("Impossible to update ordering in a round-robin stage.");
-}
-
-/**
- * Checks if a round is completed based on its matches.
- *
- * @param roundMatches Matches of the round.
- */
-export function isRoundCompleted(roundMatches: Match[]): boolean {
- return roundMatches.every((match) => match.status >= Status.Completed);
-}
-
/**
* Checks if a group is a winner bracket.
*
@@ -1586,10 +1185,7 @@ export function isRoundCompleted(roundMatches: Match[]): boolean {
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
-export function isWinnerBracket(
- stageType: StageType,
- groupNumber: number,
-): boolean {
+function isWinnerBracket(stageType: StageType, groupNumber: number): boolean {
return stageType === "double_elimination" && groupNumber === 1;
}
@@ -1599,10 +1195,7 @@ export function isWinnerBracket(
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
-export function isLoserBracket(
- stageType: StageType,
- groupNumber: number,
-): boolean {
+function isLoserBracket(stageType: StageType, groupNumber: number): boolean {
return stageType === "double_elimination" && groupNumber === 2;
}
@@ -1612,10 +1205,7 @@ export function isLoserBracket(
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
-export function isFinalGroup(
- stageType: StageType,
- groupNumber: number,
-): boolean {
+function isFinalGroup(stageType: StageType, groupNumber: number): boolean {
return (
(stageType === "single_elimination" && groupNumber === 2) ||
(stageType === "double_elimination" && groupNumber === 3)
@@ -1640,25 +1230,3 @@ export function getMatchLocation(
return "single_bracket";
}
-
-/**
- * Returns the fraction of final for the current round (e.g. `1/2` for semi finals or `1/4` for quarter finals).
- *
- * @param roundNumber Number of the current round.
- * @param roundCount Count of rounds.
- */
-export function getFractionOfFinal(
- roundNumber: number,
- roundCount: number,
-): number {
- if (roundNumber > roundCount)
- throw Error(
- `There are more rounds than possible. ${JSON.stringify({
- roundNumber,
- roundCount,
- })}`,
- );
-
- const denominator = 2 ** (roundCount - roundNumber);
- return 1 / denominator;
-}
diff --git a/app/modules/brackets-manager/index.ts b/app/modules/brackets-manager/index.ts
index 462044152..3a0fd160b 100644
--- a/app/modules/brackets-manager/index.ts
+++ b/app/modules/brackets-manager/index.ts
@@ -1,4 +1,3 @@
-import * as helpers from "./helpers";
import { BracketsManager } from "./manager";
import type {
@@ -28,5 +27,4 @@ export {
type StandardBracketResults,
type Storage,
type Table,
- helpers,
};
diff --git a/app/modules/brackets-manager/types.ts b/app/modules/brackets-manager/types.ts
index 96f88fe39..29700c2f7 100644
--- a/app/modules/brackets-manager/types.ts
+++ b/app/modules/brackets-manager/types.ts
@@ -103,15 +103,6 @@ export type Database = ValueToArray;
export type TournamentManagerDataSet = Database;
-/**
- * An item in the final standings of an elimination stage.
- */
-export interface FinalStandingsItem {
- id: number;
- name: string;
- rank: number;
-}
-
/**
* Contains the losers and the winner of the bracket.
*/
diff --git a/app/modules/brackets-model/unions.ts b/app/modules/brackets-model/unions.ts
index d76e96c84..3cef1aca7 100644
--- a/app/modules/brackets-model/unions.ts
+++ b/app/modules/brackets-model/unions.ts
@@ -29,11 +29,6 @@ export type GroupType =
*/
export type GrandFinalType = "none" | "simple" | "double";
-/**
- * The possible types of final for an elimination stage.
- */
-export type FinalType = "consolation_final" | "grand_final";
-
/**
* The possible modes for a round-robin stage.
*/
diff --git a/app/modules/i18n/i18next.server.ts b/app/modules/i18n/i18next.server.ts
index f22aedef8..be686fa26 100644
--- a/app/modules/i18n/i18next.server.ts
+++ b/app/modules/i18n/i18next.server.ts
@@ -22,5 +22,3 @@ export const i18next = new RemixI18Next({
resources: resources,
},
});
-
-export default i18next;
diff --git a/app/modules/in-game-lists/abilities.ts b/app/modules/in-game-lists/abilities.ts
index 4a18fa7a1..bfb08e5b1 100644
--- a/app/modules/in-game-lists/abilities.ts
+++ b/app/modules/in-game-lists/abilities.ts
@@ -74,10 +74,6 @@ export const abilities = [
export const abilitiesShort = abilities.map((ability) => ability.name);
-export const stackableAbilitiesShort = abilities
- .filter((ability) => ability.type === "STACKABLE")
- .map((ability) => ability.name);
-
export const mainOnlyAbilitiesShort = abilities
.filter((ability) => ability.type !== "STACKABLE")
.map((ability) => ability.name);
diff --git a/app/modules/in-game-lists/utils.ts b/app/modules/in-game-lists/utils.ts
index 4b82cb2b3..2a5333a3c 100644
--- a/app/modules/in-game-lists/utils.ts
+++ b/app/modules/in-game-lists/utils.ts
@@ -1,4 +1,4 @@
-import type { AnyWeapon } from "~/features/build-analyzer";
+import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types";
import { weaponAltNames } from "~/modules/in-game-lists/weapon-alt-names";
import { abilities } from "./abilities";
import type { Ability } from "./types";
diff --git a/app/modules/in-game-lists/weapon-alt-names.ts b/app/modules/in-game-lists/weapon-alt-names.ts
index f459725a2..2b409cca1 100644
--- a/app/modules/in-game-lists/weapon-alt-names.ts
+++ b/app/modules/in-game-lists/weapon-alt-names.ts
@@ -77,9 +77,3 @@ export const weaponAltNames = new Map()
.set(8010, ["sword", "vwiper"])
.set(8011, ["sword", "diper", "dwiper"])
.set(8012, ["rust", "kwiper", "barazushi"]);
-
-export const allWeaponAltNames = new Set(
- Array.from(weaponAltNames.values()).flatMap((name) =>
- typeof name === "string" ? [name] : name,
- ),
-);
diff --git a/app/modules/in-game-lists/weapon-ids.ts b/app/modules/in-game-lists/weapon-ids.ts
index fc4d2fee5..ea1be325e 100644
--- a/app/modules/in-game-lists/weapon-ids.ts
+++ b/app/modules/in-game-lists/weapon-ids.ts
@@ -76,10 +76,7 @@ export const mainWeaponIds = weaponCategories
.flatMap((category) => category.weaponIds)
.sort((a, b) => a - b);
-export const weaponIdToAltId = new Map<
- MainWeaponId,
- MainWeaponId | MainWeaponId[]
->([
+const weaponIdToAltId = new Map([
[40, [45, 47]],
[41, 46],
[200, 205],
@@ -207,20 +204,20 @@ export const nonBombSubWeaponIds = [
export const TRIZOOKA_ID = 1;
export const BIG_BUBBLER_ID = 2;
export const ZIPCASTER_ID = 3;
-export const TENTA_MISSILES_ID = 4;
+const TENTA_MISSILES_ID = 4;
export const INK_STORM_ID = 5;
export const BOOYAH_BOMB_ID = 6;
export const WAVE_BREAKER_ID = 7;
export const INK_VAC_ID = 8;
export const KILLER_WAIL_ID = 9;
-export const INKJET_ID = 10;
-export const ULTRA_STAMP_ID = 11;
+const INKJET_ID = 10;
+const ULTRA_STAMP_ID = 11;
export const CRAB_TANK_ID = 12;
-export const REEF_SLIDER_ID = 13;
-export const TRIPLE_INKSTRIKE_ID = 14;
-export const TACTICOOLER_ID = 15;
+const REEF_SLIDER_ID = 13;
+const TRIPLE_INKSTRIKE_ID = 14;
+const TACTICOOLER_ID = 15;
export const SUPER_CHUMP_ID = 16;
-export const KRAKEN_ROYALE_ID = 17;
+const KRAKEN_ROYALE_ID = 17;
export const TRIPLE_SPLASHDOWN_ID = 18;
export const SPLATTERCOLOR_SCREEN_ID = 19;
diff --git a/app/modules/patreon/constants.ts b/app/modules/patreon/constants.ts
index b2dbe1bda..1653225af 100644
--- a/app/modules/patreon/constants.ts
+++ b/app/modules/patreon/constants.ts
@@ -1,5 +1,5 @@
export const PATREON_INITIAL_URL =
- "https://www.patreon.com/api/oauth2/v2/campaigns/2744004/members?include=currently_entitled_tiers,user&fields%5Buser%5D=social_connections&fields%5Btier%5D=created_at";
+ "https://www.patreon.com/api/oauth2/v2/campaigns/2744004/members?include=currently_entitled_tiers,user&fields%5Buser%5D=social_connections&fields%5Bmember%5D=pledge_relationship_start";
// tier 1 lowest, tier 4 highest
export const TIER_1_ID = "6959473";
diff --git a/app/modules/patreon/schema.ts b/app/modules/patreon/schema.ts
index d41b63dc5..b4dd53612 100644
--- a/app/modules/patreon/schema.ts
+++ b/app/modules/patreon/schema.ts
@@ -10,7 +10,9 @@ import {
export const patronResponseSchema = z.object({
data: z.array(
z.object({
- attributes: z.object({}),
+ attributes: z.object({
+ pledge_relationship_start: z.string().nullish(),
+ }),
id: z.string(),
relationships: z.object({
currently_entitled_tiers: z.object({
@@ -24,11 +26,6 @@ export const patronResponseSchema = z.object({
UNKNOWN_TIER_ID,
]),
type: z.string(),
- attributes: z
- .object({
- created_at: z.string(),
- })
- .nullish(),
}),
),
}),
diff --git a/app/modules/patreon/updater.ts b/app/modules/patreon/updater.ts
index c02f6cf18..a3717b859 100644
--- a/app/modules/patreon/updater.ts
+++ b/app/modules/patreon/updater.ts
@@ -95,10 +95,7 @@ function parsePatronData({
patronsWithIds.push({
patreonId: patron.relationships.user.data.id,
patronSince: dateToDatabaseTimestamp(
- new Date(
- patron.relationships.currently_entitled_tiers.data[0].attributes
- ?.created_at ?? Date.now(),
- ),
+ new Date(patron.attributes.pledge_relationship_start ?? Date.now()),
),
patronTier: idToTierNumber(tier),
});
diff --git a/app/modules/tournament-map-list-generator/index.ts b/app/modules/tournament-map-list-generator/index.ts
deleted file mode 100644
index 623ce7529..000000000
--- a/app/modules/tournament-map-list-generator/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export { generateBalancedMapList } from "./balanced-map-list";
-export { sourceTypes } from "./constants";
-export type {
- BracketType,
- TournamentMapListMap,
- TournamentMaplistInput,
- TournamentMaplistSource,
-} from "./types";
diff --git a/app/modules/tournament-map-list-generator/types.ts b/app/modules/tournament-map-list-generator/types.ts
index f6789451f..cf1898cc0 100644
--- a/app/modules/tournament-map-list-generator/types.ts
+++ b/app/modules/tournament-map-list-generator/types.ts
@@ -2,13 +2,6 @@ import type { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { ModeShort, ModeWithStage } from "../in-game-lists/types";
import type { sourceTypes } from "./constants";
-export type BracketType =
- | "GROUPS"
- | "SE"
- | "DE_WINNERS"
- | "DE_LOSERS"
- | "SWISS";
-
export interface TournamentMaplistInput {
count: number;
seed: string;
diff --git a/app/modules/twitch/streams.ts b/app/modules/twitch/streams.ts
index 1a51de63c..456190bd9 100644
--- a/app/modules/twitch/streams.ts
+++ b/app/modules/twitch/streams.ts
@@ -134,7 +134,7 @@ async function getAllStreams() {
}
}
-export async function getStreamsChunk({
+async function getStreamsChunk({
isRetry = false,
cursor,
}: {
diff --git a/app/root.tsx b/app/root.tsx
index 9d024aa39..e83f46639 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -46,7 +46,7 @@ import {
import { getThemeSession } from "./features/theme/core/session.server";
import { useIsMounted } from "./hooks/useIsMounted";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
-import i18next, { i18nCookie } from "./modules/i18n/i18next.server";
+import { i18nCookie, i18next } from "./modules/i18n/i18next.server";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
import { allI18nNamespaces } from "./utils/i18n";
import { isRevalidation, metaTags } from "./utils/remix";
diff --git a/app/routines/deleteOldTrusts.ts b/app/routines/deleteOldTrusts.ts
index ec17fb665..2adacc6b1 100644
--- a/app/routines/deleteOldTrusts.ts
+++ b/app/routines/deleteOldTrusts.ts
@@ -1,11 +1,11 @@
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOldTrustRoutine = new Routine({
name: "DeleteOldTrusts",
func: async () => {
- const { numDeletedRows } = await QRepository.deleteOldTrust();
+ const { numDeletedRows } = await SQGroupRepository.deleteOldTrust();
logger.info(`Deleted ${numDeletedRows} old trusts`);
},
});
diff --git a/app/routines/setOldGroupsAsInactive.ts b/app/routines/setOldGroupsAsInactive.ts
index b468ee015..6389ff9c4 100644
--- a/app/routines/setOldGroupsAsInactive.ts
+++ b/app/routines/setOldGroupsAsInactive.ts
@@ -1,11 +1,11 @@
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const SetOldGroupsAsInactiveRoutine = new Routine({
name: "SetOldGroupsAsInactive",
func: async () => {
- const { numUpdatedRows } = await QRepository.setOldGroupsAsInactive();
+ const { numUpdatedRows } = await SQGroupRepository.setOldGroupsAsInactive();
logger.info(`Set ${numUpdatedRows} as inactive`);
},
});
diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts
index ffb767ecf..b0444e5ec 100644
--- a/app/utils/arrays.ts
+++ b/app/utils/arrays.ts
@@ -27,7 +27,7 @@ export function nullifyingAvg(values: number[]) {
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
}
-export function countElements(arr: T[]): Map {
+function countElements(arr: T[]): Map {
const counts = new Map();
for (const element of arr) {
diff --git a/app/utils/dates.ts b/app/utils/dates.ts
index 463b9e8b3..a71916ca1 100644
--- a/app/utils/dates.ts
+++ b/app/utils/dates.ts
@@ -1,9 +1,6 @@
import { CalendarDateTime, parseDate } from "@internationalized/date";
import type { Locale } from "date-fns";
-import {
- formatDistanceToNow as dateFnsFormatDistanceToNow,
- getWeek,
-} from "date-fns";
+import { formatDistanceToNow as dateFnsFormatDistanceToNow } from "date-fns";
import { da } from "date-fns/locale/da";
import { de } from "date-fns/locale/de";
import { enUS } from "date-fns/locale/en-US";
@@ -42,10 +39,6 @@ const LOCALE_MAP: Record = {
zh: zhCN,
};
-export function getDateFnsLocale(language: LanguageCode) {
- return LOCALE_MAP[language];
-}
-
export function formatDistanceToNow(
date: Parameters[0],
options: Omit<
@@ -55,7 +48,7 @@ export function formatDistanceToNow(
) {
return dateFnsFormatDistanceToNow(date, {
...options,
- locale: getDateFnsLocale(options.language),
+ locale: LOCALE_MAP[options.language],
});
}
@@ -112,44 +105,6 @@ export function dayMonthYearToDatabaseTimestamp(args: DayMonthYear) {
return dateToDatabaseTimestamp(dayMonthYearToDate(args));
}
-export function databaseCreatedAt() {
- return dateToDatabaseTimestamp(new Date());
-}
-
-export function dateToWeekNumber(date: Date) {
- return getWeek(date, { weekStartsOn: 1, firstWeekContainsDate: 4 });
-}
-
-export function dateToThisWeeksMonday(date: Date) {
- const copiedDate = new Date(date.getTime());
-
- while (copiedDate.getDay() !== 1) {
- copiedDate.setDate(copiedDate.getDate() - 1);
- }
-
- return copiedDate;
-}
-
-export function dateToThisWeeksSunday(date: Date) {
- const copiedDate = new Date(date.getTime());
-
- while (copiedDate.getDay() !== 0) {
- copiedDate.setDate(copiedDate.getDate() + 1);
- }
-
- return copiedDate;
-}
-
-export function getWeekStartsAtMondayDay(date: Date) {
- const currentDay = date.getDay();
-
- return dayToWeekStartsAtMondayDay(currentDay);
-}
-
-export function dayToWeekStartsAtMondayDay(day: number) {
- return day === 0 ? 7 : day;
-}
-
// https://stackoverflow.com/a/71336659
export function weekNumberToDate({
week,
@@ -202,21 +157,6 @@ export function dateToYearMonthDayHourMinuteString(date: Date) {
)}:${prefixZero(minute)}`;
}
-/** Returns date as a string with the format YYYY-MM-DD in user's time zone */
-export function dateToYearMonthDayString(date: Date) {
- const copiedDate = new Date(date.getTime());
-
- if (!isValidDate(copiedDate)) {
- throw new Error("tried to format string from invalid date");
- }
-
- const year = copiedDate.getFullYear();
- const month = copiedDate.getMonth() + 1;
- const day = copiedDate.getDate();
-
- return `${year}-${prefixZero(month)}-${prefixZero(day)}`;
-}
-
function prefixZero(number: number) {
return number < 10 ? `0${number}` : number;
}
diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts
index 26d6f7514..cfebfcecf 100644
--- a/app/utils/kysely.server.ts
+++ b/app/utils/kysely.server.ts
@@ -22,11 +22,13 @@ export type CommonUser = Pick<
"id" | "username" | "discordId" | "discordAvatar" | "customUrl"
>;
-export const userChatNameColor = sql<
+const userChatNameColorRaw = sql<
string | null
->`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as(
- "chatNameColor",
-);
+>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`;
+
+export const userChatNameColor = userChatNameColorRaw.as("chatNameColor");
+
+export const userChatNameColorForJson = userChatNameColorRaw;
export function commonUserJsonObject(eb: ExpressionBuilder) {
return jsonBuildObject({
diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts
index 75e37256b..91f36925c 100644
--- a/app/utils/remix.server.ts
+++ b/app/utils/remix.server.ts
@@ -193,7 +193,7 @@ export function canAccessLohiEndpoint(request: Request) {
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === process.env.LOHI_TOKEN;
}
-export function errorToastRedirect(message: string) {
+function errorToastRedirect(message: string) {
return redirect(`?__error=${message}`);
}
diff --git a/app/utils/sql.ts b/app/utils/sql.ts
index 648f38bc4..04d4bed6d 100644
--- a/app/utils/sql.ts
+++ b/app/utils/sql.ts
@@ -2,7 +2,9 @@ export function errorIsSqliteUniqueConstraintFailure(error: any) {
return error?.code === "SQLITE_CONSTRAINT_UNIQUE";
}
-export function errorIsSqliteForeignKeyConstraintFailure(error: unknown) {
+export function errorIsSqliteForeignKeyConstraintFailure(
+ error: unknown,
+): error is Error {
return (
error instanceof Error &&
error?.message?.includes("FOREIGN KEY constraint failed")
diff --git a/app/utils/types.ts b/app/utils/types.ts
index af44a4ee1..c228e6746 100644
--- a/app/utils/types.ts
+++ b/app/utils/types.ts
@@ -28,7 +28,3 @@ export type Nullish = T | null | undefined;
export type Unwrapped any> = Unpacked<
Awaited>
>;
-
-export type UnwrappedNonNullable any> = NonNullable<
- Unwrapped
->;
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index b8e584cdd..f6651cd8a 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -2,7 +2,7 @@ import slugify from "slugify";
import type { GearType, Preference, Tables } from "~/db/tables";
import type { ArtSource } from "~/features/art/art-types";
import type { AuthErrorCode } from "~/features/auth/core/errors";
-import { serializeBuild } from "~/features/build-analyzer";
+import { serializeBuild } from "~/features/build-analyzer/core/utils";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { StageBackgroundStyle } from "~/features/map-planner";
@@ -126,7 +126,6 @@ export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
"/static-assets/img/layout/common-preview.png";
export const ERROR_GIRL_IMAGE_PATH = "/static-assets/img/layout/error-girl";
-export const LOGO_PATH = "/static-assets/img/layout/logo";
export const SENDOU_LOVE_EMOJI_PATH = "/static-assets/img/layout/sendou_love";
export const FIRST_PLACEMENT_ICON_PATH =
"/static-assets/svg/placements/first.svg";
@@ -310,8 +309,6 @@ export const tournamentTeamPage = ({
}) => `/to/${tournamentId}/teams/${tournamentTeamId}`;
export const tournamentRegisterPage = (tournamentId: number) =>
`/to/${tournamentId}/register`;
-export const tournamentMapsPage = (tournamentId: number) =>
- `/to/${tournamentId}/maps`;
export const tournamentAdminPage = (tournamentId: number) =>
`/to/${tournamentId}/admin`;
export const tournamentBracketsPage = ({
diff --git a/app/utils/zod.ts b/app/utils/zod.ts
index ddffc85e7..dc7a3dd11 100644
--- a/app/utils/zod.ts
+++ b/app/utils/zod.ts
@@ -216,21 +216,6 @@ export function actuallyNonEmptyStringOrNull(value: unknown) {
return trimmed === "" ? null : trimmed;
}
-/**
- * Safely splits a string by a specified delimiter as Zod preprocess function.
- *
- * @param splitBy - The delimiter to split the string by. Defaults to a comma (",").
- * @returns A function that takes a value and returns the split string if the value is a string,
- * otherwise returns the original value.
- */
-export const safeSplit =
- (splitBy = ",") =>
- (value: unknown): unknown => {
- if (typeof value !== "string") return value;
-
- return value.split(splitBy);
- };
-
export function falsyToNull(value: unknown): unknown {
if (value) return value;
@@ -243,15 +228,6 @@ export function nullLiteraltoNull(value: unknown): unknown {
return value;
}
-export function jsonParseable(value: unknown) {
- try {
- JSON.parse(value as string);
- return true;
- } catch {
- return false;
- }
-}
-
export function undefinedToNull(value: unknown): unknown {
if (value === undefined) return null;
diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts
new file mode 100644
index 000000000..20901ec09
--- /dev/null
+++ b/e2e/sendouq.spec.ts
@@ -0,0 +1,244 @@
+import test, { expect } from "@playwright/test";
+import { NZAP_TEST_ID } from "~/db/seed/constants";
+import { ADMIN_ID } from "~/features/admin/admin-constants";
+import { impersonate, navigate, seed, submit } from "~/utils/playwright";
+import {
+ SENDOUQ_LOOKING_PAGE,
+ SENDOUQ_PAGE,
+ SENDOUQ_PREPARING_PAGE,
+ sendouQInviteLink,
+ sendouQMatchPage,
+ userSeasonsPage,
+} from "~/utils/urls";
+
+test.describe("SendouQ", () => {
+ test("Group preparation flow - add trusted users and users via invite link", async ({
+ page,
+ }) => {
+ await seed(page, "NO_SQ_GROUPS");
+ await impersonate(page, ADMIN_ID);
+
+ // Create preparing group
+ await navigate({ page, url: SENDOUQ_PAGE });
+ await page.getByRole("button", { name: "Join with mates" }).click();
+
+ // Verify group card visible with 1 member
+ const groupCard = page.getByTestId("sendouq-group-card").first();
+ await expect(
+ groupCard.getByTestId("sendouq-group-card-member"),
+ ).toHaveCount(1);
+
+ // -----------------
+
+ // Verify prepared groups not visible on looking page
+ // Impersonate a different user
+ await impersonate(page, 3);
+ await navigate({ page, url: SENDOUQ_PAGE });
+ await page.getByRole("button", { name: "Join solo" }).click();
+
+ await expect(page.getByTestId("sendouq-group-card")).toBeVisible();
+ // Verify ADMIN's preparing group is NOT visible
+ // Only ACTIVE groups should be shown
+ const sendouGroupCard = page
+ .getByTestId("sendouq-group-card")
+ .filter({ hasText: "Sendou" });
+ await expect(sendouGroupCard).not.toBeVisible();
+
+ // -----------------
+
+ // Add trusted user
+ await impersonate(page, ADMIN_ID);
+ await navigate({ page, url: SENDOUQ_PREPARING_PAGE });
+ const trustedUserSelect = page.locator('select[name="id"]');
+ await trustedUserSelect.selectOption({ index: 1 }); // Select first trusted user
+
+ // Find the add button with ADD_TRUSTED action and wait for it to be enabled
+ const addMemberButton = page.locator(
+ 'button[type="submit"][value="ADD_TRUSTED"]',
+ );
+ await expect(addMemberButton).toBeEnabled();
+ await addMemberButton.click();
+
+ // Wait for 2 members to appear
+ await expect(
+ groupCard.getByTestId("sendouq-group-card-member"),
+ ).toHaveCount(2);
+
+ // Extract invite code
+ const inviteCodeInput = page.locator('input[id="invite"]');
+ const inviteLink = await inviteCodeInput.inputValue();
+ const inviteCode = inviteLink.split("?join=")[1];
+ expect(inviteCode).toBeTruthy();
+
+ // Join as NZAP user via invite link
+ await impersonate(page, NZAP_TEST_ID);
+ await navigate({ page, url: sendouQInviteLink(inviteCode) });
+
+ // Verify join form appears and submit
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.getByRole("button", { name: "Join", exact: true }).click();
+
+ // Verify redirected to preparing page and NZAP added to group (3 members total)
+ await expect(page).toHaveURL(SENDOUQ_PREPARING_PAGE);
+ const prepGroupCard = page.getByTestId("sendouq-group-card").first();
+ await expect(
+ prepGroupCard.getByTestId("sendouq-group-card-member"),
+ ).toHaveCount(3);
+
+ await page.getByRole("button", { name: "Join the queue" }).click();
+ await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
+ });
+
+ test("Challenge flow - send challenge, report match, seasons page, quick rejoin with replay", async ({
+ page,
+ }) => {
+ await seed(page); // DEFAULT seed includes full groups for ADMIN and NZAP
+ await impersonate(page, ADMIN_ID);
+
+ // Send challenge
+ await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
+
+ // Challenge all available groups (we don't know which is NZAP since full groups are censored)
+ const groupCards = page.getByTestId("sendouq-group-card");
+ const count = await groupCards.count();
+ // starting idx 1 to skip own group
+ for (let i = 1; i < count; i++) {
+ const groupCard = groupCards.nth(i);
+ const challengeButton = groupCard
+ .locator('button[type="submit"]')
+ .first();
+ await challengeButton.click();
+ }
+
+ // Accept challenge as NZAP
+ await impersonate(page, NZAP_TEST_ID);
+ await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
+
+ const acceptButton = page
+ .getByRole("button", { name: "Start match" })
+ .first();
+ await expect(acceptButton).toBeVisible();
+ await acceptButton.click();
+
+ await expect(page).toHaveURL(/\/q\/match\/\d+/);
+ const matchId = page.url().split("/match/")[1];
+
+ // Verify both groups visible
+ await expect(page.getByText("Alpha", { exact: true })).toBeVisible();
+ await expect(page.getByText("Bravo", { exact: true })).toBeVisible();
+
+ // Report match score (first team - ADMIN)
+ await impersonate(page, ADMIN_ID);
+ await navigate({ page, url: sendouQMatchPage(Number(matchId)) });
+
+ // Report a 4-1 score (ADMIN wins)
+ const winners = ["BRAVO", "ALPHA", "ALPHA", "ALPHA", "ALPHA"];
+ for (let i = 0; i < winners.length; i++) {
+ const side = winners[i].toLowerCase();
+ await page.locator(`#${side}-${i}`).check();
+ }
+
+ // Submit score
+ await submit(page, "submit-score-button");
+
+ // Report same score as NZAP
+ await impersonate(page, NZAP_TEST_ID);
+ await navigate({ page, url: sendouQMatchPage(Number(matchId)) });
+
+ // Report same 4-1 score
+ for (let i = 0; i < winners.length; i++) {
+ const side = winners[i].toLowerCase();
+ await page.locator(`#${side}-${i}`).check();
+ }
+
+ // Submit score and verify match is now locked
+ await submit(page, "submit-score-button");
+ await expect(page.getByText("4 - 1")).toBeVisible();
+
+ // Verify match on seasons page
+ await navigate({
+ page,
+ url: userSeasonsPage({
+ user: { discordId: "123", customUrl: "sendou" },
+ }),
+ });
+ const matchLink = page.locator(`a[href="/q/match/${matchId}"]`);
+ await expect(matchLink).toBeVisible();
+
+ // Quick rejoin and replay indicator
+ // As ADMIN, click "Look again with same group"
+ await impersonate(page, ADMIN_ID);
+ await navigate({ page, url: sendouQMatchPage(Number(matchId)) });
+
+ const lookAgainButton = page.getByRole("button", {
+ name: "Look again with same group",
+ });
+
+ await lookAgainButton.click();
+ await page.getByRole("button", { name: "Join the queue" }).click();
+
+ // Verify redirect to looking page
+ await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
+
+ // As NZAP, do the same
+ await impersonate(page, NZAP_TEST_ID);
+ await navigate({ page, url: sendouQMatchPage(Number(matchId)) });
+
+ const lookAgainButtonNzap = page.getByRole("button", {
+ name: "Look again with same group",
+ });
+
+ await lookAgainButtonNzap.click();
+ await page.getByRole("button", { name: "Join the queue" }).click();
+
+ await expect(page.getByText("Replay")).toBeVisible();
+ });
+
+ test("Request flow - partial groups morph together", async ({ page }) => {
+ await seed(page, "NO_SQ_GROUPS");
+
+ // ADMIN creates a solo group
+ await impersonate(page, ADMIN_ID);
+ await navigate({ page, url: SENDOUQ_PAGE });
+ await page.getByRole("button", { name: "Join solo" }).click();
+
+ // User 3 creates a solo group
+ await impersonate(page, 3);
+ await navigate({ page, url: SENDOUQ_PAGE });
+ await page.getByRole("button", { name: "Join solo" }).click();
+
+ // Send request as ADMIN
+ await impersonate(page, ADMIN_ID);
+ await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
+
+ // Find user 3's group
+ const groupCards = page.getByTestId("sendouq-group-card");
+ const user3GroupCard = groupCards.nth(1); // Skip own group (index 0)
+ await expect(user3GroupCard).toBeVisible();
+
+ // Send request
+ await user3GroupCard.locator('button[type="submit"]').first().click();
+
+ // Accept request as user 3
+ await impersonate(page, 3);
+ await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
+
+ // Find ADMIN's group in the invitations
+ const adminInviteCard = page
+ .getByTestId("sendouq-group-card")
+ .filter({ hasText: "Sendou" });
+ await expect(adminInviteCard).toBeVisible();
+
+ // Accept and merge
+ await adminInviteCard.locator('button[type="submit"]').first().click();
+
+ // Verify still on looking page (not redirected to match)
+ await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
+
+ // Verify combined group has 2 members
+ const combinedGroup = page.getByTestId("sendouq-group-card").first();
+ await expect(
+ combinedGroup.getByTestId("sendouq-group-card-member"),
+ ).toHaveCount(2);
+ });
+});
diff --git a/knip.json b/knip.json
new file mode 100644
index 000000000..125334d9b
--- /dev/null
+++ b/knip.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://unpkg.com/knip@5/schema.json",
+ "ignoreExportsUsedInFile": {
+ "interface": true,
+ "type": true
+ },
+ "tags": ["-lintignore"],
+ "ignoreDependencies": [
+ "react-compiler-runtime",
+ "react-router-dom",
+ "babel-plugin-react-compiler"
+ ],
+ "entry": [
+ "app/entry.server.tsx",
+ "app/entry.client.tsx",
+ "app/routes.ts",
+ "app/features/*/routes/**/*.{ts,tsx}",
+ "migrations/**/*.js",
+ "scripts/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}",
+ "public/sw-2.js",
+ "ley.config.cjs"
+ ]
+}
diff --git a/locales/da/q.json b/locales/da/q.json
index 1a7f7ea3f..0e10a1311 100644
--- a/locales/da/q.json
+++ b/locales/da/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/de/q.json b/locales/de/q.json
index 10feb1a79..7519fb1b3 100644
--- a/locales/de/q.json
+++ b/locales/de/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/en/q.json b/locales/en/q.json
index 35b016f57..73b6394a3 100644
--- a/locales/en/q.json
+++ b/locales/en/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Invite",
"looking.groups.actions.groupUp": "Group up",
"looking.groups.actions.undo": "Undo",
- "looking.groups.actions.rechallenge": "Rechallenge with",
"looking.groups.actions.giveManager": "Give manager",
"looking.groups.actions.removeManager": "Remove manager",
"looking.groups.actions.kick": "Kick",
diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json
index 13836e717..325c653d1 100644
--- a/locales/es-ES/q.json
+++ b/locales/es-ES/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Invitar",
"looking.groups.actions.groupUp": "Crear grupo",
"looking.groups.actions.undo": "Deshacer",
- "looking.groups.actions.rechallenge": "Desafiar otra vez con",
"looking.groups.actions.giveManager": "Hacer mánager",
"looking.groups.actions.removeManager": "Quitar mánager",
"looking.groups.actions.kick": "Expulsar",
diff --git a/locales/es-US/q.json b/locales/es-US/q.json
index 6dde703f3..8c5c65703 100644
--- a/locales/es-US/q.json
+++ b/locales/es-US/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Invitar",
"looking.groups.actions.groupUp": "Crear grupo",
"looking.groups.actions.undo": "Deshacer",
- "looking.groups.actions.rechallenge": "Desafiar otra vez con",
"looking.groups.actions.giveManager": "Hacer mánager",
"looking.groups.actions.removeManager": "Quitar mánager",
"looking.groups.actions.kick": "Expulsar",
diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json
index 024bd9620..0b4a58bcb 100644
--- a/locales/fr-CA/q.json
+++ b/locales/fr-CA/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json
index f41139575..c04940fb6 100644
--- a/locales/fr-EU/q.json
+++ b/locales/fr-EU/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Invite",
"looking.groups.actions.groupUp": "Accepté",
"looking.groups.actions.undo": "Annulé",
- "looking.groups.actions.rechallenge": "Rechallenge avec",
"looking.groups.actions.giveManager": "Promouvoir",
"looking.groups.actions.removeManager": "Rétrograder",
"looking.groups.actions.kick": "Kick",
diff --git a/locales/he/q.json b/locales/he/q.json
index 252233974..5241bd314 100644
--- a/locales/he/q.json
+++ b/locales/he/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/it/q.json b/locales/it/q.json
index a26247f36..03997ca09 100644
--- a/locales/it/q.json
+++ b/locales/it/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Invita",
"looking.groups.actions.groupUp": "Forma un gruppo",
"looking.groups.actions.undo": "Annulla",
- "looking.groups.actions.rechallenge": "Risfida con",
"looking.groups.actions.giveManager": "Dai manager",
"looking.groups.actions.removeManager": "Rimuovi manager",
"looking.groups.actions.kick": "Caccia",
diff --git a/locales/ja/q.json b/locales/ja/q.json
index 7bfc13311..166dc118c 100644
--- a/locales/ja/q.json
+++ b/locales/ja/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "招待する",
"looking.groups.actions.groupUp": "グループに分かれる",
"looking.groups.actions.undo": "戻す",
- "looking.groups.actions.rechallenge": "再挑戦する",
"looking.groups.actions.giveManager": "マネージャーにあげる",
"looking.groups.actions.removeManager": "マネージャーを外す",
"looking.groups.actions.kick": "キックする",
diff --git a/locales/ko/q.json b/locales/ko/q.json
index 10feb1a79..7519fb1b3 100644
--- a/locales/ko/q.json
+++ b/locales/ko/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/nl/q.json b/locales/nl/q.json
index 10feb1a79..7519fb1b3 100644
--- a/locales/nl/q.json
+++ b/locales/nl/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/pl/q.json b/locales/pl/q.json
index 10feb1a79..7519fb1b3 100644
--- a/locales/pl/q.json
+++ b/locales/pl/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
- "looking.groups.actions.rechallenge": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json
index caf748c3f..46f435541 100644
--- a/locales/pt-BR/q.json
+++ b/locales/pt-BR/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Convidar",
"looking.groups.actions.groupUp": "Se juntar",
"looking.groups.actions.undo": "Desfazer",
- "looking.groups.actions.rechallenge": "Desafiar novamente com",
"looking.groups.actions.giveManager": "Dar gerência",
"looking.groups.actions.removeManager": "Remover gerência",
"looking.groups.actions.kick": "Chutar (kick)",
diff --git a/locales/ru/q.json b/locales/ru/q.json
index c50103609..39e73c4a5 100644
--- a/locales/ru/q.json
+++ b/locales/ru/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "Пригласить",
"looking.groups.actions.groupUp": "Вступить в группу",
"looking.groups.actions.undo": "Отменить",
- "looking.groups.actions.rechallenge": "Бросить вызов с",
"looking.groups.actions.giveManager": "Дать роль менеджера",
"looking.groups.actions.removeManager": "Удалить роль менеджера",
"looking.groups.actions.kick": "Выгнать",
diff --git a/locales/zh/q.json b/locales/zh/q.json
index 797d21055..225664d1b 100644
--- a/locales/zh/q.json
+++ b/locales/zh/q.json
@@ -96,7 +96,6 @@
"looking.groups.actions.invite": "邀请",
"looking.groups.actions.groupUp": "组队",
"looking.groups.actions.undo": "撤销",
- "looking.groups.actions.rechallenge": "重新挑战",
"looking.groups.actions.giveManager": "给予管理者权限",
"looking.groups.actions.removeManager": "移除管理者权限",
"looking.groups.actions.kick": "踢出",
diff --git a/package-lock.json b/package-lock.json
index c7d877c2f..39609d200 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@epic-web/cachified": "^5.5.2",
"@faker-js/faker": "^9.7.0",
"@hookform/resolvers": "^5.1.1",
+ "@internationalized/date": "^3.10.0",
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
@@ -73,6 +74,7 @@
"@remix-run/dev": "^2.16.8",
"@remix-run/route-config": "^2.16.8",
"@types/better-sqlite3": "^7.6.13",
+ "@types/node": "^25.0.2",
"@types/node-cron": "^3.0.11",
"@types/nprogress": "^0.2.3",
"@types/react": "^18.3.12",
@@ -80,11 +82,13 @@
"@types/web-push": "^3.6.4",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"cross-env": "^7.0.3",
+ "dotenv": "^17.2.3",
"i18next-locales-sync": "^2.1.1",
+ "knip": "^5.73.4",
"ley": "^0.8.1",
"sql-formatter": "^15.6.1",
"tsx": "^4.19.4",
- "typescript": "^5.8.3",
+ "typescript": "^5.9.3",
"vite": "^6.4.1",
"vite-node": "^3.2.4",
"vite-plugin-babel": "^1.3.2",
@@ -313,7 +317,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.797.0.tgz",
"integrity": "sha512-N7pB94mXi4fCt+rYmR9TzfbbwZsWs6Mnk/jDNX9sAZyWkZQnS3AZ/nRtnUmdCimdnOPOMNVjmAoZ4mW3Ff8LDw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
@@ -1009,7 +1012,6 @@
"integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -1663,7 +1665,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1726,6 +1727,40 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
+ "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+ "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2431,6 +2466,57 @@
"integrity": "sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==",
"license": "MIT"
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz",
+ "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/@npmcli/fs": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz",
@@ -2552,6 +2638,289 @@
"integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==",
"license": "MIT"
},
+ "node_modules/@oxc-resolver/binding-android-arm-eabi": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.15.0.tgz",
+ "integrity": "sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-android-arm64": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.15.0.tgz",
+ "integrity": "sha512-vbdBttesHR0W1oJaxgWVTboyMUuu+VnPsHXJ6jrXf4czELzB6GIg5DrmlyhAmFBhjwov+yJH/DfTnHS+2sDgOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-arm64": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.15.0.tgz",
+ "integrity": "sha512-R67lsOe1UzNjqVBCwCZX1rlItTsj/cVtBw4Uy19CvTicqEWvwaTn8t34zLD75LQwDDPCY3C8n7NbD+LIdw+ZoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-x64": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.15.0.tgz",
+ "integrity": "sha512-77mya5F8WV0EtCxI0MlVZcqkYlaQpfNwl/tZlfg4jRsoLpFbaTeWv75hFm6TE84WULVlJtSgvf7DhoWBxp9+ZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-freebsd-x64": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.15.0.tgz",
+ "integrity": "sha512-X1Sz7m5PC+6D3KWIDXMUtux+0Imj6HfHGdBStSvgdI60OravzI1t83eyn6eN0LPTrynuPrUgjk7tOnOsBzSWHw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.15.0.tgz",
+ "integrity": "sha512-L1x/wCaIRre+18I4cH/lTqSAymlV0k4HqfSYNNuI9oeL28Ks86lI6O5VfYL6sxxWYgjuWB98gNGo7tq7d4GarQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.15.0.tgz",
+ "integrity": "sha512-abGXd/zMGa0tH8nKlAXdOnRy4G7jZmkU0J85kMKWns161bxIgGn/j7zxqh3DKEW98wAzzU9GofZMJ0P5YCVPVw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.15.0.tgz",
+ "integrity": "sha512-SVjjjtMW66Mza76PBGJLqB0KKyFTBnxmtDXLJPbL6ZPGSctcXVmujz7/WAc0rb9m2oV0cHQTtVjnq6orQnI/jg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-musl": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.15.0.tgz",
+ "integrity": "sha512-JDv2/AycPF2qgzEiDeMJCcSzKNDm3KxNg0KKWipoKEMDFqfM7LxNwwSVyAOGmrYlE4l3dg290hOMsr9xG7jv9g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.15.0.tgz",
+ "integrity": "sha512-zbu9FhvBLW4KJxo7ElFvZWbSt4vP685Qc/Gyk/Ns3g2gR9qh2qWXouH8PWySy+Ko/qJ42+HJCLg+ZNcxikERfg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.15.0.tgz",
+ "integrity": "sha512-Kfleehe6B09C2qCnyIU01xLFqFXCHI4ylzkicfX/89j+gNHh9xyNdpEvit88Kq6i5tTGdavVnM6DQfOE2qNtlg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.15.0.tgz",
+ "integrity": "sha512-J7LPiEt27Tpm8P+qURDwNc8q45+n+mWgyys4/V6r5A8v5gDentHRGUx3iVk5NxdKhgoGulrzQocPTZVosq25Eg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.15.0.tgz",
+ "integrity": "sha512-+8/d2tAScPjVJNyqa7GPGnqleTB/XW9dZJQ2D/oIM3wpH3TG+DaFEXBbk4QFJ9K9AUGBhvQvWU2mQyhK/yYn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-gnu": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.15.0.tgz",
+ "integrity": "sha512-xtvSzH7Nr5MCZI2FKImmOdTl9kzuQ51RPyLh451tvD2qnkg3BaqI9Ox78bTk57YJhlXPuxWSOL5aZhKAc9J6qg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-musl": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.15.0.tgz",
+ "integrity": "sha512-14YL1zuXj06+/tqsuUZuzL0T425WA/I4nSVN1kBXeC5WHxem6lQ+2HGvG+crjeJEqHgZUT62YIgj88W+8E7eyg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-openharmony-arm64": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.15.0.tgz",
+ "integrity": "sha512-/7Qli+1Wk93coxnrQaU8ySlICYN8HsgyIrzqjgIkQEpI//9eUeaeIHZptNl2fMvBGeXa7k2QgLbRNaBRgpnvMw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.15.0.tgz",
+ "integrity": "sha512-q5rn2eIMQLuc/AVGR2rQKb2EVlgreATGG8xXg8f4XbbYCVgpxaq+dgMbiPStyNywW1MH8VU2T09UEm30UtOQvg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.15.0.tgz",
+ "integrity": "sha512-yCAh2RWjU/8wWTxQDgGPgzV9QBv0/Ojb5ej1c/58iOjyTuy/J1ZQtYi2SpULjKmwIxLJdTiCHpMilauWimE31w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-win32-ia32-msvc": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.15.0.tgz",
+ "integrity": "sha512-lmXKb6lvA6M6QIbtYfgjd+AryJqExZVSY2bfECC18OPu7Lv1mHFF171Mai5l9hG3r4IhHPPIwT10EHoilSCYeA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-win32-x64-msvc": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.15.0.tgz",
+ "integrity": "sha512-HZsfne0s/tGOcJK9ZdTGxsNU2P/dH0Shf0jqrPvsC6wX0Wk+6AyhSpHFLQCnLOuFQiHHU0ePfM8iYsoJb5hHpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -5168,7 +5537,6 @@
"integrity": "sha512-2EKByaD5CDwh7H56UFVCqc90kCZ9LukPlSwkcsR3gj7WlfL7sXtcIqIopcToAlKAeao3HDbhBlBT2CTOivxZCg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "^7.21.8",
"@babel/generator": "^7.21.5",
@@ -5255,6 +5623,19 @@
}
}
},
+ "node_modules/@remix-run/dev/node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/@remix-run/express": {
"version": "2.16.8",
"resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.16.8.tgz",
@@ -5281,7 +5662,6 @@
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.16.8.tgz",
"integrity": "sha512-foeYXU3mdaBJZnbtGbM8mNdHowz2+QnVGDRo7P3zgFkmsccMEflArGZNbkACGKd9xwDguTxxMJ6cuXBC4jIfgQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@remix-run/server-runtime": "2.16.8",
"@remix-run/web-fetch": "^4.4.2",
@@ -5308,7 +5688,6 @@
"resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.16.8.tgz",
"integrity": "sha512-JmoBUnEu/nPLkU6NGNIG7rfLM97gPpr1LYRJeV680hChr0/2UpfQQwcRLtHz03w1Gz1i/xONAAVOvRHVcXkRlA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@remix-run/router": "1.23.0",
"@remix-run/server-runtime": "2.16.8",
@@ -5357,7 +5736,6 @@
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5367,7 +5745,6 @@
"resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.16.8.tgz",
"integrity": "sha512-4exyeXCZoc/Vo8Zc+6Eyao3ONwOyNOK3Yeb0LLkWXd4aeFQ4v59i5fq/j/E+68UnpD/UZQl1Bj0k2hQnGQZhlQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@remix-run/express": "2.16.8",
"@remix-run/node": "2.16.8",
@@ -6469,7 +6846,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.7.tgz",
"integrity": "sha512-zN+NFFxLsxNEL8Qioc+DL6b8+Tt2bmRbXH22Gk6F6nD30x83eaUSFlSv3wqvgyCq3I1i1NO394So+Agmayx6rQ==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -6799,7 +7175,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
"integrity": "sha512-7gEEfz2Q6bYKXM07vzLUD0vqXFhC5geWRA6LCozTiLdVFDdHWiBrvb2rtkL5T7mfLq03zc1QhH7rI3F6VntOEA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"prosemirror-changeset": "^2.2.1",
"prosemirror-collab": "^1.3.1",
@@ -6996,6 +7371,17 @@
"@tldraw/utils": "3.12.1"
}
},
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@types/acorn": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz",
@@ -7177,13 +7563,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.13.8",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.8.tgz",
- "integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==",
+ "version": "25.0.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz",
+ "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.20.0"
+ "undici-types": "~7.16.0"
}
},
"node_modules/@types/node-cron": {
@@ -7227,7 +7613,6 @@
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -7239,7 +7624,6 @@
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -8065,7 +8449,6 @@
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8509,7 +8892,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -9561,9 +9943,9 @@
"license": "MIT"
},
"node_modules/dotenv": {
- "version": "16.4.7",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
- "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -9765,7 +10147,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -10064,7 +10445,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -10161,6 +10541,23 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
"node_modules/fast-shallow-equal": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
@@ -10194,6 +10591,16 @@
"integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==",
"license": "MIT"
},
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
"node_modules/fault": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
@@ -10208,6 +10615,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/fd-package-json": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
+ "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "walk-up-path": "^4.0.0"
+ }
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -10343,6 +10760,22 @@
"node": ">=0.4.x"
}
},
+ "node_modules/formatly": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
+ "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fd-package-json": "^2.0.0"
+ },
+ "bin": {
+ "formatly": "bin/index.mjs"
+ },
+ "engines": {
+ "node": ">=18.3.0"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -10869,7 +11302,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.23.2"
}
@@ -10933,7 +11365,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -11452,6 +11883,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/jmespath": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
@@ -11581,6 +12022,104 @@
"node": ">=6"
}
},
+ "node_modules/knip": {
+ "version": "5.73.4",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-5.73.4.tgz",
+ "integrity": "sha512-q0DDgqsRMa4z2IMEPEblns0igitG8Fu7exkvEgQx1QMLKEqSvcvKP9fMk+C1Ehy+Ux6oayl6zfAEGt6DvFtidw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/webpro"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/knip"
+ }
+ ],
+ "license": "ISC",
+ "dependencies": {
+ "@nodelib/fs.walk": "^1.2.3",
+ "fast-glob": "^3.3.3",
+ "formatly": "^0.3.0",
+ "jiti": "^2.6.0",
+ "js-yaml": "^4.1.1",
+ "minimist": "^1.2.8",
+ "oxc-resolver": "^11.15.0",
+ "picocolors": "^1.1.1",
+ "picomatch": "^4.0.1",
+ "smol-toml": "^1.5.2",
+ "strip-json-comments": "5.0.3",
+ "zod": "^4.1.11"
+ },
+ "bin": {
+ "knip": "bin/knip.js",
+ "knip-bun": "bin/knip-bun.js"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18",
+ "typescript": ">=5.0.4 <7"
+ }
+ },
+ "node_modules/knip/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/knip/node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/knip/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/knip/node_modules/strip-json-comments": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
+ "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/knip/node_modules/zod": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
+ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/kysely": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.2.tgz",
@@ -12104,6 +12643,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -12754,6 +13303,20 @@
],
"license": "MIT"
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -13485,6 +14048,38 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oxc-resolver": {
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.15.0.tgz",
+ "integrity": "sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-resolver/binding-android-arm-eabi": "11.15.0",
+ "@oxc-resolver/binding-android-arm64": "11.15.0",
+ "@oxc-resolver/binding-darwin-arm64": "11.15.0",
+ "@oxc-resolver/binding-darwin-x64": "11.15.0",
+ "@oxc-resolver/binding-freebsd-x64": "11.15.0",
+ "@oxc-resolver/binding-linux-arm-gnueabihf": "11.15.0",
+ "@oxc-resolver/binding-linux-arm-musleabihf": "11.15.0",
+ "@oxc-resolver/binding-linux-arm64-gnu": "11.15.0",
+ "@oxc-resolver/binding-linux-arm64-musl": "11.15.0",
+ "@oxc-resolver/binding-linux-ppc64-gnu": "11.15.0",
+ "@oxc-resolver/binding-linux-riscv64-gnu": "11.15.0",
+ "@oxc-resolver/binding-linux-riscv64-musl": "11.15.0",
+ "@oxc-resolver/binding-linux-s390x-gnu": "11.15.0",
+ "@oxc-resolver/binding-linux-x64-gnu": "11.15.0",
+ "@oxc-resolver/binding-linux-x64-musl": "11.15.0",
+ "@oxc-resolver/binding-openharmony-arm64": "11.15.0",
+ "@oxc-resolver/binding-wasm32-wasi": "11.15.0",
+ "@oxc-resolver/binding-win32-arm64-msvc": "11.15.0",
+ "@oxc-resolver/binding-win32-ia32-msvc": "11.15.0",
+ "@oxc-resolver/binding-win32-x64-msvc": "11.15.0"
+ }
+ },
"node_modules/p-limit": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz",
@@ -13841,7 +14436,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -14259,7 +14853,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz",
"integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -14289,7 +14882,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
"integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -14338,7 +14930,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.2.tgz",
"integrity": "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -14447,6 +15038,27 @@
"node": ">=0.4.x"
}
},
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
@@ -14522,7 +15134,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -14660,7 +15271,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.83.tgz",
"integrity": "sha512-l0m4ArKJvmFtR4e8UmKrj1pB4tUgOhJITf+mADyF/p69Ts1YAR/E+G9XEM0mHXKVRa1dQNHseyyDNzeuAXfXQw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "^0.16",
@@ -14690,7 +15300,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -14734,7 +15343,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz",
"integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -14751,7 +15359,6 @@
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.1.tgz",
"integrity": "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
@@ -15141,7 +15748,6 @@
"https://github.com/sponsors/sergiodxa"
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.0.0"
}
@@ -15333,6 +15939,17 @@
"node": ">= 4"
}
},
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/rollup": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz",
@@ -15387,6 +16004,30 @@
"@babel/runtime": "^7.1.2"
}
},
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
"node_modules/runes2": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
@@ -15765,6 +16406,19 @@
"node": ">=8.0.0"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz",
+ "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/sort-unwind": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/sort-unwind/-/sort-unwind-3.1.0.tgz",
@@ -16436,7 +17090,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -16646,8 +17299,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "peer": true
+ "license": "0BSD"
},
"node_modules/tsx": {
"version": "4.19.4",
@@ -17188,12 +17840,11 @@
}
},
"node_modules/typescript": {
- "version": "5.8.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17225,9 +17876,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.20.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
- "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
@@ -17658,7 +18309,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -18225,7 +18875,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -18341,6 +18990,16 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
+ "node_modules/walk-up-path": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
+ "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -18760,7 +19419,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 956d492a0..202d13dcb 100644
--- a/package.json
+++ b/package.json
@@ -24,9 +24,10 @@
"test:unit": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only run",
"test:e2e": "npx playwright test",
"test:e2e:flaky-detect": "npx playwright test --repeat-each=10 --max-failures=1",
- "checks": "npm run biome:fix && npm run test:unit && npm run check-translation-jsons && npm run typecheck",
+ "checks": "npm run biome:fix && npm run test:unit && npm run check-translation-jsons && npm run typecheck && npm run knip",
"setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts",
- "i18n:sync": "i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && npm run biome:fix"
+ "i18n:sync": "i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && npm run biome:fix",
+ "knip": "knip"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.797.0",
@@ -39,6 +40,7 @@
"@epic-web/cachified": "^5.5.2",
"@faker-js/faker": "^9.7.0",
"@hookform/resolvers": "^5.1.1",
+ "@internationalized/date": "^3.10.0",
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
@@ -94,6 +96,7 @@
"@remix-run/dev": "^2.16.8",
"@remix-run/route-config": "^2.16.8",
"@types/better-sqlite3": "^7.6.13",
+ "@types/node": "^25.0.2",
"@types/node-cron": "^3.0.11",
"@types/nprogress": "^0.2.3",
"@types/react": "^18.3.12",
@@ -101,11 +104,13 @@
"@types/web-push": "^3.6.4",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"cross-env": "^7.0.3",
+ "dotenv": "^17.2.3",
"i18next-locales-sync": "^2.1.1",
+ "knip": "^5.73.4",
"ley": "^0.8.1",
"sql-formatter": "^15.6.1",
"tsx": "^4.19.4",
- "typescript": "^5.8.3",
+ "typescript": "^5.9.3",
"vite": "^6.4.1",
"vite-node": "^3.2.4",
"vite-plugin-babel": "^1.3.2",
diff --git a/public/static-assets/badges/plusl.avif b/public/static-assets/badges/plusl.avif
new file mode 100644
index 000000000..ab3c2af5d
Binary files /dev/null and b/public/static-assets/badges/plusl.avif differ
diff --git a/public/static-assets/badges/plusl.gif b/public/static-assets/badges/plusl.gif
new file mode 100644
index 000000000..452a5e7bc
Binary files /dev/null and b/public/static-assets/badges/plusl.gif differ
diff --git a/public/static-assets/badges/plusl.png b/public/static-assets/badges/plusl.png
new file mode 100644
index 000000000..6f2981b2c
Binary files /dev/null and b/public/static-assets/badges/plusl.png differ
diff --git a/public/static-assets/badges/sbrella.avif b/public/static-assets/badges/sbrella.avif
new file mode 100644
index 000000000..5ea424981
Binary files /dev/null and b/public/static-assets/badges/sbrella.avif differ
diff --git a/public/static-assets/badges/sbrella.gif b/public/static-assets/badges/sbrella.gif
new file mode 100644
index 000000000..4653b1931
Binary files /dev/null and b/public/static-assets/badges/sbrella.gif differ
diff --git a/public/static-assets/badges/sbrella.png b/public/static-assets/badges/sbrella.png
new file mode 100644
index 000000000..0712ac642
Binary files /dev/null and b/public/static-assets/badges/sbrella.png differ
diff --git a/public/static-assets/badges/wkitty.avif b/public/static-assets/badges/wkitty.avif
new file mode 100644
index 000000000..81859e7c4
Binary files /dev/null and b/public/static-assets/badges/wkitty.avif differ
diff --git a/public/static-assets/badges/wkitty.gif b/public/static-assets/badges/wkitty.gif
new file mode 100644
index 000000000..9e5537f06
Binary files /dev/null and b/public/static-assets/badges/wkitty.gif differ
diff --git a/public/static-assets/badges/wkitty.png b/public/static-assets/badges/wkitty.png
new file mode 100644
index 000000000..6afed4f24
Binary files /dev/null and b/public/static-assets/badges/wkitty.png differ
diff --git a/scripts/create-analyzer-json.ts b/scripts/create-analyzer-json.ts
index adfb6ece7..5ce1ce45d 100644
--- a/scripts/create-analyzer-json.ts
+++ b/scripts/create-analyzer-json.ts
@@ -11,8 +11,11 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod/v4";
-import type { MainWeaponParams, SubWeaponParams } from "~/modules/analyzer";
-import type { ParamsJson } from "~/modules/analyzer/types";
+import type {
+ MainWeaponParams,
+ ParamsJson,
+ SubWeaponParams,
+} from "~/features/build-analyzer/analyzer-types";
import {
type SpecialWeaponId,
SQUID_BEAKON_ID,