From b98f700a21f9ffa0444cf1c74c276291efb5e9eb Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:55:37 +0300 Subject: [PATCH] Prevent double click to confirm result --- .../components/SendouQMatchActionTab.tsx | 25 +++++++++++++++---- e2e/pages/sendouq/sendouq-match-page.ts | 16 +++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 3677e7bf8..a1cac40f2 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -40,6 +40,8 @@ import styles from "./SendouQMatchActionTab.module.css"; const CONFIRM_COOLDOWN_MS = 5_000; const CONFIRM_LOSS_ARMED_MS = 5_000; +/** Guards against an accidental double-click satisfying the loss "tap again". */ +const CONFIRM_LOSS_MIN_GAP_MS = 300; const MAP_CHANGED_COOLDOWN_MS = 10_000; export function SendouQMatchActionTab({ @@ -353,8 +355,13 @@ function ScoreConfirmerSection({ const disputeScore = useActionSubmit(matchSchema); const [cooldownUntil] = useState(() => Date.now() + CONFIRM_COOLDOWN_MS); const cooldownSecondsLeft = useCooldown(cooldownUntil); - const [lossArmedUntil, setLossArmedUntil] = useState(null); - const isLossArmed = useCooldown(lossArmedUntil) > 0; + const [lossArmedAt, setLossArmedAt] = useState(null); + const isLossArmed = + useCooldown( + typeof lossArmedAt === "number" + ? lossArmedAt + CONFIRM_LOSS_ARMED_MS + : null, + ) > 0; const [hasDisputed, setHasDisputed] = useState(false); const decidingMap = [...data.match.mapList] @@ -432,9 +439,17 @@ function ScoreConfirmerSection({ isDisabled={cooldownSecondsLeft > 0} isPending={confirmScore.state !== "idle"} onClick={() => { - if (outcome === "loss" && !isLossArmed) { - setLossArmedUntil(Date.now() + CONFIRM_LOSS_ARMED_MS); - return; + if (outcome === "loss") { + if (!isLossArmed) { + setLossArmedAt(Date.now()); + return; + } + if ( + typeof lossArmedAt === "number" && + Date.now() - lossArmedAt < CONFIRM_LOSS_MIN_GAP_MS + ) { + return; + } } submitConfirmation(); }} diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 0aa6de3be..9f25d1886 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -22,6 +22,9 @@ type Tab = "action" | "result" | "rosters"; const MAPS_TO_WIN = Math.ceil(SENDOUQ_BEST_OF / 2); +/** The loss confirm ignores taps for `CONFIRM_LOSS_MIN_GAP_MS` after arming. */ +const LOSS_CONFIRM_MIN_GAP_MS = 400; + const TEAM_NAMES: Record = { ALPHA: "Group Alpha", BRAVO: "Group Bravo", @@ -161,8 +164,13 @@ export class SendouQMatchPage { hasText: "Tap again", }); + const isArmed = await armedLossButton.isVisible(); + if (isArmed) { + await waitOutLossConfirmMinGap(this.page); + } + await waitForPOSTResponse(this.page, async () => { - if (await armedLossButton.isVisible()) { + if (isArmed) { await armedLossButton.click(); } }); @@ -241,3 +249,9 @@ export class SendouQMatchPage { }); } } + +/** Waits out the gap the loss confirm keeps between arming and accepting the second tap. */ +export async function waitOutLossConfirmMinGap(page: Page) { + // biome-ignore lint/nursery/noPlaywrightWaitForTimeout: the min gap after arming has no observable end + await page.waitForTimeout(LOSS_CONFIRM_MIN_GAP_MS); +}