From ea28196169efbf924f995b290614cdba3637399f Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sat, 19 Sep 2026 13:44:26 +0300
Subject: [PATCH] Global indicator for SQ & tournaments (#3391)
---
app/components/elements/Tabs.module.css | 50 +++
app/components/elements/Tabs.tsx | 14 +-
.../layout/AnythingAdder.module.css | 15 +
app/components/layout/AnythingAdder.tsx | 11 +-
app/components/layout/GlobalSearch.module.css | 32 ++
app/components/layout/GlobalSearch.tsx | 6 +-
app/components/layout/TopNavMenus.module.css | 10 +-
app/components/layout/TopNavMenus.tsx | 3 +
.../layout/TopRightButtons.module.css | 31 +-
app/components/layout/TopRightButtons.tsx | 18 +-
app/components/layout/index.tsx | 30 +-
.../routes/user.$userId.active-match.ts | 15 +-
.../chat/ChatSystemMessage.server.test.ts | 8 +-
app/features/chat/ChatSystemMessage.server.ts | 17 +-
app/features/chat/chat-constants.ts | 13 +-
app/features/chat/chat-hooks.ts | 5 +-
app/features/chat/chat-types.ts | 7 +-
app/features/chat/chat-utils.ts | 31 --
.../revalidate-broadcast-throttle.test.ts | 4 +-
.../chat/revalidate-broadcast-throttle.ts | 14 +-
.../components-showcase/routes/components.tsx | 142 ++++++++
app/features/events/events-types.ts | 11 +-
app/features/friends/friends-utils.server.ts | 46 +--
.../global-status/GlobalStatusProvider.tsx | 110 ++++++
.../GlobalStatusIndicator.module.css | 64 ++++
.../components/GlobalStatusIndicator.tsx | 63 ++++
.../core/global-status.server.test.ts | 322 ++++++++++++++++++
.../core/global-status.server.ts | 157 +++++++++
.../global-status-expiry.browser.test.tsx | 77 +++++
.../global-status/global-status-expiry.ts | 31 ++
.../global-status-likes-seen.browser.test.tsx | 120 +++++++
.../global-status/global-status-likes-seen.ts | 50 +++
.../global-status-sounds.browser.test.tsx | 102 ++++++
.../global-status/global-status-sounds.ts | 48 +++
.../global-status/global-status-types.ts | 28 ++
.../global-status/routes/api.status.ts | 15 +
app/features/layout/LayoutDataProvider.tsx | 1 +
app/features/layout/core/layout.server.ts | 2 +
.../LeaderboardRepository.server.test.ts | 1 +
.../sendouq-match/SQMatchRepository.server.ts | 91 +++--
.../actions/q.match.$id.server.ts | 22 ++
.../sendouq/SQGroupRepository.server.ts | 16 +
.../sendouq/actions/q.looking.server.ts | 36 ++
.../sendouq/actions/q.preparing.server.ts | 8 +
app/features/sendouq/actions/q.server.ts | 8 +
.../sendouq/core/SendouQ.server.test.ts | 30 ++
app/features/sendouq/core/SendouQ.server.ts | 23 +-
app/features/sendouq/core/groups.ts | 21 +-
app/features/sendouq/core/likes.server.ts | 8 +
.../sendouq/core/ready-check.server.test.ts | 1 +
.../sendouq/core/ready-check.server.ts | 10 +
app/features/sendouq/routes/q.looking.test.ts | 1 +
app/features/sendouq/routes/q.looking.tsx | 3 +
app/features/sendouq/routes/q.ready.test.ts | 1 +
.../settings/components/SoundsTab.tsx | 1 -
.../actions/to.$id.admin.index.server.ts | 60 +++-
.../actions/to.$id.brackets.server.test.ts | 1 +
.../actions/to.$id.brackets.server.ts | 18 +
...s.module.css => BracketCheckIn.module.css} | 20 +-
.../components/BracketCheckIn.tsx | 50 +++
.../components/TournamentTeamActions.tsx | 228 -------------
.../core/Tournament.server.test.ts | 113 +++++-
.../core/Tournament.server.ts | 50 ++-
.../core/Tournament.test.ts | 43 +++
.../tournament-bracket/core/Tournament.ts | 16 +-
.../core/tests/test-utils.ts | 45 +++
.../loaders/to.$id.brackets.server.ts | 8 +-
.../routes/to.$id.brackets.module.css | 5 +-
.../routes/to.$id.brackets.tsx | 107 ++----
.../actions/to.$id.matches.$mid.server.ts | 30 +-
.../routes/to.$id.matches.$mid.test.ts | 1 +
.../tournament/TournamentRepository.server.ts | 79 +++++
.../tournament/actions/to.$id.join.server.ts | 3 +
.../actions/to.$id.register.server.ts | 22 ++
.../tournament/core/PendingCheckIns.server.ts | 59 ++++
.../loaders/to.$id.teams.$tid.server.ts | 21 ++
.../tournament/routes/to.$id.teams.$tid.tsx | 48 ++-
.../tournament/tournament-constants.ts | 2 +
.../core/UserActivity.server.test.ts | 128 +++++++
.../user-activity/core/UserActivity.server.ts | 68 ++++
app/root.tsx | 5 +-
app/routes.ts | 1 +
.../closeExpiredContinueVotes.test.ts | 1 +
app/routines/notifyCheckInStart.ts | 12 +-
app/routines/resolveStaleSQMatches.test.ts | 1 +
app/routines/resolveStaleSQMatches.ts | 7 +
app/utils/urls.ts | 1 +
.../2026-09-01-header-status-indicator.md | 8 +
.../2026-09-19-brackets-page-team-actions.md | 9 +
e2e/pages/layout/anything-adder.ts | 2 +-
e2e/pages/layout/top-right-buttons.ts | 7 +
.../tournament/tournament-brackets-page.ts | 8 -
e2e/pages/tournament/tournament-team-page.ts | 7 +
e2e/pages/trophies/trophies-page.ts | 2 +-
e2e/tournament-bracket-multi-stage.spec.ts | 6 +-
e2e/tournament-bracket.spec.ts | 7 +-
locales/da/common.json | 9 +
locales/da/settings.json | 1 -
locales/de/common.json | 9 +
locales/de/settings.json | 1 -
locales/en/common.json | 9 +
locales/en/settings.json | 1 -
locales/es-ES/common.json | 9 +
locales/es-ES/settings.json | 1 -
locales/es-US/common.json | 9 +
locales/es-US/settings.json | 1 -
locales/fr-CA/common.json | 9 +
locales/fr-CA/settings.json | 1 -
locales/fr-EU/common.json | 9 +
locales/fr-EU/settings.json | 1 -
locales/he/common.json | 9 +
locales/he/settings.json | 1 -
locales/it/common.json | 9 +
locales/it/settings.json | 1 -
locales/ja/common.json | 9 +
locales/ja/settings.json | 1 -
locales/ko/common.json | 9 +
locales/ko/settings.json | 1 -
locales/nl/common.json | 9 +
locales/nl/settings.json | 1 -
locales/pl/common.json | 9 +
locales/pl/settings.json | 1 -
locales/pt-BR/common.json | 9 +
locales/pt-BR/settings.json | 1 -
locales/ru/common.json | 9 +
locales/ru/settings.json | 1 -
locales/zh/common.json | 9 +
locales/zh/settings.json | 1 -
scripts/benchmark-db/cases.ts | 15 +
129 files changed, 2926 insertions(+), 562 deletions(-)
create mode 100644 app/components/layout/AnythingAdder.module.css
create mode 100644 app/features/global-status/GlobalStatusProvider.tsx
create mode 100644 app/features/global-status/components/GlobalStatusIndicator.module.css
create mode 100644 app/features/global-status/components/GlobalStatusIndicator.tsx
create mode 100644 app/features/global-status/core/global-status.server.test.ts
create mode 100644 app/features/global-status/core/global-status.server.ts
create mode 100644 app/features/global-status/global-status-expiry.browser.test.tsx
create mode 100644 app/features/global-status/global-status-expiry.ts
create mode 100644 app/features/global-status/global-status-likes-seen.browser.test.tsx
create mode 100644 app/features/global-status/global-status-likes-seen.ts
create mode 100644 app/features/global-status/global-status-sounds.browser.test.tsx
create mode 100644 app/features/global-status/global-status-sounds.ts
create mode 100644 app/features/global-status/global-status-types.ts
create mode 100644 app/features/global-status/routes/api.status.ts
rename app/features/tournament-bracket/components/{TournamentTeamActions.module.css => BracketCheckIn.module.css} (66%)
create mode 100644 app/features/tournament-bracket/components/BracketCheckIn.tsx
delete mode 100644 app/features/tournament-bracket/components/TournamentTeamActions.tsx
create mode 100644 app/features/tournament/core/PendingCheckIns.server.ts
create mode 100644 app/features/user-activity/core/UserActivity.server.test.ts
create mode 100644 app/features/user-activity/core/UserActivity.server.ts
create mode 100644 changelog/2026-09-01-header-status-indicator.md
create mode 100644 changelog/2026-09-19-brackets-page-team-actions.md
diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css
index 4b0ccf063..69b04c441 100644
--- a/app/components/elements/Tabs.module.css
+++ b/app/components/elements/Tabs.module.css
@@ -3,6 +3,47 @@
overflow-y: hidden;
}
+.tabListRow {
+ --tab-list-border-width: 2px;
+ container-type: inline-size;
+ display: flex;
+ align-items: flex-end;
+
+ & .tabListContainer {
+ flex: 1;
+ min-width: 0;
+ /* tabs scrolled under the actions fade out, the underline stays solid */
+ mask-image:
+ linear-gradient(to right, #000 calc(100% - var(--s-6)), transparent),
+ linear-gradient(#000, #000);
+ mask-size:
+ 100% calc(100% - var(--tab-list-border-width)),
+ 100% var(--tab-list-border-width);
+ mask-position: top, bottom;
+ mask-repeat: no-repeat;
+ }
+}
+
+.tabListActions {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding-inline-start: var(--s-2);
+ padding-bottom: var(--s-1);
+ border-bottom: var(--tab-list-border-width) solid var(--color-border);
+}
+
+@container (width < 640px) {
+ .tabListActions::before {
+ content: "";
+ width: 2px;
+ height: var(--s-4);
+ border-radius: 1px;
+ background-color: var(--color-border);
+ }
+}
+
.tabList {
display: flex;
flex-direction: row;
@@ -88,6 +129,15 @@
& .tabList:has(> :only-child) {
display: none;
}
+
+ & .tabListRow:has(.tabList > :only-child) .tabListActions {
+ border: none;
+ padding-bottom: var(--s-2);
+
+ &::before {
+ display: none;
+ }
+ }
}
.tabNumber {
diff --git a/app/components/elements/Tabs.tsx b/app/components/elements/Tabs.tsx
index b4cc53c2a..2ad70ee8c 100644
--- a/app/components/elements/Tabs.tsx
+++ b/app/components/elements/Tabs.tsx
@@ -272,6 +272,8 @@ interface SendouTabListProps {
sticky?: boolean;
/** tabs share 100% width equally */
fullWidth?: boolean;
+ /** Controls shown at the end of the tab row, outside the tablist itself (horizontal orientation only). */
+ actions?: React.ReactNode;
"aria-label"?: string;
children: React.ReactNode;
}
@@ -279,12 +281,13 @@ interface SendouTabListProps {
export function SendouTabList({
sticky,
fullWidth,
+ actions,
"aria-label": ariaLabel,
children,
}: SendouTabListProps) {
const tabs = useTabsContext();
- return (
+ const tabList = (
);
+
+ if (!actions || tabs.orientation === "vertical") return tabList;
+
+ return (
+
+ {tabList}
+
{actions}
+
+ );
}
interface SendouTabPanelProps {
diff --git a/app/components/layout/AnythingAdder.module.css b/app/components/layout/AnythingAdder.module.css
new file mode 100644
index 000000000..4d4d2966c
--- /dev/null
+++ b/app/components/layout/AnythingAdder.module.css
@@ -0,0 +1,15 @@
+/* the status indicator takes the room the label gives up */
+@container (max-width: 760px) {
+ .button.withStatus {
+ aspect-ratio: 1 / 1;
+ padding: 0;
+
+ & > svg {
+ margin-inline-end: 0;
+ }
+
+ & .label {
+ display: none;
+ }
+ }
+}
diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx
index 8583f006d..6c2b19d08 100644
--- a/app/components/layout/AnythingAdder.tsx
+++ b/app/components/layout/AnythingAdder.tsx
@@ -1,7 +1,9 @@
+import clsx from "clsx";
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { newArtPage } from "~/features/art/art-urls";
import { useUser } from "~/features/auth/core/user";
+import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider";
import { lfgNewPostPage } from "~/features/lfg/lfg-urls";
import { plusSuggestionsNewPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
@@ -23,10 +25,12 @@ import {
SendouMenuItem,
type SendouMenuItemProps,
} from "../elements/Menu";
+import styles from "./AnythingAdder.module.css";
export function AnythingAdder() {
const { t } = useTranslation(["common"]);
const user = useUser();
+ const { status: globalStatus } = useGlobalStatus();
if (!user) {
return null;
@@ -117,9 +121,14 @@ export function AnythingAdder() {
}
+ className={clsx(
+ styles.button,
+ globalStatus ? styles.withStatus : null,
+ )}
+ aria-label={t("common:actions.addNew")}
data-testid="anything-adder-menu-button"
>
- {`${t("common:actions.addNew")}…`}
+
{t("common:actions.addNew")}
}
>
diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css
index 2badedcb2..09ef28db2 100644
--- a/app/components/layout/GlobalSearch.module.css
+++ b/app/components/layout/GlobalSearch.module.css
@@ -23,6 +23,38 @@
}
}
+.searchLabel {
+ display: flex;
+ flex-grow: 1;
+ align-items: center;
+ gap: var(--s-2);
+ white-space: nowrap;
+}
+
+@container (max-width: 920px) {
+ [data-with-status] .searchLabel {
+ display: none;
+ }
+
+ [data-with-status] .searchButton {
+ aspect-ratio: 1 / 1;
+ justify-content: center;
+ padding: 0;
+ }
+}
+
+@container (width < 620px) {
+ .searchLabel {
+ display: none;
+ }
+
+ .searchButton {
+ aspect-ratio: 1 / 1;
+ justify-content: center;
+ padding: 0;
+ }
+}
+
.searchIcon {
width: 16px;
height: 16px;
diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx
index d6330f9a4..b59f33c90 100644
--- a/app/components/layout/GlobalSearch.tsx
+++ b/app/components/layout/GlobalSearch.tsx
@@ -124,8 +124,10 @@ export function GlobalSearch() {
className={styles.searchButton}
>
-
{t("common:search")}
-
{isMac ? "Cmd+K" : "Ctrl+K"}
+
+ {t("common:search")}
+ {isMac ? "Cmd+K" : "Ctrl+K"}
+
{isOpen ? (
setIsPreviewSuppressed(false)}
>
Dev
+
}
popoverClassName={styles.menuPopover}
@@ -222,6 +224,7 @@ function CategoryMenu({
onPointerEnter={() => setIsPreviewSuppressed(false)}
>
{t(`front:nav.${category.name}`)}
+
}
popoverClassName={styles.menuPopover}
diff --git a/app/components/layout/TopRightButtons.module.css b/app/components/layout/TopRightButtons.module.css
index c9b831a57..44e075861 100644
--- a/app/components/layout/TopRightButtons.module.css
+++ b/app/components/layout/TopRightButtons.module.css
@@ -2,6 +2,7 @@
display: flex;
gap: var(--s-4);
margin-left: auto;
+ min-width: 0;
}
.supportWrapper {
@@ -15,7 +16,7 @@
}
@media screen and (min-width: 600px) {
- @container (max-width: 600px) {
+ @container (max-width: 700px) {
.supportWrapper {
display: none;
}
@@ -24,12 +25,36 @@
display: block;
}
}
+
+ @container (max-width: 920px) {
+ .withStatus .supportWrapper {
+ display: none;
+ }
+
+ .withStatus .supportWrapperCompact {
+ display: block;
+ }
+ }
}
.searchAndAddContainer {
- display: grid;
- grid-template-columns: 1fr max-content;
+ display: flex;
gap: var(--s-2);
+ min-width: 0;
+}
+
+.searchWrapper {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+/* the status indicator takes the room the search button gives up */
+.withStatus {
+ @container (max-width: 920px) {
+ & .searchWrapper {
+ flex: none;
+ }
+ }
}
.searchWrapper > button {
diff --git a/app/components/layout/TopRightButtons.tsx b/app/components/layout/TopRightButtons.tsx
index 973208b95..59e91f82a 100644
--- a/app/components/layout/TopRightButtons.tsx
+++ b/app/components/layout/TopRightButtons.tsx
@@ -1,5 +1,8 @@
+import clsx from "clsx";
import { Heart, LogIn, MessageSquare } from "lucide-react";
import { useTranslation } from "react-i18next";
+import { GlobalStatusIndicator } from "~/features/global-status/components/GlobalStatusIndicator";
+import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider";
import { SUPPORT_PAGE } from "~/utils/urls";
import { LinkButton, SendouButton } from "../elements/Button";
import { AnythingAdder } from "./AnythingAdder";
@@ -21,9 +24,16 @@ export function TopRightButtons({
chatUnreadCount?: number;
}) {
const { t } = useTranslation(["common", "front"]);
+ const { status: globalStatus } = useGlobalStatus();
+ const hasGlobalStatus = globalStatus !== null;
return (
-
+
{showSupport ? (
<>
@@ -48,7 +58,11 @@ export function TopRightButtons({
>
) : null}
-
+
+
{isLoggedIn ? : }
{isLoggedIn ?
: null}
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx
index cb9333287..387ed49b8 100644
--- a/app/components/layout/index.tsx
+++ b/app/components/layout/index.tsx
@@ -18,6 +18,7 @@ import { useUser } from "~/features/auth/core/user";
import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge";
import { useChatContext } from "~/features/chat/ChatProvider";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
+import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider";
import { useLayoutData } from "~/features/layout/LayoutDataProvider";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation";
@@ -167,6 +168,13 @@ function useNavOffset(headerRef: React.RefObject
) {
const scrollAccumulator = React.useRef(0);
+ // stable so the effect revealing the nav on a status change can depend on it
+ const revealNav = React.useCallback(() => {
+ setNavOffset(0);
+ scrollAccumulator.current = 0;
+ lastScrollY.current = window.scrollY;
+ }, []);
+
React.useEffect(() => {
if (!isMobileLayout) return;
@@ -216,7 +224,24 @@ function useNavOffset(headerRef: React.RefObject) {
};
}, [headerRef, isMobileLayout]);
- return navOffset;
+ return { navOffset, revealNav };
+}
+
+/**
+ * Pops the scrolled-away mobile header back out when the global status
+ * changes, so the change is seen the moment it happens.
+ */
+function useRevealNavOnStatusChange(revealNav: () => void) {
+ const { status } = useGlobalStatus();
+ const statusKey = status ? `${status.state}:${status.count ?? ""}` : null;
+ const prevKeyRef = React.useRef(statusKey);
+
+ React.useEffect(() => {
+ if (prevKeyRef.current === statusKey) return;
+
+ prevKeyRef.current = statusKey;
+ revealNav();
+ }, [statusKey, revealNav]);
}
export function Layout({
@@ -253,7 +278,8 @@ export function Layout({
const location = useLocation();
const [authError] = useSearchParam(authErrorSearchParams, "authError");
const headerRef = React.useRef(null);
- const navOffset = useNavOffset(headerRef);
+ const { navOffset, revealNav } = useNavOffset(headerRef);
+ useRevealNavOnStatusChange(revealNav);
const user = useUser();
const { showUnseenDot } = useNotifications();
diff --git a/app/features/api-public/routes/user.$userId.active-match.ts b/app/features/api-public/routes/user.$userId.active-match.ts
index ac8635d4e..7c3630168 100644
--- a/app/features/api-public/routes/user.$userId.active-match.ts
+++ b/app/features/api-public/routes/user.$userId.active-match.ts
@@ -1,7 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import * as v from "valibot";
-import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
-import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
+import * as UserActivity from "~/features/user-activity/core/UserActivity.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/schema";
import type { GetUsersActiveMatchResponse } from "../schema";
@@ -16,10 +15,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
schema: paramsSchema,
});
- const sendouqGroup = SendouQ.findOwnGroup(userId);
- if (sendouqGroup?.matchId) {
+ const activity = UserActivity.resolve(userId);
+
+ if (activity.sendouq?.group.matchId) {
const result: GetUsersActiveMatchResponse = {
- matchId: sendouqGroup.matchId,
+ matchId: activity.sendouq.group.matchId,
lobby: "sendouq",
tournamentId: null,
bracketIdx: null,
@@ -27,9 +27,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return Response.json(result);
}
- for (const tournament of RunningTournaments.all) {
- const status = tournament.teamMemberOfProgressStatus({ id: userId });
- if (status?.type === "MATCH") {
+ for (const { tournament, status } of activity.tournaments) {
+ if (status.type === "MATCH") {
const result: GetUsersActiveMatchResponse = {
matchId: status.matchId,
lobby: "tournament",
diff --git a/app/features/chat/ChatSystemMessage.server.test.ts b/app/features/chat/ChatSystemMessage.server.test.ts
index 19484f520..85e8d98ed 100644
--- a/app/features/chat/ChatSystemMessage.server.test.ts
+++ b/app/features/chat/ChatSystemMessage.server.test.ts
@@ -36,7 +36,7 @@ describe("ChatSystemMessage.send", () => {
]);
});
- test("keeps a sound-carrying type on the broadcast, bypassing the throttle", async () => {
+ test("delivers every unthrottled type's broadcast, bypassing the throttle", async () => {
const received = subscribeTo("sq-group__102");
ChatSystemMessage.send({
@@ -52,12 +52,12 @@ describe("ChatSystemMessage.send", () => {
await flushEvents();
expect(received).toEqual([
- { kind: "revalidate", authorUserId: 5, type: "READY_CHECK_STARTED" },
- { kind: "revalidate", authorUserId: 5, type: "READY_CHECK_STARTED" },
+ { kind: "revalidate", authorUserId: 5 },
+ { kind: "revalidate", authorUserId: 5 },
]);
});
- test("throttles rapid soundless broadcasts to the same topic", async () => {
+ test("throttles rapid untyped broadcasts to the same topic", async () => {
const received = subscribeTo("tournament__104");
ChatSystemMessage.send({
diff --git a/app/features/chat/ChatSystemMessage.server.ts b/app/features/chat/ChatSystemMessage.server.ts
index ac5edb40c..f9309c421 100644
--- a/app/features/chat/ChatSystemMessage.server.ts
+++ b/app/features/chat/ChatSystemMessage.server.ts
@@ -9,10 +9,8 @@ import * as ChatRoomResolver from "./ChatRoomResolver.server";
import type {
PersistedSystemMessageType,
RevalidateScope,
- SoundOnlySystemMessageType,
- SystemMessageType,
+ UnthrottledSystemMessageType,
} from "./chat-types";
-import { soundOnlyType } from "./chat-utils";
import { createRevalidateBroadcastThrottle } from "./revalidate-broadcast-throttle";
type RevalidateBroadcast = {
@@ -21,7 +19,7 @@ type RevalidateBroadcast = {
/** Actor whose own broadcast clients skip (their submission already reran the loaders). */
authorUserId?: number;
revalidateScope?: RevalidateScope;
- type?: SoundOnlySystemMessageType;
+ type?: UnthrottledSystemMessageType;
};
const REVALIDATE_BROADCAST_THROTTLE_WINDOW_MS = 2_000;
@@ -87,13 +85,11 @@ function publishRevalidate(msg: {
channel: string;
revalidateScope?: RevalidateScope;
authorUserId?: number;
- type?: SystemMessageType;
}) {
EventBus.publish([msg.channel], {
kind: "revalidate",
scope: msg.revalidateScope,
authorUserId: msg.authorUserId ?? actorIdOrNullSafe() ?? undefined,
- type: soundOnlyType(msg.type),
});
}
@@ -106,6 +102,15 @@ export function notifyNotificationsChanged(userIds: number[]) {
});
}
+/** Publishes a contentless "your header status changed" event to the users' streams, prompting their clients to refetch the global status. Fire and forget; a missed event only delays the refetch until the next catch-up. */
+export function notifyStatusChanged(userIds: number[]) {
+ if (userIds.length === 0) return;
+
+ EventBus.publish(R.unique(userIds).map(userChannel), {
+ kind: "statusChanged",
+ });
+}
+
/** Publishes a "your chat room set changed" event after a membership change; clients refetch their room list and drop rooms (and held history) they lost access to. */
export function notifyRoomsChanged(userIds: number[]) {
if (userIds.length === 0) return;
diff --git a/app/features/chat/chat-constants.ts b/app/features/chat/chat-constants.ts
index fc4004218..6c705dd16 100644
--- a/app/features/chat/chat-constants.ts
+++ b/app/features/chat/chat-constants.ts
@@ -1,9 +1,8 @@
export const MESSAGE_MAX_LENGTH = 200;
-/** System message types that are broadcast for their sound alone rather than persisted, mapped to the sound they play. */
-export const SOUND_BY_SYSTEM_MESSAGE_TYPE = {
- NEW_GROUP: "sq_new-group",
- MATCH_STARTED: "sq_match",
- READY_CHECK_STARTED: "sq_ready-check",
- LIKE_RECEIVED: "sq_like",
-} as const;
+/** System message types that are broadcast for the moment they mark rather than persisted, and so must land unthrottled. */
+export const UNTHROTTLED_SYSTEM_MESSAGE_TYPES = [
+ "MATCH_STARTED",
+ "READY_CHECK_STARTED",
+ "LIKE_RECEIVED",
+] as const;
diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts
index c0b1dd479..1c00cfc7d 100644
--- a/app/features/chat/chat-hooks.ts
+++ b/app/features/chat/chat-hooks.ts
@@ -7,7 +7,6 @@ import {
} from "~/features/events/events-hooks";
import { useUser } from "../auth/core/user";
import type { ClientChatMessage } from "./chat-types";
-import { playMessageSound } from "./chat-utils";
import {
revalidateWithScope,
scheduleBroadcastRevalidation,
@@ -173,15 +172,13 @@ export function useLiveRevalidation(enabled = true) {
});
}
-/** Handles SSE `revalidate` events: plays the carried sound and schedules a loader revalidation, skipping the actor's own broadcasts (their submission already reran the loaders). */
+/** Handles SSE `revalidate` events: schedules a loader revalidation, skipping the actor's own broadcasts (their submission already reran the loaders). */
export function useServerRevalidationEvents(userId: number) {
const { revalidate } = useRevalidator();
useServerEventListener((event) => {
if (event.kind !== "revalidate") return;
- playMessageSound(event.type);
-
if (event.authorUserId === userId) return;
// jittered so a broadcast fanning out to a whole room does not make
diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts
index df2a16194..42a7eed8d 100644
--- a/app/features/chat/chat-types.ts
+++ b/app/features/chat/chat-types.ts
@@ -1,6 +1,6 @@
import type { Tables } from "~/db/tables";
import type { CommonUser } from "~/utils/kysely.server";
-import type { SOUND_BY_SYSTEM_MESSAGE_TYPE } from "./chat-constants";
+import type { UNTHROTTLED_SYSTEM_MESSAGE_TYPES } from "./chat-constants";
export type ChatRoomType =
| "SQ_GROUP"
@@ -10,7 +10,6 @@ export type ChatRoomType =
| "SCRIM";
export type SystemMessageType =
- | "NEW_GROUP"
| "USER_LEFT"
| "MATCH_STARTED"
| "READY_CHECK_STARTED"
@@ -41,9 +40,9 @@ export type PersistedSystemMessageType = Extract<
| "MODE_BANNED"
>;
-export type SoundOnlySystemMessageType = Extract<
+export type UnthrottledSystemMessageType = Extract<
SystemMessageType,
- keyof typeof SOUND_BY_SYSTEM_MESSAGE_TYPE
+ (typeof UNTHROTTLED_SYSTEM_MESSAGE_TYPES)[number]
>;
export interface ChatMessageAuthor extends CommonUser {
diff --git a/app/features/chat/chat-utils.ts b/app/features/chat/chat-utils.ts
index af2a3b0d6..ad718d7a7 100644
--- a/app/features/chat/chat-utils.ts
+++ b/app/features/chat/chat-utils.ts
@@ -1,23 +1,5 @@
import { logger } from "~/utils/logger";
import { soundPath } from "~/utils/urls";
-import { SOUND_BY_SYSTEM_MESSAGE_TYPE } from "./chat-constants";
-import type {
- SoundOnlySystemMessageType,
- SystemMessageType,
-} from "./chat-types";
-
-export function messageTypeToSound(type: SystemMessageType | undefined) {
- const soundOnly = soundOnlyType(type);
-
- return soundOnly ? SOUND_BY_SYSTEM_MESSAGE_TYPE[soundOnly] : null;
-}
-
-/** The type if its broadcast plays a sound, otherwise undefined. */
-export function soundOnlyType(
- type: SystemMessageType | undefined,
-): SoundOnlySystemMessageType | undefined {
- return type && playsSound(type) ? type : undefined;
-}
export function soundCodeToLocalStorageKey(soundCode: string) {
return `settings__sound-enabled__${soundCode}`;
@@ -30,13 +12,6 @@ export function soundEnabled(soundCode: string) {
return !stored || stored === "true";
}
-export function playMessageSound(type: SystemMessageType | undefined) {
- const sound = messageTypeToSound(type);
- if (!sound) return;
-
- playSound(sound);
-}
-
export function playSound(soundCode: string) {
if (!soundEnabled(soundCode)) return;
@@ -55,9 +30,3 @@ export function soundVolume() {
return volume ? Number.parseFloat(volume) : 100;
}
-
-function playsSound(
- type: SystemMessageType,
-): type is SoundOnlySystemMessageType {
- return type in SOUND_BY_SYSTEM_MESSAGE_TYPE;
-}
diff --git a/app/features/chat/revalidate-broadcast-throttle.test.ts b/app/features/chat/revalidate-broadcast-throttle.test.ts
index cd8d1d675..287a009cd 100644
--- a/app/features/chat/revalidate-broadcast-throttle.test.ts
+++ b/app/features/chat/revalidate-broadcast-throttle.test.ts
@@ -26,14 +26,14 @@ describe("createRevalidateBroadcastThrottle", () => {
vi.useRealTimers();
});
- test("does not throttle broadcasts whose sound must not be dropped", () => {
+ test("does not throttle broadcasts marking a moment", () => {
const { throttle } = setup();
expect(throttle.throttles({ type: "MATCH_STARTED" })).toBe(false);
expect(throttle.throttles({ type: "READY_CHECK_STARTED" })).toBe(false);
});
- test("throttles broadcasts that play no sound", () => {
+ test("throttles broadcasts of every other type", () => {
const { throttle } = setup();
expect(throttle.throttles({})).toBe(true);
diff --git a/app/features/chat/revalidate-broadcast-throttle.ts b/app/features/chat/revalidate-broadcast-throttle.ts
index 5334cfcdc..1054ec236 100644
--- a/app/features/chat/revalidate-broadcast-throttle.ts
+++ b/app/features/chat/revalidate-broadcast-throttle.ts
@@ -1,5 +1,9 @@
+import { UNTHROTTLED_SYSTEM_MESSAGE_TYPES } from "./chat-constants";
import type { RevalidateScope, SystemMessageType } from "./chat-types";
-import { messageTypeToSound } from "./chat-utils";
+
+const UNTHROTTLED = new Set(
+ UNTHROTTLED_SYSTEM_MESSAGE_TYPES,
+);
interface ThrottleableMessage {
channel: string;
@@ -24,8 +28,8 @@ export const MAX_ENTRIES = 5_000;
* the first is delivered immediately, the rest coalesce into one trailing broadcast at the
* window's end whose scope is the broadest seen and which carries no author or type.
*
- * Types that play a sound (starting match, ready check) are left alone: coalescing would cost
- * the sound, and they are rare. Soundless types are the bulk the throttle exists for.
+ * The types marking a moment (starting match, ready check) are left alone: coalescing would
+ * delay it, and they are rare. The rest are the bulk the throttle exists for.
*/
export function createRevalidateBroadcastThrottle({
windowMs,
@@ -53,9 +57,9 @@ export function createRevalidateBroadcastThrottle({
};
return {
- /** Whether the message is throttled; ones carrying a sound are not. */
+ /** Whether the message is throttled; the ones marking a moment are not. */
throttles(msg: Pick): boolean {
- return !messageTypeToSound(msg.type);
+ return !msg.type || !UNTHROTTLED.has(msg.type);
},
handle(msg: ThrottleableMessage): void {
const now = Date.now();
diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx
index 0da924bbb..3501d4bdb 100644
--- a/app/features/components-showcase/routes/components.tsx
+++ b/app/features/components-showcase/routes/components.tsx
@@ -59,6 +59,8 @@ import {
ChangelogGraphic,
type ChangelogGraphicEntry,
} from "~/features/changelog/components/ChangelogGraphic";
+import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider";
+import type { GlobalStatus } from "~/features/global-status/global-status-types";
import {
SeasonSummaryGraphic,
type SeasonSummaryGraphicActivity,
@@ -83,6 +85,12 @@ import type { CustomFieldRenderProps } from "~/form/FormField";
import { SendouForm } from "~/form/SendouForm";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import type { SendouRouteHandle } from "~/utils/remix.server";
+import {
+ SENDOUQ_LOOKING_PAGE,
+ SENDOUQ_PREPARING_PAGE,
+ sendouQMatchPage,
+ tournamentPage,
+} from "~/utils/urls";
import styles from "../components-showcase.module.css";
import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
import { formFieldsShowcaseSchema } from "../form-examples-schema";
@@ -157,6 +165,11 @@ export const SECTIONS = [
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
{ title: "Schedule", id: "schedule", component: ScheduleSection },
+ {
+ title: "Global Status Indicator",
+ id: "global-status-indicator",
+ component: GlobalStatusIndicatorSection,
+ },
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
] as const;
@@ -3057,6 +3070,135 @@ function ScheduleSection({ id }: { id: string }) {
);
}
+// Paddling Pool 51's logo from the dev seed; only resolves in dev
+const SEED_TOURNAMENT_LOGO_URL =
+ "http://127.0.0.1:9000/sendou/paddling-pool.png";
+
+const GLOBAL_STATUS_EXAMPLES: Array<{
+ id: string;
+ name: string;
+ status: GlobalStatus;
+}> = [
+ {
+ id: "sq-preparing",
+ name: "SendouQ: Preparing",
+ status: { state: "SQ_PREPARING", url: SENDOUQ_PREPARING_PAGE },
+ },
+ {
+ id: "sq-queued-likes-seen",
+ name: "SendouQ: In queue (likes seen)",
+ status: {
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ count: 3,
+ groupSize: { members: 2, max: 4 },
+ },
+ },
+ {
+ id: "sq-queued-new-likes",
+ name: "SendouQ: In queue (new likes)",
+ status: {
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ count: 5,
+ countNeedsAction: true,
+ groupSize: { members: 2, max: 4 },
+ },
+ },
+ {
+ id: "sq-expired",
+ name: "SendouQ: Group inactive",
+ status: { state: "SQ_EXPIRED", url: SENDOUQ_LOOKING_PAGE },
+ },
+ {
+ id: "sq-ready-check",
+ name: "SendouQ: Ready check",
+ status: { state: "SQ_READY_CHECK", url: SENDOUQ_LOOKING_PAGE },
+ },
+ {
+ id: "sq-match",
+ name: "SendouQ: In match",
+ status: { state: "SQ_MATCH", url: sendouQMatchPage(123) },
+ },
+ {
+ id: "to-checkin",
+ name: "Tournament: Check in",
+ status: {
+ state: "TO_CHECKIN",
+ url: tournamentPage(1),
+ logoUrl: SEED_TOURNAMENT_LOGO_URL,
+ },
+ },
+ {
+ id: "to-match",
+ name: "Tournament: In match",
+ status: {
+ state: "TO_MATCH",
+ url: tournamentPage(1),
+ logoUrl: SEED_TOURNAMENT_LOGO_URL,
+ },
+ },
+ {
+ id: "to-waiting-for-match",
+ name: "Tournament: Waiting for match",
+ status: {
+ state: "TO_WAITING_FOR_MATCH",
+ url: tournamentPage(1),
+ logoUrl: SEED_TOURNAMENT_LOGO_URL,
+ },
+ },
+ {
+ id: "to-waiting-for-cast",
+ name: "Tournament: Waiting for cast",
+ status: {
+ state: "TO_WAITING_FOR_CAST",
+ url: tournamentPage(1),
+ logoUrl: SEED_TOURNAMENT_LOGO_URL,
+ },
+ },
+];
+
+const GLOBAL_STATUS_SELECT_ITEMS = [
+ { id: "none", name: "None" },
+ ...GLOBAL_STATUS_EXAMPLES.map(({ id, name }) => ({ id, name })),
+];
+
+function GlobalStatusIndicatorSection({ id }: { id: string }) {
+ const { status, setStatus } = useGlobalStatus();
+
+ const selectedId =
+ GLOBAL_STATUS_EXAMPLES.find((example) => example.status === status)?.id ??
+ "none";
+
+ return (
+
+ Global Status Indicator
+
+
+
+
+ setStatus(
+ GLOBAL_STATUS_EXAMPLES.find((example) => example.id === key)
+ ?.status ?? null,
+ )
+ }
+ >
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+
+
+
+ );
+}
+
function MiscSection({ id }: { id: string }) {
const [rangeValue, setRangeValue] = useState(50);
const [colorValue, setColorValue] = useState("#3b82f6");
diff --git a/app/features/events/events-types.ts b/app/features/events/events-types.ts
index 59c6b0058..61bc050d3 100644
--- a/app/features/events/events-types.ts
+++ b/app/features/events/events-types.ts
@@ -1,7 +1,6 @@
import type {
ChatMessageWithAuthor,
RevalidateScope,
- SoundOnlySystemMessageType,
} from "~/features/chat/chat-types";
/** Prefix of each entity scoped channel, joined to the entity's id by the channel's builder. */
@@ -25,11 +24,7 @@ export function chatRoomChannel(roomId: number): string {
export type ServerEvent =
| { kind: "chatMessage"; roomId: number; message: ChatMessageWithAuthor }
- | {
- kind: "revalidate";
- scope?: RevalidateScope;
- authorUserId?: number;
- type?: SoundOnlySystemMessageType;
- }
+ | { kind: "revalidate"; scope?: RevalidateScope; authorUserId?: number }
| { kind: "notificationsChanged" }
- | { kind: "roomsChanged" };
+ | { kind: "roomsChanged" }
+ | { kind: "statusChanged" };
diff --git a/app/features/friends/friends-utils.server.ts b/app/features/friends/friends-utils.server.ts
index 981833608..155d3407a 100644
--- a/app/features/friends/friends-utils.server.ts
+++ b/app/features/friends/friends-utils.server.ts
@@ -1,12 +1,7 @@
-import { groupExpiryStatus } from "~/features/sendouq/core/groups";
-import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
-import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
-import type {
- Tournament,
- TournamentTeamMemberProgressStatus,
-} from "~/features/tournament-bracket/core/Tournament";
+import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
+import * as UserActivity from "~/features/user-activity/core/UserActivity.server";
import { twitchUrl } from "~/utils/urls";
import {
type FriendActivityType,
@@ -23,21 +18,6 @@ export interface FriendActivity {
streamUrl: string | null;
}
-const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record<
- TournamentTeamMemberProgressStatus["type"],
- boolean
-> = {
- MATCH: true,
- WAITING_FOR_MATCH: true,
- WAITING_FOR_CAST: true,
- WAITING_FOR_ROUND: true,
- WAITING_FOR_GROUPS: true,
- // to counter 2 day tournaments showing as in progress in between
- WAITING_FOR_BRACKET: false,
- CHECKIN: false,
- THANKS_FOR_PLAYING: false,
-};
-
/** Twitch account streaming each ongoing SendouQ match, keyed by match id. Resolved once per request, activity being resolved per friend. */
export async function resolveSendouQMatchStreams() {
const streams = await cachedStreams();
@@ -68,7 +48,8 @@ export function resolveFriendActivity({
tournamentMinTeamSize: number | null;
sendouQMatchStreams: ReadonlyMap;
}): FriendActivity {
- const ownGroup = SendouQ.findOwnGroup(friendId);
+ const activity = UserActivity.resolve(friendId);
+ const ownGroup = activity.sendouq?.group;
if (ownGroup?.matchId) {
const twitchAccount = sendouQMatchStreams.get(ownGroup.matchId);
@@ -83,13 +64,13 @@ export function resolveFriendActivity({
};
}
- const tournamentActivity = resolveTournamentActivity(friendId);
+ const tournamentActivity = resolveTournamentActivity(activity, friendId);
if (tournamentActivity) return tournamentActivity;
if (
ownGroup &&
ownGroup.members.length < FULL_GROUP_SIZE &&
- groupExpiryStatus(ownGroup.latestActionAt) !== "EXPIRED"
+ !activity.sendouq?.expired
) {
return {
type: "SENDOUQ",
@@ -122,10 +103,17 @@ export function resolveFriendActivity({
};
}
-function resolveTournamentActivity(friendId: number): FriendActivity | null {
- for (const tournament of RunningTournaments.all) {
- const status = tournament.teamMemberOfProgressStatus({ id: friendId });
- if (!status || !TOURNAMENT_STATUS_IS_IN_PROGRESS[status.type]) continue;
+function resolveTournamentActivity(
+ activity: UserActivity.UserActivity,
+ friendId: number,
+): FriendActivity | null {
+ for (const { tournament, status } of activity.tournaments) {
+ if (
+ status.type === "CHECKIN" ||
+ !UserActivity.TOURNAMENT_STATUS_IS_IN_PROGRESS[status.type]
+ ) {
+ continue;
+ }
const isInMatch = status.type === "MATCH";
diff --git a/app/features/global-status/GlobalStatusProvider.tsx b/app/features/global-status/GlobalStatusProvider.tsx
new file mode 100644
index 000000000..4cf1b3bed
--- /dev/null
+++ b/app/features/global-status/GlobalStatusProvider.tsx
@@ -0,0 +1,110 @@
+import * as React from "react";
+import {
+ useEventStreamCatchUp,
+ useServerEventListener,
+} from "~/features/events/events-hooks";
+import { useLayoutData } from "~/features/layout/LayoutDataProvider";
+import { useBackgroundResource } from "~/hooks/useBackgroundResource";
+import type { SerializeFrom } from "~/utils/remix";
+import { STATUS_DATA_ROUTE } from "~/utils/urls";
+import { useHasSqGroupExpired } from "./global-status-expiry";
+import { useHasUnseenSqLikes } from "./global-status-likes-seen";
+import { useGlobalStatusSounds } from "./global-status-sounds";
+import type { GlobalStatus } from "./global-status-types";
+import type { loader } from "./routes/api.status";
+
+interface GlobalStatusContextValue {
+ status: GlobalStatus | null;
+ setStatus: (status: GlobalStatus | null) => void;
+}
+
+const GlobalStatusContext = React.createContext({
+ status: null,
+ setStatus: () => {},
+});
+
+/**
+ * Serves the user's current SendouQ/tournament status shown in the app header
+ * and keeps it fresh push-first: the layout data seeds the first paint, then a
+ * refetch of the status's own resource route whenever the server publishes
+ * over the shared SSE connection that the user's status changed. The refetch
+ * happens without jitter: a bracket-wide event (bracket start, swiss round
+ * advance, check-in opening) reaches every participant at once, but the status
+ * resolves mostly from in-memory activity so the burst is cheap, and those are
+ * the moments the header is the call to action.
+ */
+export function GlobalStatusProvider({
+ user,
+ children,
+}: {
+ user?: { id: number } | null;
+ children: React.ReactNode;
+}) {
+ const [override, setOverride] = React.useState<
+ GlobalStatus | null | undefined
+ >(undefined);
+ const { globalStatus: layoutStatus } = useLayoutData();
+ const { data, refresh } =
+ useBackgroundResource>(STATUS_DATA_ROUTE);
+
+ const loggedIn = Boolean(user);
+
+ useEventStreamCatchUp({
+ enabled: loggedIn,
+ onCatchUp: refresh,
+ });
+
+ // the event carries no data on purpose: it only says that the user's
+ // status changed server-side
+ useServerEventListener((event) => {
+ if (event.kind === "statusChanged") {
+ refresh();
+ }
+ });
+
+ // the layout data covers the first paint; once the dedicated route has
+ // answered it is the fresher source and always wins
+ const serverStatus =
+ data !== undefined ? data.globalStatus : (layoutStatus ?? null);
+ const resolvedStatus = loggedIn ? serverStatus : null;
+ const hasUnseenLikes = useHasUnseenSqLikes(resolvedStatus);
+ const hasExpired = useHasSqGroupExpired(resolvedStatus);
+ // the server-resolved status on purpose: the showcase's override is not a moment to announce
+ useGlobalStatusSounds(resolvedStatus);
+
+ const status =
+ override !== undefined
+ ? override
+ : resolvedStatus
+ ? withClientState(resolvedStatus, { hasUnseenLikes, hasExpired })
+ : null;
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The user's current SendouQ/tournament status shown in the app header.
+ * `setStatus` overrides the server-resolved status; used by the components
+ * showcase.
+ */
+export function useGlobalStatus() {
+ return React.useContext(GlobalStatusContext);
+}
+
+/** Folds in what only the browser knows: the likes seen on this device and the group's expiry passing. */
+function withClientState(
+ status: GlobalStatus,
+ {
+ hasUnseenLikes,
+ hasExpired,
+ }: { hasUnseenLikes: boolean; hasExpired: boolean },
+): GlobalStatus {
+ // an expired group has nothing to fill or to like anymore, only to refresh
+ if (hasExpired) return { state: "SQ_EXPIRED", url: status.url };
+
+ return { ...status, countNeedsAction: hasUnseenLikes };
+}
diff --git a/app/features/global-status/components/GlobalStatusIndicator.module.css b/app/features/global-status/components/GlobalStatusIndicator.module.css
new file mode 100644
index 000000000..e21c64b70
--- /dev/null
+++ b/app/features/global-status/components/GlobalStatusIndicator.module.css
@@ -0,0 +1,64 @@
+.status {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: var(--s-1-5);
+ height: var(--field-size-sm);
+ padding: 0 var(--s-3);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg-higher);
+ color: var(--color-text);
+ font-size: var(--font-xs);
+ font-weight: var(--weight-semi);
+ white-space: nowrap;
+
+ &:hover {
+ background-color: var(--color-bg-high);
+ }
+}
+
+.iconContainer {
+ display: flex;
+ flex-shrink: 0;
+}
+
+.text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @container (width < 660px) {
+ display: none;
+ }
+}
+
+.logo {
+ width: 20px;
+ height: 20px;
+ border-radius: var(--radius-avatar);
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.countBadge {
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 var(--s-1);
+ border-radius: var(--radius-full);
+ background-color: var(--color-bg);
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.countBadgeAction {
+ background-color: var(--color-text-accent);
+ color: var(--color-text-inverse);
+}
+
+.alertBadge {
+ color: var(--color-warning);
+}
diff --git a/app/features/global-status/components/GlobalStatusIndicator.tsx b/app/features/global-status/components/GlobalStatusIndicator.tsx
new file mode 100644
index 000000000..bb073b494
--- /dev/null
+++ b/app/features/global-status/components/GlobalStatusIndicator.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { Image } from "~/components/Image";
+import { navIconUrl } from "~/utils/urls";
+import { useGlobalStatus } from "../GlobalStatusProvider";
+import type { GlobalStatusState } from "../global-status-types";
+import styles from "./GlobalStatusIndicator.module.css";
+
+const STATE_NAV_ICON: Record = {
+ SQ_PREPARING: "sendouq",
+ SQ_QUEUED: "sendouq",
+ SQ_EXPIRED: "sendouq",
+ SQ_READY_CHECK: "sendouq",
+ SQ_MATCH: "sendouq",
+ TO_CHECKIN: "medal",
+ TO_MATCH: "medal",
+ TO_WAITING_FOR_MATCH: "medal",
+ TO_WAITING_FOR_CAST: "medal",
+};
+
+const ALERT_BADGE_STATES: GlobalStatusState[] = ["SQ_EXPIRED", "TO_CHECKIN"];
+
+export function GlobalStatusIndicator() {
+ const { status } = useGlobalStatus();
+ const { t } = useTranslation(["common"]);
+
+ if (!status) return null;
+
+ const text = `${t(`common:globalStatus.${status.state}`)}${
+ status.groupSize
+ ? ` (${status.groupSize.members}/${status.groupSize.max})`
+ : ""
+ }`;
+
+ return (
+
+ {status.logoUrl ? (
+
+ ) : (
+
+ )}
+ {text}
+ {ALERT_BADGE_STATES.includes(status.state) ? (
+ !
+ ) : status.count ? (
+
+ {status.count}
+
+ ) : null}
+
+ );
+}
diff --git a/app/features/global-status/core/global-status.server.test.ts b/app/features/global-status/core/global-status.server.test.ts
new file mode 100644
index 000000000..71680db15
--- /dev/null
+++ b/app/features/global-status/core/global-status.server.test.ts
@@ -0,0 +1,322 @@
+import { addHours, addMinutes, subHours } from "date-fns";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { backdate } from "~/db/seed/core/backdate";
+import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
+import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
+import * as SQReadyCheckFactory from "~/db/seed/factories/SQReadyCheckFactory";
+import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
+import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
+import * as UserFactory from "~/db/seed/factories/UserFactory";
+import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
+import * as PendingCheckIns from "~/features/tournament/core/PendingCheckIns.server";
+import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
+import {
+ runningTournamentWithMatch,
+ testTournament,
+ tournamentCtxTeam,
+} from "~/features/tournament-bracket/core/tests/test-utils";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import {
+ SENDOUQ_LOOKING_PAGE,
+ SENDOUQ_PREPARING_PAGE,
+ SENDOUQ_READY_PAGE,
+ sendouQMatchPage,
+ tournamentRegisterPage,
+} from "~/utils/urls";
+import { resolveGlobalStatus } from "./global-status.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,
+}));
+
+/** Users are interchangeable here, so tests name them by 1-based position. */
+const users = UserFactory.pool();
+
+const userIds = (positions: number[]) =>
+ positions.map((position) => users.id(position));
+
+const runningTournamentWithOpenCheckIn = ({
+ tournamentId,
+ teamUserIds,
+}: {
+ tournamentId: number;
+ teamUserIds: number[];
+}) =>
+ testTournament({
+ ctx: {
+ id: tournamentId,
+ startsAt: dateToDatabaseTimestamp(new Date(Date.now() + 30 * 60 * 1000)),
+ teams: [
+ tournamentCtxTeam(1, { memberUserIds: teamUserIds, checkIns: [] }),
+ ],
+ },
+ });
+
+describe("resolveGlobalStatus", () => {
+ beforeEach(async () => {
+ await users.create(8);
+ RunningTournaments.clear();
+ PendingCheckIns.clearCache();
+ await refreshSendouQInstance();
+ });
+
+ /** A tournament inside its check-in window, with the users' team registered. */
+ const tournamentWithCheckInOpen = async ({
+ isCheckedIn,
+ }: {
+ isCheckedIn?: boolean;
+ } = {}) => {
+ const { id: tournamentId } = await TournamentFactory.create({
+ authorId: users.id(8),
+ startTimes: [dateToDatabaseTimestamp(addMinutes(new Date(), 30))],
+ });
+ await TournamentTeamFactory.create(
+ { tournamentId, memberUserIds: userIds([1, 2, 3, 4]) },
+ { isCheckedIn },
+ );
+
+ return tournamentId;
+ };
+
+ test("returns null for a user with nothing ongoing", async () => {
+ expect(await resolveGlobalStatus(users.id(1))).toBeNull();
+ });
+
+ test("resolves a preparing group", async () => {
+ await SQGroupFactory.create({
+ status: "PREPARING",
+ memberUserIds: userIds([1, 2]),
+ });
+ await refreshSendouQInstance();
+
+ expect(await resolveGlobalStatus(users.id(1))).toEqual({
+ state: "SQ_PREPARING",
+ url: SENDOUQ_PREPARING_PAGE,
+ groupSize: { members: 2, max: 4 },
+ });
+ });
+
+ test("resolves a queued group with its likes received", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([5]),
+ });
+ const group = await SQGroupFactory.create(
+ { memberUserIds: userIds([1, 2]) },
+ { likedByGroupIds: [likerGroup.id] },
+ );
+ await refreshSendouQInstance();
+
+ expect(await resolveGlobalStatus(users.id(1))).toEqual({
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ groupSize: { members: 2, max: 4 },
+ count: 1,
+ groupId: group.id,
+ expiresAt: expect.any(Number),
+ });
+ });
+
+ test("resolves a group inactive for too long as expired", async () => {
+ const group = await SQGroupFactory.create({
+ memberUserIds: userIds([1, 2]),
+ });
+ await backdate("Group", group.id, {
+ latestActionAt: subHours(new Date(), 2),
+ });
+ await refreshSendouQInstance();
+
+ expect(await resolveGlobalStatus(users.id(1))).toEqual({
+ state: "SQ_EXPIRED",
+ url: SENDOUQ_LOOKING_PAGE,
+ });
+ });
+
+ test("resolves a ready check", async () => {
+ const alphaGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([1, 2, 3, 4]),
+ });
+ const bravoGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([5, 6, 7, 8]),
+ });
+ await SQReadyCheckFactory.create({
+ alphaGroupId: alphaGroup.id,
+ bravoGroupId: bravoGroup.id,
+ confirmedByUserId: users.id(1),
+ });
+ await refreshSendouQInstance();
+
+ expect(await resolveGlobalStatus(users.id(5))).toEqual({
+ state: "SQ_READY_CHECK",
+ url: SENDOUQ_READY_PAGE,
+ });
+ });
+
+ test("resolves an ongoing match", async () => {
+ const match = await SQMatchFactory.create({
+ alphaUserIds: userIds([1, 2, 3, 4]),
+ bravoUserIds: userIds([5, 6, 7, 8]),
+ });
+ await refreshSendouQInstance();
+
+ expect(await resolveGlobalStatus(users.id(1))).toEqual({
+ state: "SQ_MATCH",
+ url: sendouQMatchPage(match.id),
+ });
+ });
+
+ test("resolves a reported match still awaiting the confirmation", async () => {
+ const match = await SQMatchFactory.create(
+ {
+ alphaUserIds: userIds([1, 2, 3, 4]),
+ bravoUserIds: userIds([5, 6, 7, 8]),
+ },
+ { isReported: true },
+ );
+ await refreshSendouQInstance();
+
+ expect(await resolveGlobalStatus(users.id(5))).toEqual({
+ state: "SQ_MATCH",
+ url: sendouQMatchPage(match.id),
+ });
+ });
+
+ test("resolves a tournament the user has yet to check in to", async () => {
+ const tournamentId = await tournamentWithCheckInOpen();
+
+ expect(await resolveGlobalStatus(users.id(1))).toEqual({
+ state: "TO_CHECKIN",
+ url: tournamentRegisterPage(tournamentId),
+ logoUrl: expect.any(String),
+ });
+ });
+
+ test("resolves nothing once the team has checked in", async () => {
+ await tournamentWithCheckInOpen({ isCheckedIn: true });
+
+ expect(await resolveGlobalStatus(users.id(1))).toBeNull();
+ });
+
+ test("resolves nothing while check-in has yet to open", async () => {
+ const { id: tournamentId } = await TournamentFactory.create({
+ authorId: users.id(8),
+ startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 5))],
+ });
+ await TournamentTeamFactory.create({
+ tournamentId,
+ memberUserIds: userIds([1, 2, 3, 4]),
+ });
+
+ expect(await resolveGlobalStatus(users.id(1))).toBeNull();
+ });
+
+ test("an ongoing tournament match beats a check-in of another tournament", async () => {
+ await tournamentWithCheckInOpen();
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 100,
+ teamOneUserIds: userIds([1]),
+ teamTwoUserIds: userIds([2]),
+ }),
+ );
+
+ expect((await resolveGlobalStatus(users.id(1)))?.state).toBe("TO_MATCH");
+ });
+
+ test("SendouQ status beats a tournament status", async () => {
+ await SQGroupFactory.create({ memberUserIds: userIds([1]) });
+ await refreshSendouQInstance();
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: userIds([1]),
+ teamTwoUserIds: userIds([2]),
+ }),
+ );
+
+ expect((await resolveGlobalStatus(users.id(1)))?.state).toBe("SQ_QUEUED");
+ });
+
+ test("resolves an ongoing tournament match with the tournament's logo", async () => {
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: userIds([1]),
+ teamTwoUserIds: userIds([2]),
+ }),
+ );
+
+ const status = await resolveGlobalStatus(users.id(1));
+
+ expect(status?.state).toBe("TO_MATCH");
+ expect(status?.url).toMatch(/^\/to\/1\/matches\/\d+$/);
+ expect(status?.logoUrl).toBe("/test.avif");
+ });
+
+ test("resolves a match locked for cast as waiting for it", async () => {
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: userIds([1]),
+ teamTwoUserIds: userIds([2]),
+ lockFirstMatchForCast: true,
+ }),
+ );
+
+ expect((await resolveGlobalStatus(users.id(1)))?.state).toBe(
+ "TO_WAITING_FOR_CAST",
+ );
+ });
+
+ test("resolves an open regular check-in", async () => {
+ RunningTournaments.add(
+ runningTournamentWithOpenCheckIn({
+ tournamentId: 1,
+ teamUserIds: userIds([1]),
+ }),
+ );
+
+ const status = await resolveGlobalStatus(users.id(1));
+
+ expect(status?.state).toBe("TO_CHECKIN");
+ expect(status?.url).toBe(tournamentRegisterPage(1));
+ });
+
+ test("ignores leagues", async () => {
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: userIds([1]),
+ teamTwoUserIds: userIds([2]),
+ isLeague: true,
+ }),
+ );
+
+ expect(await resolveGlobalStatus(users.id(1))).toBeNull();
+ });
+
+ test("the most urgent status of many running tournaments wins", async () => {
+ RunningTournaments.add(
+ runningTournamentWithOpenCheckIn({
+ tournamentId: 1,
+ teamUserIds: userIds([1]),
+ }),
+ );
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 2,
+ teamOneUserIds: userIds([1]),
+ teamTwoUserIds: userIds([2]),
+ }),
+ );
+
+ expect((await resolveGlobalStatus(users.id(1)))?.state).toBe("TO_MATCH");
+ });
+});
diff --git a/app/features/global-status/core/global-status.server.ts b/app/features/global-status/core/global-status.server.ts
new file mode 100644
index 000000000..192003394
--- /dev/null
+++ b/app/features/global-status/core/global-status.server.ts
@@ -0,0 +1,157 @@
+import * as R from "remeda";
+import { groupExpiresAt } from "~/features/sendouq/core/groups";
+import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
+import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
+import * as PendingCheckIns from "~/features/tournament/core/PendingCheckIns.server";
+import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament";
+import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls";
+import * as UserActivity from "~/features/user-activity/core/UserActivity.server";
+import {
+ SENDOUQ_LOOKING_PAGE,
+ SENDOUQ_PREPARING_PAGE,
+ SENDOUQ_READY_PAGE,
+ sendouQMatchPage,
+ tournamentMatchPage,
+ tournamentRegisterPage,
+} from "~/utils/urls";
+import type { GlobalStatus } from "../global-status-types";
+
+const TOURNAMENT_STATUS_URGENCY: Record<
+ TournamentTeamMemberProgressStatus["type"],
+ number
+> = {
+ MATCH: 0,
+ CHECKIN: 1,
+ WAITING_FOR_MATCH: 2,
+ WAITING_FOR_CAST: 2,
+ WAITING_FOR_ROUND: 2,
+ WAITING_FOR_GROUPS: 2,
+ WAITING_FOR_BRACKET: 3,
+ THANKS_FOR_PLAYING: 3,
+};
+
+/**
+ * Resolves the status shown in the app header, or null when the user has
+ * nothing ongoing. SendouQ states always beat tournament states; leagues are
+ * excluded. A tournament the user has yet to check in to comes last: it is the
+ * only one resolved outside the in-memory activity, as a tournament that has
+ * not started is not running.
+ */
+export async function resolveGlobalStatus(
+ userId: number,
+): Promise {
+ const activity = UserActivity.resolve(userId);
+
+ return (
+ (await resolveSendouQStatus(activity)) ??
+ resolveTournamentStatus(activity) ??
+ (await resolvePendingCheckInStatus(userId))
+ );
+}
+
+async function resolvePendingCheckInStatus(
+ userId: number,
+): Promise {
+ const pendingCheckIn = await PendingCheckIns.byUserId(userId);
+ if (!pendingCheckIn) return null;
+
+ return {
+ state: "TO_CHECKIN",
+ url: tournamentRegisterPage(pendingCheckIn.tournamentId),
+ logoUrl: pendingCheckIn.logoUrl,
+ };
+}
+
+async function resolveSendouQStatus(
+ activity: UserActivity.UserActivity,
+): Promise {
+ if (!activity.sendouq) return null;
+
+ const { group, likesReceivedCount, expired } = activity.sendouq;
+ const groupSize = { members: group.members.length, max: FULL_GROUP_SIZE };
+
+ if (group.status === "PREPARING") {
+ return { state: "SQ_PREPARING", url: SENDOUQ_PREPARING_PAGE, groupSize };
+ }
+
+ if (group.matchId) {
+ return resolveSendouQMatchStatus(group.matchId);
+ }
+
+ if (group.status === "READY_CHECK") {
+ return { state: "SQ_READY_CHECK", url: SENDOUQ_READY_PAGE };
+ }
+
+ if (expired) {
+ return { state: "SQ_EXPIRED", url: SENDOUQ_LOOKING_PAGE };
+ }
+
+ return {
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ groupSize,
+ count: likesReceivedCount,
+ groupId: group.id,
+ expiresAt: groupExpiresAt(group.latestActionAt).getTime(),
+ };
+}
+
+async function resolveSendouQMatchStatus(
+ matchId: number,
+): Promise {
+ const match = await SQMatchRepository.findLiveStateById(matchId);
+ if (!match || match.isLocked || match.isCanceled) return null;
+
+ return { state: "SQ_MATCH", url: sendouQMatchPage(matchId) };
+}
+
+function resolveTournamentStatus(
+ activity: UserActivity.UserActivity,
+): GlobalStatus | null {
+ const relevant = activity.tournaments.filter(
+ (entry) => UserActivity.TOURNAMENT_STATUS_IS_IN_PROGRESS[entry.status.type],
+ );
+
+ const mostUrgent = R.firstBy(
+ relevant,
+ (entry) => TOURNAMENT_STATUS_URGENCY[entry.status.type],
+ );
+ if (!mostUrgent) return null;
+
+ const { tournament, status } = mostUrgent;
+ const tournamentId = tournament.ctx.id;
+ const logoUrl = tournament.ctx.logoUrl ?? undefined;
+
+ switch (status.type) {
+ case "MATCH":
+ return {
+ state: "TO_MATCH",
+ url: tournamentMatchPage({ tournamentId, matchId: status.matchId }),
+ logoUrl,
+ };
+ case "CHECKIN":
+ return {
+ state: "TO_CHECKIN",
+ url:
+ "bracketIdx" in status
+ ? tournamentBracketsPage({
+ tournamentId,
+ bracketIdx: status.bracketIdx,
+ })
+ : tournamentRegisterPage(tournamentId),
+ logoUrl,
+ };
+ case "WAITING_FOR_CAST":
+ return {
+ state: "TO_WAITING_FOR_CAST",
+ url: tournamentBracketsPage({ tournamentId }),
+ logoUrl,
+ };
+ default:
+ return {
+ state: "TO_WAITING_FOR_MATCH",
+ url: tournamentBracketsPage({ tournamentId }),
+ logoUrl,
+ };
+ }
+}
diff --git a/app/features/global-status/global-status-expiry.browser.test.tsx b/app/features/global-status/global-status-expiry.browser.test.tsx
new file mode 100644
index 000000000..26591616f
--- /dev/null
+++ b/app/features/global-status/global-status-expiry.browser.test.tsx
@@ -0,0 +1,77 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { render } from "vitest-browser-react";
+import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
+import { useHasSqGroupExpired } from "./global-status-expiry";
+import type { GlobalStatus } from "./global-status-types";
+
+const THIRTY_MINUTES_MS = 30 * 60 * 1000;
+
+function Expiry({ status }: { status: GlobalStatus }) {
+ const hasExpired = useHasSqGroupExpired(status);
+
+ return {hasExpired ? "expired" : "queued"}
;
+}
+
+const expiryStatus = (screen: Awaited>) =>
+ screen.getByTestId("expiry").element().textContent;
+
+const queuedStatus = (expiresInMs: number): GlobalStatus => ({
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ expiresAt: Date.now() + expiresInMs,
+});
+
+/** Runs the fake clock forward and lets React paint: renders go through a MessageChannel fake timers don't control, so a message of our own posted after signals the render happened. */
+const advanceTimers = async (ms: number) => {
+ await vi.advanceTimersByTimeAsync(ms);
+
+ return new Promise((resolve) => {
+ const channel = new MessageChannel();
+ channel.port1.onmessage = () => resolve();
+ channel.port2.postMessage(null);
+ });
+};
+
+describe("useHasSqGroupExpired", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("stays unexpired while the expiry is still ahead", async () => {
+ const screen = await render(
+ ,
+ );
+ await advanceTimers(THIRTY_MINUTES_MS - 1000);
+
+ expect(expiryStatus(screen)).toBe("queued");
+ });
+
+ test("expires once the expiry passes", async () => {
+ const screen = await render(
+ ,
+ );
+ await advanceTimers(THIRTY_MINUTES_MS);
+
+ expect(expiryStatus(screen)).toBe("expired");
+ });
+
+ test("expires right away for an expiry already in the past", async () => {
+ const screen = await render( );
+ await advanceTimers(0);
+
+ expect(expiryStatus(screen)).toBe("expired");
+ });
+
+ test("never expires a status that is not queued", async () => {
+ const screen = await render(
+ ,
+ );
+ await advanceTimers(THIRTY_MINUTES_MS);
+
+ expect(expiryStatus(screen)).toBe("queued");
+ });
+});
diff --git a/app/features/global-status/global-status-expiry.ts b/app/features/global-status/global-status-expiry.ts
new file mode 100644
index 000000000..8c3538557
--- /dev/null
+++ b/app/features/global-status/global-status-expiry.ts
@@ -0,0 +1,31 @@
+import * as React from "react";
+import type { GlobalStatus } from "./global-status-types";
+
+/**
+ * Whether a queued group has gone inactive since the server resolved the
+ * status. Expiry is exactly what happens to a user who sits still, leaving no
+ * navigation or event to refresh the status by, so the browser times it out.
+ */
+export function useHasSqGroupExpired(status: GlobalStatus | null): boolean {
+ const expiresAt =
+ status?.state === "SQ_QUEUED" ? status.expiresAt : undefined;
+
+ const [hasExpired, setHasExpired] = React.useState(false);
+
+ React.useEffect(() => {
+ if (expiresAt === undefined) return;
+
+ const msLeft = expiresAt - Date.now();
+ if (msLeft <= 0) {
+ setHasExpired(true);
+ return;
+ }
+
+ setHasExpired(false);
+ const timeout = setTimeout(() => setHasExpired(true), msLeft);
+
+ return () => clearTimeout(timeout);
+ }, [expiresAt]);
+
+ return expiresAt !== undefined && hasExpired;
+}
diff --git a/app/features/global-status/global-status-likes-seen.browser.test.tsx b/app/features/global-status/global-status-likes-seen.browser.test.tsx
new file mode 100644
index 000000000..c322c7706
--- /dev/null
+++ b/app/features/global-status/global-status-likes-seen.browser.test.tsx
@@ -0,0 +1,120 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { render } from "vitest-browser-react";
+import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
+import {
+ useHasUnseenSqLikes,
+ useMarkSqLikesSeen,
+} from "./global-status-likes-seen";
+import type { GlobalStatus } from "./global-status-types";
+
+const SEEN_SQ_LIKES_KEY = "seen-sq-likes";
+const GROUP_ID = 1;
+
+function UnseenLikes({ status }: { status: GlobalStatus | null }) {
+ const hasUnseen = useHasUnseenSqLikes(status);
+
+ return {hasUnseen ? "unseen" : "seen"}
;
+}
+
+function MarkSeen({
+ groupId,
+ receivedLikesCount,
+}: {
+ groupId: number | undefined;
+ receivedLikesCount: number;
+}) {
+ useMarkSqLikesSeen(groupId, receivedLikesCount);
+
+ return null;
+}
+
+const unseenStatus = (screen: Awaited>) =>
+ screen.getByTestId("unseen").element().textContent;
+
+const queuedStatus = (count: number | undefined): GlobalStatus => ({
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ expiresAt: Date.now() + 30 * 60 * 1000,
+ groupId: GROUP_ID,
+ count,
+});
+
+const seedSeen = (seen: { groupId: number; seenCount: number }) =>
+ window.localStorage.setItem(SEEN_SQ_LIKES_KEY, JSON.stringify(seen));
+
+afterEach(() => {
+ window.localStorage.clear();
+});
+
+describe("useHasUnseenSqLikes", () => {
+ test("highlights likes with nothing seen yet", async () => {
+ const screen = await render( );
+
+ expect(unseenStatus(screen)).toBe("unseen");
+ });
+
+ test("highlights likes seen on another group", async () => {
+ seedSeen({ groupId: GROUP_ID + 1, seenCount: 5 });
+ const screen = await render( );
+
+ expect(unseenStatus(screen)).toBe("unseen");
+ });
+
+ test.each([
+ { why: "more likes than seen", seenCount: 1, expected: "unseen" },
+ { why: "as many likes as seen", seenCount: 2, expected: "seen" },
+ { why: "fewer likes than seen", seenCount: 3, expected: "seen" },
+ ])("$why → $expected", async ({ seenCount, expected }) => {
+ seedSeen({ groupId: GROUP_ID, seenCount });
+ const screen = await render( );
+
+ expect(unseenStatus(screen)).toBe(expected);
+ });
+
+ test("never highlights without a status", async () => {
+ const screen = await render( );
+
+ expect(unseenStatus(screen)).toBe("seen");
+ });
+
+ test("never highlights a status that is not queued", async () => {
+ const screen = await render(
+ ,
+ );
+
+ expect(unseenStatus(screen)).toBe("seen");
+ });
+
+ test("never highlights a queued status without likes", async () => {
+ const screen = await render( );
+
+ expect(unseenStatus(screen)).toBe("seen");
+ });
+});
+
+describe("useMarkSqLikesSeen", () => {
+ test("persists the group's received likes as seen", async () => {
+ await render( );
+
+ expect(window.localStorage.getItem(SEEN_SQ_LIKES_KEY)).toBe(
+ JSON.stringify({ groupId: GROUP_ID, seenCount: 3 }),
+ );
+ });
+
+ test("persists nothing without a group", async () => {
+ await render( );
+
+ expect(window.localStorage.getItem(SEEN_SQ_LIKES_KEY)).toBeNull();
+ });
+
+ test("clears the highlight for the likes it marks seen", async () => {
+ const screen = await render(
+ <>
+
+
+ >,
+ );
+
+ expect(unseenStatus(screen)).toBe("seen");
+ });
+});
diff --git a/app/features/global-status/global-status-likes-seen.ts b/app/features/global-status/global-status-likes-seen.ts
new file mode 100644
index 000000000..a3ced71d3
--- /dev/null
+++ b/app/features/global-status/global-status-likes-seen.ts
@@ -0,0 +1,50 @@
+import * as React from "react";
+import * as v from "valibot";
+import { usePersistedState } from "~/modules/persisted-state/hooks";
+import * as PersistedState from "~/modules/persisted-state/persisted-state";
+import type { GlobalStatus } from "./global-status-types";
+
+const seenSqLikesPersisted = PersistedState.define({
+ key: "seen-sq-likes",
+ storage: "local",
+ schema: v.nullable(v.object({ groupId: v.number(), seenCount: v.number() })),
+ default: null,
+});
+
+/**
+ * Whether the queued status carries likes the user has not seen on this device
+ * yet, highlighting the header's count badge. Likes count as seen once the
+ * /q/looking page — where they are shown — has been viewed with them present.
+ */
+export function useHasUnseenSqLikes(status: GlobalStatus | null): boolean {
+ const [seen] = usePersistedState(seenSqLikesPersisted);
+
+ if (
+ status?.state !== "SQ_QUEUED" ||
+ !status.count ||
+ typeof status.groupId !== "number"
+ ) {
+ return false;
+ }
+ if (!seen || seen.groupId !== status.groupId) return true;
+
+ return status.count > seen.seenCount;
+}
+
+/**
+ * Records the group's currently received likes as seen while the user is on
+ * the page showing them, clearing the header badge highlight.
+ */
+export function useMarkSqLikesSeen(
+ groupId: number | undefined,
+ receivedLikesCount: number,
+) {
+ React.useEffect(() => {
+ if (typeof groupId !== "number") return;
+
+ PersistedState.write(seenSqLikesPersisted, {
+ groupId,
+ seenCount: receivedLikesCount,
+ });
+ }, [groupId, receivedLikesCount]);
+}
diff --git a/app/features/global-status/global-status-sounds.browser.test.tsx b/app/features/global-status/global-status-sounds.browser.test.tsx
new file mode 100644
index 000000000..81e5c4d48
--- /dev/null
+++ b/app/features/global-status/global-status-sounds.browser.test.tsx
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { render } from "vitest-browser-react";
+import {
+ SENDOUQ_LOOKING_PAGE,
+ SENDOUQ_PREPARING_PAGE,
+ SENDOUQ_READY_PAGE,
+ sendouQMatchPage,
+} from "~/utils/urls";
+import { useGlobalStatusSounds } from "./global-status-sounds";
+import type { GlobalStatus } from "./global-status-types";
+
+const mocks = vi.hoisted(() => ({
+ playSound: vi.fn(),
+}));
+
+vi.mock("~/features/chat/chat-utils", () => ({
+ playSound: mocks.playSound,
+}));
+
+function Sounds({ status }: { status: GlobalStatus | null }) {
+ useGlobalStatusSounds(status);
+
+ return null;
+}
+
+/** Renders the first status, then the second, as the provider does when a fresher one lands. */
+const transitionTo = async (
+ from: GlobalStatus | null,
+ to: GlobalStatus | null,
+) => {
+ const screen = await render( );
+ await screen.rerender( );
+};
+
+const queued = (overrides: Partial = {}): GlobalStatus => ({
+ state: "SQ_QUEUED",
+ url: SENDOUQ_LOOKING_PAGE,
+ groupId: 1,
+ count: 0,
+ ...overrides,
+});
+
+describe("useGlobalStatusSounds", () => {
+ beforeEach(() => {
+ mocks.playSound.mockClear();
+ });
+
+ test.each([
+ ["SQ_READY_CHECK", "sq_ready-check"],
+ ["SQ_MATCH", "sq_match"],
+ ["TO_MATCH", "tournament_match"],
+ ] as const)("plays %s's sound on moving into it", async (state, sound) => {
+ await transitionTo(queued(), { state, url: SENDOUQ_READY_PAGE });
+
+ expect(mocks.playSound).toHaveBeenCalledWith(sound);
+ });
+
+ test("stays silent for a status that is already there on the first render", async () => {
+ await render(
+ ,
+ );
+
+ expect(mocks.playSound).not.toHaveBeenCalled();
+ });
+
+ test("stays silent while the state is unchanged", async () => {
+ const match: GlobalStatus = { state: "SQ_MATCH", url: sendouQMatchPage(1) };
+ await transitionTo(match, { ...match });
+
+ expect(mocks.playSound).not.toHaveBeenCalled();
+ });
+
+ test("stays silent for a state of its own with no sound", async () => {
+ await transitionTo(queued(), {
+ state: "SQ_PREPARING",
+ url: SENDOUQ_PREPARING_PAGE,
+ });
+
+ expect(mocks.playSound).not.toHaveBeenCalled();
+ });
+
+ test("plays the like sound when the queued group's likes grow", async () => {
+ await transitionTo(queued({ count: 1 }), queued({ count: 2 }));
+
+ expect(mocks.playSound).toHaveBeenCalledWith("sq_like");
+ });
+
+ test("stays silent when the queued group's likes shrink", async () => {
+ await transitionTo(queued({ count: 2 }), queued({ count: 1 }));
+
+ expect(mocks.playSound).not.toHaveBeenCalled();
+ });
+
+ test("stays silent for likes carried over from another group", async () => {
+ await transitionTo(
+ queued({ groupId: 1, count: 0 }),
+ queued({ groupId: 2, count: 3 }),
+ );
+
+ expect(mocks.playSound).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/features/global-status/global-status-sounds.ts b/app/features/global-status/global-status-sounds.ts
new file mode 100644
index 000000000..f4650ba31
--- /dev/null
+++ b/app/features/global-status/global-status-sounds.ts
@@ -0,0 +1,48 @@
+import * as React from "react";
+import { playSound } from "~/features/chat/chat-utils";
+import type { GlobalStatus, GlobalStatusState } from "./global-status-types";
+
+const SOUND_BY_STATE: Partial> = {
+ SQ_READY_CHECK: "sq_ready-check",
+ SQ_MATCH: "sq_match",
+ TO_MATCH: "tournament_match",
+};
+
+/**
+ * Plays the alert sound of a status the user has just moved into (a ready
+ * check starting, a match being ready to play) as well as of a like landing
+ * while queued. Driving them off the header status is what makes them heard
+ * anywhere on the site rather than only on the page the moment belongs to.
+ *
+ * The first status seen never plays: a sound announces a moment arriving, not
+ * one already underway when the page was opened.
+ */
+export function useGlobalStatusSounds(status: GlobalStatus | null) {
+ const previousRef = React.useRef(undefined);
+
+ React.useEffect(() => {
+ const previous = previousRef.current;
+ previousRef.current = status;
+
+ if (previous === undefined || !status) return;
+
+ const sound = soundForTransition(previous, status);
+ if (sound) playSound(sound);
+ }, [status]);
+}
+
+function soundForTransition(
+ previous: GlobalStatus | null,
+ next: GlobalStatus,
+): string | null {
+ if (next.state !== previous?.state) {
+ return SOUND_BY_STATE[next.state] ?? null;
+ }
+
+ const likeReceived =
+ next.state === "SQ_QUEUED" &&
+ next.groupId === previous.groupId &&
+ (next.count ?? 0) > (previous.count ?? 0);
+
+ return likeReceived ? "sq_like" : null;
+}
diff --git a/app/features/global-status/global-status-types.ts b/app/features/global-status/global-status-types.ts
new file mode 100644
index 000000000..5ac5003cf
--- /dev/null
+++ b/app/features/global-status/global-status-types.ts
@@ -0,0 +1,28 @@
+export type GlobalStatusState =
+ | "SQ_PREPARING"
+ | "SQ_QUEUED"
+ | "SQ_EXPIRED"
+ | "SQ_READY_CHECK"
+ | "SQ_MATCH"
+ | "TO_CHECKIN"
+ | "TO_MATCH"
+ | "TO_WAITING_FOR_MATCH"
+ | "TO_WAITING_FOR_CAST";
+
+export interface GlobalStatus {
+ state: GlobalStatusState;
+ /** Page the indicator links to e.g. the SendouQ match page. */
+ url: string;
+ /** Logo shown instead of the default state icon, e.g. the tournament's logo. */
+ logoUrl?: string;
+ /** Current SendouQ group fill shown after the state text, e.g. 2/4 members. */
+ groupSize?: { members: number; max: number };
+ /** Count shown as a badge e.g. likes received while in queue. */
+ count?: number;
+ /** Highlights the count badge when it calls for the user's attention. */
+ countNeedsAction?: boolean;
+ /** SendouQ group the queued status belongs to, scoping the likes seen tracking. */
+ groupId?: number;
+ /** When the queued group drops out of the looking pool, flipping the status to `SQ_EXPIRED`. */
+ expiresAt?: number;
+}
diff --git a/app/features/global-status/routes/api.status.ts b/app/features/global-status/routes/api.status.ts
new file mode 100644
index 000000000..4af4db1db
--- /dev/null
+++ b/app/features/global-status/routes/api.status.ts
@@ -0,0 +1,15 @@
+import { requireUser } from "~/features/auth/core/user.server";
+import { resolveGlobalStatus } from "../core/global-status.server";
+
+/**
+ * The header status indicator's data. Fetched by `GlobalStatusProvider`
+ * whenever an event announces that the user's status changed, instead of being
+ * polled with the rest of the app shell data.
+ */
+export const loader = async () => {
+ const user = requireUser();
+
+ return {
+ globalStatus: await resolveGlobalStatus(user.id),
+ };
+};
diff --git a/app/features/layout/LayoutDataProvider.tsx b/app/features/layout/LayoutDataProvider.tsx
index baef17665..3c533576f 100644
--- a/app/features/layout/LayoutDataProvider.tsx
+++ b/app/features/layout/LayoutDataProvider.tsx
@@ -13,6 +13,7 @@ interface LayoutData {
/** `null` when the server has no session, `undefined` when it has not said. */
loggedInUserId?: number | null;
sidebar?: RootLoaderData["sidebar"];
+ globalStatus?: RootLoaderData["globalStatus"];
buildCommit?: string;
}
diff --git a/app/features/layout/core/layout.server.ts b/app/features/layout/core/layout.server.ts
index dc910bfd2..48f5051a0 100644
--- a/app/features/layout/core/layout.server.ts
+++ b/app/features/layout/core/layout.server.ts
@@ -1,4 +1,5 @@
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
+import { resolveGlobalStatus } from "~/features/global-status/core/global-status.server";
import { resolveSidebarData } from "~/features/sidebar/core/sidebar.server";
import { GIT_COMMIT } from "~/utils/git-commit";
@@ -10,6 +11,7 @@ export async function resolveLayoutData(user: AuthenticatedUser | undefined) {
return {
loggedInUserId: user?.id ?? null,
sidebar: await resolveSidebarData(user),
+ globalStatus: user ? await resolveGlobalStatus(user.id) : null,
buildCommit: GIT_COMMIT,
};
}
diff --git a/app/features/leaderboards/LeaderboardRepository.server.test.ts b/app/features/leaderboards/LeaderboardRepository.server.test.ts
index 72951464b..c8beedca8 100644
--- a/app/features/leaderboards/LeaderboardRepository.server.test.ts
+++ b/app/features/leaderboards/LeaderboardRepository.server.test.ts
@@ -15,6 +15,7 @@ import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "./leaderboards-constants";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
notifyRoomsChangedByRoomIds: vi.fn(),
}));
diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts
index fafd97e64..487242339 100644
--- a/app/features/sendouq-match/SQMatchRepository.server.ts
+++ b/app/features/sendouq-match/SQMatchRepository.server.ts
@@ -104,6 +104,18 @@ export async function findAllByChatRoomIds(chatRoomIds: number[]) {
.execute();
}
+/** Just enough of a match to tell still being played apart from over, for the header status. */
+export async function findLiveStateById(id: number) {
+ return db
+ .selectFrom("GroupMatch")
+ .select((eb) => [
+ isLockedSubquery(eb, id).as("isLocked"),
+ isCanceledSubquery(eb, id).as("isCanceled"),
+ ])
+ .where("GroupMatch.id", "=", id)
+ .executeTakeFirst();
+}
+
export async function findById(id: number) {
const result = await db
.selectFrom("GroupMatch")
@@ -117,23 +129,8 @@ export async function findById(id: number) {
"GroupMatch.cancelAcceptedByUserId",
"GroupMatch.noScreen",
- eb
- .exists(
- eb
- .selectFrom("Skill")
- .select("Skill.id")
- .where("Skill.groupMatchId", "=", id),
- )
- .as("isLocked"),
- eb
- .exists(
- eb
- .selectFrom("Skill")
- .select("Skill.id")
- .where("Skill.groupMatchId", "=", id)
- .where("Skill.season", "=", CANCELED_MATCH_SEASON),
- )
- .as("isCanceled"),
+ isLockedSubquery(eb, id).as("isLocked"),
+ isCanceledSubquery(eb, id).as("isCanceled"),
jsonArrayFrom(
eb
.selectFrom("GroupMatchMap")
@@ -244,6 +241,32 @@ function skillDifferences(match: {
return { users, groups };
}
+/** Whether the match's skills have been calculated, i.e. it can no longer be edited. */
+function isLockedSubquery(
+ eb: ExpressionBuilder,
+ matchId: number,
+) {
+ return eb.exists(
+ eb
+ .selectFrom("Skill")
+ .select("Skill.id")
+ .where("Skill.groupMatchId", "=", matchId),
+ );
+}
+
+function isCanceledSubquery(
+ eb: ExpressionBuilder,
+ matchId: number,
+) {
+ return eb.exists(
+ eb
+ .selectFrom("Skill")
+ .select("Skill.id")
+ .where("Skill.groupMatchId", "=", matchId)
+ .where("Skill.season", "=", CANCELED_MATCH_SEASON),
+ );
+}
+
function groupWithTeamAndMembers(
eb: ExpressionBuilder,
groupIdRef: "GroupMatch.alphaGroupId" | "GroupMatch.bravoGroupId",
@@ -1609,14 +1632,7 @@ function findLockState(matchId: number, trx: Transaction) {
.selectFrom("GroupMatch")
.select((eb) => [
"GroupMatch.confirmedAt",
- eb
- .exists(
- eb
- .selectFrom("Skill")
- .select("Skill.id")
- .where("Skill.groupMatchId", "=", matchId),
- )
- .as("isLocked"),
+ isLockedSubquery(eb, matchId).as("isLocked"),
])
.where("GroupMatch.id", "=", matchId)
.executeTakeFirstOrThrow();
@@ -1626,7 +1642,21 @@ function findLockState(matchId: number, trx: Transaction) {
export function findUnfinishedMatchesCreatedBefore(cutoff: Date) {
return db
.selectFrom("GroupMatch")
- .select(["GroupMatch.id", "GroupMatch.chatRoomId"])
+ .select(({ eb }) => [
+ "GroupMatch.id",
+ "GroupMatch.chatRoomId",
+ jsonArrayFrom(
+ eb
+ .selectFrom("GroupMember")
+ .select("GroupMember.userId")
+ .where((wb) =>
+ wb.or([
+ wb("GroupMember.groupId", "=", wb.ref("GroupMatch.alphaGroupId")),
+ wb("GroupMember.groupId", "=", wb.ref("GroupMatch.bravoGroupId")),
+ ]),
+ ),
+ ).as("members"),
+ ])
.where("GroupMatch.confirmedAt", "is", null)
.where("GroupMatch.createdAt", "<", dateToDatabaseTimestamp(cutoff))
.where((eb) =>
@@ -1832,14 +1862,7 @@ function findCancelState(matchId: number, trx: Transaction) {
.selectFrom("GroupMatch")
.select((eb) => [
"GroupMatch.cancelRequestedByUserId",
- eb
- .exists(
- eb
- .selectFrom("Skill")
- .select("Skill.id")
- .where("Skill.groupMatchId", "=", matchId),
- )
- .as("isLocked"),
+ isLockedSubquery(eb, matchId).as("isLocked"),
])
.where("GroupMatch.id", "=", matchId)
.executeTakeFirstOrThrow();
diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts
index 94ee81822..78fe46dc5 100644
--- a/app/features/sendouq-match/actions/q.match.$id.server.ts
+++ b/app/features/sendouq-match/actions/q.match.$id.server.ts
@@ -56,6 +56,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
"Not a participant of this match",
);
+ const notifyMatchStatusChanged = () =>
+ ChatSystemMessage.notifyStatusChanged(
+ SendouQMatch.allMembers(match).map((m) => m.id),
+ );
+
try {
switch (data._action) {
case "REPORT_SCORE": {
@@ -95,6 +100,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
await refreshSendouQInstance();
+ notifyMatchStatusChanged();
+
if (match.chatRoomId) {
if (result.status === "MATCH_FINALIZED") {
ChatSystemMessage.sendPersisted({
@@ -158,6 +165,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
// the group re-enters the looking pool
ChatSystemMessage.send({ channel: SENDOUQ_LOOKING_CHANNEL });
+ ChatSystemMessage.notifyStatusChanged(
+ previousGroup.members.map((m) => m.id),
+ );
+
break;
}
case "CAST_CONTINUE_VOTE": {
@@ -229,6 +240,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.notifyRoomsChanged(
viewerGroup.members.map((member) => member.id),
);
+ ChatSystemMessage.notifyStatusChanged(
+ viewerGroup.members.map((member) => member.id),
+ );
// the continuing group re-enters the looking pool
ChatSystemMessage.send({ channel: SENDOUQ_LOOKING_CHANNEL });
@@ -275,6 +289,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
await refreshSendouQInstance();
+ notifyMatchStatusChanged();
+
if (match.chatRoomId) {
ChatSystemMessage.send({
channel: chatRoomChannel(match.chatRoomId),
@@ -298,6 +314,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
await refreshSendouQInstance();
+ notifyMatchStatusChanged();
+
if (match.chatRoomId) {
ChatSystemMessage.send({
channel: chatRoomChannel(match.chatRoomId),
@@ -361,6 +379,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
await refreshSendouQInstance();
+
+ notifyMatchStatusChanged();
break;
}
case "ADMIN_CANCEL": {
@@ -382,6 +402,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
await refreshSendouQInstance();
+ notifyMatchStatusChanged();
+
if (match.chatRoomId) {
ChatSystemMessage.send({
channel: chatRoomChannel(match.chatRoomId),
diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts
index 3faaac2cf..6db3a91ba 100644
--- a/app/features/sendouq/SQGroupRepository.server.ts
+++ b/app/features/sendouq/SQGroupRepository.server.ts
@@ -468,6 +468,22 @@ export async function insertMember(
return { chatRoomIdToRevalidate };
}
+/** Count of pending likes each non-inactive group has received, keyed by group id. */
+export async function findCurrentReceivedLikeCounts() {
+ const rows = await db
+ .selectFrom("GroupLike")
+ .innerJoin("Group", "Group.id", "GroupLike.targetGroupId")
+ .select((eb) => [
+ "GroupLike.targetGroupId",
+ eb.fn.countAll().as("count"),
+ ])
+ .where("Group.status", "!=", "INACTIVE")
+ .groupBy("GroupLike.targetGroupId")
+ .execute();
+
+ return new Map(rows.map((row) => [row.targetGroupId, row.count]));
+}
+
export async function findAllLikesByGroupId(groupId: number) {
const rows = await db
.selectFrom("GroupLike")
diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts
index 8dd62f5d1..382736ad0 100644
--- a/app/features/sendouq/actions/q.looking.server.ts
+++ b/app/features/sendouq/actions/q.looking.server.ts
@@ -53,6 +53,13 @@ export const action: ActionFunction = async ({ request }) => {
type: "LIKE_RECEIVED",
});
+ const notifyGroupStatusChanged = (groupId: number) =>
+ ChatSystemMessage.notifyStatusChanged(
+ SendouQ.findUncensoredGroupById(groupId)?.members.map(
+ (member) => member.id,
+ ) ?? [],
+ );
+
try {
switch (data._action) {
case "LIKE": {
@@ -62,8 +69,12 @@ export const action: ActionFunction = async ({ request }) => {
createdByUserId: user.id,
});
+ await refreshSendouQInstance();
+
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
+ notifyGroupStatusChanged(data.targetGroupId);
+ notifyGroupStatusChanged(currentGroup.id);
break;
}
@@ -90,6 +101,7 @@ export const action: ActionFunction = async ({ request }) => {
});
revalidateGroupTopic(currentGroup.id);
+ notifyGroupStatusChanged(currentGroup.id);
break;
}
@@ -99,8 +111,11 @@ export const action: ActionFunction = async ({ request }) => {
targetGroupId: data.targetGroupId,
});
+ await refreshSendouQInstance();
+
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
+ notifyGroupStatusChanged(data.targetGroupId);
break;
}
case "UNLIKE": {
@@ -109,8 +124,12 @@ export const action: ActionFunction = async ({ request }) => {
targetGroupId: data.targetGroupId,
});
+ await refreshSendouQInstance();
+
revalidateGroupTopic(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
+ notifyGroupStatusChanged(data.targetGroupId);
+ notifyGroupStatusChanged(currentGroup.id);
break;
}
@@ -148,6 +167,11 @@ export const action: ActionFunction = async ({ request }) => {
(member) => member.id,
),
);
+ ChatSystemMessage.notifyStatusChanged(
+ [...ourGroup.members, ...theirGroup.members].map(
+ (member) => member.id,
+ ),
+ );
broadcastLookingUpdate();
@@ -203,6 +227,9 @@ export const action: ActionFunction = async ({ request }) => {
ChatSystemMessage.notifyRoomsChanged(
currentGroup.members.map((member) => member.id),
);
+ ChatSystemMessage.notifyStatusChanged(
+ currentGroup.members.map((member) => member.id),
+ );
broadcastLookingUpdate();
@@ -239,6 +266,9 @@ export const action: ActionFunction = async ({ request }) => {
ChatSystemMessage.notifyRoomsChanged(
currentGroup.members.map((member) => member.id),
);
+ ChatSystemMessage.notifyStatusChanged(
+ currentGroup.members.map((member) => member.id),
+ );
broadcastLookingUpdate();
@@ -249,6 +279,10 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
+ ChatSystemMessage.notifyStatusChanged(
+ currentGroup.members.map((member) => member.id),
+ );
+
broadcastLookingUpdate();
break;
@@ -261,6 +295,8 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
+ notifyGroupStatusChanged(currentGroup.id);
+
broadcastLookingUpdate();
break;
diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts
index ecbcc0a91..a1898f9b1 100644
--- a/app/features/sendouq/actions/q.preparing.server.ts
+++ b/app/features/sendouq/actions/q.preparing.server.ts
@@ -37,6 +37,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await refreshSendouQInstance();
+ ChatSystemMessage.notifyStatusChanged(
+ ownGroup.members.map((member) => member.id),
+ );
ChatSystemMessage.send({ channel: SENDOUQ_LOOKING_CHANNEL });
return redirect(SENDOUQ_LOOKING_PAGE);
@@ -78,6 +81,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
? updatedGroup.members.map((member) => member.id)
: [data.id],
);
+ ChatSystemMessage.notifyStatusChanged(
+ updatedGroup
+ ? updatedGroup.members.map((member) => member.id)
+ : [data.id],
+ );
ChatSystemMessage.send({ channel: sqGroupChannel(ownGroup.id) });
diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts
index 3fd1f43a7..35d61c2c3 100644
--- a/app/features/sendouq/actions/q.server.ts
+++ b/app/features/sendouq/actions/q.server.ts
@@ -65,6 +65,8 @@ export const action: ActionFunction = async ({ request, url }) => {
await refreshSendouQInstance();
+ ChatSystemMessage.notifyStatusChanged([user.id]);
+
// joining directly creates an ACTIVE group that enters the pool (a PREPARING one isn't in it)
if (data.direct === "true") {
ChatSystemMessage.send({ channel: SENDOUQ_LOOKING_CHANNEL });
@@ -101,6 +103,12 @@ export const action: ActionFunction = async ({ request, url }) => {
await refreshSendouQInstance();
+ ChatSystemMessage.notifyStatusChanged(
+ SendouQ.findUncensoredGroupById(groupInvitedTo.id)?.members.map(
+ (member) => member.id,
+ ) ?? [user.id],
+ );
+
if (groupInvitedTo.status === "PREPARING") {
// a preparing group isn't in the pool, so only its members (on the preparing page)
ChatSystemMessage.send({
diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts
index 695d780ba..fcc3c2e69 100644
--- a/app/features/sendouq/core/SendouQ.server.test.ts
+++ b/app/features/sendouq/core/SendouQ.server.test.ts
@@ -104,6 +104,36 @@ const createSkill = (position: number, mu: number) =>
);
describe("SendouQ", () => {
+ describe("likesReceivedCount", () => {
+ beforeEach(async () => {
+ await users.create(8);
+ });
+
+ test("returns 0 for a group nobody has liked", async () => {
+ const groupId = await createGroup([1]);
+ await refreshSendouQInstance();
+
+ expect(SendouQ.likesReceivedCount(groupId)).toBe(0);
+ });
+
+ test("counts likes received, not given", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([5]),
+ });
+ const secondLikerGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([6]),
+ });
+ const target = await SQGroupFactory.create(
+ { memberUserIds: userIds([1, 2]) },
+ { likedByGroupIds: [likerGroup.id, secondLikerGroup.id] },
+ );
+ await refreshSendouQInstance();
+
+ expect(SendouQ.likesReceivedCount(target.id)).toBe(2);
+ expect(SendouQ.likesReceivedCount(likerGroup.id)).toBe(0);
+ });
+ });
+
describe("currentViewByUserId", () => {
beforeEach(async () => {
await users.create(8);
diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts
index ebca4a313..edf94feac 100644
--- a/app/features/sendouq/core/SendouQ.server.ts
+++ b/app/features/sendouq/core/SendouQ.server.ts
@@ -61,6 +61,7 @@ class SendouQClass {
readonly #isAccurateTiers;
readonly #userSkills;
readonly #intervals;
+ readonly #receivedLikeCounts;
usersInQueue;
constructor(
@@ -71,7 +72,9 @@ class SendouQClass {
userSkills: calculatedUserSkills,
isAccurateTiers,
}: Awaited>,
+ receivedLikeCounts: Map,
) {
+ this.#receivedLikeCounts = receivedLikeCounts;
this.#recentMatches = recentMatches;
this.#isAccurateTiers = isAccurateTiers;
this.#userSkills = calculatedUserSkills;
@@ -128,6 +131,11 @@ class SendouQClass {
);
}
+ /** Pending likes/challenges the group has received, 0 when none. */
+ likesReceivedCount(groupId: number) {
+ return this.#receivedLikeCounts.get(groupId) ?? 0;
+ }
+
/** A group by id without censoring sensitive data. */
findUncensoredGroupById(groupId: number) {
return this.groups.find((group) => group.id === groupId);
@@ -580,13 +588,16 @@ export async function refreshSendouQInstance() {
async function freshSendouQInstance() {
const season = Seasons.currentOrPrevious();
- const [groups, recentMatches, skills] = await Promise.all([
- SQGroupRepository.findCurrentGroups(),
- SQGroupRepository.findRecentlyFinishedMatches(),
- userSkills(season!.nth),
- ]);
+ const [groups, recentMatches, skills, receivedLikeCounts] = await Promise.all(
+ [
+ SQGroupRepository.findCurrentGroups(),
+ SQGroupRepository.findRecentlyFinishedMatches(),
+ userSkills(season!.nth),
+ SQGroupRepository.findCurrentReceivedLikeCounts(),
+ ],
+ );
- return new SendouQClass(groups, recentMatches, skills);
+ return new SendouQClass(groups, recentMatches, skills, receivedLikeCounts);
}
/** Throws a redirect when the user loads a page other than the one their SendouQ group status puts them on. */
diff --git a/app/features/sendouq/core/groups.ts b/app/features/sendouq/core/groups.ts
index 1e73cdcf1..af465e124 100644
--- a/app/features/sendouq/core/groups.ts
+++ b/app/features/sendouq/core/groups.ts
@@ -3,6 +3,10 @@ import { databaseTimestampToDate } from "~/utils/dates";
import type { GroupExpiryStatus } from "../q-types";
import type { SQGroup } from "./SendouQ.server";
+// group expires in 30min without actions performed
+const GROUP_EXPIRES_IN_MS = 30 * 60 * 1000;
+const GROUP_EXPIRING_SOON_IN_MS = 10 * 60 * 1000;
+
/** Whose settings two morphing groups keep: the bigger group's, or at equal size the liked group's. */
export function groupAfterMorph({
ourGroup,
@@ -43,22 +47,25 @@ export function canSuggest(group: { members: unknown[] }) {
return group.members.length > 1;
}
+/** When the group falls out of the looking pool unless an action refreshes it. */
+export function groupExpiresAt(latestActionAt: number) {
+ return new Date(
+ databaseTimestampToDate(latestActionAt).getTime() + GROUP_EXPIRES_IN_MS,
+ );
+}
+
export function groupExpiryStatus(
latestActionAt: number,
): GroupExpiryStatus | null {
- // group expires in 30min without actions performed
- const groupExpiresAt =
- databaseTimestampToDate(latestActionAt).getTime() + 30 * 60 * 1000;
+ const expiresAt = groupExpiresAt(latestActionAt).getTime();
const now = Date.now();
- if (now > groupExpiresAt) {
+ if (now > expiresAt) {
return "EXPIRED";
}
- const tenMinutesFromNow = now + 10 * 60 * 1000;
-
- if (tenMinutesFromNow > groupExpiresAt) {
+ if (now + GROUP_EXPIRING_SOON_IN_MS > expiresAt) {
return "EXPIRING_SOON";
}
diff --git a/app/features/sendouq/core/likes.server.ts b/app/features/sendouq/core/likes.server.ts
index ac03a6bf6..392d9ee56 100644
--- a/app/features/sendouq/core/likes.server.ts
+++ b/app/features/sendouq/core/likes.server.ts
@@ -32,4 +32,12 @@ export async function cancelActiveGroupLikes(userId: number) {
})),
{ channel: SENDOUQ_LOOKING_CHANNEL },
]);
+ ChatSystemMessage.notifyStatusChanged(
+ [...affectedGroupIds, ownGroup.id].flatMap(
+ (groupId) =>
+ SendouQ.findUncensoredGroupById(groupId)?.members.map(
+ (member) => member.id,
+ ) ?? [],
+ ),
+ );
}
diff --git a/app/features/sendouq/core/ready-check.server.test.ts b/app/features/sendouq/core/ready-check.server.test.ts
index f7987760b..8fd903cc5 100644
--- a/app/features/sendouq/core/ready-check.server.test.ts
+++ b/app/features/sendouq/core/ready-check.server.test.ts
@@ -19,6 +19,7 @@ import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));
diff --git a/app/features/sendouq/core/ready-check.server.ts b/app/features/sendouq/core/ready-check.server.ts
index d12b9c7a8..643ec7083 100644
--- a/app/features/sendouq/core/ready-check.server.ts
+++ b/app/features/sendouq/core/ready-check.server.ts
@@ -73,6 +73,10 @@ export async function start({
},
{ channel: SENDOUQ_LOOKING_CHANNEL },
]);
+ ChatSystemMessage.notifyStatusChanged([
+ ...ownGroup.members.map((m) => m.id),
+ ...theirGroup.members.map((m) => m.id),
+ ]);
notify({
userIds: [
@@ -165,6 +169,9 @@ async function endReadyCheck(
await refreshSendouQInstance();
revalidateGroups(readyCheck);
+ ChatSystemMessage.notifyStatusChanged(
+ readyCheck.members.map((member) => member.userId),
+ );
// both groups return to the looking pool, so its shape changed for everyone
ChatSystemMessage.send({ channel: SENDOUQ_LOOKING_CHANNEL });
}
@@ -221,6 +228,9 @@ async function createMatch({
},
{ channel: SENDOUQ_LOOKING_CHANNEL },
]);
+ ChatSystemMessage.notifyStatusChanged(
+ readyCheck.members.map((member) => member.userId),
+ );
notify({
userIds: readyCheck.members.map((member) => member.userId),
diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts
index 45ceedcfa..2e0de6b5e 100644
--- a/app/features/sendouq/routes/q.looking.test.ts
+++ b/app/features/sendouq/routes/q.looking.test.ts
@@ -17,6 +17,7 @@ import { action as rawLookingAction } from "./q.looking";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index a79fe2099..98af2042d 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -17,6 +17,7 @@ import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useUser } from "~/features/auth/core/user";
import { useTopicRevalidation } from "~/features/chat/chat-hooks";
+import { useMarkSqLikesSeen } from "~/features/global-status/global-status-likes-seen";
import type { UserCardData } from "~/features/user-card/user-card-types";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
@@ -85,6 +86,8 @@ function QLookingPage() {
const data = useLoaderData();
const [joining] = useSearchParam(qLookingSearchParams, "joining");
+ useMarkSqLikesSeen(data.ownGroup?.id, data.likes.received.length);
+
// pool-shape changes (a group joining/leaving, a morph, a match starting)
useTopicRevalidation(SENDOUQ_LOOKING_CHANNEL);
// group-specific updates (e.g. a received like)
diff --git a/app/features/sendouq/routes/q.ready.test.ts b/app/features/sendouq/routes/q.ready.test.ts
index e6cc689d0..67344fb24 100644
--- a/app/features/sendouq/routes/q.ready.test.ts
+++ b/app/features/sendouq/routes/q.ready.test.ts
@@ -17,6 +17,7 @@ import { action as rawReadyAction, loader as rawReadyLoader } from "./q.ready";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));
diff --git a/app/features/settings/components/SoundsTab.tsx b/app/features/settings/components/SoundsTab.tsx
index 82e4d11fe..b5f9742fa 100644
--- a/app/features/settings/components/SoundsTab.tsx
+++ b/app/features/settings/components/SoundsTab.tsx
@@ -26,7 +26,6 @@ function SoundCheckboxes() {
const sounds = [
{ code: "sq_like", name: t("settings:sounds.likeReceived") },
- { code: "sq_new-group", name: t("settings:sounds.groupNewMember") },
{ code: "sq_ready-check", name: t("settings:sounds.readyCheckStarted") },
{ code: "sq_match", name: t("settings:sounds.matchStarted") },
{
diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts
index 38488d18d..8c9eb3e66 100644
--- a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts
+++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts
@@ -4,12 +4,15 @@ import { db } from "~/db/sql";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
+import * as PendingCheckIns from "~/features/tournament/core/PendingCheckIns.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
+import type * as Engine from "~/features/tournament-bracket/core/engine";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
clearTournamentDataCache,
+ notifyTournamentStatusChanged,
requireTournamentOrganizer,
tournamentFromParams,
} from "~/features/tournament-bracket/core/Tournament.server";
@@ -32,6 +35,8 @@ export const action: ActionFunction = async ({ request, params }) => {
{ for: "action" },
);
+ let statusChangedUserIds: number[] = [];
+
switch (data._action) {
case "CHECK_IN": {
requireTournamentOrganizer(tournament, user);
@@ -58,6 +63,7 @@ export const action: ActionFunction = async ({ request, params }) => {
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
if (!bracket.sources) {
+ PendingCheckIns.clearCache();
await resolveNotifications({
userIds: team.memberUserIds,
type: "TO_CHECK_IN_OPENED",
@@ -65,6 +71,8 @@ export const action: ActionFunction = async ({ request, params }) => {
});
}
+ statusChangedUserIds = team.memberUserIds;
+
break;
}
case "CHECK_OUT": {
@@ -85,11 +93,16 @@ export const action: ActionFunction = async ({ request, params }) => {
// no sources = regular check in
bracketIdx: !bracket.sources ? null : data.bracketIdx,
});
+ if (!bracket.sources) {
+ PendingCheckIns.clearCache();
+ }
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
logger.info(
`Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`,
);
+ statusChangedUserIds = team.memberUserIds;
+
break;
}
case "DELETE_TEAM": {
@@ -111,13 +124,15 @@ export const action: ActionFunction = async ({ request, params }) => {
}
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
+ statusChangedUserIds = team.memberUserIds;
+
break;
}
case "DROP_TEAM_OUT": {
requireTournamentOrganizer(tournament, user);
errorToastIfFalsy(tournament.teamById(data.teamId), "Invalid team id");
- const endedMatchIds = await dropTeamOut({
+ const { endedMatchIds, statusChangedTeamIds } = await dropTeamOut({
tournament,
teamId: data.teamId,
});
@@ -127,6 +142,10 @@ export const action: ActionFunction = async ({ request, params }) => {
endedMatchIds,
});
+ statusChangedUserIds = statusChangedTeamIds.flatMap(
+ (teamId) => tournament.teamById(teamId)?.memberUserIds ?? [],
+ );
+
break;
}
case "UNDO_DROP_TEAM_OUT": {
@@ -134,6 +153,9 @@ export const action: ActionFunction = async ({ request, params }) => {
await TournamentTeamRepository.undoDropOut(data.teamId);
+ statusChangedUserIds =
+ tournament.teamById(data.teamId)?.memberUserIds ?? [];
+
break;
}
default: {
@@ -143,12 +165,15 @@ export const action: ActionFunction = async ({ request, params }) => {
clearTournamentDataCache(tournamentId);
+ await notifyTournamentStatusChanged(tournamentId, statusChangedUserIds);
+
return null;
};
/**
* Drops a team out: random active roster for teams with subs, ends their in-progress matches,
- * marks them dropped. Returns the ended match ids for one batch of chat messages.
+ * marks them dropped. Returns the ended match ids for one batch of chat messages and the ids of
+ * the teams whose header status the drop moved.
*/
async function dropTeamOut({
tournament,
@@ -174,7 +199,7 @@ async function dropTeamOut({
});
}
- const { endedMatchIds, changedChatRoomIds } = await db
+ const { endedMatchIds, changedChatRoomIds, statusChangedTeamIds } = await db
.transaction()
.execute(async (trx) => {
const bracketData = await BracketRepository.findByTournamentId(
@@ -198,6 +223,10 @@ async function dropTeamOut({
return {
endedMatchIds: droppedResult.endedMatchIds,
changedChatRoomIds: chatRoomIds,
+ statusChangedTeamIds: teamIdsAffectedByDrop({
+ droppedTeamId: teamId,
+ changedMatches: droppedResult.changedMatches,
+ }),
};
});
@@ -211,7 +240,30 @@ async function dropTeamOut({
),
});
- return endedMatchIds;
+ return { endedMatchIds, statusChangedTeamIds };
+}
+
+/**
+ * The dropped team plus the teams whose header status the drop moves. Read off the propagation's
+ * own changed matches: the follow-up match a walkover fills only shares a participant with the
+ * ended match once the winner has been written into it.
+ */
+function teamIdsAffectedByDrop({
+ droppedTeamId,
+ changedMatches,
+}: {
+ droppedTeamId: number;
+ changedMatches: Engine.MatchData[];
+}) {
+ const teamIds = new Set([droppedTeamId]);
+
+ for (const match of changedMatches) {
+ for (const opponentId of [match.opponent1?.id, match.opponent2?.id]) {
+ if (typeof opponentId === "number") teamIds.add(opponentId);
+ }
+ }
+
+ return Array.from(teamIds);
}
function sendDroppedMatchChatMessages({
diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts
index 6323a8556..a01177c07 100644
--- a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts
+++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts
@@ -13,6 +13,7 @@ import { action } from "./to.$id.brackets.server";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
notifyRoomsChangedByRoomIds: vi.fn(),
}));
diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts
index 9b317f291..5af2e2fa3 100644
--- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts
+++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts
@@ -25,6 +25,7 @@ import * as PreparedMapsUtils from "../core/PreparedMaps";
import type { Tournament } from "../core/Tournament";
import {
clearTournamentDataCache,
+ notifyTournamentStatusChanged,
requireTournamentOrganizer,
tournamentFromDB,
tournamentFromParams,
@@ -40,6 +41,7 @@ export const action: ActionFunction = async ({ params, request }) => {
const data = await parseRequestPayload({ request, schema: bracketSchema });
let emitTournamentUpdate = false;
+ let statusChangedUserIds: number[] = [];
switch (data._action) {
case "START_BRACKET": {
@@ -192,6 +194,10 @@ export const action: ActionFunction = async ({ params, request }) => {
await tournamentFromDB(tournamentId);
emitTournamentUpdate = true;
+ statusChangedUserIds = seeding.flatMap(
+ (tournamentTeamId) =>
+ tournament.teamById(tournamentTeamId)!.memberUserIds,
+ );
break;
}
@@ -259,6 +265,9 @@ export const action: ActionFunction = async ({ params, request }) => {
});
emitTournamentUpdate = true;
+ statusChangedUserIds = bracket.participantTournamentTeamIds.flatMap(
+ (teamId) => tournament.teamById(teamId)?.memberUserIds ?? [],
+ );
break;
}
@@ -287,6 +296,9 @@ export const action: ActionFunction = async ({ params, request }) => {
});
emitTournamentUpdate = true;
+ statusChangedUserIds = bracket.participantTournamentTeamIds.flatMap(
+ (teamId) => tournament.teamById(teamId)?.memberUserIds ?? [],
+ );
break;
}
@@ -311,6 +323,8 @@ export const action: ActionFunction = async ({ params, request }) => {
logger.info(
`Checking in (bracket success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournament.ctx.id} - bracket idx: ${data.bracketIdx}`,
);
+
+ statusChangedUserIds = teamMemberOf.memberUserIds;
break;
}
case "OVERRIDE_BRACKET_PROGRESSION": {
@@ -340,6 +354,8 @@ export const action: ActionFunction = async ({ params, request }) => {
});
emitTournamentUpdate = true;
+ statusChangedUserIds =
+ tournament.teamById(data.tournamentTeamId)?.memberUserIds ?? [];
break;
}
@@ -350,6 +366,8 @@ export const action: ActionFunction = async ({ params, request }) => {
clearTournamentDataCache(tournamentId);
+ await notifyTournamentStatusChanged(tournamentId, statusChangedUserIds);
+
if (emitTournamentUpdate) {
ChatSystemMessage.send([{ channel: tournamentChannel(tournament.ctx.id) }]);
}
diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.module.css b/app/features/tournament-bracket/components/BracketCheckIn.module.css
similarity index 66%
rename from app/features/tournament-bracket/components/TournamentTeamActions.module.css
rename to app/features/tournament-bracket/components/BracketCheckIn.module.css
index 14108dc94..0ca36e4dc 100644
--- a/app/features/tournament-bracket/components/TournamentTeamActions.module.css
+++ b/app/features/tournament-bracket/components/BracketCheckIn.module.css
@@ -1,4 +1,7 @@
-.quickAction {
+.checkIn {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
font-size: var(--font-xs);
color: var(--color-text);
background-color: var(--color-bg);
@@ -7,19 +10,4 @@
padding-block: var(--s-1-5);
padding-inline: var(--s-3);
font-weight: var(--weight-bold);
- align-items: center;
-}
-
-.quickActionSpaced {
- display: flex;
- gap: var(--s-1-5);
-}
-
-.quickActionVerySpaced {
- display: flex;
- gap: var(--s-3);
-}
-
-.quickActionCheckmark {
- color: var(--color-success);
}
diff --git a/app/features/tournament-bracket/components/BracketCheckIn.tsx b/app/features/tournament-bracket/components/BracketCheckIn.tsx
new file mode 100644
index 000000000..53aea2597
--- /dev/null
+++ b/app/features/tournament-bracket/components/BracketCheckIn.tsx
@@ -0,0 +1,50 @@
+import { sub } from "date-fns";
+import { ActionButton } from "~/components/ActionButton";
+import { LocaleTimeRange } from "~/components/LocaleTimeRange";
+import { useUser } from "~/features/auth/core/user";
+import { useTournament } from "~/features/tournament/tournament-context";
+import { bracketSchema } from "~/features/tournament-bracket/tournament-bracket-schemas";
+import styles from "./BracketCheckIn.module.css";
+
+/** Prompts the viewer's team to check in to a follow-up bracket; other progress states live in the header status indicator. */
+export function BracketCheckIn({ bracketIdx }: { bracketIdx: number }) {
+ const tournament = useTournament();
+ const user = useUser();
+
+ const bracket = tournament.bracketMetaByIdx(bracketIdx);
+ if (!bracket) return null;
+
+ return (
+
+ {bracket.name} check-in
+ {tournament.canCheckInToBracket(bracket.idx, user) ? (
+
+ Check-in
+
+ ) : bracket.startTime && bracket.startTime > new Date() ? (
+
+ open{" "}
+
+
+ ) : bracket.startTime && bracket.startTime < new Date() ? (
+
over
+ ) : null}
+
+ );
+}
diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.tsx b/app/features/tournament-bracket/components/TournamentTeamActions.tsx
deleted file mode 100644
index 64272bf54..000000000
--- a/app/features/tournament-bracket/components/TournamentTeamActions.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import clsx from "clsx";
-import { sub } from "date-fns";
-import { Check } from "lucide-react";
-import * as React from "react";
-import { ActionButton } from "~/components/ActionButton";
-import { LinkButton, SendouButton } from "~/components/elements/Button";
-import { SendouPopover } from "~/components/elements/Popover";
-import { LocaleTimeRange } from "~/components/LocaleTimeRange";
-import { useUser } from "~/features/auth/core/user";
-import { playSound } from "~/features/chat/chat-utils";
-import { useTournament } from "~/features/tournament/tournament-context";
-import { checkInSchema } from "~/features/tournament/tournament-schemas";
-import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament";
-import { bracketSchema } from "~/features/tournament-bracket/tournament-bracket-schemas";
-import { logger } from "~/utils/logger";
-import { tournamentMatchPage, tournamentRegisterPage } from "~/utils/urls";
-import styles from "./TournamentTeamActions.module.css";
-
-export function TournamentTeamActions({
- status,
-}: {
- status: TournamentTeamMemberProgressStatus | null;
-}) {
- const tournament = useTournament();
- const user = useUser();
-
- useMatchReadySound(status?.type);
-
- if (!status) return null;
-
- if (status.type === "MATCH") {
- return (
-
- vs. {status.opponent}
-
- Go to match
-
-
- );
- }
- if (status.type === "CHECKIN") {
- const bracket =
- typeof status.bracketIdx === "number"
- ? tournament.bracketMetaByIdx(status.bracketIdx)
- : null;
-
- if (!bracket) {
- return (
-
- Your team needs to check-in
- {status.canCheckIn ? (
-
- Check-in now
-
- ) : (
-
- Check-in now
-
- }
- >
- {tournament.ctx.mapPickingStyle !== "TO"
- ? "Can't check-in, registration needs to be finished by the captain (full roster & map pool picked)"
- : "Can't check-in, registration needs to be finished by the captain (full roster)"}
-
- )}
-
- );
- }
-
- return (
-
- {bracket.name} check-in
- {tournament.canCheckInToBracket(bracket.idx, user) ? (
-
- Check-in
-
- ) : bracket.startTime && bracket.startTime > new Date() ? (
-
- open{" "}
-
-
- ) : bracket.startTime && bracket.startTime < new Date() ? (
- over
- ) : null}
-
- );
- }
-
- if (status.type === "WAITING_FOR_MATCH") {
- return (
-
- Waiting on match
-
-
- );
- }
-
- if (status.type === "WAITING_FOR_CAST") {
- return (
-
- Waiting on cast
-
-
- );
- }
-
- if (status.type === "WAITING_FOR_ROUND") {
- return (
-
- Waiting on next round
-
-
- );
- }
-
- if (status.type === "WAITING_FOR_GROUPS") {
- return (
-
- Waiting on group to finish
-
-
- );
- }
-
- if (status.type === "WAITING_FOR_BRACKET") {
- return (
-
- {" "}
-
- Checked in, waiting on bracket
-
-
-
- );
- }
-
- if (status.type === "THANKS_FOR_PLAYING") {
- return Thank you for playing! ;
- }
-
- logger.warn("Unexpected status", status);
- return null;
-}
-
-function Container({
- children,
- spaced,
-}: {
- children: React.ReactNode;
- spaced?: boolean | "very";
-}) {
- return (
-
- {children}
-
- );
-}
-
-function Dots() {
- const [thirdVisible, setThirdVisible] = React.useState(false);
-
- React.useEffect(() => {
- const timeout = setInterval(() => {
- setThirdVisible((prev) => !prev);
- }, 1500);
-
- return () => {
- clearInterval(timeout);
- };
- }, []);
-
- return (
-
- ...
-
- );
-}
-
-function useMatchReadySound(statusType?: string) {
- const isWaiting = React.useRef(false);
-
- React.useEffect(() => {
- if (statusType === "MATCH" && isWaiting.current) {
- playSound("tournament_match");
- }
-
- isWaiting.current = !statusType || statusType?.startsWith("WAITING_");
- }, [statusType]);
-
- return isWaiting;
-}
diff --git a/app/features/tournament-bracket/core/Tournament.server.test.ts b/app/features/tournament-bracket/core/Tournament.server.test.ts
index f4ba5da56..e44f76e87 100644
--- a/app/features/tournament-bracket/core/Tournament.server.test.ts
+++ b/app/features/tournament-bracket/core/Tournament.server.test.ts
@@ -1,7 +1,13 @@
-import { describe, expect, test } from "vitest";
+import { addHours, addMinutes, subHours, subMinutes } from "date-fns";
+import { beforeEach, describe, expect, test } from "vitest";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as Engine from "./engine";
-import { serializeBracket } from "./Tournament.server";
-import { testTournament } from "./tests/test-utils";
+import { RunningTournaments } from "./RunningTournaments.server";
+import {
+ evictStaleRunningTournaments,
+ serializeBracket,
+} from "./Tournament.server";
+import { progressions, testTournament } from "./tests/test-utils";
const SWISS_SETTINGS = { groupCount: 2, roundCount: 3 };
@@ -65,3 +71,104 @@ describe("serializeBracket", () => {
expect(serialized.data.group).toEqual(bracket.data.group);
});
});
+
+describe("evictStaleRunningTournaments", () => {
+ beforeEach(() => {
+ RunningTournaments.clear();
+ });
+
+ const tournamentStarted = ({
+ startsAt,
+ bracketStartedAt,
+ nextBracketStartsAt,
+ }: {
+ startsAt: Date;
+ bracketStartedAt: Date;
+ /** Schedules a follow-up bracket, as a tournament running over several days has. */
+ nextBracketStartsAt?: Date;
+ }) => {
+ const data = Engine.create({
+ type: "swiss",
+ seeding: [1, 2],
+ settings: {},
+ });
+
+ return testTournament({
+ data: {
+ ...data,
+ stage: data.stage.map((stage) => ({
+ ...stage,
+ createdAt: dateToDatabaseTimestamp(bracketStartedAt),
+ })),
+ },
+ ctx: {
+ startsAt: dateToDatabaseTimestamp(startsAt),
+ settings: {
+ bracketProgression: nextBracketStartsAt
+ ? [
+ ...progressions.swissOneGroup,
+ {
+ ...progressions.roundRobinToSingleElimination[1],
+ startTime: dateToDatabaseTimestamp(nextBracketStartsAt),
+ },
+ ]
+ : progressions.swissOneGroup,
+ },
+ },
+ });
+ };
+
+ test("keeps a tournament whose bracket was actually started recently despite an old scheduled start", () => {
+ RunningTournaments.add(
+ tournamentStarted({
+ startsAt: subHours(new Date(), 24),
+ bracketStartedAt: subMinutes(new Date(), 5),
+ }),
+ );
+
+ evictStaleRunningTournaments();
+
+ expect(RunningTournaments.has(1)).toBe(true);
+ });
+
+ test("evicts a tournament whose every start is older than the liveness window", () => {
+ RunningTournaments.add(
+ tournamentStarted({
+ startsAt: subHours(new Date(), 24),
+ bracketStartedAt: subHours(new Date(), 7),
+ }),
+ );
+
+ evictStaleRunningTournaments();
+
+ expect(RunningTournaments.has(1)).toBe(false);
+ });
+
+ test("keeps a long paused tournament whose next bracket's check-in has opened", () => {
+ RunningTournaments.add(
+ tournamentStarted({
+ startsAt: subHours(new Date(), 24),
+ bracketStartedAt: subHours(new Date(), 7),
+ nextBracketStartsAt: addMinutes(new Date(), 30),
+ }),
+ );
+
+ evictStaleRunningTournaments();
+
+ expect(RunningTournaments.has(1)).toBe(true);
+ });
+
+ test("evicts it again while that bracket's check-in has yet to open", () => {
+ RunningTournaments.add(
+ tournamentStarted({
+ startsAt: subHours(new Date(), 24),
+ bracketStartedAt: subHours(new Date(), 7),
+ nextBracketStartsAt: addHours(new Date(), 5),
+ }),
+ );
+
+ evictStaleRunningTournaments();
+
+ expect(RunningTournaments.has(1)).toBe(false);
+ });
+});
diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts
index 3b50431af..3ea51c83b 100644
--- a/app/features/tournament-bracket/core/Tournament.server.ts
+++ b/app/features/tournament-bracket/core/Tournament.server.ts
@@ -6,8 +6,10 @@ import {
getUser,
requireUser,
} from "~/features/auth/core/user.server";
+import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import { LRUCache } from "~/modules/cache";
@@ -273,6 +275,21 @@ export async function tournamentFromDB(tournamentId: number) {
return tournament;
}
+/**
+ * Prompts the users' clients to refetch their header status after a change to the tournament.
+ * Fills the (just cleared) cache and syncs the registry from that one rebuild so the refetch
+ * reads post-change state and the revalidation that follows the action finds a warm cache.
+ */
+export async function notifyTournamentStatusChanged(
+ tournamentId: number,
+ userIds: number[],
+) {
+ if (userIds.length === 0) return;
+
+ syncTournamentToRegistry(await tournamentSharedCached(tournamentId));
+ ChatSystemMessage.notifyStatusChanged(userIds);
+}
+
const TOURNAMENT_DATA_CACHE_MAX_ENTRIES = 250;
const TOURNAMENT_DATA_CACHE_TTL_MS = IN_MILLISECONDS.HALF_HOUR;
@@ -310,6 +327,13 @@ export async function tournamentSharedCached(tournamentId: number) {
entry.tournament = new Tournament(notFoundIfNullish(await entry.data));
}
+ if (
+ !RunningTournaments.has(tournamentId) &&
+ hasImminentBracket(entry.tournament)
+ ) {
+ syncTournamentToRegistry(entry.tournament);
+ }
+
return entry.tournament;
}
@@ -508,15 +532,39 @@ function mostRecentStartTime(tournament: Tournament) {
.filter((b) => b.startTime)
.map((b) => databaseTimestampToDate(b.startTime!));
- const allStartTimes = [tournament.ctx.startsAt, ...bracketStartTimes];
+ // a bracket actually starting keeps the tournament live even when it was
+ // never scheduled, or the schedule has long slipped
+ const actualBracketStartTimes = tournament.brackets
+ .filter((bracket) => !bracket.preview && bracket.createdAt)
+ .map((bracket) => databaseTimestampToDate(bracket.createdAt!));
+
+ const allStartTimes = [
+ tournament.ctx.startsAt,
+ ...bracketStartTimes,
+ ...actualBracketStartTimes,
+ ];
return allStartTimes
.filter((t) => t <= new Date())
.sort((a, b) => b.getTime() - a.getTime())[0];
}
+/** A scheduled bracket the tournament is about to resume with, e.g. day 2 of a two day event once its check-in opens. */
+function hasImminentBracket(tournament: Tournament) {
+ const opensAt = new Date(Date.now() + TOURNAMENT.REGULAR_CHECK_IN_WINDOW_MS);
+
+ return tournament.ctx.settings.bracketProgression.some((bracket) => {
+ if (!bracket.startTime) return false;
+
+ const startTime = databaseTimestampToDate(bracket.startTime);
+
+ return startTime > new Date() && startTime <= opensAt;
+ });
+}
+
function isTournamentLive(tournament: Tournament) {
if (!tournament.hasStarted || tournament.everyBracketOver) return false;
+ if (hasImminentBracket(tournament)) return true;
const cutoff = sub(new Date(), { hours: RUNNING_TOURNAMENT_MAX_AGE_HOURS });
const latestStartTime = mostRecentStartTime(tournament);
diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts
index b5f92cda5..efce77e2b 100644
--- a/app/features/tournament-bracket/core/Tournament.test.ts
+++ b/app/features/tournament-bracket/core/Tournament.test.ts
@@ -1,9 +1,11 @@
+import { addMinutes } from "date-fns";
import { describe, expect, test } from "vitest";
import type {
BracketData,
GeneratedRound,
MatchData,
} from "~/features/tournament-bracket/core/engine/types";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
import { unwrap } from "~/utils/result";
import * as Engine from "./engine";
import type * as Progression from "./Progression";
@@ -605,6 +607,47 @@ describe("teamMemberOfProgressStatus in swiss", () => {
});
});
+describe("teamMemberOfProgressStatus with a follow-up bracket check-in", () => {
+ const teamsWithMembers = [1, 2, 3, 4].map((teamId) =>
+ tournamentCtxTeam(teamId, { memberUserIds: [100 + teamId] }),
+ );
+
+ const progressionStartingIn = (
+ minutes: number,
+ ): Progression.ParsedBracket[] => [
+ {
+ ...progressions.swissEarlyAdvance[0],
+ },
+ {
+ ...progressions.swissEarlyAdvance[1],
+ requiresCheckIn: true,
+ startTime: dateToDatabaseTimestamp(addMinutes(new Date(), minutes)),
+ },
+ ];
+
+ const progressStatusWithFollowUpIn = (minutes: number) => {
+ const bracketProgression = progressionStartingIn(minutes);
+
+ return testTournament({
+ data: playOutEarlyAdvanceSwiss(bracketProgression),
+ ctx: { settings: { bracketProgression }, teams: teamsWithMembers },
+ }).teamMemberOfProgressStatus({ id: 101 });
+ };
+
+ test("asks for the check-in once the bracket's check-in has opened", () => {
+ expect(progressStatusWithFollowUpIn(30)).toEqual({
+ type: "CHECKIN",
+ bracketIdx: 1,
+ });
+ });
+
+ test("waits for the bracket while its check-in has yet to open", () => {
+ expect(progressStatusWithFollowUpIn(3 * 60)?.type).toBe(
+ "WAITING_FOR_BRACKET",
+ );
+ });
+});
+
describe("Swiss early advance bracket sourcing", () => {
const progressionWithConsolation: Progression.ParsedBracket[] = [
{
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts
index 6561bfacc..68027fbb7 100644
--- a/app/features/tournament-bracket/core/Tournament.ts
+++ b/app/features/tournament-bracket/core/Tournament.ts
@@ -960,7 +960,9 @@ export class Tournament {
get regularCheckInStartsAt() {
// elapsed time math so the window stays one hour long across a DST transition
- return new Date(this.ctx.startsAt.getTime() - 60 * 60 * 1000);
+ return new Date(
+ this.ctx.startsAt.getTime() - TOURNAMENT.REGULAR_CHECK_IN_WINDOW_MS,
+ );
}
get regularCheckInEndsAt() {
@@ -1240,10 +1242,14 @@ export class Tournament {
} as const;
}
- for (const [bracketIdx, bracket] of this.brackets.entries()) {
- if (bracket.teamsPendingCheckIn?.includes(team.id)) {
- return { type: "CHECKIN", bracketIdx } as const;
- }
+ for (const bracketIdx of this.ctx.settings.bracketProgression.keys()) {
+ const bracket = this.bracketMetaByIdx(bracketIdx);
+ if (!bracket?.teamsPendingCheckIn?.includes(team.id)) continue;
+
+ // a follow-up bracket's check-in only opens an hour before it starts
+ return this.canCheckInToBracket(bracketIdx, user)
+ ? ({ type: "CHECKIN", bracketIdx } as const)
+ : ({ type: "WAITING_FOR_BRACKET" } as const);
}
for (const bracket of startedBrackets) {
diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts
index bc35aa250..e748652e0 100644
--- a/app/features/tournament-bracket/core/tests/test-utils.ts
+++ b/app/features/tournament-bracket/core/tests/test-utils.ts
@@ -1,4 +1,5 @@
import * as R from "remeda";
+import * as Engine from "~/features/tournament-bracket/core/engine";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import type * as Progression from "../Progression";
import { Tournament } from "../Tournament";
@@ -122,6 +123,50 @@ export const testTournament = ({
});
};
+/** A started swiss tournament of two teams with their first match ongoing, optionally locked for a cast. */
+export const runningTournamentWithMatch = ({
+ tournamentId,
+ teamOneUserIds,
+ teamTwoUserIds,
+ isLeague,
+ lockFirstMatchForCast,
+}: {
+ tournamentId: number;
+ teamOneUserIds: number[];
+ teamTwoUserIds: number[];
+ isLeague?: boolean;
+ lockFirstMatchForCast?: boolean;
+}) => {
+ const data = Engine.create({
+ type: "swiss",
+ seeding: [1, 2],
+ settings: {},
+ });
+
+ return testTournament({
+ data,
+ ctx: {
+ id: tournamentId,
+ settings: {
+ bracketProgression: progressions.swissOneGroup,
+ isLeague,
+ },
+ castedMatchesInfo: lockFirstMatchForCast
+ ? {
+ lockedMatches: [
+ { matchId: data.match[0].id, twitchAccount: "test" },
+ ],
+ castedMatches: [],
+ }
+ : null,
+ teams: [
+ tournamentCtxTeam(1, { memberUserIds: teamOneUserIds }),
+ tournamentCtxTeam(2, { memberUserIds: teamTwoUserIds }),
+ ],
+ },
+ });
+};
+
/** Combines brackets into one tournament's data, offsetting local ids the way the database does on adding a stage. */
export const mergeStages = (...brackets: BracketData[]): BracketData => {
const merged: BracketData = { stage: [], group: [], round: [], match: [] };
diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts
index 4550ea5f5..a45f4a31a 100644
--- a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts
+++ b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts
@@ -1,6 +1,5 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
-import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import { tournamentDivisionsPage } from "~/utils/urls";
import type { Bracket } from "../core/Bracket";
@@ -50,8 +49,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
});
}
- const ownedTeam = tournament.ownedTeamByUser(user);
-
return {
bracketIdx,
divisionIdx,
@@ -62,12 +59,9 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
groupId: bracket.preview ? null : groupId,
})
: null,
- // the invite link of the add subs popover, only the team's own captain sees it
- ownTeamInviteCode: ownedTeam
- ? await TournamentTeamRepository.findInviteCodeById(ownedTeam.id)
- : null,
// the layout does not ship these, standings derived in the view need them
participatedUserIds: tournament.participatedUserIds,
+ // only its bracket check-in is acted on here, the header status indicator covers the rest
teamProgressStatus: tournament.teamMemberOfProgressStatus(user),
// the match cards' LIVE badges need these, also not shipped by the layout
streams: tournament.streams,
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.module.css b/app/features/tournament-bracket/routes/to.$id.brackets.module.css
index f58b7a0bb..7f3bc958f 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.module.css
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.module.css
@@ -6,9 +6,6 @@
}
.compactifyButton {
- font-size: var(--font-2xs);
color: var(--color-text-high);
- border-color: var(--color-bg-higher);
- background-color: var(--color-bg);
- border-radius: var(--radius-box);
+ white-space: nowrap;
}
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
index 8504219a8..59fd12d8d 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
@@ -7,7 +7,6 @@ import {
ShieldMinus,
ShieldPlus,
Stamp,
- UserPlus,
} from "lucide-react";
import * as React from "react";
import { ErrorBoundary } from "react-error-boundary";
@@ -19,7 +18,6 @@ import {
useOutletContext,
} from "react-router";
import { Alert } from "~/components/Alert";
-import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import {
@@ -28,7 +26,6 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
-import { InviteLinkInput } from "~/components/InviteLinkInput";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { useUser } from "~/features/auth/core/user";
import { useTopicRevalidation } from "~/features/chat/chat-hooks";
@@ -37,12 +34,10 @@ import {
TournamentProvider,
useTournament,
} from "~/features/tournament/tournament-context";
-import { tournamentJoinPage } from "~/features/tournament/tournament-urls";
import { useHydrated } from "~/hooks/useHydrated";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { useSearchParam } from "~/modules/search-params/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
-import { SENDOU_INK_BASE_URL } from "~/utils/urls";
import {
useBracketExpanded,
useTournamentPreparedMaps,
@@ -50,8 +45,8 @@ import {
import { action } from "../actions/to.$id.brackets.server";
import { Bracket } from "../components/Bracket";
import { useBracketSpoilerCensor } from "../components/Bracket/useBracketSpoilerCensor";
+import { BracketCheckIn } from "../components/BracketCheckIn";
import { BracketMapListDialog } from "../components/BracketMapListDialog";
-import { TournamentTeamActions } from "../components/TournamentTeamActions";
import * as AbDivisions from "../core/AbDivisions";
import type { Bracket as BracketType } from "../core/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
@@ -122,13 +117,11 @@ function TournamentBracketsView() {
!tournament.ctx.isFinalized,
);
- const teamProgressStatus = data.teamProgressStatus;
- const showAddSubsButton =
- !tournament.canFinalize(user) &&
- !tournament.everyBracketOver &&
- tournament.hasStarted &&
- tournament.autonomousSubs &&
- teamProgressStatus?.type !== "THANKS_FOR_PLAYING";
+ const bracketCheckInIdx =
+ data.teamProgressStatus?.type === "CHECKIN" &&
+ typeof data.teamProgressStatus.bracketIdx === "number"
+ ? data.teamProgressStatus.bracketIdx
+ : null;
const {
censored,
@@ -137,8 +130,6 @@ function TournamentBracketsView() {
hide: hideSpoiler,
} = useBracketSpoilerCensor();
- const showTeamActionsRow =
- (!tournament.isLeague && Boolean(teamProgressStatus)) || showAddSubsButton;
const showSecondaryActionsRow =
tournament.canFinalize(user) || censored || canToggle;
@@ -220,13 +211,9 @@ function TournamentBracketsView() {
return (
- {showTeamActionsRow ? (
-
- {/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */}
- {!tournament.isLeague ? (
-
- ) : null}
- {showAddSubsButton ?
: null}
+ {bracketCheckInIdx !== null ? (
+
+
) : null}
{showSecondaryActionsRow ? (
@@ -253,6 +240,7 @@ function TournamentBracketsView() {
) : null}
@@ -432,73 +420,17 @@ function MapPreparer({
);
}
-function AddSubsPopOver() {
- const { t } = useTranslation(["common", "tournament"]);
- const tournament = useTournament();
- const user = useUser();
- const data = useLoaderData();
-
- const ownedTeam = tournament.ownedTeamByUser(user);
- if (!ownedTeam || !data.ownTeamInviteCode) {
- const teamMemberOf = tournament.teamMemberOfByUser(user);
- if (!teamMemberOf) return null;
-
- return Only team captain or a TO can add subs ;
- }
-
- const subsAvailableToAdd =
- tournament.maxMembersPerTeam - ownedTeam.memberUserIds.length;
-
- const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
- tournamentId: tournament.ctx.id,
- inviteCode: data.ownTeamInviteCode,
- })}`;
-
- return (
-
- {t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })}
- {subsAvailableToAdd > 0 ? (
- <>
-
-
- >
- ) : null}
-
- );
-}
-
-function SubsPopover({ children }: { children: React.ReactNode }) {
- const { t } = useTranslation(["tournament"]);
-
- return (
- }
- data-testid="add-sub-button"
- >
- {t("tournament:actions.addSub")}
-
- }
- >
- {children}
-
- );
-}
-
/**
* Only the bracket the loader shipped is rendered; switching navigates to load the new one, the previous
* staying up until it arrives. A league switches only within the loader's division.
*/
function BracketTabs({
+ loadedBracket,
loadedBracketIdx,
divisionIdx,
children,
}: {
+ loadedBracket: BracketType | null;
loadedBracketIdx: number;
divisionIdx: number | null;
children: React.ReactNode;
@@ -510,12 +442,18 @@ function BracketTabs({
const bracketNameForTab = (name: string) => name.replace("bracket", "");
+ const canCompactify =
+ loadedBracket &&
+ loadedBracket.type !== "round_robin" &&
+ !loadedBracket.preview &&
+ tournament.bracketsMeta[loadedBracketIdx].enoughTeams;
+
return (
setIdxParam(Number(key))}
>
-
+ : null}>
{visibleBrackets.map((bracket) => (
{tournament.bracketsMeta[bracketIdx].enoughTeams ? (
<>
- {bracket.type !== "round_robin" && !bracket.preview ? (
-
-
-
- ) : null}
{
setBracketExpanded(!bracketExpanded);
}}
+ variant="minimal"
+ size="miniscule"
className={styles.compactifyButton}
icon={bracketExpanded ? : }
>
diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
index 52b489a08..804ef8b86 100644
--- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
@@ -97,6 +97,7 @@ export const action: ActionFunction = async ({ params, request }) => {
// lets broadcast receivers skip revalidating the tournament layout and root loaders
let onlyMatchResultsChanged = false;
let setIsOver = false;
+ let emitStatusUpdate = false;
let endedDroppedMatchIds: number[] = [];
let followingMatchIds: number[] = [];
@@ -515,6 +516,7 @@ export const action: ActionFunction = async ({ params, request }) => {
emitMatchUpdate = true;
emitTournamentUpdate = true;
+ emitStatusUpdate = true;
break;
}
@@ -561,6 +563,7 @@ export const action: ActionFunction = async ({ params, request }) => {
});
emitMatchUpdate = true;
+ emitStatusUpdate = true;
break;
}
@@ -576,6 +579,7 @@ export const action: ActionFunction = async ({ params, request }) => {
});
emitMatchUpdate = true;
+ emitStatusUpdate = true;
break;
}
@@ -676,12 +680,28 @@ export const action: ActionFunction = async ({ params, request }) => {
clearTournamentDataCache(tournamentId);
// refresh RunningTournaments so the sidebar doesn't show stale matches while the TO delays finalizing
- if (setIsOver) {
+ if (setIsOver || emitStatusUpdate) {
const refreshedTournament = await tournamentFromDB(tournamentId);
- // teams just advanced into following matches: their "waiting for teams" pages revalidate too
- followingMatchIds = refreshedTournament
- .followingMatches(match.id)
- .map((followingMatch) => followingMatch.id);
+ const followingMatches = refreshedTournament.followingMatches(match.id);
+
+ if (setIsOver) {
+ // teams just advanced into following matches: their "waiting for teams" pages revalidate too
+ followingMatchIds = followingMatches.map(
+ (followingMatch) => followingMatch.id,
+ );
+ }
+
+ ChatSystemMessage.notifyStatusChanged([
+ ...match.players.map((player) => player.id),
+ ...followingMatches.flatMap((followingMatch) =>
+ [followingMatch.opponent1?.id, followingMatch.opponent2?.id].flatMap(
+ (teamId) =>
+ typeof teamId === "number"
+ ? (refreshedTournament.teamById(teamId)?.memberUserIds ?? [])
+ : [],
+ ),
+ ),
+ ]);
}
const revalidateScope = onlyMatchResultsChanged
diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts
index af188c0c7..78d3eea03 100644
--- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts
+++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts
@@ -16,6 +16,7 @@ import { action, loader } from "./to.$id.matches.$mid";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
sendPersisted: vi.fn(),
notifyNotificationsChanged: vi.fn(),
notifyRoomsChangedByRoomIds: vi.fn(),
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts
index 370b833bf..4001b6395 100644
--- a/app/features/tournament/TournamentRepository.server.ts
+++ b/app/features/tournament/TournamentRepository.server.ts
@@ -32,6 +32,7 @@ import { nullFilledArray, nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { invariant } from "~/utils/invariant";
import {
+ calendarEventStartTime,
commonUserSelect,
concatUserSubmittedImagePrefix,
jsonArrayFrom,
@@ -939,6 +940,84 @@ export function findAllBetweenTwoTimestamps({
.execute();
}
+/**
+ * Members of teams that have not checked in nor dropped out, for every tournament whose first day starts inside the window.
+ * One row per member per tournament; the caller narrows the window to the check-in period.
+ */
+export function findPendingCheckInsStartingBetween({
+ startsAfter,
+ startsBefore,
+}: {
+ startsAfter: Date;
+ startsBefore: Date;
+}) {
+ return (
+ db
+ .selectFrom("TournamentTeamMember")
+ .innerJoin(
+ "TournamentTeam",
+ "TournamentTeamMember.tournamentTeamId",
+ "TournamentTeam.id",
+ )
+ .innerJoin("Tournament", "TournamentTeam.tournamentId", "Tournament.id")
+ .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
+ .innerJoin(
+ "CalendarEventDate",
+ "CalendarEvent.id",
+ "CalendarEventDate.eventId",
+ )
+ .select((eb) => [
+ "TournamentTeamMember.userId",
+ "Tournament.id as tournamentId",
+ tournamentLogoWithDefault(eb).as("logoUrl"),
+ ])
+ // a multi-day tournament checks in before its first day only
+ .where("CalendarEventDate.startsAt", "=", (eb) =>
+ calendarEventStartTime(eb),
+ )
+ .where(
+ "CalendarEventDate.startsAt",
+ ">",
+ dateToDatabaseTimestamp(startsAfter),
+ )
+ .where(
+ "CalendarEventDate.startsAt",
+ "<=",
+ dateToDatabaseTimestamp(startsBefore),
+ )
+ .where("CalendarEvent.hidden", "=", 0)
+ .where("Tournament.isFinalized", "=", 0)
+ .where("TournamentTeam.droppedOut", "=", 0)
+ .where(
+ sql`json_extract("Tournament"."settings", '$.isTest')`,
+ "is not",
+ 1,
+ )
+ .where(
+ sql`json_extract("Tournament"."settings", '$.isDraft')`,
+ "is not",
+ 1,
+ )
+ .where((eb) =>
+ eb.not(
+ eb.exists(
+ eb
+ .selectFrom("TournamentTeamCheckIn")
+ .select("TournamentTeamCheckIn.tournamentTeamId")
+ .whereRef(
+ "TournamentTeamCheckIn.tournamentTeamId",
+ "=",
+ "TournamentTeam.id",
+ )
+ .where("TournamentTeamCheckIn.bracketIdx", "is", null),
+ ),
+ ),
+ )
+ .orderBy("CalendarEventDate.startsAt")
+ .execute()
+ );
+}
+
/** `ORGANIZE` and `MANAGE_MATCHES` holders keyed by tournament id, without loading the tournaments themselves. */
export async function findOrganizerPermissionsByTournamentIds(
tournamentIds: number[],
diff --git a/app/features/tournament/actions/to.$id.join.server.ts b/app/features/tournament/actions/to.$id.join.server.ts
index 267290fbc..44176904d 100644
--- a/app/features/tournament/actions/to.$id.join.server.ts
+++ b/app/features/tournament/actions/to.$id.join.server.ts
@@ -5,6 +5,7 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
+ notifyTournamentStatusChanged,
tournamentFromParams,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
@@ -92,6 +93,8 @@ export const action: ActionFunction = async ({ params, url }) => {
clearTournamentDataCache(tournamentId);
+ await notifyTournamentStatusChanged(tournamentId, [user.id]);
+
throw redirect(
tournament.registrationOpen
? tournamentRegisterPage(leanTeam.tournamentId)
diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts
index a38914ddd..15d38d621 100644
--- a/app/features/tournament/actions/to.$id.register.server.ts
+++ b/app/features/tournament/actions/to.$id.register.server.ts
@@ -8,11 +8,13 @@ import { resolveNotifications } from "~/features/notifications/core/resolve.serv
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { getMemberRoleType } from "~/features/team/team-utils";
+import * as PendingCheckIns from "~/features/tournament/core/PendingCheckIns.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
clearTournamentDataCache,
+ notifyTournamentStatusChanged,
tournamentFromParams,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
@@ -59,6 +61,7 @@ export const action: ActionFunction = async ({ request, params }) => {
);
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
+ let statusChangedUserIds: number[] = [];
switch (data._action) {
case "UPSERT_TEAM": {
@@ -147,6 +150,11 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: user.id,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
+
+ // registration and check-in windows overlap, so a fresh registrant can
+ // already be pending check-in
+ PendingCheckIns.clearCache();
+ statusChangedUserIds = [user.id];
}
break;
}
@@ -180,6 +188,9 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
+
+ PendingCheckIns.clearCache();
+ statusChangedUserIds = [data.userId];
break;
}
case "LEAVE_TEAM": {
@@ -215,6 +226,9 @@ export const action: ActionFunction = async ({ request, params }) => {
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
+ PendingCheckIns.clearCache();
+ statusChangedUserIds = [user.id];
+
break;
}
case "UPDATE_MAP_POOL": {
@@ -260,6 +274,7 @@ export const action: ActionFunction = async ({ request, params }) => {
);
await TournamentTeamRepository.checkIn(teamMemberOf.id);
+ PendingCheckIns.clearCache();
logger.info(
`Checking in (success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournamentId}`,
);
@@ -269,6 +284,8 @@ export const action: ActionFunction = async ({ request, params }) => {
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId },
});
+
+ statusChangedUserIds = teamMemberOf.memberUserIds;
break;
}
case "ADD_PLAYER": {
@@ -419,6 +436,9 @@ export const action: ActionFunction = async ({ request, params }) => {
}
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
+ PendingCheckIns.clearCache();
+ statusChangedUserIds = ownTeam.memberUserIds;
+
break;
}
default: {
@@ -428,6 +448,8 @@ export const action: ActionFunction = async ({ request, params }) => {
clearTournamentDataCache(tournamentId);
+ await notifyTournamentStatusChanged(tournamentId, statusChangedUserIds);
+
return null;
};
diff --git a/app/features/tournament/core/PendingCheckIns.server.ts b/app/features/tournament/core/PendingCheckIns.server.ts
new file mode 100644
index 000000000..e90f1eb12
--- /dev/null
+++ b/app/features/tournament/core/PendingCheckIns.server.ts
@@ -0,0 +1,59 @@
+import cachified from "@epic-web/cachified";
+import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import { cache, ttl } from "~/utils/cache.server";
+import { TOURNAMENT } from "../tournament-constants";
+
+const CACHE_KEY = "pending-check-ins";
+const CACHE_TTL_MS = 60 * 1000;
+
+export interface PendingCheckIn {
+ tournamentId: number;
+ logoUrl: string;
+}
+
+/**
+ * The tournament the user has to check in to right now, null when none has
+ * check-in open. Cached site-wide rather than resolved per user: the header
+ * asks on every page load, while the answer only moves as tournaments approach
+ * their start.
+ */
+export async function byUserId(userId: number): Promise {
+ return (await cachedByUserId()).get(userId) ?? null;
+}
+
+/** Drops the cache so a team checking in (or out) shows in the header without waiting out the TTL. */
+export function clearCache() {
+ cache.delete(CACHE_KEY);
+}
+
+function cachedByUserId() {
+ return cachified({
+ key: CACHE_KEY,
+ cache,
+ ttl: ttl(CACHE_TTL_MS),
+ getFreshValue: resolveByUserId,
+ });
+}
+
+async function resolveByUserId() {
+ const now = new Date();
+ const rows = await TournamentRepository.findPendingCheckInsStartingBetween({
+ startsAfter: now,
+ startsBefore: new Date(
+ now.getTime() + TOURNAMENT.REGULAR_CHECK_IN_WINDOW_MS,
+ ),
+ });
+
+ const result = new Map();
+ // rows come in start time order, so the soonest tournament wins a double booking
+ for (const row of rows) {
+ if (result.has(row.userId)) continue;
+
+ result.set(row.userId, {
+ tournamentId: row.tournamentId,
+ logoUrl: row.logoUrl,
+ });
+ }
+
+ return result;
+}
diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts
index f1ad165c2..37bf85a6f 100644
--- a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts
+++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts
@@ -1,4 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
+import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
tournamentDataCached,
tournamentFromParams,
@@ -11,6 +12,7 @@ import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import * as Standings from "../core/Standings";
import { type AllRoundsItem, tournamentTeamSets } from "../core/sets.server";
+import * as TournamentTeamRepository from "../TournamentTeamRepository.server";
export type TournamentTeamLoaderData = SerializeFrom;
@@ -62,6 +64,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return {
tournamentTeamId,
team,
+ // the invite link of the add sub popover, only the team's own captain gets it
+ subInviteCode: canAddSubs(fullTournament, tournamentTeamId, user)
+ ? await TournamentTeamRepository.findInviteCodeById(tournamentTeamId)
+ : null,
activePlayers:
sets.length > 0
? fullTournament.participatedPlayerUserIdsByTeamId(tournamentTeamId)
@@ -98,3 +104,18 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
: undefined,
};
};
+
+function canAddSubs(
+ tournament: Tournament,
+ tournamentTeamId: number,
+ user: { id: number } | undefined,
+) {
+ if (tournament.ownedTeamByUser(user)?.id !== tournamentTeamId) return false;
+
+ return (
+ tournament.hasStarted &&
+ !tournament.everyBracketOver &&
+ tournament.autonomousSubs &&
+ tournament.teamMemberOfProgressStatus(user)?.type !== "THANKS_FOR_PLAYING"
+ );
+}
diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx
index 6b43806d5..1cccd9a6a 100644
--- a/app/features/tournament/routes/to.$id.teams.$tid.tsx
+++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx
@@ -1,11 +1,13 @@
import clsx from "clsx";
-import { HardDriveDownload } from "lucide-react";
+import { HardDriveDownload, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useFetcher, useLoaderData } from "react-router";
+import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { ModeImage, StageImage } from "~/components/Image";
+import { InviteLinkInput } from "~/components/InviteLinkInput";
import { Placement } from "~/components/Placement";
import { UserLink } from "~/components/UserLink";
import { useUser } from "~/features/auth/core/user";
@@ -15,10 +17,12 @@ import {
type TournamentRunGraphicSeriesWin,
} from "~/features/img-export/components/TournamentRunGraphic";
import { useTournament } from "~/features/tournament/tournament-context";
+import { tournamentJoinPage } from "~/features/tournament/tournament-urls";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { metaTags } from "~/utils/remix";
import {
+ SENDOU_INK_BASE_URL,
teamPage,
tournamentMatchPage,
tournamentTeamCompsPage,
@@ -78,6 +82,9 @@ export default function TournamentTeamPage() {
{t("tournament:team.teamPage")}
) : null}
+ {data.subInviteCode ? (
+
+ ) : null}
{data.record ? (
();
+
+ const subsAvailableToAdd =
+ tournament.maxMembersPerTeam - data.team.members.length;
+
+ const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
+ tournamentId: tournament.ctx.id,
+ inviteCode,
+ })}`;
+
+ return (
+ }
+ data-testid="add-sub-button"
+ >
+ {t("tournament:actions.addSub")}
+
+ }
+ >
+ {t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })}
+ {subsAvailableToAdd > 0 ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
+
function winPercentage(won: number, total: number) {
return total === 0 ? 0 : Math.round((won / total) * 100);
}
diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts
index b639fd783..6aa14b40e 100644
--- a/app/features/tournament/tournament-constants.ts
+++ b/app/features/tournament/tournament-constants.ts
@@ -17,6 +17,8 @@ export const TOURNAMENT = {
MAX_SAVED_COUNT: 20,
/** How many days after a tournament ends VOD links are shown on the bracket */
VOD_VISIBILITY_DAYS: 7,
+ /** How long before the start regular check-in opens, closing when the tournament starts */
+ REGULAR_CHECK_IN_WINDOW_MS: 60 * 60 * 1000,
ROUND_NAMES: {
WB_FINALS: "WB Finals",
GRAND_FINALS: "Grand Finals",
diff --git a/app/features/user-activity/core/UserActivity.server.test.ts b/app/features/user-activity/core/UserActivity.server.test.ts
new file mode 100644
index 000000000..1a5ff300b
--- /dev/null
+++ b/app/features/user-activity/core/UserActivity.server.test.ts
@@ -0,0 +1,128 @@
+import { subHours } from "date-fns";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { backdate } from "~/db/seed/core/backdate";
+import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
+import * as UserFactory from "~/db/seed/factories/UserFactory";
+import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
+import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
+import { runningTournamentWithMatch } from "~/features/tournament-bracket/core/tests/test-utils";
+import * as UserActivity from "./UserActivity.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,
+}));
+
+/** Users are interchangeable here, so tests name them by 1-based position. */
+const users = UserFactory.pool();
+
+describe("UserActivity.resolve", () => {
+ beforeEach(async () => {
+ await users.create(8);
+ RunningTournaments.clear();
+ await refreshSendouQInstance();
+ });
+
+ test("returns empty activity for a user doing nothing", () => {
+ const activity = UserActivity.resolve(users.id(1));
+
+ expect(activity.sendouq).toBeNull();
+ expect(activity.tournaments).toEqual([]);
+ });
+
+ test("resolves the user's SendouQ group with its received like count", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: [users.id(5)],
+ });
+ const group = await SQGroupFactory.create(
+ { memberUserIds: [users.id(1), users.id(2)] },
+ { likedByGroupIds: [likerGroup.id] },
+ );
+ await refreshSendouQInstance();
+
+ const activity = UserActivity.resolve(users.id(1));
+
+ expect(activity.sendouq?.group.id).toBe(group.id);
+ expect(activity.sendouq?.likesReceivedCount).toBe(1);
+ expect(activity.sendouq?.expired).toBe(false);
+ });
+
+ test("a given like does not count as received", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: [users.id(5)],
+ });
+ await SQGroupFactory.create(
+ { memberUserIds: [users.id(1)] },
+ { likedByGroupIds: [likerGroup.id] },
+ );
+ await refreshSendouQInstance();
+
+ expect(UserActivity.resolve(users.id(5)).sendouq?.likesReceivedCount).toBe(
+ 0,
+ );
+ });
+
+ test("marks a group inactive for too long as expired", async () => {
+ const group = await SQGroupFactory.create({
+ memberUserIds: [users.id(1)],
+ });
+ await backdate("Group", group.id, {
+ latestActionAt: subHours(new Date(), 2),
+ });
+ await refreshSendouQInstance();
+
+ expect(UserActivity.resolve(users.id(1)).sendouq?.expired).toBe(true);
+ });
+
+ test("resolves running tournament statuses", () => {
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: [users.id(1)],
+ teamTwoUserIds: [users.id(2)],
+ }),
+ );
+
+ const activity = UserActivity.resolve(users.id(1));
+
+ expect(activity.tournaments).toHaveLength(1);
+ expect(activity.tournaments[0].status.type).toBe("MATCH");
+ });
+
+ test("omits leagues, whose matches are not something the user is doing right now", () => {
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: [users.id(1)],
+ teamTwoUserIds: [users.id(2)],
+ isLeague: true,
+ }),
+ );
+
+ expect(UserActivity.resolve(users.id(1)).tournaments).toEqual([]);
+ });
+
+ test("resolves SendouQ and tournament activity at the same time", async () => {
+ await SQGroupFactory.create({ memberUserIds: [users.id(1)] });
+ await refreshSendouQInstance();
+ RunningTournaments.add(
+ runningTournamentWithMatch({
+ tournamentId: 1,
+ teamOneUserIds: [users.id(1)],
+ teamTwoUserIds: [users.id(2)],
+ }),
+ );
+
+ const activity = UserActivity.resolve(users.id(1));
+
+ expect(activity.sendouq).not.toBeNull();
+ expect(activity.tournaments).toHaveLength(1);
+ });
+});
diff --git a/app/features/user-activity/core/UserActivity.server.ts b/app/features/user-activity/core/UserActivity.server.ts
new file mode 100644
index 000000000..b439c8942
--- /dev/null
+++ b/app/features/user-activity/core/UserActivity.server.ts
@@ -0,0 +1,68 @@
+import { groupExpiryStatus } from "~/features/sendouq/core/groups";
+import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
+import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
+import type {
+ Tournament,
+ TournamentTeamMemberProgressStatus,
+} from "~/features/tournament-bracket/core/Tournament";
+
+export interface UserActivity {
+ /** The user's SendouQ group (never INACTIVE), with its pending received likes and expiry resolved. */
+ sendouq: {
+ group: NonNullable>;
+ likesReceivedCount: number;
+ expired: boolean;
+ } | null;
+ /** The user's status in every running tournament they are playing, in registry order. Leagues are not activity: their matches run over days, not something the user is doing right now. */
+ tournaments: Array<{
+ tournament: Tournament;
+ status: TournamentTeamMemberProgressStatus;
+ }>;
+}
+
+/**
+ * Resolves everything the user is doing right now (SendouQ + running
+ * tournaments) from in-memory state, no DB reads. The single source shared by
+ * the friends sidebar, the public active-match API and the header status
+ * indicator; each consumer applies its own priority order.
+ */
+export function resolve(userId: number): UserActivity {
+ const group = SendouQ.findOwnGroup(userId);
+
+ return {
+ sendouq: group
+ ? {
+ group,
+ likesReceivedCount: SendouQ.likesReceivedCount(group.id),
+ expired: groupExpiryStatus(group.latestActionAt) === "EXPIRED",
+ }
+ : null,
+ tournaments: RunningTournaments.all.flatMap((tournament) => {
+ if (tournament.isLeague) return [];
+
+ const status = tournament.teamMemberOfProgressStatus({ id: userId });
+
+ return status ? [{ tournament, status }] : [];
+ }),
+ };
+}
+
+/**
+ * Whether a tournament progress status counts as "playing right now". CHECKIN
+ * is in-progress for the user themself; the friends projection drops it as a
+ * friend needing to check in is not watchable activity.
+ */
+export const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record<
+ TournamentTeamMemberProgressStatus["type"],
+ boolean
+> = {
+ MATCH: true,
+ WAITING_FOR_MATCH: true,
+ WAITING_FOR_CAST: true,
+ WAITING_FOR_ROUND: true,
+ WAITING_FOR_GROUPS: true,
+ CHECKIN: true,
+ // to counter 2 day tournaments showing as in progress in between
+ WAITING_FOR_BRACKET: false,
+ THANKS_FOR_PLAYING: false,
+};
diff --git a/app/root.tsx b/app/root.tsx
index 40d434230..71fa3633c 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -39,6 +39,7 @@ import { getUser } from "./features/auth/core/user.server";
import { userMiddleware } from "./features/auth/core/user-middleware.server";
import { ChatProvider } from "./features/chat/ChatProvider";
import { isMatchResultsScopedRevalidation } from "./features/chat/revalidation-scope";
+import { GlobalStatusProvider } from "./features/global-status/GlobalStatusProvider";
import { getSidenavSession } from "./features/layout/core/sidenav-session.server";
import { LayoutDataProvider } from "./features/layout/LayoutDataProvider";
import { NotificationsProvider } from "./features/notifications/NotificationsProvider";
@@ -268,7 +269,9 @@ function Document({
- {children}
+
+ {children}
+
diff --git a/app/routes.ts b/app/routes.ts
index 571fd6f13..537659414 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -343,6 +343,7 @@ export default [
"/api/notifications",
"features/notifications/routes/api.notifications.ts",
),
+ route("/api/status", "features/global-status/routes/api.status.ts"),
route("/api", "features/api/routes/api.tsx"),
...prefix("/a", [
diff --git a/app/routines/closeExpiredContinueVotes.test.ts b/app/routines/closeExpiredContinueVotes.test.ts
index f7c2a3976..af4c0bad2 100644
--- a/app/routines/closeExpiredContinueVotes.test.ts
+++ b/app/routines/closeExpiredContinueVotes.test.ts
@@ -9,6 +9,7 @@ import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));
diff --git a/app/routines/notifyCheckInStart.ts b/app/routines/notifyCheckInStart.ts
index 7499c72d7..05bc80d90 100644
--- a/app/routines/notifyCheckInStart.ts
+++ b/app/routines/notifyCheckInStart.ts
@@ -1,3 +1,4 @@
+import * as ChatSystemMessage from "../features/chat/ChatSystemMessage.server";
import { notify } from "../features/notifications/core/notify.server";
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
import { tournamentDataCached } from "../features/tournament-bracket/core/Tournament.server";
@@ -24,6 +25,9 @@ export const NotifyCheckInStartRoutine = new Routine({
logger.info(
`Notifying check-in start for tournament ${tournament.ctx.id}`,
);
+ const memberUserIds = tournament.ctx.teams.flatMap(
+ (team) => team.memberUserIds,
+ );
await notify({
notification: {
type: "TO_CHECK_IN_OPENED",
@@ -33,10 +37,12 @@ export const NotifyCheckInStartRoutine = new Routine({
},
pictureUrl: tournament.ctx.logoUrl,
},
- userIds: tournament.ctx.teams
- .flatMap((team) => team.memberUserIds)
- .concat(tournament.ctx.staff.map((staff) => staff.id)),
+ userIds: memberUserIds.concat(
+ tournament.ctx.staff.map((staff) => staff.id),
+ ),
});
+ // so the header check-in reminder appears without waiting for a navigation
+ ChatSystemMessage.notifyStatusChanged(memberUserIds);
}
},
});
diff --git a/app/routines/resolveStaleSQMatches.test.ts b/app/routines/resolveStaleSQMatches.test.ts
index 11a323003..93969dbd7 100644
--- a/app/routines/resolveStaleSQMatches.test.ts
+++ b/app/routines/resolveStaleSQMatches.test.ts
@@ -9,6 +9,7 @@ import { ResolveStaleSQMatchesRoutine } from "./resolveStaleSQMatches";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
+ notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));
diff --git a/app/routines/resolveStaleSQMatches.ts b/app/routines/resolveStaleSQMatches.ts
index 1e107ee71..35492211e 100644
--- a/app/routines/resolveStaleSQMatches.ts
+++ b/app/routines/resolveStaleSQMatches.ts
@@ -22,6 +22,7 @@ export const ResolveStaleSQMatchesRoutine = new Routine({
let canceledCount = 0;
let confirmedCount = 0;
+ const resolvedParticipantIds: number[] = [];
for (const staleMatch of staleMatches) {
const result = await SQMatchRepository.resolveUnfinishedMatch(
staleMatch.id,
@@ -31,6 +32,10 @@ export const ResolveStaleSQMatchesRoutine = new Routine({
if (result.status === "CANCELED") canceledCount++;
if (result.status === "CONFIRMED") confirmedCount++;
+ resolvedParticipantIds.push(
+ ...staleMatch.members.map((member) => member.userId),
+ );
+
if (staleMatch.chatRoomId) {
ChatSystemMessage.send({
channel: chatRoomChannel(staleMatch.chatRoomId),
@@ -52,6 +57,8 @@ export const ResolveStaleSQMatchesRoutine = new Routine({
await refreshSendouQInstance();
+ ChatSystemMessage.notifyStatusChanged(resolvedParticipantIds);
+
logger.info(
`Resolved stale SendouQ matches: ${canceledCount} canceled, ${confirmedCount} auto-confirmed`,
);
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index 6624efc67..a030a6726 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -219,6 +219,7 @@ export const NOTIFICATIONS_URL = "/notifications";
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
export const NOTIFICATIONS_SUBSCRIBE_ROUTE = "/notifications/subscribe";
export const NOTIFICATIONS_DATA_ROUTE = "/api/notifications";
+export const STATUS_DATA_ROUTE = "/api/status";
export const CHAT_ROOMS_DATA_ROUTE = "/api/chat/rooms";
export const chatRoomDataRoute = (roomId: number) =>
diff --git a/changelog/2026-09-01-header-status-indicator.md b/changelog/2026-09-01-header-status-indicator.md
new file mode 100644
index 000000000..2645408ac
--- /dev/null
+++ b/changelog/2026-09-01-header-status-indicator.md
@@ -0,0 +1,8 @@
+---
+navItem: [sendouq, medal]
+type: feature
+---
+New header status indicator
+
+- While you are in SendouQ or playing a tournament, the header shows what you have going on: your group filling up, likes received while in queue, ready checks, ongoing matches, scores waiting to be confirmed and bracket check-ins
+- It links straight to the right page and updates live as things happen, so you can freely browse the site while waiting
diff --git a/changelog/2026-09-19-brackets-page-team-actions.md b/changelog/2026-09-19-brackets-page-team-actions.md
new file mode 100644
index 000000000..d8b6d613b
--- /dev/null
+++ b/changelog/2026-09-19-brackets-page-team-actions.md
@@ -0,0 +1,9 @@
+---
+navItem: medal
+type: feature
+---
+Tournament brackets page minor cleanup
+
+- The "waiting on match" status strip is gone since the header now shows the same
+- The Add sub button moved to your team's page
+- The Compactify toggle moved up into the bracket tab row, so the bracket now starts right below the tabs
diff --git a/e2e/pages/layout/anything-adder.ts b/e2e/pages/layout/anything-adder.ts
index 8a81e1131..2f70a7ff7 100644
--- a/e2e/pages/layout/anything-adder.ts
+++ b/e2e/pages/layout/anything-adder.ts
@@ -13,7 +13,7 @@ type AddableItem =
| "tournament"
| "vods";
-/** The "Add new…" menu of the site layout, available on every page. */
+/** The "Add new" menu of the site layout, available on every page. */
export class AnythingAdder {
private readonly page: Page;
readonly locators;
diff --git a/e2e/pages/layout/top-right-buttons.ts b/e2e/pages/layout/top-right-buttons.ts
index f08094fca..03fb475cc 100644
--- a/e2e/pages/layout/top-right-buttons.ts
+++ b/e2e/pages/layout/top-right-buttons.ts
@@ -2,9 +2,11 @@ import type { Page } from "@playwright/test";
/** The button cluster at the right end of the site header. */
export class TopRightButtons {
+ private readonly page: Page;
readonly locators;
constructor(page: Page) {
+ this.page = page;
this.locators = {
// hidden for users with any patron tier
supportLink: page
@@ -16,4 +18,9 @@ export class TopRightButtons {
.getByRole("button", { name: "Search" }),
};
}
+
+ /** The header's status indicator showing what the user has going on, e.g. "Waiting for match". */
+ globalStatus(text: string) {
+ return this.page.getByRole("banner").getByRole("link", { name: text });
+ }
}
diff --git a/e2e/pages/tournament/tournament-brackets-page.ts b/e2e/pages/tournament/tournament-brackets-page.ts
index 53d877e89..d7f735b40 100644
--- a/e2e/pages/tournament/tournament-brackets-page.ts
+++ b/e2e/pages/tournament/tournament-brackets-page.ts
@@ -39,7 +39,6 @@ export class TournamentBracketsPage {
byeTeam: page.getByTestId("bye-team"),
prepareMapsButton: page.getByTestId("prepare-maps-button"),
preparedMapsCheckIcon: page.getByTestId("prepared-maps-check-icon"),
- waitingOnGroupText: page.getByText("Waiting on group to finish"),
};
}
@@ -163,13 +162,6 @@ export class TournamentBracketsPage {
return new TournamentMatchPage(this.page);
}
- /** Copies the sub invite link of the own team and reads it off the clipboard. */
- async copySubInviteLink(): Promise {
- await this.page.getByTestId("add-sub-button").click();
- await this.page.getByTestId("copy-invite-link-button").click();
- return this.page.evaluate("navigator.clipboard.readText()");
- }
-
/** Checks the own team in to a bracket that requires a check-in. */
checkInBracket() {
return submit(this.page, "check-in-bracket-button");
diff --git a/e2e/pages/tournament/tournament-team-page.ts b/e2e/pages/tournament/tournament-team-page.ts
index f5bc035ba..dda64e65e 100644
--- a/e2e/pages/tournament/tournament-team-page.ts
+++ b/e2e/pages/tournament/tournament-team-page.ts
@@ -21,6 +21,13 @@ export class TournamentTeamPage {
});
}
+ /** Copies the sub invite link of the own team and reads it off the clipboard. */
+ async copySubInviteLink(): Promise {
+ await this.page.getByTestId("add-sub-button").click();
+ await this.page.getByTestId("copy-invite-link-button").click();
+ return this.page.evaluate("navigator.clipboard.readText()");
+ }
+
/** A map of the team's counterpick map pool, shown to organizers before the tournament starts. */
mapPoolStage(mode: ModeShort, stageId: StageId) {
return this.page.getByTestId(`team-map-pool-${mode}-${stageId}`);
diff --git a/e2e/pages/trophies/trophies-page.ts b/e2e/pages/trophies/trophies-page.ts
index 6c1f7a62f..04352fac4 100644
--- a/e2e/pages/trophies/trophies-page.ts
+++ b/e2e/pages/trophies/trophies-page.ts
@@ -11,7 +11,7 @@ export class TrophiesPage {
this.page = page;
this.locators = {
searchInput: page.getByRole("textbox"),
- // the "Add new…" menu links to /trophies/new from every page
+ // the "Add new" menu links to /trophies/new from every page
trophyLinks: page.locator("main").locator("a[href^='/trophies/']"),
};
}
diff --git a/e2e/tournament-bracket-multi-stage.spec.ts b/e2e/tournament-bracket-multi-stage.spec.ts
index 489c28384..bdce6a162 100644
--- a/e2e/tournament-bracket-multi-stage.spec.ts
+++ b/e2e/tournament-bracket-multi-stage.spec.ts
@@ -13,6 +13,7 @@ import {
teamSeeds,
} from "./helpers/tournament";
import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page";
+import { TopRightButtons } from "./pages/layout/top-right-buttons";
import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page";
import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
@@ -209,7 +210,10 @@ test.describe("Tournament bracket multi stage", () => {
await match.backToBracket();
}
- await expect(brackets.locators.waitingOnGroupText).toBeVisible();
+ // Sendou's team has finished its group's matches, the header says so while the last one plays out
+ await expect(
+ new TopRightButtons(page).globalStatus("Waiting for match"),
+ ).toBeVisible();
const lastGroupsMatch = await brackets.openMatch(5);
await lastGroupsMatch.openTab("action");
diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts
index 04c563082..991e74089 100644
--- a/e2e/tournament-bracket.spec.ts
+++ b/e2e/tournament-bracket.spec.ts
@@ -12,6 +12,7 @@ import {
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentJoinPage } from "./pages/tournament/tournament-join-page";
import { TournamentMatchPage } from "./pages/tournament/tournament-match-page";
+import { TournamentTeamPage } from "./pages/tournament/tournament-team-page";
test.describe("Tournament bracket", () => {
test("sets active roster as regular member", async ({ page, factories }) => {
@@ -134,10 +135,10 @@ test.describe("Tournament bracket", () => {
// captain of the last seeded team
await impersonate(page, teams[2].ownerUserId);
- const brackets = new TournamentBracketsPage(page);
- await brackets.goto(tournament.id);
+ const teamPage = new TournamentTeamPage(page);
+ await teamPage.goto(tournament.id, teams[2].id);
- const inviteLink = await brackets.copySubInviteLink();
+ const inviteLink = await teamPage.copySubInviteLink();
await impersonate(page, sub.id);
diff --git a/locales/da/common.json b/locales/da/common.json
index 861f05cd6..d4354348e 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/da/settings.json b/locales/da/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/da/settings.json
+++ b/locales/da/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/de/common.json b/locales/de/common.json
index 762a72a01..1116adb24 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/de/settings.json b/locales/de/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/de/settings.json
+++ b/locales/de/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/en/common.json b/locales/en/common.json
index b86ec5417..5e679c93d 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Plus suggestion",
"header.adder.trophy": "Trophy",
+ "globalStatus.SQ_PREPARING": "Preparing",
+ "globalStatus.SQ_QUEUED": "In queue",
+ "globalStatus.SQ_EXPIRED": "Group inactive",
+ "globalStatus.SQ_READY_CHECK": "Ready check",
+ "globalStatus.SQ_MATCH": "In match",
+ "globalStatus.TO_CHECKIN": "Check in",
+ "globalStatus.TO_MATCH": "In match",
+ "globalStatus.TO_WAITING_FOR_MATCH": "Waiting for match",
+ "globalStatus.TO_WAITING_FOR_CAST": "Waiting for cast",
"notifications.title": "Notifications",
"notifications.empty": "None yet, check back later",
"notifications.fullList.explanation": "Max of 100 notifications shown. Notifications older than 14 days are automatically deleted.",
diff --git a/locales/en/settings.json b/locales/en/settings.json
index 41bfc46c0..9d5173f48 100644
--- a/locales/en/settings.json
+++ b/locales/en/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Prefer",
"matchProfile.maps.neutral": "Neutral",
"sounds.likeReceived": "Group invitation received",
- "sounds.groupNewMember": "Group invitation accepted",
"sounds.readyCheckStarted": "SendouQ ready check started",
"sounds.matchStarted": "SendouQ match started",
"sounds.tournamentMatchStarted": "Tournament match started"
diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json
index 6d386fdad..35da9b70d 100644
--- a/locales/es-ES/common.json
+++ b/locales/es-ES/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Sugerencia Plus",
"header.adder.trophy": "Trofeo",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "Notificaciones",
"notifications.empty": "Nada aún, vuelve más tarde",
"notifications.fullList.explanation": "Se muestran máximo 100 notificaciones. Las notificaciones de más de 14 días se eliminan automáticamente.",
diff --git a/locales/es-ES/settings.json b/locales/es-ES/settings.json
index 3001d07f3..c17957017 100644
--- a/locales/es-ES/settings.json
+++ b/locales/es-ES/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Preferir",
"matchProfile.maps.neutral": "Neutral",
"sounds.likeReceived": "Invitación de grupo recibida",
- "sounds.groupNewMember": "Invitación de grupo aceptada",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "Partida de SendouQ iniciada",
"sounds.tournamentMatchStarted": "Set de torneo iniciado"
diff --git a/locales/es-US/common.json b/locales/es-US/common.json
index c20331ca7..04eb29ff2 100644
--- a/locales/es-US/common.json
+++ b/locales/es-US/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Sugerencia Plus",
"header.adder.trophy": "Trofeo",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "Notificaciones",
"notifications.empty": "Nada aún, vuelve más tarde",
"notifications.fullList.explanation": "Se muestran máximo 100 notificaciones. Las notificaciones de más de 14 días se eliminan automáticamente.",
diff --git a/locales/es-US/settings.json b/locales/es-US/settings.json
index 3001d07f3..c17957017 100644
--- a/locales/es-US/settings.json
+++ b/locales/es-US/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Preferir",
"matchProfile.maps.neutral": "Neutral",
"sounds.likeReceived": "Invitación de grupo recibida",
- "sounds.groupNewMember": "Invitación de grupo aceptada",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "Partida de SendouQ iniciada",
"sounds.tournamentMatchStarted": "Set de torneo iniciado"
diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json
index 62797636e..52e97f395 100644
--- a/locales/fr-CA/common.json
+++ b/locales/fr-CA/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/fr-CA/settings.json b/locales/fr-CA/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/fr-CA/settings.json
+++ b/locales/fr-CA/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json
index b0400ea44..e1c3b7572 100644
--- a/locales/fr-EU/common.json
+++ b/locales/fr-EU/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Suggestion au Plus",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "Notification",
"notifications.empty": "Rien ici encore, revenez plus tard",
"notifications.fullList.explanation": "100 notifications au maximum peuvent être afficher. Les notifications sont effacer automatiquement au bout de 14 jours.",
diff --git a/locales/fr-EU/settings.json b/locales/fr-EU/settings.json
index adac6f574..407320a57 100644
--- a/locales/fr-EU/settings.json
+++ b/locales/fr-EU/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Préférer",
"matchProfile.maps.neutral": "Neutre",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/he/common.json b/locales/he/common.json
index 56d50745f..2ad12ecaa 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/he/settings.json b/locales/he/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/he/settings.json
+++ b/locales/he/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/it/common.json b/locales/it/common.json
index d7b7d3520..a2bd0d022 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Suggerimento Plus",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "Notifiche",
"notifications.empty": "Nessuna notifica, controlla più tardi",
"notifications.fullList.explanation": "Un massimo di 100 notifiche sono state mostrate. Le notifiche più vecchie di 14 giorni vengono eliminate automaticamente.",
diff --git a/locales/it/settings.json b/locales/it/settings.json
index 4afea4587..2748edd27 100644
--- a/locales/it/settings.json
+++ b/locales/it/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Preferisci",
"matchProfile.maps.neutral": "Neutrale",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/ja/common.json b/locales/ja/common.json
index ee2d21cc2..75c3d533e 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "動画",
"header.adder.plusSuggestion": "プラスサーバー推薦",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "通知",
"notifications.empty": "通知はありません",
"notifications.fullList.explanation": "最大100件の通知が表示されます。14日以上前の通知は削除されます。",
diff --git a/locales/ja/settings.json b/locales/ja/settings.json
index add12eb56..6cd87536b 100644
--- a/locales/ja/settings.json
+++ b/locales/ja/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "好み",
"matchProfile.maps.neutral": "普通",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 33e713d27..6ef939890 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/ko/settings.json b/locales/ko/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/ko/settings.json
+++ b/locales/ko/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 1cf8d7c74..39dd94e97 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/nl/settings.json b/locales/nl/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/nl/settings.json
+++ b/locales/nl/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 7910e9481..791ffbd45 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/pl/settings.json b/locales/pl/settings.json
index 93f455784..15e13a78e 100644
--- a/locales/pl/settings.json
+++ b/locales/pl/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json
index 1cc7151a9..b1adb8cf9 100644
--- a/locales/pt-BR/common.json
+++ b/locales/pt-BR/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.fullList.explanation": "",
diff --git a/locales/pt-BR/settings.json b/locales/pt-BR/settings.json
index 7af7415cd..bcbf63b52 100644
--- a/locales/pt-BR/settings.json
+++ b/locales/pt-BR/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Prefere",
"matchProfile.maps.neutral": "Neutro",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 8ab4916a2..9c46cde6b 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Рекомендация Плюс",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "Уведомления",
"notifications.empty": "Пока нет, проверьте позже",
"notifications.fullList.explanation": "Может быть показано максимум 100 уведомлений. Уведомления старше 14 дней автоматически удалены.",
diff --git a/locales/ru/settings.json b/locales/ru/settings.json
index 061a5f66a..66019e13c 100644
--- a/locales/ru/settings.json
+++ b/locales/ru/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "Предпочитаю",
"matchProfile.maps.neutral": "Нейтрально",
"sounds.likeReceived": "",
- "sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 24fc126ad..5f5eaddd6 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -47,6 +47,15 @@
"header.adder.vod": "视频",
"header.adder.plusSuggestion": "Plus 推荐",
"header.adder.trophy": "",
+ "globalStatus.SQ_PREPARING": "",
+ "globalStatus.SQ_QUEUED": "",
+ "globalStatus.SQ_EXPIRED": "",
+ "globalStatus.SQ_READY_CHECK": "",
+ "globalStatus.SQ_MATCH": "",
+ "globalStatus.TO_CHECKIN": "",
+ "globalStatus.TO_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_MATCH": "",
+ "globalStatus.TO_WAITING_FOR_CAST": "",
"notifications.title": "通知",
"notifications.empty": "还没有通知,晚点再来看看吧",
"notifications.fullList.explanation": "最多显示 100 条通知。超过 14 天的通知将被自动删除。",
diff --git a/locales/zh/settings.json b/locales/zh/settings.json
index 0f329d700..12e7a49be 100644
--- a/locales/zh/settings.json
+++ b/locales/zh/settings.json
@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "偏好",
"matchProfile.maps.neutral": "中立",
"sounds.likeReceived": "收到小组邀请",
- "sounds.groupNewMember": "小组邀请已通过",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "SendouQ 对局开始",
"sounds.tournamentMatchStarted": "赛事对局开始"
diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts
index 436004fcc..95c70abe1 100644
--- a/scripts/benchmark-db/cases.ts
+++ b/scripts/benchmark-db/cases.ts
@@ -761,6 +761,9 @@ export function buildCases(fx: Fixtures): {
add("SQMatchRepository.findById", fx.heavyGroupMatchId, (matchId) =>
SQMatchRepository.findById(matchId),
);
+ add("SQMatchRepository.findLiveStateById", fx.heavyGroupMatchId, (matchId) =>
+ SQMatchRepository.findLiveStateById(matchId),
+ );
add("SQMatchRepository.countSeasonResultPagesByUserId", fx.sq, (sq) =>
SQMatchRepository.countSeasonResultPagesByUserId(sq),
);
@@ -847,6 +850,9 @@ export function buildCases(fx: Fixtures): {
addStatic("SQGroupRepository.findRecentlyFinishedMatches", () =>
SQGroupRepository.findRecentlyFinishedMatches(),
);
+ addStatic("SQGroupRepository.findCurrentReceivedLikeCounts", () =>
+ SQGroupRepository.findCurrentReceivedLikeCounts(),
+ );
addStatic("SplatoonRotationRepository.findAll", () =>
SplatoonRotationRepository.findAll(),
@@ -1230,6 +1236,15 @@ export function buildCases(fx: Fixtures): {
fx.calendarWindow,
(window) => TournamentRepository.findAllBetweenTwoTimestamps(window),
);
+ add(
+ "TournamentRepository.findPendingCheckInsStartingBetween",
+ fx.calendarWindow,
+ (window) =>
+ TournamentRepository.findPendingCheckInsStartingBetween({
+ startsAfter: window.startTime,
+ startsBefore: window.endTime,
+ }),
+ );
add(
"TournamentRepository.findTopThreeResultsByTournamentIds",
fx.heavyTournamentId,