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,