Global indicator for SQ & tournaments (#3391)

This commit is contained in:
Kalle
2026-09-19 13:44:26 +03:00
committed by GitHub
parent 42c3af663c
commit ea28196169
129 changed files with 2926 additions and 562 deletions

View File

@@ -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 {

View File

@@ -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 {

View 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;
}
}
}

View File

@@ -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>
}
>

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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({

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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");

View File

@@ -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" };

View File

@@ -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";

View 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 };
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View 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");
});
});

View 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,
};
}
}

View File

@@ -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");
});
});

View 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;
}

View File

@@ -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");
});
});

View 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]);
}

View 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();
});
});

View 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;
}

View 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;
}

View 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),
};
};

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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(),
}));

View File

@@ -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();

View File

@@ -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),

View File

@@ -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")

View File

@@ -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;

View File

@@ -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) });

View File

@@ -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({

View File

@@ -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);

View File

@@ -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. */

View File

@@ -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";
}

View File

@@ -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,
) ?? [],
),
);
}

View File

@@ -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(),
}));

View File

@@ -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),

View File

@@ -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(),
}));

View File

@@ -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)

View File

@@ -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(),
}));

View File

@@ -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") },
{

View File

@@ -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({

View File

@@ -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(),
}));

View File

@@ -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) }]);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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[] = [
{

View File

@@ -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) {

View File

@@ -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: [] };

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 />}
>

View File

@@ -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

View File

@@ -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(),

View File

@@ -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[],

View File

@@ -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)

View File

@@ -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;
};

View 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;
}

View File

@@ -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"
);
}

View File

@@ -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);
}

View File

@@ -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",

View 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);
});
});

View 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,
};

View File

@@ -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>

View File

@@ -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", [

View File

@@ -9,6 +9,7 @@ import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));

View File

@@ -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);
}
},
});

View File

@@ -9,6 +9,7 @@ import { ResolveStaleSQMatchesRoutine } from "./resolveStaleSQMatches";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyStatusChanged: vi.fn(),
notifyNotificationsChanged: vi.fn(),
}));

View File

@@ -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`,
);

View File

@@ -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) =>

View 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

View 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

View File

@@ -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;

View File

@@ -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 });
}
}

View File

@@ -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");

View File

@@ -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}`);

View File

@@ -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/']"),
};
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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": "",

View File

@@ -9,7 +9,6 @@
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""

View File

@@ -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": "",

View File

@@ -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