mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 21:58:08 -05:00
Global indicator for SQ & tournaments (#3391)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = (
|
||||
<div className={clsx(styles.tabListContainer, "scrollbar")}>
|
||||
<div
|
||||
className={clsx(styles.tabList, {
|
||||
@@ -299,6 +302,15 @@ export function SendouTabList({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!actions || tabs.orientation === "vertical") return tabList;
|
||||
|
||||
return (
|
||||
<div className={styles.tabListRow}>
|
||||
{tabList}
|
||||
<div className={styles.tabListActions}>{actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SendouTabPanelProps {
|
||||
|
||||
15
app/components/layout/AnythingAdder.module.css
Normal file
15
app/components/layout/AnythingAdder.module.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={<Plus />}
|
||||
className={clsx(
|
||||
styles.button,
|
||||
globalStatus ? styles.withStatus : null,
|
||||
)}
|
||||
aria-label={t("common:actions.addNew")}
|
||||
data-testid="anything-adder-menu-button"
|
||||
>
|
||||
{`${t("common:actions.addNew")}…`}
|
||||
<span className={styles.label}>{t("common:actions.addNew")}</span>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -124,8 +124,10 @@ export function GlobalSearch() {
|
||||
className={styles.searchButton}
|
||||
>
|
||||
<Search className={styles.searchIcon} />
|
||||
<span className={styles.searchPlaceholder}>{t("common:search")}</span>
|
||||
<kbd className={styles.searchKbd}>{isMac ? "Cmd+K" : "Ctrl+K"}</kbd>
|
||||
<span className={styles.searchLabel}>
|
||||
<span className={styles.searchPlaceholder}>{t("common:search")}</span>
|
||||
<kbd className={styles.searchKbd}>{isMac ? "Cmd+K" : "Ctrl+K"}</kbd>
|
||||
</span>
|
||||
</Link>
|
||||
{isOpen ? (
|
||||
<SendouModal
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--s-2);
|
||||
gap: var(--s-1);
|
||||
padding: 0 var(--s-1-5);
|
||||
height: var(--field-size-sm);
|
||||
border: none;
|
||||
border-radius: var(--radius-field);
|
||||
@@ -31,6 +32,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuButtonChevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--color-text-high);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menuPopover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Form, Link, useLocation } from "react-router";
|
||||
@@ -100,6 +101,7 @@ function DevMenu() {
|
||||
onPointerEnter={() => setIsPreviewSuppressed(false)}
|
||||
>
|
||||
Dev
|
||||
<ChevronDown className={styles.menuButtonChevron} />
|
||||
</button>
|
||||
}
|
||||
popoverClassName={styles.menuPopover}
|
||||
@@ -222,6 +224,7 @@ function CategoryMenu({
|
||||
onPointerEnter={() => setIsPreviewSuppressed(false)}
|
||||
>
|
||||
{t(`front:nav.${category.name}`)}
|
||||
<ChevronDown className={styles.menuButtonChevron} />
|
||||
</button>
|
||||
}
|
||||
popoverClassName={styles.menuPopover}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.container,
|
||||
hasGlobalStatus ? styles.withStatus : null,
|
||||
)}
|
||||
>
|
||||
{showSupport ? (
|
||||
<>
|
||||
<div className={styles.supportWrapper}>
|
||||
@@ -48,7 +58,11 @@ export function TopRightButtons({
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.searchAndAddContainer}>
|
||||
<div className={styles.searchWrapper}>
|
||||
<GlobalStatusIndicator />
|
||||
<div
|
||||
className={styles.searchWrapper}
|
||||
data-with-status={hasGlobalStatus ? "" : undefined}
|
||||
>
|
||||
{isLoggedIn ? <GlobalSearch /> : <LoggedOutGlobalSearch />}
|
||||
</div>
|
||||
{isLoggedIn ? <AnythingAdder /> : null}
|
||||
|
||||
@@ -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<HTMLElement | null>) {
|
||||
|
||||
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<HTMLElement | null>) {
|
||||
};
|
||||
}, [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<HTMLElement>(null);
|
||||
const navOffset = useNavOffset(headerRef);
|
||||
const { navOffset, revealNav } = useNavOffset(headerRef);
|
||||
useRevealNavOnStatusChange(revealNav);
|
||||
|
||||
const user = useUser();
|
||||
const { showUnseenDot } = useNotifications();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SystemMessageType>(
|
||||
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<ThrottleableMessage, "type">): boolean {
|
||||
return !messageTypeToSound(msg.type);
|
||||
return !msg.type || !UNTHROTTLED.has(msg.type);
|
||||
},
|
||||
handle(msg: ThrottleableMessage): void {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -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 (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Global Status Indicator</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<ComponentRow label="Status (shown next to the search & add new buttons in the header)">
|
||||
<SendouSelect
|
||||
items={GLOBAL_STATUS_SELECT_ITEMS}
|
||||
label="Status"
|
||||
selectedKey={selectedId}
|
||||
onSelectionChange={(key) =>
|
||||
setStatus(
|
||||
GLOBAL_STATUS_EXAMPLES.find((example) => example.id === key)
|
||||
?.status ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<SendouSelectItem key={item.id} id={item.id}>
|
||||
{item.name}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function MiscSection({ id }: { id: string }) {
|
||||
const [rangeValue, setRangeValue] = useState(50);
|
||||
const [colorValue, setColorValue] = useState("#3b82f6");
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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<number, string>;
|
||||
}): 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";
|
||||
|
||||
|
||||
110
app/features/global-status/GlobalStatusProvider.tsx
Normal file
110
app/features/global-status/GlobalStatusProvider.tsx
Normal file
@@ -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<GlobalStatusContextValue>({
|
||||
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<SerializeFrom<typeof loader>>(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 (
|
||||
<GlobalStatusContext.Provider value={{ status, setStatus: setOverride }}>
|
||||
{children}
|
||||
</GlobalStatusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<GlobalStatusState, string> = {
|
||||
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 (
|
||||
<Link to={status.url} className={styles.status} aria-label={text}>
|
||||
{status.logoUrl ? (
|
||||
<img src={status.logoUrl} alt="" className={styles.logo} />
|
||||
) : (
|
||||
<Image
|
||||
path={navIconUrl(STATE_NAV_ICON[status.state])}
|
||||
size={20}
|
||||
alt=""
|
||||
containerClassName={styles.iconContainer}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.text}>{text}</span>
|
||||
{ALERT_BADGE_STATES.includes(status.state) ? (
|
||||
<span className={clsx(styles.countBadge, styles.alertBadge)}>!</span>
|
||||
) : status.count ? (
|
||||
<span
|
||||
className={clsx(
|
||||
styles.countBadge,
|
||||
status.countNeedsAction ? styles.countBadgeAction : null,
|
||||
)}
|
||||
>
|
||||
{status.count}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
322
app/features/global-status/core/global-status.server.test.ts
Normal file
322
app/features/global-status/core/global-status.server.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
157
app/features/global-status/core/global-status.server.ts
Normal file
157
app/features/global-status/core/global-status.server.ts
Normal file
@@ -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<GlobalStatus | null> {
|
||||
const activity = UserActivity.resolve(userId);
|
||||
|
||||
return (
|
||||
(await resolveSendouQStatus(activity)) ??
|
||||
resolveTournamentStatus(activity) ??
|
||||
(await resolvePendingCheckInStatus(userId))
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePendingCheckInStatus(
|
||||
userId: number,
|
||||
): Promise<GlobalStatus | null> {
|
||||
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<GlobalStatus | null> {
|
||||
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<GlobalStatus | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 <div data-testid="expiry">{hasExpired ? "expired" : "queued"}</div>;
|
||||
}
|
||||
|
||||
const expiryStatus = (screen: Awaited<ReturnType<typeof render>>) =>
|
||||
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<void>((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(
|
||||
<Expiry status={queuedStatus(THIRTY_MINUTES_MS)} />,
|
||||
);
|
||||
await advanceTimers(THIRTY_MINUTES_MS - 1000);
|
||||
|
||||
expect(expiryStatus(screen)).toBe("queued");
|
||||
});
|
||||
|
||||
test("expires once the expiry passes", async () => {
|
||||
const screen = await render(
|
||||
<Expiry status={queuedStatus(THIRTY_MINUTES_MS)} />,
|
||||
);
|
||||
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(<Expiry status={queuedStatus(-1000)} />);
|
||||
await advanceTimers(0);
|
||||
|
||||
expect(expiryStatus(screen)).toBe("expired");
|
||||
});
|
||||
|
||||
test("never expires a status that is not queued", async () => {
|
||||
const screen = await render(
|
||||
<Expiry status={{ state: "SQ_MATCH", url: SENDOUQ_LOOKING_PAGE }} />,
|
||||
);
|
||||
await advanceTimers(THIRTY_MINUTES_MS);
|
||||
|
||||
expect(expiryStatus(screen)).toBe("queued");
|
||||
});
|
||||
});
|
||||
31
app/features/global-status/global-status-expiry.ts
Normal file
31
app/features/global-status/global-status-expiry.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 <div data-testid="unseen">{hasUnseen ? "unseen" : "seen"}</div>;
|
||||
}
|
||||
|
||||
function MarkSeen({
|
||||
groupId,
|
||||
receivedLikesCount,
|
||||
}: {
|
||||
groupId: number | undefined;
|
||||
receivedLikesCount: number;
|
||||
}) {
|
||||
useMarkSqLikesSeen(groupId, receivedLikesCount);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const unseenStatus = (screen: Awaited<ReturnType<typeof render>>) =>
|
||||
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(<UnseenLikes status={queuedStatus(1)} />);
|
||||
|
||||
expect(unseenStatus(screen)).toBe("unseen");
|
||||
});
|
||||
|
||||
test("highlights likes seen on another group", async () => {
|
||||
seedSeen({ groupId: GROUP_ID + 1, seenCount: 5 });
|
||||
const screen = await render(<UnseenLikes status={queuedStatus(1)} />);
|
||||
|
||||
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(<UnseenLikes status={queuedStatus(2)} />);
|
||||
|
||||
expect(unseenStatus(screen)).toBe(expected);
|
||||
});
|
||||
|
||||
test("never highlights without a status", async () => {
|
||||
const screen = await render(<UnseenLikes status={null} />);
|
||||
|
||||
expect(unseenStatus(screen)).toBe("seen");
|
||||
});
|
||||
|
||||
test("never highlights a status that is not queued", async () => {
|
||||
const screen = await render(
|
||||
<UnseenLikes status={{ state: "SQ_MATCH", url: SENDOUQ_LOOKING_PAGE }} />,
|
||||
);
|
||||
|
||||
expect(unseenStatus(screen)).toBe("seen");
|
||||
});
|
||||
|
||||
test("never highlights a queued status without likes", async () => {
|
||||
const screen = await render(<UnseenLikes status={queuedStatus(0)} />);
|
||||
|
||||
expect(unseenStatus(screen)).toBe("seen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMarkSqLikesSeen", () => {
|
||||
test("persists the group's received likes as seen", async () => {
|
||||
await render(<MarkSeen groupId={GROUP_ID} receivedLikesCount={3} />);
|
||||
|
||||
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(<MarkSeen groupId={undefined} receivedLikesCount={3} />);
|
||||
|
||||
expect(window.localStorage.getItem(SEEN_SQ_LIKES_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
test("clears the highlight for the likes it marks seen", async () => {
|
||||
const screen = await render(
|
||||
<>
|
||||
<MarkSeen groupId={GROUP_ID} receivedLikesCount={2} />
|
||||
<UnseenLikes status={queuedStatus(2)} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(unseenStatus(screen)).toBe("seen");
|
||||
});
|
||||
});
|
||||
50
app/features/global-status/global-status-likes-seen.ts
Normal file
50
app/features/global-status/global-status-likes-seen.ts
Normal file
@@ -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]);
|
||||
}
|
||||
102
app/features/global-status/global-status-sounds.browser.test.tsx
Normal file
102
app/features/global-status/global-status-sounds.browser.test.tsx
Normal file
@@ -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(<Sounds status={from} />);
|
||||
await screen.rerender(<Sounds status={to} />);
|
||||
};
|
||||
|
||||
const queued = (overrides: Partial<GlobalStatus> = {}): 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(
|
||||
<Sounds status={{ state: "SQ_MATCH", url: sendouQMatchPage(1) }} />,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
48
app/features/global-status/global-status-sounds.ts
Normal file
48
app/features/global-status/global-status-sounds.ts
Normal file
@@ -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<Record<GlobalStatusState, string>> = {
|
||||
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<GlobalStatus | null | undefined>(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;
|
||||
}
|
||||
28
app/features/global-status/global-status-types.ts
Normal file
28
app/features/global-status/global-status-types.ts
Normal file
@@ -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;
|
||||
}
|
||||
15
app/features/global-status/routes/api.status.ts
Normal file
15
app/features/global-status/routes/api.status.ts
Normal file
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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<DB, "GroupMatch">,
|
||||
matchId: number,
|
||||
) {
|
||||
return eb.exists(
|
||||
eb
|
||||
.selectFrom("Skill")
|
||||
.select("Skill.id")
|
||||
.where("Skill.groupMatchId", "=", matchId),
|
||||
);
|
||||
}
|
||||
|
||||
function isCanceledSubquery(
|
||||
eb: ExpressionBuilder<DB, "GroupMatch">,
|
||||
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<DB, "GroupMatch">,
|
||||
groupIdRef: "GroupMatch.alphaGroupId" | "GroupMatch.bravoGroupId",
|
||||
@@ -1609,14 +1632,7 @@ function findLockState(matchId: number, trx: Transaction<DB>) {
|
||||
.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<DB>) {
|
||||
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<DB>) {
|
||||
.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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<number>().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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ReturnType<typeof userSkills>>,
|
||||
receivedLikeCounts: Map<number, number>,
|
||||
) {
|
||||
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. */
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<typeof loader>();
|
||||
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)
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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") },
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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) }]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.checkIn}>
|
||||
{bracket.name} check-in
|
||||
{tournament.canCheckInToBracket(bracket.idx, user) ? (
|
||||
<ActionButton
|
||||
schema={bracketSchema}
|
||||
action="BRACKET_CHECK_IN"
|
||||
fields={{ bracketIdx: bracket.idx }}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
testId="check-in-bracket-button"
|
||||
>
|
||||
Check-in
|
||||
</ActionButton>
|
||||
) : bracket.startTime && bracket.startTime > new Date() ? (
|
||||
<span className="text-lighter text-xxs">
|
||||
open{" "}
|
||||
<LocaleTimeRange
|
||||
from={sub(bracket.startTime, { hours: 1 })}
|
||||
to={bracket.startTime}
|
||||
options={{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
weekday: "short",
|
||||
}}
|
||||
inline
|
||||
/>
|
||||
</span>
|
||||
) : bracket.startTime && bracket.startTime < new Date() ? (
|
||||
<span className="text-warning">over</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Container spaced="very">
|
||||
vs. {status.opponent}
|
||||
<LinkButton
|
||||
to={tournamentMatchPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
matchId: status.matchId,
|
||||
})}
|
||||
variant="minimal"
|
||||
size="small"
|
||||
>
|
||||
Go to match
|
||||
</LinkButton>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
if (status.type === "CHECKIN") {
|
||||
const bracket =
|
||||
typeof status.bracketIdx === "number"
|
||||
? tournament.bracketMetaByIdx(status.bracketIdx)
|
||||
: null;
|
||||
|
||||
if (!bracket) {
|
||||
return (
|
||||
<Container spaced="very">
|
||||
Your team needs to check-in
|
||||
{status.canCheckIn ? (
|
||||
<ActionButton
|
||||
schema={checkInSchema}
|
||||
action="CHECK_IN"
|
||||
formAction={tournamentRegisterPage(tournament.ctx.id)}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
testId="check-in-bracket-button"
|
||||
>
|
||||
Check-in now
|
||||
</ActionButton>
|
||||
) : (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" size="small">
|
||||
Check-in now
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{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)"}
|
||||
</SendouPopover>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container spaced="very">
|
||||
{bracket.name} check-in
|
||||
{tournament.canCheckInToBracket(bracket.idx, user) ? (
|
||||
<ActionButton
|
||||
schema={bracketSchema}
|
||||
action="BRACKET_CHECK_IN"
|
||||
fields={{ bracketIdx: bracket.idx }}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
testId="check-in-bracket-button"
|
||||
>
|
||||
Check-in
|
||||
</ActionButton>
|
||||
) : bracket.startTime && bracket.startTime > new Date() ? (
|
||||
<span className="text-lighter text-xxs">
|
||||
open{" "}
|
||||
<LocaleTimeRange
|
||||
from={sub(bracket.startTime, { hours: 1 })}
|
||||
to={bracket.startTime}
|
||||
options={{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
weekday: "short",
|
||||
}}
|
||||
inline
|
||||
/>
|
||||
</span>
|
||||
) : bracket.startTime && bracket.startTime < new Date() ? (
|
||||
<span className="text-warning">over</span>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.type === "WAITING_FOR_MATCH") {
|
||||
return (
|
||||
<Container>
|
||||
Waiting on match
|
||||
<Dots />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.type === "WAITING_FOR_CAST") {
|
||||
return (
|
||||
<Container>
|
||||
Waiting on cast
|
||||
<Dots />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.type === "WAITING_FOR_ROUND") {
|
||||
return (
|
||||
<Container>
|
||||
Waiting on next round
|
||||
<Dots />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.type === "WAITING_FOR_GROUPS") {
|
||||
return (
|
||||
<Container>
|
||||
Waiting on group to finish
|
||||
<Dots />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.type === "WAITING_FOR_BRACKET") {
|
||||
return (
|
||||
<Container spaced>
|
||||
<Check className={styles.quickActionCheckmark} />{" "}
|
||||
<div>
|
||||
Checked in, waiting on bracket
|
||||
<Dots />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.type === "THANKS_FOR_PLAYING") {
|
||||
return <Container>Thank you for playing!</Container>;
|
||||
}
|
||||
|
||||
logger.warn("Unexpected status", status);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Container({
|
||||
children,
|
||||
spaced,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
spaced?: boolean | "very";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.quickAction, {
|
||||
[styles.quickActionSpaced]: spaced,
|
||||
[styles.quickActionVerySpaced]: spaced === "very",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dots() {
|
||||
const [thirdVisible, setThirdVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setInterval(() => {
|
||||
setThirdVisible((prev) => !prev);
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
clearInterval(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span>
|
||||
..<span className={clsx({ invisible: !thirdVisible })}>.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Outlet context={ctx} />
|
||||
{showTeamActionsRow ? (
|
||||
<div className="stack horizontal mb-4 sm justify-between items-center">
|
||||
{/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */}
|
||||
{!tournament.isLeague ? (
|
||||
<TournamentTeamActions status={teamProgressStatus} />
|
||||
) : null}
|
||||
{showAddSubsButton ? <AddSubsPopOver /> : null}
|
||||
{bracketCheckInIdx !== null ? (
|
||||
<div className="stack horizontal mb-4 items-center">
|
||||
<BracketCheckIn bracketIdx={bracketCheckInIdx} />
|
||||
</div>
|
||||
) : null}
|
||||
{showSecondaryActionsRow ? (
|
||||
@@ -253,6 +240,7 @@ function TournamentBracketsView() {
|
||||
</div>
|
||||
) : null}
|
||||
<BracketTabs
|
||||
loadedBracket={bracket}
|
||||
loadedBracketIdx={data.bracketIdx}
|
||||
divisionIdx={data.divisionIdx}
|
||||
>
|
||||
@@ -432,73 +420,17 @@ function MapPreparer({
|
||||
);
|
||||
}
|
||||
|
||||
function AddSubsPopOver() {
|
||||
const { t } = useTranslation(["common", "tournament"]);
|
||||
const tournament = useTournament();
|
||||
const user = useUser();
|
||||
const data = useLoaderData<TournamentBracketsLoaderData>();
|
||||
|
||||
const ownedTeam = tournament.ownedTeamByUser(user);
|
||||
if (!ownedTeam || !data.ownTeamInviteCode) {
|
||||
const teamMemberOf = tournament.teamMemberOfByUser(user);
|
||||
if (!teamMemberOf) return null;
|
||||
|
||||
return <SubsPopover>Only team captain or a TO can add subs</SubsPopover>;
|
||||
}
|
||||
|
||||
const subsAvailableToAdd =
|
||||
tournament.maxMembersPerTeam - ownedTeam.memberUserIds.length;
|
||||
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
inviteCode: data.ownTeamInviteCode,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<SubsPopover>
|
||||
{t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })}
|
||||
{subsAvailableToAdd > 0 ? (
|
||||
<>
|
||||
<Divider className="my-2" />
|
||||
<InviteLinkInput link={inviteLink} />
|
||||
</>
|
||||
) : null}
|
||||
</SubsPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function SubsPopover({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
popoverClassName="text-xs"
|
||||
trigger={
|
||||
<SendouButton
|
||||
className="ml-auto"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
icon={<UserPlus />}
|
||||
data-testid="add-sub-button"
|
||||
>
|
||||
{t("tournament:actions.addSub")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SendouTabs
|
||||
selectedKey={String(loadedBracketIdx)}
|
||||
onSelectionChange={(key) => setIdxParam(Number(key))}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTabList actions={canCompactify ? <CompactifyButton /> : null}>
|
||||
{visibleBrackets.map((bracket) => (
|
||||
<SendouTab
|
||||
key={bracket.name}
|
||||
@@ -556,11 +494,6 @@ function BracketTabContent({
|
||||
<PrepareMapsButton bracket={bracket} bracketIdx={bracketIdx} />
|
||||
{tournament.bracketsMeta[bracketIdx].enoughTeams ? (
|
||||
<>
|
||||
{bracket.type !== "round_robin" && !bracket.preview ? (
|
||||
<div className="stack horizontal sm mb-4">
|
||||
<CompactifyButton />
|
||||
</div>
|
||||
) : null}
|
||||
<StartBracketAlert bracket={bracket} bracketIdx={bracketIdx} />
|
||||
<Bracket
|
||||
bracket={bracket}
|
||||
@@ -730,6 +663,8 @@ function CompactifyButton() {
|
||||
onClick={() => {
|
||||
setBracketExpanded(!bracketExpanded);
|
||||
}}
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
className={styles.compactifyButton}
|
||||
icon={bracketExpanded ? <EyeOff /> : <Eye />}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<number>`json_extract("Tournament"."settings", '$.isTest')`,
|
||||
"is not",
|
||||
1,
|
||||
)
|
||||
.where(
|
||||
sql<number>`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[],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
59
app/features/tournament/core/PendingCheckIns.server.ts
Normal file
59
app/features/tournament/core/PendingCheckIns.server.ts
Normal file
@@ -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<PendingCheckIn | null> {
|
||||
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<number, PendingCheckIn>();
|
||||
// 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;
|
||||
}
|
||||
@@ -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<typeof loader>;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
</Link>
|
||||
) : null}
|
||||
{data.subInviteCode ? (
|
||||
<AddSubPopover inviteCode={data.subInviteCode} />
|
||||
) : null}
|
||||
</div>
|
||||
{data.record ? (
|
||||
<StatSquares
|
||||
@@ -170,6 +177,45 @@ function StatSquares({
|
||||
);
|
||||
}
|
||||
|
||||
function AddSubPopover({ inviteCode }: { inviteCode: string }) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const tournament = useTournament();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const subsAvailableToAdd =
|
||||
tournament.maxMembersPerTeam - data.team.members.length;
|
||||
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
inviteCode,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
popoverClassName="text-xs"
|
||||
trigger={
|
||||
<SendouButton
|
||||
className="mx-auto"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
icon={<UserPlus />}
|
||||
data-testid="add-sub-button"
|
||||
>
|
||||
{t("tournament:actions.addSub")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })}
|
||||
{subsAvailableToAdd > 0 ? (
|
||||
<>
|
||||
<Divider className="my-2" />
|
||||
<InviteLinkInput link={inviteLink} />
|
||||
</>
|
||||
) : null}
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function winPercentage(won: number, total: number) {
|
||||
return total === 0 ? 0 : Math.round((won / total) * 100);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
128
app/features/user-activity/core/UserActivity.server.test.ts
Normal file
128
app/features/user-activity/core/UserActivity.server.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
68
app/features/user-activity/core/UserActivity.server.ts
Normal file
68
app/features/user-activity/core/UserActivity.server.ts
Normal file
@@ -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<ReturnType<(typeof SendouQ)["findOwnGroup"]>>;
|
||||
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,
|
||||
};
|
||||
@@ -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({
|
||||
<ChatProvider user={rootData?.user}>
|
||||
<NotificationsProvider user={rootData?.user}>
|
||||
<LayoutDataProvider data={rootData}>
|
||||
<Layout data={rootData}>{children}</Layout>
|
||||
<GlobalStatusProvider user={rootData?.user}>
|
||||
<Layout data={rootData}>{children}</Layout>
|
||||
</GlobalStatusProvider>
|
||||
</LayoutDataProvider>
|
||||
</NotificationsProvider>
|
||||
</ChatProvider>
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
|
||||
|
||||
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
|
||||
send: vi.fn(),
|
||||
notifyStatusChanged: vi.fn(),
|
||||
notifyNotificationsChanged: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ResolveStaleSQMatchesRoutine } from "./resolveStaleSQMatches";
|
||||
|
||||
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
|
||||
send: vi.fn(),
|
||||
notifyStatusChanged: vi.fn(),
|
||||
notifyNotificationsChanged: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
8
changelog/2026-09-01-header-status-indicator.md
Normal file
8
changelog/2026-09-01-header-status-indicator.md
Normal file
@@ -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
|
||||
9
changelog/2026-09-19-brackets-page-team-actions.md
Normal file
9
changelog/2026-09-19-brackets-page-team-actions.md
Normal file
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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");
|
||||
|
||||
@@ -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<string> {
|
||||
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}`);
|
||||
|
||||
@@ -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/']"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"matchProfile.maps.prefer": "",
|
||||
"matchProfile.maps.neutral": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"matchProfile.maps.prefer": "",
|
||||
"matchProfile.maps.neutral": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user