@@ -104,12 +105,9 @@ function DeclinedSection() {
{
- rejoinFetcher.submit(
- { _action: "JOIN_QUEUE", direct: "true" },
- { method: "post", action: SENDOUQ_PAGE },
- );
+ rejoinQueue.submit("JOIN_QUEUE", { direct: "true" });
}}
>
{t("q:match.rematch.rejoinQueue")}
diff --git a/app/features/sendouq-match/components/RematchVotePanel.tsx b/app/features/sendouq-match/components/RematchVotePanel.tsx
index 9e809af82..8e0a46aa8 100644
--- a/app/features/sendouq-match/components/RematchVotePanel.tsx
+++ b/app/features/sendouq-match/components/RematchVotePanel.tsx
@@ -4,8 +4,10 @@ import { type FetcherWithComponents, Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
+import { useActionSubmit } from "~/hooks/useActionSubmit";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
import * as RejoinVote from "../core/RejoinVote";
+import { matchSchema } from "../q-match-schemas";
import styles from "./RematchVotePanel.module.css";
export type RematchVoteMember = {
@@ -30,6 +32,7 @@ export function RematchVotePanel({
fetcher,
}: RematchVotePanelProps) {
const { t } = useTranslation(["q"]);
+ const { submit } = useActionSubmit(matchSchema, { fetcher });
const isPending = fetcher.state !== "idle";
@@ -98,15 +101,7 @@ export function RematchVotePanel({
variant="primary"
size="small"
isDisabled={isPending || viewerVotedYes}
- onPress={() =>
- fetcher.submit(
- {
- _action: "CAST_CONTINUE_VOTE",
- isContinuing: "1",
- },
- { method: "post" },
- )
- }
+ onPress={() => submit("CAST_CONTINUE_VOTE", { isContinuing: true })}
>
{t("q:match.rematch.vote.yes")}
diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx
index bf4ebba7c..bc727b3df 100644
--- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx
+++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx
@@ -15,6 +15,7 @@ import { WeaponReporter } from "~/components/match-page/WeaponReporter";
import { useUser } from "~/features/auth/core/user";
import { FormField } from "~/form/FormField";
import { SendouForm } from "~/form/SendouForm";
+import { useActionSubmit } from "~/hooks/useActionSubmit";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import {
resolveGroupNames,
@@ -23,7 +24,11 @@ import {
} from "../core/match-timeline";
import * as SendouQMatch from "../core/SendouQMatch";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
-import { acceptCancelSchema, requestCancelSchema } from "../q-match-schemas";
+import {
+ acceptCancelSchema,
+ matchSchema,
+ requestCancelSchema,
+} from "../q-match-schemas";
import {
MatchmadeRejoinSection,
OffSeasonRejoinSection,
@@ -135,7 +140,7 @@ function CancelPendingTab() {
function CancelRespondTab({ data }: { data: SendouQMatchLoaderData }) {
const { t } = useTranslation(["q", "common"]);
- const cancelFetcher = useFetcher();
+ const refuseCancel = useActionSubmit(matchSchema);
return (
@@ -147,12 +152,9 @@ function CancelRespondTab({ data }: { data: SendouQMatchLoaderData }) {
}
- isDisabled={cancelFetcher.state !== "idle"}
+ isDisabled={refuseCancel.state !== "idle"}
onPress={() => {
- cancelFetcher.submit(
- { _action: "REFUSE_CANCEL" },
- { method: "post" },
- );
+ refuseCancel.submit("REFUSE_CANCEL");
}}
>
{t("common:actions.refuse")}
@@ -324,8 +326,8 @@ function WeaponReportSection({
function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) {
const { t } = useTranslation(["q"]);
- const fetcher = useFetcher();
- const confirmFetcherPending = fetcher.state !== "idle";
+ const { submit, state } = useActionSubmit(matchSchema);
+ const confirmFetcherPending = state !== "idle";
const decidingMap = [...data.match.mapList]
.reverse()
@@ -341,14 +343,10 @@ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) {
isPending={confirmFetcherPending}
onPress={() => {
if (!decidingMap?.winnerGroupId) return;
- fetcher.submit(
- {
- _action: "REPORT_SCORE",
- winnerId: String(decidingMap.winnerGroupId),
- reportedCount: String(reportedCount),
- },
- { method: "post" },
- );
+ submit("REPORT_SCORE", {
+ winnerId: decidingMap.winnerGroupId,
+ reportedCount,
+ });
}}
>
{t("q:match.confirmScore")}
@@ -362,7 +360,7 @@ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) {
function ReporterUndoSection() {
const { t } = useTranslation(["q"]);
- const undoFetcher = useFetcher();
+ const undoReport = useActionSubmit(matchSchema);
return (
@@ -372,12 +370,9 @@ function ReporterUndoSection() {
{
- undoFetcher.submit(
- { _action: "UNDO_MATCH_REPORT" },
- { method: "post" },
- );
+ undoReport.submit("UNDO_MATCH_REPORT");
}}
>
{t("q:match.undoReport")}
@@ -400,8 +395,8 @@ function InProgressTab({
user: { id: number };
}) {
const { t } = useTranslation(["q", "common"]);
- const fetcher = useFetcher();
- const undoFetcher = useFetcher();
+ const reportScore = useActionSubmit(matchSchema);
+ const undoReport = useActionSubmit(matchSchema);
const cancelFetcher = useFetcher();
const isStaffOnly = ownTeamId == null;
@@ -460,17 +455,10 @@ function InProgressTab({
stageId={currentMap.stageId}
mode={currentMap.mode}
withKo={false}
- isSubmitting={fetcher.state !== "idle"}
+ isSubmitting={reportScore.state !== "idle"}
setEnding={setEnding}
onSubmit={({ winnerId }) => {
- fetcher.submit(
- {
- _action: "REPORT_SCORE",
- winnerId: String(winnerId),
- reportedCount: String(reportedCount),
- },
- { method: "post" },
- );
+ reportScore.submit("REPORT_SCORE", { winnerId, reportedCount });
}}
secondaryAction={
isStaffOnly ? null :
@@ -513,20 +501,14 @@ function InProgressTab({
variant="minimal-destructive"
size="miniscule"
icon={}
- isPending={undoFetcher.state !== "idle"}
+ isPending={undoReport.state !== "idle"}
isDisabled={!scoreIsNotZero}
onPress={() => {
const mapIndex = data.match.mapList.findLastIndex(
(m) => m.winnerGroupId !== null,
);
if (mapIndex < 0) return;
- undoFetcher.submit(
- {
- _action: "UNDO_MAP_REPORT",
- mapIndex: String(mapIndex),
- },
- { method: "post" },
- );
+ undoReport.submit("UNDO_MAP_REPORT", { mapIndex });
}}
>
{t("q:match.undoReport")}
diff --git a/app/features/sendouq-match/core/RejoinVote.test.ts b/app/features/sendouq-match/core/RejoinVote.test.ts
index 0d5f79858..946a007dd 100644
--- a/app/features/sendouq-match/core/RejoinVote.test.ts
+++ b/app/features/sendouq-match/core/RejoinVote.test.ts
@@ -65,16 +65,45 @@ describe("RejoinVote.userContinueStatus()", () => {
describe("RejoinVote.canCastVote()", () => {
test("is true when the user has not voted yet", () => {
- expect(RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1)).toBe(
- true,
- );
+ expect(
+ RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1, true),
+ ).toBe(true);
+ expect(
+ RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1, false),
+ ).toBe(true);
});
- test("is false once the user has voted", () => {
+ test("is true when changing a yes vote to a no", () => {
expect(
- RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1),
+ RejoinVote.canCastVote([{ userId: 1, isContinuing: true }], 1, false),
+ ).toBe(true);
+ });
+
+ test("is false when re-casting a yes vote", () => {
+ expect(
+ RejoinVote.canCastVote([{ userId: 1, isContinuing: true }], 1, true),
).toBe(false);
});
+
+ test("is false once the user has voted no", () => {
+ expect(
+ RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1, true),
+ ).toBe(false);
+ expect(
+ RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1, false),
+ ).toBe(false);
+ });
+
+ test("is false once the vote has settled", () => {
+ const resolvedVotes = [
+ { userId: 1, isContinuing: true },
+ { userId: 2, isContinuing: true },
+ { userId: 3, isContinuing: true },
+ { userId: 4, isContinuing: true },
+ ];
+
+ expect(RejoinVote.canCastVote(resolvedVotes, 1, false)).toBe(false);
+ });
});
describe("RejoinVote.extractOwnGroupVotesFromSendouqMatch()", () => {
diff --git a/app/features/sendouq-match/core/RejoinVote.ts b/app/features/sendouq-match/core/RejoinVote.ts
index af5844557..201304d71 100644
--- a/app/features/sendouq-match/core/RejoinVote.ts
+++ b/app/features/sendouq-match/core/RejoinVote.ts
@@ -41,10 +41,21 @@ export function userContinueStatus(votes: RejoinVote[], userId: number) {
}
/**
- * Whether the given user is still eligible to cast their vote.
+ * Whether the given user is still eligible to cast `isContinuing` as their vote.
+ * A first vote is always allowed while the vote is ongoing, and a yes vote can
+ * still be changed to a no; a no vote is final.
*/
-export function canCastVote(votes: RejoinVote[], userId: number) {
- return !votes.some((vote) => vote.userId === userId);
+export function canCastVote(
+ votes: RejoinVote[],
+ userId: number,
+ isContinuing: boolean,
+) {
+ if (result(votes).type !== "ONGOING") return false;
+
+ const currentVote = userContinueStatus(votes, userId);
+ if (currentVote === null) return true;
+
+ return currentVote && !isContinuing;
}
/**
diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts
index e2ca2c932..4b6922d9c 100644
--- a/app/features/sendouq-match/q-match-schemas.ts
+++ b/app/features/sendouq-match/q-match-schemas.ts
@@ -1,6 +1,10 @@
import { z } from "zod";
+import {
+ reportWeaponSchema,
+ undoWeaponReportSchema,
+} from "~/components/match-page/match-page-schemas";
import { checkboxGroupDynamic, stringConstant, textArea } from "~/form/fields";
-import { _action, id, weaponSplId } from "~/utils/zod";
+import { _action, id } from "~/utils/zod";
import { SENDOUQ } from "../sendouq/q-constants";
const cancelNominatedUserIdsField = checkboxGroupDynamic({
@@ -38,13 +42,17 @@ export const matchSchema = z.union([
}),
z.object({
_action: _action("CAST_CONTINUE_VOTE"),
- isContinuing: z.enum(["0", "1"]).transform((v) => v === "1"),
- }),
- z.object({
- _action: _action("REPORT_WEAPON"),
- weaponSplId,
- mapIndex: z.coerce.number().int().nonnegative(),
+ isContinuing: z.preprocess(
+ (value) =>
+ value === "1" || value === "true"
+ ? true
+ : value === "0" || value === "false"
+ ? false
+ : value,
+ z.boolean(),
+ ),
}),
+ reportWeaponSchema,
z.object({
_action: _action("UNDO_MATCH_REPORT"),
}),
@@ -52,10 +60,7 @@ export const matchSchema = z.union([
_action: _action("UNDO_MAP_REPORT"),
mapIndex: z.coerce.number().int().nonnegative(),
}),
- z.object({
- _action: _action("UNDO_WEAPON_REPORT"),
- mapIndex: z.coerce.number().int().nonnegative(),
- }),
+ undoWeaponReportSchema,
requestCancelSchema,
acceptCancelSchema,
z.object({
diff --git a/app/features/sendouq-streams/routes/q.streams.module.css b/app/features/sendouq-streams/routes/q.streams.module.css
index 8a2545239..f7db99ce4 100644
--- a/app/features/sendouq-streams/routes/q.streams.module.css
+++ b/app/features/sendouq-streams/routes/q.streams.module.css
@@ -1,10 +1,6 @@
.userContainer {
font-size: var(--font-xs);
- display: flex;
- gap: var(--s-2);
- align-items: center;
font-weight: var(--weight-semi);
- color: var(--color-text);
}
.viewerCount {
diff --git a/app/features/sendouq-streams/routes/q.streams.tsx b/app/features/sendouq-streams/routes/q.streams.tsx
index f96247aa2..4e6a51cbd 100644
--- a/app/features/sendouq-streams/routes/q.streams.tsx
+++ b/app/features/sendouq-streams/routes/q.streams.tsx
@@ -2,16 +2,16 @@ import { User } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
-import { Avatar } from "~/components/Avatar";
import { TierImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
+import { UserLink } from "~/components/UserLink";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useHydrated } from "~/hooks/useHydrated";
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
-import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls";
+import { FAQ_PAGE, sendouQMatchPage, twitchUrl } from "~/utils/urls";
import { loader } from "../loaders/q.streams.server";
@@ -58,13 +58,10 @@ export default function SendouQStreamsPage() {
return (
-
-
{" "}
- {streamedMatch.user.username}
-
+ />
{streamedMatch.weaponSplId ? (
diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts
index 91d1d56a7..f4c2184eb 100644
--- a/app/features/sendouq/SQGroupRepository.server.ts
+++ b/app/features/sendouq/SQGroupRepository.server.ts
@@ -1,6 +1,6 @@
import { sub } from "date-fns";
import { type NotNull, sql, type Transaction } from "kysely";
-import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
+import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type { UserMapModePreferences } from "~/db/tables-json";
@@ -9,8 +9,8 @@ import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import {
+ commonUserMembersAgg,
commonUserSelect,
- customAvatarUrl,
matchProfileWeapons,
} from "~/utils/kysely.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
@@ -54,27 +54,6 @@ export async function findMapModePreferencesByGroupId(groupId: number) {
}
export async function findCurrentGroups() {
- type SendouQMemberObject = {
- id: Tables["User"]["id"];
- username: Tables["User"]["username"];
- discordId: Tables["User"]["discordId"];
- discordAvatar: Tables["User"]["discordAvatar"];
- customAvatarUrl: string | null;
- customUrl: Tables["User"]["customUrl"];
- pronouns: Tables["User"]["pronouns"] | null;
- 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:
- | (Pick
& {
- isTenStar: number;
- })[]
- | null;
- };
-
return (
db
.selectFrom("Group")
@@ -88,35 +67,23 @@ export async function findCurrentGroups() {
]),
),
)
- .select(({ fn, eb }) => [
+ .select(({ 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"),
- customAvatarUrl: customAvatarUrl(eb),
- 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: matchProfileWeapons(eb),
- languages: eb.ref("User.languages"),
- vc: eb.ref("User.vc"),
- }),
- ])
- .$castTo()
- .as("members"),
+ commonUserMembersAgg(eb, {
+ pronouns: eb.ref("User.pronouns"),
+ mapModePreferences: eb.ref("User.mapModePreferences"),
+ noScreen: eb.ref("User.noScreen"),
+ role: eb.ref("GroupMember.role"),
+ note: eb.ref("GroupMember.note"),
+ weapons: matchProfileWeapons(eb),
+ languages: eb.ref("User.languages"),
+ vc: eb.ref("User.vc"),
+ }).as("members"),
])
// != INACTIVE (same set as ACTIVE or PREPARING) so the partial
// group_status_active index applies
diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts
index db68d1f99..154c18199 100644
--- a/app/features/sendouq/actions/q.looking.server.ts
+++ b/app/features/sendouq/actions/q.looking.server.ts
@@ -16,8 +16,8 @@ import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import { lookingSchema } from "../q-action-schemas";
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
-import { lookingSchema } from "../q-schemas.server";
import { resolveFutureMatchModes } from "../q-utils";
import {
SendouQError,
@@ -167,6 +167,13 @@ export const action: ActionFunction = async ({ request }) => {
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
if (!ownGroup || !theirGroup) return null;
+ const allLikes = await SQGroupRepository.findAllLikesByGroupId(
+ data.targetGroupId,
+ );
+ if (!allLikes.given.some((like) => like.groupId === ownGroup.id)) {
+ return null;
+ }
+
const ownGroupPreferences =
await SQGroupRepository.findMapModePreferencesByGroupId(ownGroup.id);
const theirGroupPreferences =
diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts
index 73bc5cd33..99ffb48cf 100644
--- a/app/features/sendouq/actions/q.preparing.server.ts
+++ b/app/features/sendouq/actions/q.preparing.server.ts
@@ -10,8 +10,8 @@ import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import { preparingSchema } from "../q-action-schemas";
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
-import { preparingSchema } from "../q-schemas.server";
import { SendouQError, setGroupChatMetadata } from "../q-utils.server";
export type SendouQPreparingAction = typeof action;
diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts
index 48f6c65c9..f2e248b55 100644
--- a/app/features/sendouq/actions/q.server.ts
+++ b/app/features/sendouq/actions/q.server.ts
@@ -17,8 +17,8 @@ import {
} from "~/utils/urls";
import { normalizeFriendCode } from "~/utils/zod";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import { frontPageSchema } from "../q-action-schemas";
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
-import { frontPageSchema } from "../q-schemas.server";
import { qSearchParams } from "../q-search-params";
import { userCanJoinQueueAt } from "../q-utils";
import {
diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
index b67d80e30..ead7671e5 100644
--- a/app/features/sendouq/components/GroupCard.tsx
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -4,13 +4,13 @@ import { Mic, Star, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
-import { Link, useFetcher } from "react-router";
+import { Link } from "react-router";
+import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
import { NoteAvatar } from "~/components/NoteAvatar";
-import { SubmitButton } from "~/components/SubmitButton";
import type { ParsedMemento } from "~/db/tables-json";
import { useUser } from "~/features/auth/core/user";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
@@ -35,6 +35,7 @@ import type {
SQGroupMember,
SQOwnGroup,
} from "../core/SendouQ.server";
+import { lookingSchema } from "../q-action-schemas";
import { FULL_GROUP_SIZE } from "../q-constants";
import { updateGroupNoteSchema } from "../q-schemas";
import { resolveFutureMatchModes } from "../q-utils";
@@ -60,7 +61,6 @@ export function GroupCard({
layout?: "mobile" | "desktop";
}) {
const { t } = useTranslation(["q"]);
- const fetcher = useFetcher();
const hideNote =
displayOnly ||
@@ -202,26 +202,25 @@ export function GroupCard({
{action &&
(ownGroup?.usersRole === "OWNER" ||
ownGroup?.usersRole === "MANAGER") ? (
-
-
-
- {action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE"
- ? t("q:looking.groups.actions.startMatch")
- : action === "LIKE" && !group.members
- ? t("q:looking.groups.actions.challenge")
- : action === "LIKE"
- ? t("q:looking.groups.actions.invite")
- : action === "GROUP_UP"
- ? t("q:looking.groups.actions.groupUp")
- : t("q:looking.groups.actions.undo")}
-
-
+
+ {action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE"
+ ? t("q:looking.groups.actions.startMatch")
+ : action === "LIKE" && !group.members
+ ? t("q:looking.groups.actions.challenge")
+ : action === "LIKE"
+ ? t("q:looking.groups.actions.invite")
+ : action === "GROUP_UP"
+ ? t("q:looking.groups.actions.groupUp")
+ : t("q:looking.groups.actions.undo")}
+
) : null}
@@ -525,7 +524,6 @@ function MemberRoleManager({
enableKicking?: boolean;
}) {
const loggedInUser = useUser();
- const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
if (displayOnly && member.role !== "OWNER") return null;
@@ -549,43 +547,44 @@ function MemberRoleManager({
{t(`q:roles.${member.role}`)}
{member.role !== "OWNER" && !displayOnly ? (
-
-
+
{member.role === "REGULAR" ? (
-
{t("q:looking.groups.actions.giveManager")}
-
+
) : null}
{member.role === "MANAGER" ? (
-
{t("q:looking.groups.actions.removeManager")}
-
+
) : null}
{enableKicking && member.id !== loggedInUser?.id ? (
-
{t("q:looking.groups.actions.kick")}
-
+
) : null}
-
+
) : null}
diff --git a/app/features/sendouq/components/GroupLeaver.tsx b/app/features/sendouq/components/GroupLeaver.tsx
index dc3afe174..b9b4737c3 100644
--- a/app/features/sendouq/components/GroupLeaver.tsx
+++ b/app/features/sendouq/components/GroupLeaver.tsx
@@ -1,9 +1,7 @@
import { useTranslation } from "react-i18next";
-import { useFetcher } from "react-router";
-import { SendouButton } from "~/components/elements/Button";
-import { FormWithConfirm } from "~/components/FormWithConfirm";
-import { SubmitButton } from "~/components/SubmitButton";
+import { ActionButton } from "~/components/ActionButton";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
+import { lookingSchema } from "../q-action-schemas";
export function GroupLeaver({
type,
@@ -11,36 +9,29 @@ export function GroupLeaver({
type: "LEAVE_GROUP" | "LEAVE_Q" | "GO_BACK";
}) {
const { t } = useTranslation(["q", "common"]);
- const fetcher = useFetcher();
- if (type === "LEAVE_GROUP") {
- return (
-
-
- {t("q:looking.groups.actions.leaveGroup")}
-
-
- );
- }
-
- // leave without confirm if alone
return (
-
-
- {type === "LEAVE_Q"
+
+ {type === "LEAVE_GROUP"
+ ? t("q:looking.groups.actions.leaveGroup")
+ : type === "LEAVE_Q"
? t("q:looking.groups.actions.leaveQ")
: t("q:looking.groups.actions.goBack")}
-
-
+
);
}
diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx
index 49c716f76..1aaaabf9c 100644
--- a/app/features/sendouq/components/MemberAdder.tsx
+++ b/app/features/sendouq/components/MemberAdder.tsx
@@ -12,6 +12,7 @@ import {
sendouQInviteLink,
} from "~/utils/urls";
import type { SendouQPreparingAction } from "../actions/q.preparing.server";
+import { preparingSchema } from "../q-action-schemas";
export function MemberAdder({
inviteCode,
@@ -66,6 +67,7 @@ function AddFriendRow({ groupMemberIds }: { groupMemberIds: number[] }) {
}
diff --git a/app/features/sendouq/core/default-maps.server.test.ts b/app/features/sendouq/core/default-maps.server.test.ts
index 64f3fe3f8..efcf0bbf9 100644
--- a/app/features/sendouq/core/default-maps.server.test.ts
+++ b/app/features/sendouq/core/default-maps.server.test.ts
@@ -305,6 +305,57 @@ describe("getDefaultMapWeights()", () => {
expect(result.has("SZ-7")).toBe(false);
});
+ test("recalculates weights when the resolved season changes (day 7 switch from previous to current season)", async () => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ mockSeasonCurrent.mockReturnValue({
+ nth: 2,
+ starts: yesterday,
+ ends: new Date("2030-12-31"),
+ });
+ mockSeasonPrevious.mockReturnValue({
+ nth: 1,
+ starts: new Date("2023-01-01"),
+ ends: new Date("2023-12-31"),
+ });
+
+ await createUsersWithPreferences({
+ seasonNth: 1,
+ preferences: [
+ {
+ modes: [],
+ pool: [{ mode: "SZ", stages: [0] }],
+ },
+ ],
+ });
+ await createUsersWithPreferences({
+ seasonNth: 2,
+ preferences: [
+ {
+ modes: [],
+ pool: [{ mode: "SZ", stages: [1] }],
+ },
+ ],
+ });
+
+ const weightsBeforeDaySeven = await getDefaultMapWeights();
+ expect(weightsBeforeDaySeven.has("SZ-0")).toBe(true);
+ expect(weightsBeforeDaySeven.has("SZ-1")).toBe(false);
+
+ const eightDaysAgo = new Date();
+ eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
+ mockSeasonCurrent.mockReturnValue({
+ nth: 2,
+ starts: eightDaysAgo,
+ ends: new Date("2030-12-31"),
+ });
+
+ const weightsAfterDaySeven = await getDefaultMapWeights();
+ expect(weightsAfterDaySeven.has("SZ-1")).toBe(true);
+ expect(weightsAfterDaySeven.has("SZ-0")).toBe(false);
+ });
+
test("handles empty preferences array", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
diff --git a/app/features/sendouq/core/default-maps.server.ts b/app/features/sendouq/core/default-maps.server.ts
index 473989e35..26547df26 100644
--- a/app/features/sendouq/core/default-maps.server.ts
+++ b/app/features/sendouq/core/default-maps.server.ts
@@ -6,7 +6,10 @@ import { logger } from "~/utils/logger";
import { SENDOUQ_BEST_OF } from "../q-constants";
import * as SQGroupRepository from "../SQGroupRepository.server";
-let cachedDefaults: Map | null = null;
+let cachedDefaults: {
+ seasonNth: number;
+ weights: Map;
+} | null = null;
const ONE_WEEK_IN_DAYS = 7;
const DEFAULT_MAP_WEIGHT = -SENDOUQ_BEST_OF;
@@ -25,15 +28,15 @@ export async function getDefaultMapWeights(): Promise