Merge branch 'sendou-ink:main' into main

This commit is contained in:
BrushMommy 2026-05-09 04:35:00 -04:00 committed by GitHub
commit ecd5ececb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
493 changed files with 19700 additions and 10202 deletions

View File

@ -82,10 +82,10 @@ pnpm exec playwright show-trace test-results/<test-folder>/trace.zip
## Test pattern reference
Every test follows this pattern — use these imports from `~/utils/playwright`, NOT raw Playwright APIs:
Every test follows this pattern — use these imports from `./helpers/playwright`, NOT raw Playwright APIs:
```typescript
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
import { expect, impersonate, navigate, seed, test } from "./helpers/playwright";
test.describe("Feature", () => {
test("does something", async ({ page }) => {
@ -104,7 +104,7 @@ Key rules:
- Use `seed(page, variation?)` to reset the database. Available variations: DEFAULT, NO_TOURNAMENT_TEAMS, REG_OPEN, SMALL_SOS, NZAP_IN_TEAM, NO_SCRIMS, NO_SQ_GROUPS
- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID)
- Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead
- Import `test` from `~/utils/playwright` (not from `@playwright/test`) — it includes worker port fixtures
- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures
## Environment variables

View File

@ -24,6 +24,7 @@
- always use named exports
- Remeda is the utility library of choice
- date-fns should be used for date related logic
- do not use `forEach`, prefer `for...of`
## React
@ -47,6 +48,7 @@
- one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css`
- clsx library is used for conditional class names
- prefer using [CSS variables](./app/styles/vars.css) for theming
- for any CSS variable used, make sure it is defined either locally or in the `vars.css` file
- for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class
- use CSS nesting with the `&` selector to group related selectors (pseudo-classes, pseudo-elements, child selectors, attribute selectors) under their parent instead of repeating the parent selector

View File

@ -56,6 +56,7 @@ Another key objective is to bridge the gap between casual and competitive player
- [Git](https://git-scm.com/)
- [Node.js v22](https://nodejs.org/en)
- [pnpm](https://pnpm.io/installation)
Optionally [nvm](https://github.com/nvm-sh/nvm) can be convenient for managing multiple Node.js installs
@ -66,6 +67,7 @@ First verify you have Node.js and git installed:
```bash
node --version
git --version
pnpm --version
```
You should see something like:
@ -73,6 +75,7 @@ You should see something like:
```
v22.13.0
git version 2.39.5 (Apple Git-154)
10.33.0
```
(if not then go back to "Prerequisites" and install what is missing)

View File

@ -15,5 +15,4 @@ i18next.use(initReactI18next).init({
...config,
lng: "en",
resources,
showSupportNotice: false,
});

View File

@ -128,7 +128,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
{isHydrated
? formatDate(databaseTimestampToDate(updatedAt), {
day: "numeric",
month: "long",
month: "numeric",
year: "numeric",
})
: "t"}

View File

@ -2,7 +2,6 @@ import clsx from "clsx";
import * as React from "react";
import { type AxisOptions, Chart as ReactChart } from "react-charts";
import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer";
import { useTranslation } from "react-i18next";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
@ -23,9 +22,9 @@ export default function Chart({
valueSuffix?: string;
xAxis: "linear" | "localTime";
}) {
const { i18n } = useTranslation();
const theme = useTheme();
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
const primaryAxis = React.useMemo<
AxisOptions<(typeof options)[number]["data"][number]>
@ -38,7 +37,7 @@ export default function Chart({
formatters: {
scale: (val: any) => {
if (val instanceof Date) {
return val.toLocaleDateString(i18n.language, {
return formatDate(val, {
day: "numeric",
month: "numeric",
});
@ -48,7 +47,7 @@ export default function Chart({
},
},
}),
[i18n.language, xAxis],
[formatDate, xAxis],
);
const secondaryAxes = React.useMemo<
@ -117,7 +116,7 @@ function ChartTooltip({
return formatDate(primaryValue, {
weekday: "short",
day: "numeric",
month: "long",
month: "numeric",
});
}

View File

@ -2,6 +2,7 @@ import { isToday, isTomorrow } from "date-fns";
import { useTranslation } from "react-i18next";
import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import styles from "./EventsList.module.css";
import { Placeholder } from "./Placeholder";
import { ListLink } from "./SideNav";
@ -14,6 +15,7 @@ export function EventsList({
onClick?: () => void;
}) {
const { t, i18n } = useTranslation(["front"]);
const { formatDate, formatTime } = useTimeFormat();
const isHydrated = useHydrated();
if (events.length === 0) {
@ -48,20 +50,13 @@ export function EventsList({
const str = rtf.format(1, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
return date.toLocaleDateString(i18n.language, {
return formatDate(date, {
weekday: "long",
month: "short",
month: "numeric",
day: "numeric",
});
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
});
};
const groupedEvents = events.reduce<Record<string, typeof events>>(
(acc, event) => {
const key = getDayKey(event.startTime);

View File

@ -10,6 +10,7 @@
& > label {
margin: 0;
text-box: trim-start cap alphabetic;
}
}

View File

@ -20,7 +20,7 @@ export function RelativeTime({
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
month: "numeric",
timeZoneName: "short",
})
: undefined

View File

@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import type { SidebarStream } from "~/features/core/streams/streams.server";
import { useHydrated } from "~/hooks/useHydrated";
import type { LanguageCode } from "~/modules/i18n/config";
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl, tournamentRegisterPage } from "~/utils/urls";
import { Image } from "./Image";
import { ListLink } from "./SideNav";
@ -25,14 +25,12 @@ export function StreamListItems({
savedTournamentIds?: number[];
}) {
const { t, i18n } = useTranslation(["front"]);
const { formatDateTime, formatTime, formatDistanceToNow } = useTimeFormat();
const isHydrated = useHydrated();
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const timeStr = date.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
});
const timeStr = formatTime(date);
if (isToday(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
@ -49,8 +47,8 @@ export function StreamListItems({
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
}
return date.toLocaleDateString(i18n.language, {
month: "short",
return formatDateTime(date, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
@ -101,7 +99,6 @@ export function StreamListItems({
) : (
formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
)
}

View File

@ -16,7 +16,7 @@ export default function TimePopover({
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "long",
month: "numeric",
},
underline = true,
className,

View File

@ -28,6 +28,7 @@ export interface SendouButtonProps
shape?: "circle" | "square";
icon?: JSX.Element;
children?: React.ReactNode;
testId?: string;
}
export function SendouButton({
@ -37,10 +38,12 @@ export function SendouButton({
shape,
className,
icon,
testId,
...rest
}: SendouButtonProps) {
return (
<ReactAriaButton
data-testid={testId}
{...rest}
className={buttonClassName({ className, variant, size, shape })}
>

View File

@ -54,14 +54,18 @@ export interface SendouMenuItemProps extends MenuItemProps {
export function SendouMenuSection({
children,
headerText,
headerClassName,
}: {
children: React.ReactNode;
headerText?: string;
headerText?: React.ReactNode;
headerClassName?: string;
}) {
return (
<Section>
{headerText ? (
<Header className={styles.menuHeader}>{headerText}</Header>
<Header className={clsx(styles.menuHeader, headerClassName)}>
{headerText}
</Header>
) : null}
{children}
</Section>

View File

@ -1,5 +1,5 @@
import clsx from "clsx";
import { format, sub } from "date-fns";
import { sub } from "date-fns";
import { ChevronsUpDown, Search, X } from "lucide-react";
import * as React from "react";
import {
@ -21,6 +21,7 @@ import { useDebounce } from "react-use";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouLabel } from "~/components/elements/Label";
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import selectStyles from "./Select.module.css";
@ -150,6 +151,7 @@ function TournamentItem({
};
}) {
const { t } = useTranslation(["common"]);
const { formatDate } = useTimeFormat();
if (typeof item.id === "string") {
return (
@ -167,7 +169,11 @@ function TournamentItem({
const additionalText = () => {
const date = databaseTimestampToDate(item.startTime);
return format(date, "MMM d, yyyy");
return formatDate(date, {
day: "numeric",
month: "numeric",
year: "numeric",
});
};
return (
@ -185,11 +191,9 @@ function TournamentItem({
<img src={item.logoUrl} alt="" className={tournamentSearchStyles.logo} />
<div className={tournamentSearchStyles.itemTextsContainer}>
<span>{item.name}</span>
{additionalText() ? (
<div className={tournamentSearchStyles.itemAdditionalText}>
{additionalText()}
</div>
) : null}
<div className={tournamentSearchStyles.itemAdditionalText}>
{additionalText()}
</div>
</div>
</ListBoxItem>
);

View File

@ -2,7 +2,12 @@ import clsx from "clsx";
import { ArrowLeft, MessageSquare, X } from "lucide-react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Link, useFetcher } from "react-router";
import { useCurrentRouteChatCode } from "~/features/chat/ChatProvider";
import {
extractRoomLink,
isMatchRoomUrl,
} from "~/features/chat/chat-constants";
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
@ -62,8 +67,18 @@ function RoomList({ onClose }: { onClose?: () => void }) {
const chatContext = useChatContext()!;
const { formatDateTime } = useTimeFormat();
const nonExpiredRooms = chatContext.rooms
.filter((room) => room.expiresAt > Date.now())
const rawRouteChatCode = useCurrentRouteChatCode();
const routeChatCodes = rawRouteChatCode
? Array.isArray(rawRouteChatCode)
? rawRouteChatCode
: [rawRouteChatCode]
: [];
const visibleRooms = chatContext.rooms
.filter(
(room) =>
room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode),
)
.sort((a, b) => {
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
const aRecency = a.lastMessageTimestamp || a.createdAt;
@ -75,12 +90,12 @@ function RoomList({ onClose }: { onClose?: () => void }) {
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<div className={styles.roomList}>
{nonExpiredRooms.length === 0 ? (
{visibleRooms.length === 0 ? (
<div className={styles.emptyState}>
{t("common:chat.sidebar.noActiveChats")}
</div>
) : (
nonExpiredRooms.map((room) => {
visibleRooms.map((room) => {
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
return (
@ -114,7 +129,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
>
{resolveDatePlaceholders(room.header, (d) =>
formatDateTime(d, {
month: "short",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
@ -154,6 +169,7 @@ function ChatView({ onClose }: { onClose?: () => void }) {
.filter(([code]) => code !== activeRoom)
.reduce((sum, [, count]) => sum + count, 0);
const roomLinkFetcher = useFetcher();
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now());
const messages = chatContext.messagesForRoom(activeRoom);
@ -169,9 +185,27 @@ function ChatView({ onClose }: { onClose?: () => void }) {
}
}
const isMatchRoom = room?.url ? isMatchRoomUrl(room.url) : false;
const chatAdapter = {
messages,
send: (contents: string) => chatContext.send(activeRoom, contents),
send: (contents: string) => {
chatContext.send(activeRoom, contents);
if (isMatchRoom) {
const link = extractRoomLink(contents);
if (link) {
roomLinkFetcher.submit(
{ _action: "UPSERT", url: link },
{
method: "post",
action: "/room",
encType: "application/json",
},
);
}
}
},
currentRoom: activeRoom,
setCurrentRoom: () => {},
readyState: chatContext.readyState,
@ -202,7 +236,7 @@ function ChatView({ onClose }: { onClose?: () => void }) {
room?.header ?? t("common:chat.sidebar.title"),
(d) =>
formatDateTime(d, {
month: "short",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",

View File

@ -231,7 +231,10 @@ function GlobalSearchContent({
);
const hasQuery = query.length >= 3;
const fetchedQuery = fetcher.data?.query ?? null;
const fetchedType = fetcher.data?.type ?? null;
const isCurrentFetch =
hasQuery && fetchedQuery === query && fetchedType === searchType;
const results =
hasQuery && fetchedType === searchType ? (fetcher.data?.results ?? []) : [];
@ -388,15 +391,27 @@ function GlobalSearchContent({
className={clsx(styles.listBox, "scrollbar")}
aria-label={t("common:search")}
onAction={handleSelect}
renderEmptyState={() =>
hasQuery ? (
renderEmptyState={() => {
if (!hasQuery) {
return (
<div className={styles.emptyState}>
{t("common:search.hint")}
</div>
);
}
if (!isCurrentFetch) {
return (
<div className={styles.emptyState}>
{t("common:search.searching")}
</div>
);
}
return (
<div className={styles.emptyState}>
{t("common:search.noResults")}
</div>
) : (
<div className={styles.emptyState}>{t("common:search.hint")}</div>
)
}
);
}}
>
{results.map((result) => (
<ListBoxItem

View File

@ -73,9 +73,10 @@ function CategoryMenu({
const [isOpen, setIsOpen] = useState(false);
const user = useUser();
const isStaff = user?.roles.includes("STAFF") ?? false;
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
const visibleItems = category.items.filter(
(item) => !("staffOnly" in item) || isStaff,
(item) => !("staffOnly" in item) || showStaffOnly,
);
return (

View File

@ -25,6 +25,7 @@ import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import {
@ -55,12 +56,9 @@ import { TopRightButtons } from "./TopRightButtons";
const MAX_DESKTOP_FRIENDS = 4;
function useTimeFormat() {
function useRelativeDayFormat() {
const { i18n } = useTranslation();
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => {
return date.toLocaleTimeString(i18n.language, options);
};
const { formatTime, formatDateTime } = useTimeFormat();
const formatRelativeDay = (daysFromToday: number) => {
const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" });
@ -70,7 +68,7 @@ function useTimeFormat() {
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const timeStr = formatTime(date, { hour: "numeric", minute: "2-digit" });
const timeStr = formatTime(date);
if (isToday(date)) {
return `${formatRelativeDay(0)}, ${timeStr}`;
@ -79,15 +77,15 @@ function useTimeFormat() {
return `${formatRelativeDay(1)}, ${timeStr}`;
}
return date.toLocaleDateString(i18n.language, {
month: "short",
return formatDateTime(date, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
return { formatTime, formatRelativeDate };
return { formatRelativeDate };
}
function useBreadcrumbData() {
@ -215,7 +213,7 @@ export function Layout({
const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {});
const { t } = useTranslation(["front", "common"]);
const { formatRelativeDate } = useTimeFormat();
const { formatRelativeDate } = useRelativeDayFormat();
const isHydrated = useHydrated();
const location = useLocation();
const headerRef = React.useRef<HTMLElement>(null);

View File

@ -0,0 +1,197 @@
.root {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
"header"
"options"
"prompt"
"submit";
justify-items: center;
align-items: center;
gap: var(--s-6);
container-type: inline-size;
}
.title {
grid-area: header;
font-size: var(--font-md);
font-weight: var(--weight-semi);
text-align: center;
text-box: trim-start cap alphabetic;
}
.options {
grid-area: options;
display: flex;
flex-direction: column;
gap: var(--s-6);
width: 100%;
}
.prompt {
grid-area: prompt;
margin: 0;
font-size: var(--font-sm);
color: var(--color-text-lighter);
text-align: center;
}
.verbPick {
color: var(--color-success);
font-weight: var(--weight-semi);
}
.verbBan {
color: var(--color-error);
font-weight: var(--weight-semi);
}
.submit {
grid-area: submit;
}
.waiting {
grid-row: prompt-start / submit-end;
margin: 0;
font-size: var(--font-sm);
color: var(--color-text-lighter);
text-align: center;
}
.modeGroup {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.divider {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
display: flex;
gap: var(--s-2);
&::before,
&::after {
border-bottom: 2px dotted var(--color-bg-higher);
}
}
.stageGrid {
--tile-width: 90px;
--tile-height: 50px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-4) var(--s-3);
}
.modeGrid {
--tile-width: 90px;
--tile-height: 90px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-4) var(--s-3);
}
.tileContainer {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: var(--tile-width);
text-align: center;
}
.tileWrapper {
position: relative;
width: var(--tile-width);
height: var(--tile-height);
}
.tile {
height: var(--tile-height);
width: var(--tile-width);
border: none;
background-color: transparent;
transition:
filter,
opacity 0.2s;
border-radius: var(--radius-box);
cursor: pointer;
&:active {
transform: none;
}
&:disabled {
cursor: not-allowed;
}
}
.stageTile {
background-image: var(--map-image-url);
background-size: cover;
}
.modeTile {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg-higher);
}
.tileSelected {
filter: grayscale(100%);
opacity: 0.4;
}
.tileIcon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
pointer-events: none;
}
.tileIconPick {
color: var(--color-success);
}
.tileIconBan {
color: var(--color-error);
}
.tileNumber {
position: absolute;
background-color: var(--color-text-accent);
border-radius: 100%;
width: 18px;
height: 18px;
display: grid;
place-items: center;
color: var(--color-text-inverse);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
top: -5px;
left: 0;
pointer-events: none;
}
.tileFrom {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
line-height: 1;
margin-block-start: var(--s-0-5);
}
.tileLabel {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
margin-block-start: var(--s-1);
}

View File

@ -0,0 +1,380 @@
import clsx from "clsx";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { stageImageUrl } from "~/utils/urls";
import { Divider } from "../Divider";
import { SendouTabPanel } from "../elements/Tabs";
import { ModeImage } from "../Image";
import styles from "./MatchActionPickBanTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
export interface PickBanMapOption {
stageId?: StageId;
mode?: ModeShort;
picker?: "US" | "THEM" | "BOTH";
nth?: number;
}
interface PickBanSubmission {
type: "PICK" | "BAN";
map: PickBanMapOption;
}
interface MatchActionPickBanTabProps {
options: PickBanMapOption[];
type: "PICK" | "BAN";
onSubmit?: (data: PickBanSubmission) => void;
isSubmitting?: boolean;
weaponReport?: WeaponReporterProps;
waitingFor?: string;
}
export function MatchActionPickBanTab({
options,
type,
onSubmit,
isSubmitting,
weaponReport,
waitingFor,
}: MatchActionPickBanTabProps) {
const { t } = useTranslation(["q", "common", "game-misc"]);
const [selected, setSelected] = useState<PickBanMapOption>();
const isWaiting = waitingFor !== undefined;
const hasStage = options.every((option) => option.stageId !== undefined);
const hasMode = options.every((option) => option.mode !== undefined);
const layout: "STAGE_BY_MODE" | "STAGE_ONLY" | "MODE_ONLY" =
hasStage && hasMode
? "STAGE_BY_MODE"
: hasStage
? "STAGE_ONLY"
: "MODE_ONLY";
const titleKey =
layout === "MODE_ONLY"
? type === "PICK"
? "q:match.action.pickMode"
: "q:match.action.banMode"
: type === "PICK"
? "q:match.action.pickStage"
: "q:match.action.banStage";
const selectedLabel = (() => {
if (!selected) return null;
const stageName =
selected.stageId !== undefined
? t(`game-misc:STAGE_${selected.stageId}`)
: null;
const modeName =
selected.mode !== undefined
? t(
selected.stageId !== undefined
? `game-misc:MODE_SHORT_${selected.mode}`
: `game-misc:MODE_LONG_${selected.mode}`,
)
: null;
if (stageName && modeName) return `${stageName} (${modeName})`;
return stageName ?? modeName;
})();
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.root}>
<div className={styles.title}>{t(titleKey)}</div>
<div className={styles.options}>
{layout === "STAGE_BY_MODE" ? (
<StageByModeGrid
options={options}
type={type}
selected={selected}
onSelect={setSelected}
disabled={isWaiting}
/>
) : layout === "STAGE_ONLY" ? (
<StageOnlyGrid
options={options}
type={type}
selected={selected}
onSelect={setSelected}
disabled={isWaiting}
/>
) : (
<ModeOnlyGrid
options={options}
type={type}
selected={selected}
onSelect={setSelected}
disabled={isWaiting}
/>
)}
</div>
{isWaiting ? (
<p className={styles.waiting}>
{t("q:match.action.pickBanWaiting", { teamName: waitingFor })}
</p>
) : (
<>
<p className={styles.prompt}>
{selectedLabel ? (
<>
<span
className={
type === "PICK" ? styles.verbPick : styles.verbBan
}
>
{type === "PICK"
? t("q:match.action.picking")
: t("q:match.action.banning")}
</span>{" "}
{selectedLabel}
</>
) : (
t("q:match.action.pickBanPrompt")
)}
</p>
<SendouButton
variant="primary"
className={styles.submit}
isDisabled={!selected || isSubmitting}
onPress={() => {
if (!selected) return;
onSubmit?.({ type, map: selected });
}}
testId="pick-ban-submit-button"
>
{t("common:actions.submit")}
</SendouButton>
</>
)}
</div>
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
</SendouTabPanel>
);
}
function StageByModeGrid({
options,
type,
selected,
onSelect,
disabled,
}: {
options: PickBanMapOption[];
type: "PICK" | "BAN";
selected?: PickBanMapOption;
onSelect: (option: PickBanMapOption) => void;
disabled?: boolean;
}) {
const modesInOrder: ModeShort[] = [];
const byMode = new Map<ModeShort, PickBanMapOption[]>();
for (const option of options) {
const mode = option.mode!;
if (!byMode.has(mode)) {
byMode.set(mode, []);
modesInOrder.push(mode);
}
byMode.get(mode)!.push(option);
}
return (
<>
{modesInOrder.map((mode) => (
<div key={mode} className={styles.modeGroup}>
<Divider className={styles.divider}>
<ModeImage mode={mode} size={32} />
</Divider>
<div className={styles.stageGrid}>
{byMode.get(mode)!.map((option) => (
<StageTile
key={`${option.stageId}-${option.mode}`}
option={option}
type={type}
isSelected={isSameOption(option, selected)}
onSelect={() => onSelect(option)}
disabled={disabled}
/>
))}
</div>
</div>
))}
</>
);
}
function StageOnlyGrid({
options,
type,
selected,
onSelect,
disabled,
}: {
options: PickBanMapOption[];
type: "PICK" | "BAN";
selected?: PickBanMapOption;
onSelect: (option: PickBanMapOption) => void;
disabled?: boolean;
}) {
return (
<div className={styles.stageGrid}>
{options.map((option) => (
<StageTile
key={option.stageId}
option={option}
type={type}
isSelected={isSameOption(option, selected)}
onSelect={() => onSelect(option)}
disabled={disabled}
/>
))}
</div>
);
}
function ModeOnlyGrid({
options,
type,
selected,
onSelect,
disabled,
}: {
options: PickBanMapOption[];
type: "PICK" | "BAN";
selected?: PickBanMapOption;
onSelect: (option: PickBanMapOption) => void;
disabled?: boolean;
}) {
return (
<div className={styles.modeGrid}>
{options.map((option) => (
<ModeTile
key={option.mode}
option={option}
type={type}
isSelected={isSameOption(option, selected)}
onSelect={() => onSelect(option)}
disabled={disabled}
/>
))}
</div>
);
}
function StageTile({
option,
type,
isSelected,
onSelect,
disabled,
}: {
option: PickBanMapOption;
type: "PICK" | "BAN";
isSelected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation(["q", "game-misc"]);
return (
<div className={styles.tileContainer}>
<div className={styles.tileWrapper}>
<button
type="button"
className={clsx(styles.tile, styles.stageTile, {
[styles.tileSelected]: isSelected,
})}
style={{
"--map-image-url": `url("${stageImageUrl(option.stageId!)}.avif")`,
}}
onClick={onSelect}
disabled={disabled}
data-testid="pick-ban-button"
/>
{isSelected ? (
type === "PICK" ? (
<Check className={clsx(styles.tileIcon, styles.tileIconPick)} />
) : (
<X className={clsx(styles.tileIcon, styles.tileIconBan)} />
)
) : null}
{option.nth ? (
<span className={styles.tileNumber}>{option.nth}</span>
) : null}
</div>
<div className={styles.tileLabel}>
{shortStageName(t(`game-misc:STAGE_${option.stageId!}`))}
</div>
{option.picker ? (
<span
className={clsx(styles.tileFrom, {
"text-theme": option.picker === "BOTH",
"text-success": option.picker === "US",
"text-error": option.picker === "THEM",
})}
>
{option.picker === "US"
? t("q:match.action.pickerUs")
: option.picker === "THEM"
? t("q:match.action.pickerThem")
: t("q:match.action.pickerBoth")}
</span>
) : null}
</div>
);
}
function ModeTile({
option,
type,
isSelected,
onSelect,
disabled,
}: {
option: PickBanMapOption;
type: "PICK" | "BAN";
isSelected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation(["game-misc"]);
return (
<div className={styles.tileContainer}>
<div className={styles.tileWrapper}>
<button
type="button"
className={clsx(styles.tile, styles.modeTile, {
[styles.tileSelected]: isSelected,
})}
onClick={onSelect}
disabled={disabled}
data-testid="pick-ban-button"
>
<ModeImage mode={option.mode!} size={48} />
</button>
{isSelected ? (
type === "PICK" ? (
<Check className={clsx(styles.tileIcon, styles.tileIconPick)} />
) : (
<X className={clsx(styles.tileIcon, styles.tileIconBan)} />
)
) : null}
</div>
<div className={styles.tileLabel}>
{t(`game-misc:MODE_LONG_${option.mode!}`)}
</div>
</div>
);
}
function isSameOption(a: PickBanMapOption, b: PickBanMapOption | undefined) {
if (!b) return false;
return a.stageId === b.stageId && a.mode === b.mode;
}

View File

@ -0,0 +1,273 @@
.root {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas:
"header header header"
"actions actions actions"
"selection selection selection"
"submit submit submit";
justify-items: center;
align-items: center;
gap: var(--s-5);
container-type: inline-size;
@container (max-width: 599px) {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"actions"
"selection"
"submit";
}
}
.withPoints {
grid-template-areas:
"header header header"
"actions actions actions"
"selection selection selection"
"ko ko ko"
"submit submit submit";
@container (max-width: 599px) {
grid-template-areas:
"header"
"actions"
"selection"
"ko"
"submit";
}
}
.title {
grid-area: header;
font-size: var(--font-md);
font-weight: var(--weight-semi);
text-align: center;
text-box: trim-start cap alphabetic;
}
.actionButtons {
grid-area: actions;
display: flex;
gap: var(--s-6);
margin-block-start: calc(-1 * var(--s-4));
}
.selectionRow {
grid-area: selection;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas:
"alpha stage bravo"
". text .";
column-gap: var(--s-4);
row-gap: var(--s-1);
justify-items: center;
align-items: center;
width: 100%;
@container (max-width: 599px) {
column-gap: var(--s-2);
grid-template-columns: auto 1fr;
grid-template-areas:
"stage alpha"
"stage bravo"
"text .";
}
}
.teamRadioContainer {
--label-margin: 0;
width: 100%;
height: 100%;
max-width: 250px;
@container (max-width: 599px) {
max-width: unset;
}
}
.alpha {
grid-area: alpha;
justify-self: end;
@container (max-width: 599px) {
justify-self: stretch;
align-self: end;
}
}
.bravo {
grid-area: bravo;
justify-self: start;
@container (max-width: 599px) {
justify-self: stretch;
align-self: start;
}
}
.stageImageWrapper {
grid-area: stage;
@container (max-width: 599px) {
align-self: stretch;
width: 90px;
}
}
.stageImage {
border-radius: var(--radius-box);
display: block;
@container (max-width: 599px) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.stageLabel {
grid-area: text;
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.ko {
grid-area: ko;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.submit {
grid-area: submit;
}
.checkCircle {
width: 24px;
height: 24px;
border-radius: 100%;
border: 2px solid var(--color-border-high);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.teamRadio {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-1-5) var(--s-3);
border-radius: var(--radius-field);
border: 2px solid var(--color-border);
cursor: pointer;
background-color: var(--color-bg-high);
min-width: 160px;
transition: background-color 0.15s;
height: 100%;
&:hover .checkCircle {
border-color: var(--color-accent-high);
}
@container (max-width: 599px) {
min-width: unset;
}
}
.selected {
background-color: var(--color-bg-higher);
}
.focusVisible {
outline: var(--focus-ring);
}
.teamAvatarInfo {
display: flex;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
}
.checkCircleSelected {
background-color: var(--color-accent-high);
border-color: var(--color-accent-high);
color: var(--color-text-inverse);
& svg {
stroke-width: 3px;
}
}
.teamInfo {
display: flex;
flex-direction: column;
line-height: 1.3;
min-width: 0;
}
.teamName {
font-weight: var(--weight-semi);
font-size: var(--font-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.teamNameLong {
font-size: var(--font-2xs);
}
.teamLabel {
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
}
.myTeamLabel {
color: var(--color-success-high);
}
.opponentLabel {
color: var(--color-error-high);
}
.koLabel {
display: flex;
align-items: center;
gap: var(--s-1-5);
font-weight: var(--weight-semi);
cursor: pointer;
}
.confirmationRoot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
}
.confirmationMessage {
font-weight: var(--weight-semi);
text-align: center;
color: var(--color-warning);
margin-block-end: var(--s-4);
}
.confirmationButtons {
display: flex;
gap: var(--s-3);
margin-block-start: var(--s-4);
}

View File

@ -0,0 +1,334 @@
import clsx from "clsx";
import { Check } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { Radio, RadioGroup } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useWebHaptics } from "web-haptics/react";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouTabPanel } from "../elements/Tabs";
import { ModeImage, StageImage } from "../Image";
import styles from "./MatchActionTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
import {
MatchTimeline,
type MatchTimelineProps,
type TimelineMap,
} from "./MatchTimeline";
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
const LONG_TEAM_NAME_THRESHOLD = 16;
interface ActionTabTeam {
id: number;
name: string;
avatar?: string;
}
interface SetEndingData extends MatchTimelineProps {
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
setEndingTeamIds: number[];
}
interface MatchActionTabProps {
teams: [ActionTabTeam, ActionTabTeam];
ownTeamId: number | null;
stageId: StageId;
mode: ModeShort;
withPoints: boolean;
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
weaponReport?: WeaponReporterProps;
}
export function MatchActionTab({
teams,
ownTeamId,
stageId,
mode,
withPoints,
onSubmit,
isSubmitting,
setEnding,
actionButtons,
weaponReport,
}: MatchActionTabProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const [winnerId, setWinnerId] = useState<number | null>(null);
const [isKo, setIsKo] = useState(false);
const [confirming, setConfirming] = useState(false);
const { trigger } = useWebHaptics();
const canSubmit = winnerId !== null;
const isOnTeam =
ownTeamId != null &&
(teams[0].id === ownTeamId || teams[1].id === ownTeamId);
const submit = () => {
if (winnerId === null) return;
const submitPoints: [number, number] | undefined = withPoints
? isKo
? winnerId === teams[0].id
? [100, 0]
: [0, 100]
: [0, 0]
: undefined;
onSubmit?.({ winnerId, points: submitPoints });
};
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
{confirming && winnerId !== null && setEnding ? (
<SetEndingConfirmation
setEnding={setEnding}
stageId={stageId}
mode={mode}
winnerId={winnerId}
teams={teams}
withPoints={withPoints}
isKo={isKo}
isSubmitting={isSubmitting}
onBack={() => setConfirming(false)}
onConfirm={submit}
/>
) : (
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
) : null}
<RadioGroup
value={winnerId !== null ? String(winnerId) : null}
onChange={(value) => {
const selectedId = Number(value);
setWinnerId(selectedId);
const isEnemySelection = isOnTeam && selectedId !== ownTeamId;
if (isEnemySelection) {
trigger([
{ duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.9 },
{ delay: 40, duration: 50, intensity: 0.6 },
]);
} else {
trigger([
{ duration: 30 },
{ delay: 60, duration: 40, intensity: 1 },
]);
}
}}
isDisabled={isSubmitting}
aria-label={t("q:match.action.selectWinner")}
className={styles.selectionRow}
>
<TeamRadioOption
team={teams[0]}
isOwnTeam={teams[0].id === ownTeamId}
hideLabel={ownTeamId == null}
className={styles.alpha}
testId="winner-radio-1"
/>
<StageImage
stageId={stageId}
width={90}
className={styles.stageImage}
containerClassName={styles.stageImageWrapper}
/>
<div className={styles.stageLabel}>
<ModeImage mode={mode} size={14} />
<span>{shortStageName(t(`game-misc:STAGE_${stageId}`))}</span>
</div>
<TeamRadioOption
team={teams[1]}
isOwnTeam={teams[1].id === ownTeamId}
hideLabel={ownTeamId == null}
className={clsx(styles.bravo)}
testId="winner-radio-2"
/>
</RadioGroup>
{withPoints ? (
<div className={styles.ko}>
<label className={styles.koLabel}>
<input
type="checkbox"
checked={isKo}
onChange={(e) => setIsKo(e.target.checked)}
data-testid="ko-checkbox"
/>
{t("q:match.action.ko")}
</label>
</div>
) : null}
<SendouButton
variant="primary"
isDisabled={!canSubmit || isSubmitting}
onPress={() => {
if (winnerId === null) return;
if (setEnding?.setEndingTeamIds.includes(winnerId)) {
setConfirming(true);
} else {
submit();
}
}}
className={styles.submit}
testId="report-score-button"
>
{t("common:actions.submit")}
</SendouButton>
</div>
)}
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
</SendouTabPanel>
);
}
function SetEndingConfirmation({
setEnding,
stageId,
mode,
winnerId,
teams,
withPoints,
isKo,
isSubmitting,
onBack,
onConfirm,
}: {
setEnding: SetEndingData;
stageId: StageId;
mode: ModeShort;
winnerId: number;
teams: [ActionTabTeam, ActionTabTeam];
withPoints: boolean;
isKo: boolean;
isSubmitting?: boolean;
onBack: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation(["q", "common"]);
const winnerSide = winnerId === teams[0].id ? "ALPHA" : "BRAVO";
const newMap: TimelineMap = {
stageId,
mode,
timestamp: Date.now(),
winner: winnerSide,
rosters: setEnding.currentRosters,
points: withPoints
? isKo
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
: [0, 0]
: undefined,
};
const updatedScore = {
alpha: setEnding.score.alpha + (winnerSide === "ALPHA" ? 1 : 0),
bravo: setEnding.score.bravo + (winnerSide === "BRAVO" ? 1 : 0),
};
return (
<div className={styles.confirmationRoot}>
<div className={styles.confirmationMessage}>
{t("q:match.action.confirmSetEnding")}
</div>
<MatchTimeline
teams={setEnding.teams}
score={updatedScore}
maps={[...setEnding.maps, newMap]}
/>
<div className={styles.confirmationButtons}>
<SendouButton
variant="primary"
isDisabled={isSubmitting}
onPress={onConfirm}
testId="confirm-set-end-button"
>
{t("common:actions.confirm")}
</SendouButton>
<SendouButton variant="outlined" onPress={onBack}>
{t("common:actions.back")}
</SendouButton>
</div>
</div>
);
}
function TeamRadioOption({
team,
isOwnTeam,
hideLabel,
className,
testId,
}: {
team: ActionTabTeam;
isOwnTeam: boolean;
hideLabel?: boolean;
className?: string;
testId?: string;
}) {
const { t } = useTranslation(["q"]);
const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD;
return (
<Radio
value={String(team.id)}
aria-label={team.name}
className={clsx(styles.teamRadioContainer, className)}
data-testid={testId}
>
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.teamRadio, {
[styles.selected]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<span
className={clsx(styles.checkCircle, {
[styles.checkCircleSelected]: isSelected,
})}
>
{isSelected ? <Check size={14} /> : null}
</span>
<span className={styles.teamAvatarInfo}>
<Avatar url={team.avatar} identiconInput={team.name} size="xxs" />
<span className={styles.teamInfo}>
<span
className={clsx(styles.teamName, {
[styles.teamNameLong]: isLongName,
})}
>
{team.name}
</span>
{hideLabel ? null : (
<span
className={clsx(styles.teamLabel, {
[styles.myTeamLabel]: isOwnTeam,
[styles.opponentLabel]: !isOwnTeam,
})}
>
{isOwnTeam
? t("q:match.action.myTeam")
: t("q:match.action.opponent")}
</span>
)}
</span>
</span>
</span>
)}
</Radio>
);
}

View File

@ -0,0 +1,157 @@
.root {
--banner-height: 175px;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
container-type: inline-size;
}
.banner {
position: relative;
display: grid;
grid-template-columns: max-content 1fr;
grid-template-areas: "map info";
background-size: cover;
background-position: center;
background-repeat: no-repeat;
width: 100%;
height: var(--banner-height);
border-radius: var(--radius-box);
padding: var(--s-2);
background-image:
linear-gradient(
to top,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6)
),
var(--stage-img);
color: var(--color-text);
:global(html.light) & {
color: var(--color-text-inverse);
}
}
.info {
grid-area: info;
justify-self: flex-end;
}
.map {
grid-area: map;
display: flex;
gap: var(--s-1);
}
.notice {
position: absolute;
bottom: var(--s-2);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--s-0-5);
align-items: center;
color: var(--color-text-high);
background-color: var(--color-bg-high);
padding: var(--s-0-5) var(--s-1-5);
border-radius: var(--radius-field);
font-size: var(--font-3xs);
font-weight: normal;
height: auto;
}
.infoBadge {
display: flex;
gap: var(--s-1-5);
align-items: center;
height: auto;
color: inherit;
font-size: inherit;
font-weight: inherit;
}
.thickText {
font-size: var(--font-md);
font-weight: var(--weight-semi);
}
.legalIcon {
color: var(--color-success);
}
.illegalIcon {
color: var(--color-error);
}
.multiBanner {
display: flex;
padding: 0;
overflow: hidden;
background-image: none;
}
.segment {
--slant: 13px;
flex: 1 1 0;
min-width: 0;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-image:
linear-gradient(
to top,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6)
),
var(--stage-img);
clip-path: polygon(
var(--slant) 0,
100% 0,
calc(100% - var(--slant)) 100%,
0 100%
);
margin-inline-start: calc(var(--slant) * -1);
&:first-child {
clip-path: polygon(0 0, 100% 0, calc(100% - var(--slant)) 100%, 0 100%);
margin-inline-start: 0;
}
&:last-child {
clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, 0 100%);
}
}
.iconBanner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-1);
width: 100%;
height: var(--banner-height);
border-radius: var(--radius-box);
background-color: var(--color-bg-higher);
}
.iconBannerHeader {
font-size: var(--font-md);
font-weight: var(--weight-bold);
}
.iconBannerSubtitle {
font-size: var(--font-xs);
color: var(--color-text-low);
}
.iconBannerBottomRight {
position: absolute;
top: var(--s-2);
right: var(--s-2);
}

View File

@ -0,0 +1,144 @@
import clsx from "clsx";
import { Check, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { specialWeaponImageUrl, stageBannerImageUrl } from "~/utils/urls";
import { ModeImage } from "../Image";
import styles from "./MatchBanner.module.css";
export function MatchBannerContainer({
children,
}: {
children: React.ReactNode;
}) {
return <div className={styles.root}>{children}</div>;
}
interface MatchBannerProps {
stageId: StageId;
mode: ModeShort;
screenLegal?: boolean;
children: React.ReactNode;
}
export function MatchBanner({
stageId,
mode,
screenLegal,
children,
}: MatchBannerProps) {
const { t } = useTranslation(["game-misc"]);
return (
<div
className={styles.banner}
style={{
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
}}
data-testid="stage-banner"
>
<div className={clsx(styles.map, styles.thickText)}>
<ModeImage mode={mode} size={24} />
{t(`game-misc:MODE_SHORT_${mode}`)} {t(`game-misc:STAGE_${stageId}`)}
</div>
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
</div>
);
}
export function MultiMatchBanner({ stageIds }: { stageIds: StageId[] }) {
return (
<div className={clsx(styles.banner, styles.multiBanner)}>
{stageIds.map((stageId, i) => (
<div
key={`${stageId}-${i}`}
className={styles.segment}
style={
{
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
} as React.CSSProperties
}
/>
))}
</div>
);
}
interface IconBannerProps {
icon: React.ReactNode;
header: string;
subtitle?: string;
screenLegal?: boolean;
topRight?: React.ReactNode;
testId?: string;
}
export function IconBanner({
icon,
header,
subtitle,
screenLegal,
topRight,
testId,
}: IconBannerProps) {
return (
<div className={styles.iconBanner} data-testid={testId}>
{icon}
<div className={styles.iconBannerHeader}>{header}</div>
{subtitle ? (
<div className={styles.iconBannerSubtitle}>{subtitle}</div>
) : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
{topRight ? (
<div className={styles.iconBannerBottomRight}>{topRight}</div>
) : null}
</div>
);
}
function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
const { t } = useTranslation(["weapons", "q"]);
const imgSize = 18;
const Icon = screenLegal ? Check : X;
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
className={styles.notice}
testId={screenLegal ? "screen-allowed" : "screen-banned"}
>
<Icon
size={imgSize}
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
/>
<img
src={`${specialWeaponImageUrl(19)}.avif`}
width={imgSize}
height={imgSize}
alt=""
/>
</SendouButton>
}
>
{screenLegal
? t("q:match.screen.allowed", {
special: t("weapons:SPECIAL_19"),
})
: t("q:match.screen.ban", {
special: t("weapons:SPECIAL_19"),
})}
</SendouPopover>
);
}

View File

@ -0,0 +1,56 @@
.root {
display: flex;
justify-content: space-between;
padding-inline: var(--s-1-5);
@container (max-width: 500px) {
flex-direction: column-reverse;
align-items: center;
gap: var(--s-3);
}
}
.activeRosters {
display: flex;
align-items: center;
gap: var(--s-2);
}
.modeProgress {
display: flex;
align-items: center;
gap: var(--s-1);
}
.mode {
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-1);
}
.modePlaceholder {
background-color: transparent;
padding: calc(var(--s-1) - 1px);
border: 1px dashed var(--color-bg-higher);
color: var(--color-text-low);
display: flex;
align-items: center;
justify-content: center;
}
.modeCount {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
}
.team {
display: flex;
gap: var(--s-1);
}
.vs {
text-transform: uppercase;
font-size: var(--font-3xs);
font-weight: var(--weight-bold);
color: var(--color-text-high);
}

View File

@ -0,0 +1,102 @@
import clsx from "clsx";
import { MousePointerClick } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ModeShort } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
import { Avatar } from "../Avatar";
import { ModeImage } from "../Image";
import styles from "./MatchBannerBottomRow.module.css";
interface MatchBannerBottomRowProps {
games: Array<{ mode: ModeShort | null; winner?: "ALPHA" | "BRAVO" }>;
activeRosters: {
alpha: CommonUser[] | null;
bravo: CommonUser[] | null;
} | null;
}
export function MatchBannerBottomRow({
games,
activeRosters,
}: MatchBannerBottomRowProps) {
return (
<div className={styles.root}>
<ModeProgress games={games} />
<ActiveRosters activeRosters={activeRosters} />
</div>
);
}
function ModeProgress({ games }: Pick<MatchBannerBottomRowProps, "games">) {
const knownModes = games.flatMap((game) => (game.mode ? [game.mode] : []));
const allSameMode =
knownModes.length === games.length &&
games.length > 1 &&
knownModes.every((mode) => mode === knownModes[0]);
if (allSameMode) {
return (
<div className={styles.modeProgress}>
<div
className={styles.mode}
data-testid={`mode-progress-${knownModes[0]}`}
>
<ModeImage mode={knownModes[0]} size={16} />
</div>
<div className={styles.modeCount}>×{games.length}</div>
</div>
);
}
return (
<div className={styles.modeProgress}>
{games.map((game, i) =>
game.mode ? (
<div
key={i}
className={styles.mode}
data-testid={`mode-progress-${game.mode}`}
>
<ModeImage mode={game.mode} size={16} />
</div>
) : (
<div
key={i}
className={clsx(styles.mode, styles.modePlaceholder)}
data-testid="mode-progress-banned"
>
<MousePointerClick size={16} />
</div>
),
)}
</div>
);
}
function Roster({ users }: { users: CommonUser[] }) {
return (
<div className={styles.team}>
{users.map((user) => (
<Avatar key={user.id} user={user} size="xxs" />
))}
</div>
);
}
function ActiveRosters({
activeRosters,
}: Pick<MatchBannerBottomRowProps, "activeRosters">) {
const { t } = useTranslation(["q"]);
if (!activeRosters?.alpha || !activeRosters.bravo) {
return null;
}
return (
<div className={styles.activeRosters}>
<Roster users={activeRosters.alpha} />
<div className={styles.vs}>{t("q:match.banner.vs")}</div>
<Roster users={activeRosters.bravo} />
</div>
);
}

View File

@ -0,0 +1,15 @@
.root {
display: flex;
justify-content: space-between;
padding-inline: var(--s-1-5);
}
.values {
display: flex;
gap: var(--s-2);
font-weight: var(--weight-semi);
}
.sub {
color: var(--color-text-high);
}

View File

@ -0,0 +1,88 @@
import { useTranslation } from "react-i18next";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./MatchBannerTopRow.module.css";
interface MatchBannerTopRowProps {
score: {
alpha: number;
bravo: number;
isFinal: boolean;
count: number;
bestOf: boolean;
};
time?: {
currentMinutes: number;
totalMinutes: number;
};
}
export function MatchBannerTopRow({ score, time }: MatchBannerTopRowProps) {
return (
<div className={styles.root}>
<Score score={score} />
{time ? <Timer time={time} /> : null}
</div>
);
}
function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
const { t } = useTranslation(["q"]);
return (
<div className={styles.values}>
<div>
{score.alpha}-{score.bravo}
</div>
<div
className={styles.sub}
data-testid={score.isFinal ? "match-final" : undefined}
>
{score.isFinal
? t("q:match.banner.final")
: score.bestOf
? t("q:match.banner.bestOf", { count: score.count })
: t("q:match.banner.playAll", { count: score.count })}
</div>
</div>
);
}
function Timer({
time,
}: {
time: NonNullable<MatchBannerTopRowProps["time"]>;
}) {
const isHydrated = useHydrated();
const { i18n } = useTranslation();
if (!isHydrated) return null;
const minuteFormatter = new Intl.NumberFormat(i18n.language, {
style: "unit",
unit: "minute",
unitDisplay: "short",
});
const hourFormatter = new Intl.NumberFormat(i18n.language, {
style: "unit",
unit: "hour",
unitDisplay: "short",
});
const MAX_MINUTES = 60;
const dateTime = (minutes: number) => `PT0H${minutes}M`;
const displayValue = (minutes: number) =>
minutes >= MAX_MINUTES
? `${hourFormatter.format(1)}+`
: minuteFormatter.format(minutes);
return (
<div className={styles.values} data-testid="match-timer">
<time dateTime={dateTime(time.currentMinutes)} className={styles.sub}>
{displayValue(time.currentMinutes)}
</time>
<time dateTime={dateTime(time.totalMinutes)}>
{displayValue(time.totalMinutes)}
</time>
</div>
);
}

View File

@ -0,0 +1,86 @@
.joinContent {
display: grid;
grid-template-areas: "time x" "qr join";
gap: var(--s-1) var(--s-4);
justify-content: center;
}
.joinInfo {
display: flex;
flex-direction: column;
grid-area: join;
gap: var(--s-2);
}
.infoHeader {
text-transform: uppercase;
color: var(--color-text-high);
font-size: var(--font-2xs);
line-height: 1.1;
}
.infoValue {
font-size: var(--font-lg);
font-weight: var(--weight-semi);
letter-spacing: 1px;
}
.qrCodeContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
grid-area: qr;
}
.roomAge {
grid-area: time;
font-size: var(--font-2xs);
color: var(--color-text-high);
text-align: center;
}
.qrCode {
background-color: white;
padding: var(--s-2);
border-radius: var(--radius-field);
}
.joinLink {
font-size: var(--font-2xs);
text-overflow: ellipsis;
overflow-x: hidden;
text-wrap: nowrap;
max-width: 140px;
}
.qrOverlay {
width: 172px;
height: 172px;
border-radius: var(--radius-field);
padding: var(--s-2);
background-color: var(--color-bg-higher);
grid-area: qr;
}
.stalePrompt {
display: flex;
gap: var(--s-6);
flex-direction: column;
align-items: center;
justify-content: center;
}
.staleText {
font-size: var(--font-sm);
color: var(--color-text-high);
text-align: center;
}
.noRoomHint {
font-size: var(--font-sm);
color: var(--color-text-high);
display: grid;
place-items: center;
text-align: center;
}

View File

@ -0,0 +1,146 @@
import clsx from "clsx";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { Alert } from "~/components/Alert";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { SendouButton } from "../elements/Button";
import { SendouTabPanel } from "../elements/Tabs";
import styles from "./MatchJoinTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
interface MatchJoinTabProps {
joinLink?: string;
hostedBy?: string;
pool: string;
pass: string;
showNoSplatnetAlert: boolean;
isStale?: boolean;
staleMinutesAgo?: number;
refreshedAt?: Date;
onConfirmRoom?: () => void;
isConfirming?: boolean;
}
export function MatchJoinTab({
joinLink,
hostedBy,
pool,
pass,
showNoSplatnetAlert,
isStale,
staleMinutesAgo,
refreshedAt,
onConfirmRoom,
isConfirming,
}: MatchJoinTabProps) {
const { t } = useTranslation(["q"]);
const { formatDistanceToNow } = useTimeFormat();
return (
<SendouTabPanel id={TAB_KEYS.JOIN}>
<div className="stack lg">
{showNoSplatnetAlert ? (
<Alert variation="WARNING" tiny>
{t("q:match.noSplatnetWarning")}
</Alert>
) : null}
<div className={styles.joinContent}>
{joinLink ? (
isStale ? (
<StaleRoomPrompt
minutesAgo={staleMinutesAgo ?? 0}
onConfirm={onConfirmRoom}
isConfirming={isConfirming}
/>
) : (
<>
{refreshedAt ? (
<div className={styles.roomAge}>
{formatDistanceToNow(refreshedAt, { addSuffix: true })}
</div>
) : null}
<div className={styles.qrCodeContainer}>
<QRCodeSVG
value={joinLink}
size={140}
className={styles.qrCode}
/>
<a
href={joinLink}
target="_blank"
rel="noopener noreferrer"
className={styles.joinLink}
>
{joinLink}
</a>
</div>
</>
)
) : (
<div className={clsx(styles.qrOverlay, styles.noRoomHint)}>
{t("q:match.room.noRoomHint")}
</div>
)}
<div className={styles.joinInfo}>
{hostedBy ? (
<InfoWithHeader header={t("q:match.hostedBy")} value={hostedBy} />
) : null}
<InfoWithHeader header={t("q:match.pool")} value={pool} />
<InfoWithHeader
header={t("q:match.password.short")}
value={pass}
testId="room-pass"
/>
</div>
</div>
</div>
</SendouTabPanel>
);
}
function StaleRoomPrompt({
minutesAgo,
onConfirm,
isConfirming,
}: {
minutesAgo: number;
onConfirm?: () => void;
isConfirming?: boolean;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={clsx(styles.qrOverlay, styles.stalePrompt)}>
<div className={styles.staleText}>
{t("q:match.room.stalePrompt", { minutes: minutesAgo })}
</div>
<SendouButton
variant="outlined"
size="small"
onPress={onConfirm}
isDisabled={isConfirming}
>
{t("q:match.room.confirm")}
</SendouButton>
</div>
);
}
function InfoWithHeader({
header,
value,
testId,
}: {
header: string;
value: string;
testId?: string;
}) {
return (
<div>
<div className={styles.infoHeader}>{header}</div>
<div className={styles.infoValue} data-testid={testId}>
{value}
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-6);
}

View File

@ -0,0 +1,12 @@
import clsx from "clsx";
import styles from "./MatchPage.module.css";
export function MatchPage({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return <div className={clsx(styles.root, className)}>{children}</div>;
}

View File

@ -0,0 +1,14 @@
.root {
display: flex;
justify-content: space-between;
}
.title {
font-size: var(--font-lg);
}
.subtitle {
font-weight: var(--weight-bold);
font-size: var(--font-xs);
color: var(--color-text-high);
}

View File

@ -0,0 +1,21 @@
import styles from "./MatchPageHeader.module.css";
export function MatchPageHeader({
children,
subtitle,
topRight,
}: {
children: React.ReactNode;
subtitle: string;
topRight?: React.ReactNode;
}) {
return (
<div className={styles.root}>
<div>
<h2 className={styles.title}>{children}</h2>
<div className={styles.subtitle}>{subtitle}</div>
</div>
{topRight ? <div>{topRight}</div> : null}
</div>
);
}

View File

@ -0,0 +1,16 @@
import type * as React from "react";
import { SendouTabPanel } from "../elements/Tabs";
import { TAB_KEYS } from "./MatchTabs";
import { MatchTimeline, type MatchTimelineProps } from "./MatchTimeline";
export function MatchResultTab({
children,
...props
}: MatchTimelineProps & { children?: React.ReactNode }) {
return (
<SendouTabPanel id={TAB_KEYS.RESULT}>
<MatchTimeline {...props} />
{children}
</SendouTabPanel>
);
}

View File

@ -0,0 +1,258 @@
.rosters {
display: flex;
flex-direction: column;
gap: var(--s-8);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
width: max-content;
max-width: 100%;
margin-inline: auto;
}
.rostersDivider {
display: none;
}
@container (width >= 640px) {
.rosters {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--s-4);
width: auto;
max-width: none;
margin-inline: 0;
}
.rosterColumn {
margin-inline: auto;
width: max-content;
max-width: 100%;
}
.rostersDivider {
display: block;
background-color: var(--color-border);
width: 1px;
align-self: stretch;
}
}
.rostersSpacedHeader {
min-height: 45px;
display: flex;
align-items: center;
}
.rosterMembers {
position: relative;
padding-inline-start: 34px;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--s-2-5);
margin-top: var(--s-2);
&::before {
content: "";
position: absolute;
inset-inline-start: 21px;
top: -8px;
bottom: 0;
width: 3px;
background-color: var(--color-border-high);
opacity: 0.3;
border-radius: 0 0 var(--radius-field) var(--radius-field);
}
}
.tierBadge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 100%;
background-color: var(--color-bg-higher);
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.tierPopover {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.tierPopoverName {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
text-transform: capitalize;
}
.memberGrid {
display: grid;
grid-template-areas:
"avatar name"
"tier meta";
grid-template-columns: auto 1fr;
column-gap: var(--s-2);
row-gap: var(--s-1);
align-items: center;
}
.memberLink {
grid-row: 1;
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
column-gap: var(--s-2);
align-items: center;
}
.memberNameStack {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.memberInGameName {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
.memberMenuTrigger {
background: none;
border: 0;
padding: 0;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
width: 100%;
}
.friendCodeHeader {
text-align: center;
}
.memberMenuHeader {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.memberMenuIgn {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.memberMenuIgnLabel {
font-weight: var(--weight-bold);
text-transform: uppercase;
font-size: var(--font-3xs);
}
.memberTier {
grid-area: tier;
justify-self: center;
}
.memberMetaArea {
grid-area: meta;
}
.memberMeta {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-2xs);
}
.plusTier {
display: flex;
align-items: center;
gap: var(--s-0-5);
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-0-5) var(--s-1-5);
padding-inline-start: var(--s-1);
font-weight: var(--weight-semi);
color: var(--color-text);
}
.subbedOutTrigger {
display: flex;
align-items: center;
gap: var(--s-1-5);
}
.subbedOutIcon {
--subbed-out-icon-size: 24px;
display: flex;
align-items: center;
justify-content: center;
width: var(--subbed-out-icon-size);
height: var(--subbed-out-icon-size);
border-radius: 100%;
background-color: var(--color-bg-higher);
}
.subbedOutPopover {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.subbedOutHeader {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
text-transform: uppercase;
}
.rosterEditCount {
font-size: var(--font-xs);
color: var(--color-text-high);
margin-block-end: var(--s-2);
text-align: center;
}
.rosterEditButtons {
display: flex;
gap: var(--s-2);
margin-block-start: var(--s-4);
justify-content: center;
}
.teamOneDot {
border-radius: 100%;
background-color: var(--color-accent);
width: 8px;
height: 8px;
}
.teamTwoDot {
border-radius: 100%;
background-color: var(--color-second);
width: 8px;
height: 8px;
}
.teamAvatar {
border-radius: var(--radius-avatar);
width: 44px;
height: 44px;
flex-shrink: 0;
&[data-side="alpha"] {
background-color: var(--color-accent);
}
&[data-side="bravo"] {
background-color: var(--color-second);
}
}

View File

@ -0,0 +1,530 @@
import clsx from "clsx";
import { Armchair, Edit, User } from "lucide-react";
import { useState } from "react";
import { Button as ReactAriaButton } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import {
SendouMenu,
SendouMenuItem,
SendouMenuSection,
} from "~/components/elements/Menu";
import { SendouPopover } from "~/components/elements/Popover";
import { Image, TierImage } from "~/components/Image";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import type { CommonUser } from "~/utils/kysely.server";
import {
navIconUrl,
preferenceEmojiUrl,
tierImageUrl,
userPage,
} from "~/utils/urls";
import { SendouTabPanel } from "../elements/Tabs";
import styles from "./MatchRosterTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
import { WeaponPool } from "./WeaponPool";
type RosterTabMember = CommonUser & {
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
plusTier?: number | null;
weaponPool?: Array<MainWeaponId>;
friendCode?: string | null;
privateNote?: { sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE" } | null;
inGameName?: string | null;
};
interface RosterTabTeam {
team?: {
id: number;
name: string;
url: string;
avatar?: string;
};
defaultName?: string;
members: Array<RosterTabMember>;
/** Sub user ids i.e. those who are not the current active roster */
subbedOut?: Array<number>;
tier?: { name: TierName; isPlus: boolean };
}
interface MatchRosterTabProps {
teams: [RosterTabTeam, RosterTabTeam];
minMembersPerTeam: number;
canEditSubbedOut?: [boolean, boolean];
defaultIsEditing?: [boolean, boolean];
onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void;
isSubmitting?: boolean;
}
export function MatchRosterTab({
teams,
minMembersPerTeam,
canEditSubbedOut,
defaultIsEditing,
onSubbedOutChange,
isSubmitting,
}: MatchRosterTabProps) {
return (
<SendouTabPanel id={TAB_KEYS.ROSTERS}>
<div className={styles.rosters}>
<TeamRoster
team={teams[0]}
side="alpha"
canEditSubbedOut={canEditSubbedOut?.[0] ?? false}
defaultIsEditing={defaultIsEditing?.[0] ?? false}
minMembersPerTeam={minMembersPerTeam}
onSubbedOutChange={onSubbedOutChange}
isSubmitting={isSubmitting}
/>
<div className={styles.rostersDivider} />
<TeamRoster
team={teams[1]}
side="bravo"
canEditSubbedOut={canEditSubbedOut?.[1] ?? false}
defaultIsEditing={defaultIsEditing?.[1] ?? false}
minMembersPerTeam={minMembersPerTeam}
onSubbedOutChange={onSubbedOutChange}
isSubmitting={isSubmitting}
/>
</div>
</SendouTabPanel>
);
}
function TeamRoster({
team,
side,
canEditSubbedOut,
defaultIsEditing,
minMembersPerTeam,
onSubbedOutChange,
isSubmitting,
}: {
team: RosterTabTeam;
side: "alpha" | "bravo";
canEditSubbedOut: boolean;
defaultIsEditing: boolean;
minMembersPerTeam: number;
onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void;
isSubmitting?: boolean;
}) {
const { t } = useTranslation(["common", "q"]);
const [isEditing, setIsEditing] = useState(defaultIsEditing);
const [selectedMemberIds, setSelectedMemberIds] = useState<number[]>([]);
const dotClassName = side === "alpha" ? styles.teamOneDot : styles.teamTwoDot;
const label =
side === "alpha" ? t("q:match.sides.alpha") : t("q:match.sides.bravo");
const subbedOutSet = new Set(team.subbedOut);
const activeMembers = team.members.filter(
(member) => !subbedOutSet.has(member.id),
);
const subbedOutMembers = team.members.filter((member) =>
subbedOutSet.has(member.id),
);
const showEditButton = canEditSubbedOut && team.team && !isEditing;
return (
<div className={clsx("stack xxs", styles.rosterColumn)}>
<TeamHeader
team={team}
side={side}
label={label}
dotClassName={dotClassName}
/>
{team.members.length > 0 ? (
<ul className={styles.rosterMembers}>
{isEditing
? team.members.map((member, index) => (
<li key={member.id}>
<label className="stack horizontal sm items-center cursor-pointer">
<input
type="checkbox"
checked={selectedMemberIds.includes(member.id)}
onChange={() => handleToggleMember(member.id)}
data-testid={`player-checkbox-${side}-${index}`}
/>
<Avatar user={member} size="xxs" />
<span>{member.username}</span>
</label>
</li>
))
: activeMembers.map((member) => (
<li key={member.id} className={styles.memberGrid}>
<RosterMemberLink
member={member}
className={styles.memberLink}
/>
<div className={styles.memberTier}>
<MemberTierPopover tier={member.tier} />
</div>
<div className={styles.memberMetaArea}>
<MemberMeta
plusTier={member.plusTier}
weaponPool={member.weaponPool}
/>
</div>
</li>
))}
{!isEditing && subbedOutMembers.length > 0 ? (
<li>
<SubbedOutPopover members={subbedOutMembers} />
</li>
) : null}
</ul>
) : null}
{isEditing ? (
<div>
<div className={styles.rosterEditCount}>
{selectedMemberIds.length}/{minMembersPerTeam}
</div>
<div className={styles.rosterEditButtons}>
<SendouButton
variant="primary"
size="small"
isDisabled={
isSubmitting || selectedMemberIds.length !== minMembersPerTeam
}
onPress={handleSubmit}
testId={`save-active-roster-button-${side}`}
>
{t("common:actions.submit")}
</SendouButton>
{defaultIsEditing ? null : (
<SendouButton
variant="outlined"
size="small"
onPress={handleCancel}
>
{t("common:actions.cancel")}
</SendouButton>
)}
</div>
</div>
) : null}
{showEditButton ? (
<SendouButton
icon={<Edit />}
className="mt-4 mx-auto"
size="small"
onPress={() => {
setSelectedMemberIds(activeMembers.map((m) => m.id));
setIsEditing(true);
}}
testId={`edit-active-roster-button-${side}`}
>
{t("common:actions.edit")}
</SendouButton>
) : null}
</div>
);
function handleToggleMember(memberId: number) {
setSelectedMemberIds((prev) =>
prev.includes(memberId)
? prev.filter((id) => id !== memberId)
: [...prev, memberId],
);
}
function handleSubmit() {
if (!team.team || !onSubbedOutChange) return;
const subbedOutIds = team.members
.filter((m) => !selectedMemberIds.includes(m.id))
.map((m) => m.id);
onSubbedOutChange(team.team.id, subbedOutIds);
setIsEditing(false);
}
function handleCancel() {
setSelectedMemberIds(activeMembers.map((m) => m.id));
setIsEditing(false);
}
}
function TeamHeader({
team,
side,
label,
dotClassName,
}: {
team: RosterTabTeam;
side: "alpha" | "bravo";
label: string;
dotClassName: string;
}) {
const tierText = team.tier
? `${team.tier.name.toLowerCase()}${team.tier.isPlus ? "+" : ""}`
: undefined;
if (team.team) {
return (
<Link to={team.team.url} className="stack horizontal sm">
<Avatar
url={team.team.avatar}
identiconInput={team.team.name}
size="sm"
/>
<div className="stack justify-center line-height-tight">
<h2 className="text-main-forced font-bold">{team.team.name}</h2>
<div className="stack xs horizontal items-center text-lighter">
<div className={dotClassName} />
{label}
{tierText ? (
<>
<span></span>
<span className="text-capitalize">{tierText}</span>
</>
) : null}
</div>
</div>
</Link>
);
}
invariant(team.defaultName, "team or defaultName must be provided");
return (
<div className="stack horizontal sm">
<div className={styles.teamAvatar} data-side={side} />
<div className="stack justify-center line-height-tight">
<h2 className="text-main-forced font-bold">{team.defaultName}</h2>
<div className="stack xs horizontal items-center text-lighter">
<div className={dotClassName} />
{label}
{tierText ? (
<>
<span></span>
<span className="text-capitalize">{tierText}</span>
</>
) : null}
</div>
</div>
</div>
);
}
function MemberTierPopover({
tier,
}: {
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
}) {
if (!tier) return null;
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={styles.tierBadge}>
{tier === "CALCULATING" ? (
<Image
path={tierImageUrl("CALCULATING")}
alt=""
width={22}
height={22 * 0.8675}
/>
) : (
<TierImage tier={tier} width={22} />
)}
</SendouButton>
}
>
<MemberTierPopoverContent tier={tier} />
</SendouPopover>
);
}
function MemberTierPopoverContent({
tier,
}: {
tier: { name: TierName; isPlus: boolean } | "CALCULATING";
}) {
const { t } = useTranslation(["q"]);
if (tier === "CALCULATING") {
return (
<div className={styles.tierPopover}>
<Image
path={tierImageUrl("CALCULATING")}
alt=""
width={80}
height={80 * 0.8675}
/>
<span className={styles.tierPopoverName}>
{t("q:looking.sp.calculating")}
</span>
</div>
);
}
return (
<div className={styles.tierPopover}>
<TierImage tier={tier} width={80} />
<span className={styles.tierPopoverName}>
{tier.name.toLowerCase()}
{tier.isPlus ? "+" : ""}
</span>
</div>
);
}
function MemberMeta({
plusTier,
weaponPool,
}: {
plusTier?: number | null;
weaponPool?: Array<MainWeaponId>;
}) {
const hasPlusTier = typeof plusTier === "number";
const hasWeapons = weaponPool && weaponPool.length > 0;
if (!hasPlusTier && !hasWeapons) return null;
return (
<div className={styles.memberMeta}>
{hasPlusTier ? (
<div className={styles.plusTier}>
<Image path={navIconUrl("plus")} width={16} height={16} alt="" />
<span>{plusTier}</span>
</div>
) : null}
{hasWeapons ? <WeaponPool weapons={weaponPool} size={18} /> : null}
</div>
);
}
function SubbedOutPopover({ members }: { members: Array<RosterTabMember> }) {
const { t } = useTranslation(["q"]);
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" size="small" className="h-max">
<div className={styles.subbedOutTrigger}>
<div className={styles.subbedOutIcon}>
<Armchair size={16} />
</div>
+{members.length}
</div>
</SendouButton>
}
>
<div className={styles.subbedOutPopover}>
<div className={styles.subbedOutHeader}>{t("q:match.subbedOut")}</div>
{members.map((member) => (
<RosterMemberLink
key={member.id}
member={member}
className="stack horizontal sm items-center"
/>
))}
</div>
</SendouPopover>
);
}
function RosterMemberLink({
member,
className,
}: {
member: RosterTabMember;
className?: string;
}) {
const { t } = useTranslation(["friends", "q", "user"]);
const showNoteItem = member.privateNote !== undefined;
const hasContentBelowName = !!(
member.tier ||
typeof member.plusTier === "number" ||
(member.weaponPool && member.weaponPool.length > 0)
);
const showIgnInMenu = hasContentBelowName && !!member.inGameName;
const showIgnUnderName = !hasContentBelowName && !!member.inGameName;
const useMenu = !!member.friendCode || showNoteItem || showIgnInMenu;
const nameContent = (
<div className={styles.memberNameStack}>
<span>{member.username}</span>
{showIgnUnderName ? (
<span className={styles.memberInGameName}>{member.inGameName}</span>
) : null}
</div>
);
if (!useMenu) {
return (
<Link to={userPage(member)} className={className}>
<Avatar user={member} size="xxs" />
{nameContent}
</Link>
);
}
const headerContent =
member.friendCode || showIgnInMenu ? (
<div className={styles.memberMenuHeader}>
{member.friendCode ? <span>{`SW-${member.friendCode}`}</span> : null}
{showIgnInMenu ? (
<span className={styles.memberMenuIgn}>
<span className={styles.memberMenuIgnLabel}>
{t("user:ign.short")}:
</span>{" "}
{member.inGameName}
</span>
) : null}
</div>
) : undefined;
return (
<SendouMenu
trigger={
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
<Avatar user={member} size="xxs" />
{nameContent}
</ReactAriaButton>
}
>
<SendouMenuSection
headerText={headerContent}
headerClassName={styles.friendCodeHeader}
>
<SendouMenuItem href={userPage(member)} icon={<User />}>
{t("friends:friendsList.viewUserPage")}
</SendouMenuItem>
{showNoteItem ? (
<SendouMenuItem
href={`?note=${member.id}`}
icon={
member.privateNote ? (
<img
src={preferenceEmojiUrl(
member.privateNote.sentiment === "POSITIVE"
? "PREFER"
: member.privateNote.sentiment === "NEGATIVE"
? "AVOID"
: undefined,
)}
alt=""
width={18}
height={18}
/>
) : (
<Edit />
)
}
>
{member.privateNote
? t("q:looking.groups.editNote")
: t("q:looking.groups.addNote")}
</SendouMenuItem>
) : null}
</SendouMenuSection>
</SendouMenu>
);
}

View File

@ -0,0 +1,7 @@
.root {
& [class*="tabPanel"] {
background-color: var(--color-bg-high);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-6) var(--s-4);
}
}

View File

@ -0,0 +1,76 @@
import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import invariant from "~/utils/invariant";
import { SendouTab, SendouTabList, SendouTabs } from "../elements/Tabs";
import styles from "./MatchTabs.module.css";
type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
interface MatchTabsProps {
children: React.ReactNode;
tabs: Array<MatchTabsKey>;
}
const TAB_KEY = "tab";
export const TAB_KEYS = {
ROSTERS: "rosters",
ACTION: "action",
JOIN: "join",
RESULT: "result",
ADMIN: "admin",
} as const;
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
rosters: <Users />,
action: <Tally5 />,
join: <DoorOpen />,
result: <ScrollText />,
admin: <Key />,
};
const TAB_TRANSLATION_KEYS = {
rosters: "q:match.tabs.rosters",
action: "q:match.tabs.action",
join: "common:actions.join",
result: "q:match.tabs.result",
admin: "common:pages.admin",
} as const;
export function MatchTabs({ children, tabs }: MatchTabsProps) {
const { t } = useTranslation(["q", "common"]);
const [searchParams, setSearchParams] = useSearchParams();
const currentTab =
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ?? tabs.at(0);
invariant(currentTab);
return (
<div className={styles.root}>
<SendouTabs
selectedKey={currentTab}
onSelectionChange={(key) =>
setSearchParams(
{ [TAB_KEY]: key as string },
{
preventScrollReset: true,
unstable_defaultShouldRevalidate: false,
},
)
}
disappearing={false}
>
<SendouTabList>
{tabs.map((tab) => (
<SendouTab key={tab} id={tab} icon={TAB_ICONS[tab]}>
{t(TAB_TRANSLATION_KEYS[tab])}
</SendouTab>
))}
</SendouTabList>
{children}
</SendouTabs>
</div>
);
}

View File

@ -0,0 +1,289 @@
.root {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
row-gap: var(--s-6);
column-gap: var(--s-4);
align-items: center;
width: 100%;
}
.header {
display: contents;
}
.headerTeam {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-1-5);
}
.headerTeamBravo {
align-items: flex-start;
}
.headerTeamName {
font-weight: var(--weight-bold);
font-size: var(--font-md);
text-box: trim-start cap alphabetic;
overflow-wrap: anywhere;
}
.headerTeamNameLong {
font-size: var(--font-xs);
}
.headerAvatars {
display: flex;
gap: var(--s-1);
}
.headerScore {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.headerScoreValue {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
line-height: 1;
}
.headerScoreLive {
margin-top: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-bold);
letter-spacing: 0.1em;
color: var(--color-error);
}
.mapEvent {
display: contents;
}
.mapSide {
display: grid;
grid-template-rows: auto 1fr auto;
align-self: stretch;
&:first-child {
justify-self: end;
}
&:last-child {
justify-self: start;
}
}
.mapCenter {
display: grid;
grid-template-rows: auto 1fr auto;
justify-items: center;
gap: var(--s-1);
}
.mapTimestamp {
font-size: var(--font-3xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
.mapStageImage {
border-radius: var(--radius-box);
}
.mapLabel {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.sideResult {
grid-row: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-2-5);
}
.resultHeader {
display: flex;
align-items: baseline;
gap: var(--s-1);
}
.resultLabel {
font-size: var(--font-xs);
font-weight: var(--weight-extra);
text-transform: uppercase;
}
.resultPoints {
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
}
.eventRow {
display: contents;
}
.eventAlpha {
justify-self: end;
}
.subCenter {
display: flex;
justify-content: center;
align-items: center;
}
.eventIcon {
color: var(--color-text-high);
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-1);
}
.subDetail {
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
row-gap: var(--s-1);
column-gap: var(--s-3);
}
.subLabelOut {
color: var(--color-error);
font-weight: var(--weight-bold);
font-size: var(--font-3xs);
text-transform: uppercase;
}
.subLabelIn {
color: var(--color-success);
font-weight: var(--weight-bold);
font-size: var(--font-3xs);
text-transform: uppercase;
}
.subPlayerName {
font-weight: var(--weight-semi);
font-size: var(--font-xs);
}
.pickIcon {
color: var(--color-success);
}
.banIcon {
color: var(--color-error);
}
.pickBanGroup {
display: flex;
flex-wrap: wrap;
gap: var(--s-1-5);
justify-content: flex-end;
&.pickBanGroupBravo {
justify-content: flex-start;
}
}
.pickBanStageImage {
border-radius: var(--radius-box);
}
.pickBanModeTile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 32px;
background-color: var(--color-bg-higher);
border-radius: var(--radius-box);
}
.spSection {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr auto 1fr;
column-gap: var(--s-8);
align-items: center;
}
.spIcon {
display: flex;
justify-content: center;
align-items: center;
}
.spColumn {
display: grid;
grid-template-columns: auto auto;
row-gap: var(--s-1-5);
column-gap: var(--s-2);
align-items: center;
justify-content: start;
&:first-child {
justify-content: end;
}
}
.spDetail {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
font-size: var(--font-xs);
align-items: center;
}
.spDetailContent {
display: flex;
align-items: center;
gap: var(--s-2);
}
.spDeltaTrigger {
display: flex;
align-items: center;
gap: var(--s-2);
background: transparent;
border: none;
padding: var(--s-0-5) var(--s-1);
border-radius: var(--radius-field);
font-size: var(--font-xs);
font-weight: inherit;
color: inherit;
cursor: pointer;
}
.spRawPopover {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.spCalculatingIcon {
font-size: 18px;
}
.spTeamIcon {
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background-color: var(--color-bg-higher);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-high);
}

View File

@ -0,0 +1,633 @@
import clsx from "clsx";
import {
ArrowRight,
MousePointerClick,
RefreshCcw,
TrendingUp,
Users,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
import { roundToNDecimalPlaces } from "~/utils/number";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { ModeImage, StageImage } from "../Image";
import styles from "./MatchTimeline.module.css";
import { type InferredSubstitution, inferSubstitutions } from "./utils";
import { WeaponPool } from "./WeaponPool";
const LONG_TEAM_NAME_THRESHOLD = 16;
type MatchSide = "ALPHA" | "BRAVO";
export interface TimelineTeam {
name: string;
avatar?: string;
}
export interface TimelineMap {
stageId: StageId;
mode: ModeShort;
timestamp: number;
winner: MatchSide;
rosters: {
alpha: CommonUser[];
bravo: CommonUser[];
};
weapons?: {
alpha: Array<MainWeaponId | null>;
bravo: Array<MainWeaponId | null>;
};
/** Optional point values [alpha, bravo] */
points?: [number, number];
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
pickedBy?: MatchSide;
}
interface TimelineSpMember {
user: CommonUser;
skillDifference: UserSkillDifference;
}
export interface TimelineSpChanges {
alpha: {
members: TimelineSpMember[];
skillDifference?: GroupSkillDifference;
};
bravo: {
members: TimelineSpMember[];
skillDifference?: GroupSkillDifference;
};
}
export interface TimelinePickBanEvent {
/** "PICK" covers MODE_PICK (and the rare trailing-bucket map PICK); "BAN" covers map and mode bans. */
kind: "PICK" | "BAN";
/** Consecutive events of the same kind get merged into one row, regardless of side. */
alphaEntries: Array<{ stageId?: StageId; mode?: ModeShort }>;
bravoEntries: Array<{ stageId?: StageId; mode?: ModeShort }>;
}
export interface MatchTimelineProps {
teams: { alpha: TimelineTeam; bravo: TimelineTeam };
score: { alpha: number; bravo: number };
maps: TimelineMap[];
spChanges?: TimelineSpChanges;
/** When true, render only the team + score header (no per-map rows or SP section). */
compact?: boolean;
/** When true, the match is still in progress; renders a small LIVE label under the score. */
isOngoing?: boolean;
/**
* Pick/ban events keyed by the slot they precede. Length = `maps.length + 1`.
* Bucket `i` renders above map row `i`; the trailing bucket renders after the
* last map row (covers events made after the latest result, or the
* pick/ban-only state with no maps reported yet).
*/
pickBanRowsBySlot?: TimelinePickBanEvent[][];
}
export function MatchTimeline({
teams,
score,
maps,
spChanges,
compact = false,
isOngoing = false,
pickBanRowsBySlot,
}: MatchTimelineProps) {
return (
<div className={styles.root}>
<TimelineHeader
teams={teams}
score={score}
maps={maps}
isOngoing={isOngoing}
/>
{compact
? null
: maps.map((map, i) => {
const previousMap = maps[i - 1];
const substitutions = previousMap
? inferSubstitutions(previousMap.rosters, map.rosters)
: [];
const pickBanRows = pickBanRowsBySlot?.[i] ?? [];
return (
<div key={i} className="contents">
{pickBanRows.map((event, j) => (
<TimelinePickBanRow key={`pb-${j}`} event={event} />
))}
{substitutions.map((sub, j) => (
<TimelineSubstitutionRow key={j} substitution={sub} />
))}
<TimelineMapRow map={map} />
</div>
);
})}
{!compact && pickBanRowsBySlot
? (pickBanRowsBySlot[maps.length] ?? []).map((event, j) => (
<TimelinePickBanRow key={`pb-trailing-${j}`} event={event} />
))
: null}
{!compact && spChanges ? (
<TimelineSpSection spChanges={spChanges} />
) : null}
</div>
);
}
function TimelineHeader({
teams,
score,
maps,
isOngoing,
}: Pick<MatchTimelineProps, "teams" | "score" | "maps" | "isOngoing">) {
const { t } = useTranslation(["q"]);
const initialRosters = maps[0]?.rosters;
return (
<div className={styles.header}>
<div className={styles.headerTeam}>
<div
className={clsx(styles.headerTeamName, {
[styles.headerTeamNameLong]:
teams.alpha.name.length > LONG_TEAM_NAME_THRESHOLD,
})}
>
{teams.alpha.name}
</div>
{initialRosters ? (
<div className={styles.headerAvatars}>
{initialRosters.alpha.map((user) => (
<Avatar key={user.id} user={user} size="xxs" />
))}
</div>
) : null}
</div>
<div className={styles.headerScore}>
<span className={styles.headerScoreValue}>
{score.alpha}-{score.bravo}
</span>
{isOngoing ? (
<span className={styles.headerScoreLive}>
{t("q:match.timeline.live")}
</span>
) : null}
</div>
<div className={clsx(styles.headerTeam, styles.headerTeamBravo)}>
<div
className={clsx(styles.headerTeamName, {
[styles.headerTeamNameLong]:
teams.bravo.name.length > LONG_TEAM_NAME_THRESHOLD,
})}
>
{teams.bravo.name}
</div>
{initialRosters ? (
<div className={styles.headerAvatars}>
{initialRosters.bravo.map((user) => (
<Avatar key={user.id} user={user} size="xxs" />
))}
</div>
) : null}
</div>
</div>
);
}
function TimelineMapRow({ map }: { map: TimelineMap }) {
const { t } = useTranslation(["game-misc"]);
const isHydrated = useHydrated();
const { formatTime } = useTimeFormat();
const alphaPoints = map.points?.[0];
const bravoPoints = map.points?.[1];
return (
<div className={styles.mapEvent}>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
points={alphaPoints}
weapons={map.weapons?.alpha}
isPicked={map.pickedBy === "ALPHA"}
/>
</div>
<div className={styles.mapCenter}>
<time className={styles.mapTimestamp}>
{isHydrated ? (
formatTime(new Date(map.timestamp))
) : (
<div className="invisible">X</div>
)}
</time>
<StageImage
stageId={map.stageId}
width={80}
className={styles.mapStageImage}
/>
<div className={styles.mapLabel}>
<ModeImage mode={map.mode} size={14} />
<span>{shortStageName(t(`game-misc:STAGE_${map.stageId}`))}</span>
</div>
</div>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
points={bravoPoints}
weapons={map.weapons?.bravo}
isPicked={map.pickedBy === "BRAVO"}
/>
</div>
</div>
);
}
function SideResult({
result,
points,
weapons,
isPicked,
}: {
result: "WIN" | "LOSS";
points?: number;
weapons?: Array<MainWeaponId | null>;
isPicked?: boolean;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={styles.sideResult}>
<div className={styles.resultHeader}>
{isPicked ? (
<MousePointerClick
size={14}
className={result === "WIN" ? "text-success" : "text-error"}
aria-label={t("q:match.timeline.picked")}
/>
) : null}
<span
className={clsx(
styles.resultLabel,
result === "WIN" ? "text-success" : "text-error",
)}
>
{result === "WIN"
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{points === 100 ? (
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
) : null}
</div>
{weapons ? <WeaponPool weapons={weapons} /> : null}
</div>
);
}
function TimelineEventRow({
icon,
alphaContent,
bravoContent,
}: {
icon: React.ReactNode;
alphaContent: React.ReactNode;
bravoContent: React.ReactNode;
}) {
return (
<div className={styles.eventRow}>
<div className={styles.eventAlpha}>{alphaContent}</div>
<div className={styles.subCenter}>{icon}</div>
<div>{bravoContent}</div>
</div>
);
}
function TimelinePickBanRow({ event }: { event: TimelinePickBanEvent }) {
const isPick = event.kind === "PICK";
const icon = isPick ? (
<MousePointerClick
size={32}
className={clsx(styles.eventIcon, styles.pickIcon)}
/>
) : (
<X size={32} className={clsx(styles.eventIcon, styles.banIcon)} />
);
return (
<TimelineEventRow
icon={icon}
alphaContent={
event.alphaEntries.length > 0 ? (
<PickBanGroup entries={event.alphaEntries} side="ALPHA" />
) : null
}
bravoContent={
event.bravoEntries.length > 0 ? (
<PickBanGroup entries={event.bravoEntries} side="BRAVO" />
) : null
}
/>
);
}
function PickBanGroup({
entries,
side,
}: {
entries: Array<{ stageId?: StageId; mode?: ModeShort }>;
side: MatchSide;
}) {
return (
<div
className={clsx(styles.pickBanGroup, {
[styles.pickBanGroupBravo]: side === "BRAVO",
})}
>
{entries.map((entry, i) => (
<PickBanEntry key={i} entry={entry} />
))}
</div>
);
}
function PickBanEntry({
entry,
}: {
entry: { stageId?: StageId; mode?: ModeShort };
}) {
if (entry.stageId !== undefined) {
return (
<StageImage
stageId={entry.stageId}
width={56}
className={styles.pickBanStageImage}
/>
);
}
if (entry.mode !== undefined) {
return (
<div className={styles.pickBanModeTile}>
<ModeImage mode={entry.mode} size={24} />
</div>
);
}
return null;
}
function TimelineSubstitutionRow({
substitution,
}: {
substitution: InferredSubstitution;
}) {
return (
<TimelineEventRow
icon={<RefreshCcw size={32} className={styles.eventIcon} />}
alphaContent={
substitution.side === "ALPHA" ? (
<SubstitutionDetail substitution={substitution} />
) : null
}
bravoContent={
substitution.side === "BRAVO" ? (
<SubstitutionDetail substitution={substitution} />
) : null
}
/>
);
}
function SubstitutionDetail({
substitution,
}: {
substitution: InferredSubstitution;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={styles.subDetail}>
<span className={styles.subLabelOut}>{t("q:match.timeline.out")}</span>
<div className="stack horizontal items-center sm">
<Avatar user={substitution.playerOut} size="xxxs" />
<span className={styles.subPlayerName}>
{substitution.playerOut.username}
</span>
</div>
<span className={styles.subLabelIn}>{t("q:match.timeline.in")}</span>
<div className="stack horizontal items-center sm">
<Avatar user={substitution.playerIn} size="xxxs" />
<span className={styles.subPlayerName}>
{substitution.playerIn.username}
</span>
</div>
</div>
);
}
function TimelineSpSection({ spChanges }: { spChanges: TimelineSpChanges }) {
const alphaMembersWithDiff = spChanges.alpha.members.filter(
(m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0,
);
const bravoMembersWithDiff = spChanges.bravo.members.filter(
(m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0,
);
const maxMemberRows = Math.max(
alphaMembersWithDiff.length,
bravoMembersWithDiff.length,
);
if (
maxMemberRows === 0 &&
!spChanges.alpha.skillDifference &&
!spChanges.bravo.skillDifference
) {
return null;
}
return (
<div className={styles.spSection}>
<div className={styles.spColumn}>
{alphaMembersWithDiff.map((m) => (
<SpMemberDetail key={m.user.id} member={m} />
))}
{spChanges.alpha.skillDifference ? (
<SpTeamDetail skillDifference={spChanges.alpha.skillDifference} />
) : null}
</div>
<div className={styles.spIcon}>
<TrendingUp size={32} className={styles.eventIcon} />
</div>
<div className={styles.spColumn}>
{bravoMembersWithDiff.map((m) => (
<SpMemberDetail key={m.user.id} member={m} />
))}
{spChanges.bravo.skillDifference ? (
<SpTeamDetail skillDifference={spChanges.bravo.skillDifference} />
) : null}
</div>
</div>
);
}
function SpMemberDetail({ member }: { member: TimelineSpMember }) {
if (member.skillDifference.calculated) {
const { spDiff, oldSp, newSp } = member.skillDifference;
const isPositive = spDiff > 0;
const arrow = isPositive ? "▲" : "▼";
return (
<div className={styles.spDetail}>
<Avatar user={member.user} size="xxs" />
<SpDeltaTrigger
arrow={arrow}
isPositive={isPositive}
value={Math.abs(spDiff)}
oldSp={oldSp}
newSp={newSp}
/>
</div>
);
}
if (
member.skillDifference.matchesCount ===
member.skillDifference.matchesCountNeeded
) {
return (
<div className={styles.spDetail}>
<Avatar user={member.user} size="xxs" />
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>
{member.skillDifference.newSp ? (
<>{member.skillDifference.newSp}SP</>
) : null}
</span>
</div>
</div>
);
}
return (
<div className={styles.spDetail}>
<Avatar user={member.user} size="xxs" />
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>
{member.skillDifference.matchesCount}/
{member.skillDifference.matchesCountNeeded}
</span>
</div>
</div>
);
}
function SpTeamDetail({
skillDifference,
}: {
skillDifference: GroupSkillDifference;
}) {
if (skillDifference.calculated) {
const { oldSp, newSp } = skillDifference;
const diff = roundToNDecimalPlaces(newSp - oldSp);
const isPositive = diff > 0;
const arrow = isPositive ? "▲" : "▼";
return (
<div className={styles.spDetail}>
<div className={styles.spTeamIcon}>
<Users size={16} />
</div>
<SpDeltaTrigger
arrow={arrow}
isPositive={isPositive}
value={Math.abs(diff)}
oldSp={oldSp}
newSp={newSp}
/>
</div>
);
}
if (skillDifference.newSp) {
return (
<div className={styles.spDetail}>
<div className={styles.spTeamIcon}>
<Users size={16} />
</div>
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>{skillDifference.newSp}SP</span>
</div>
</div>
);
}
return (
<div className={styles.spDetail}>
<div className={styles.spTeamIcon}>
<Users size={16} />
</div>
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded}
</span>
</div>
</div>
);
}
function SpDeltaTrigger({
arrow,
isPositive,
value,
oldSp,
newSp,
}: {
arrow: string;
isPositive: boolean;
value: number;
oldSp?: number;
newSp?: number;
}) {
const arrowClass = isPositive ? "text-success" : "text-warning";
if (oldSp === undefined || newSp === undefined) {
return (
<div className={styles.spDetailContent}>
<span className={arrowClass}>{arrow}</span>
<span>{value}SP</span>
</div>
);
}
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={styles.spDeltaTrigger}>
<span className={arrowClass}>{arrow}</span>
<span>{value}SP</span>
</SendouButton>
}
>
<div className={styles.spRawPopover}>
<span>{oldSp}SP</span>
<ArrowRight size={16} />
<span>{newSp}SP</span>
</div>
</SendouPopover>
);
}

View File

@ -0,0 +1,27 @@
.weaponRow {
display: flex;
gap: var(--s-0-5);
background-color: var(--color-bg-higher);
border: none;
border-radius: var(--radius-full);
padding: var(--s-0-5) var(--s-1-5);
cursor: pointer;
}
:global(html.light) .unknownWeapon {
filter: drop-shadow(0 0 1px var(--color-text));
}
.weaponPopover {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.weaponPopoverRow {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}

View File

@ -0,0 +1,54 @@
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { SendouPopover } from "../elements/Popover";
import { Image, WeaponImage } from "../Image";
import styles from "./WeaponPool.module.css";
export function WeaponPool({
weapons,
size = 24,
}: {
weapons: Array<MainWeaponId | null>;
size?: number;
}) {
const { t } = useTranslation(["weapons"]);
return (
<SendouPopover
trigger={
<Button className={styles.weaponRow}>
{weapons.map((weaponId, i) =>
weaponId !== null ? (
<WeaponImage
key={i}
weaponSplId={weaponId}
variant="badge"
size={size}
/>
) : (
<Image
key={i}
className={styles.unknownWeapon}
path="/static-assets/img/abilities/UNKNOWN"
alt="?"
size={size}
/>
),
)}
</Button>
}
>
<div className={styles.weaponPopover}>
{weapons.map((weaponId, i) =>
weaponId !== null ? (
<div key={i} className={styles.weaponPopoverRow}>
<WeaponImage weaponSplId={weaponId} variant="badge" size={32} />
<span>{t(`weapons:MAIN_${weaponId}` as any)}</span>
</div>
) : null,
)}
</div>
</SendouPopover>
);
}

View File

@ -0,0 +1,108 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-4);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
container-type: inline-size;
}
.pastRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.mapRow {
display: flex;
align-items: center;
gap: var(--s-4);
@container (max-width: 479px) {
flex-direction: column;
}
}
.mapInfo {
display: flex;
flex-direction: column;
align-self: flex-end;
gap: var(--s-2);
@container (max-width: 479px) {
align-self: center;
}
}
.mapLabel {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.stageImage {
border-radius: var(--radius-box);
}
.inputRow {
display: flex;
align-items: flex-end;
gap: var(--s-4);
@container (max-width: 479px) {
width: 100%;
}
}
.weaponSelectContainer {
min-width: 200px;
& button {
border: var(--border-style-high);
}
@container (max-width: 479px) {
flex: 1;
}
}
.unreportedRow {
display: flex;
gap: var(--s-1);
}
.rootCollapsed {
display: flex;
justify-content: center;
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-2);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
}
.rootExpanded {
position: relative;
}
.rootStandalone {
margin-block-start: calc(-1 * var(--s-6));
min-height: 200px;
justify-content: center;
}
.collapseButton {
position: absolute;
top: var(--s-2);
right: var(--s-3);
& svg {
min-width: 22px;
max-width: 22px;
}
}

View File

@ -0,0 +1,170 @@
import clsx from "clsx";
import { ChevronUp, Crosshair } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { useUser } from "~/features/auth/core/user";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { abilityImageUrl, SETTINGS_PAGE } from "~/utils/urls";
import { SendouButton } from "../elements/Button";
import { Image, StageImage, WeaponImage } from "../Image";
import { WeaponSelect } from "../WeaponSelect";
import styles from "./WeaponReporter.module.css";
interface WeaponReporterMap {
stageId: StageId;
mode: ModeShort;
}
export interface WeaponReporterProps {
maps: WeaponReporterMap[];
pastReported: MainWeaponId[];
nextMapIndex: number;
quickSelectWeaponIds?: MainWeaponId[];
onSubmit: (weaponSplId: MainWeaponId) => void;
onUndo: () => void;
isSubmitting?: boolean;
standalone?: boolean;
}
export function WeaponReporter({
maps,
pastReported,
nextMapIndex,
quickSelectWeaponIds,
onSubmit,
onUndo,
isSubmitting,
standalone,
}: WeaponReporterProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const user = useUser();
const fetcher = useFetcher();
const [isOpen, setIsOpen] = useState(
() => user?.preferences.weaponReportDefaultOpen ?? false,
);
const [selectedWeapon, setSelectedWeapon] = useState<MainWeaponId | null>(
null,
);
const inputTargetMap = nextMapIndex >= 0 ? maps[nextMapIndex] : undefined;
const unreportedCount = inputTargetMap
? maps.length - pastReported.length - 1
: maps.length - pastReported.length;
const handleToggle = (newOpen: boolean) => {
setIsOpen(newOpen);
fetcher.submit(
{ _action: "UPDATE_WEAPON_REPORT_DEFAULT_OPEN", newValue: newOpen },
{ method: "post", action: SETTINGS_PAGE, encType: "application/json" },
);
};
if (!isOpen && !standalone) {
return (
<div className={styles.rootCollapsed}>
<SendouButton
variant="minimal"
size="small"
icon={<Crosshair size={16} />}
onPress={() => handleToggle(true)}
>
{t("q:match.actions.reportWeapons")}
</SendouButton>
</div>
);
}
return (
<div
className={clsx(styles.root, styles.rootExpanded, {
[styles.rootStandalone]: standalone,
})}
>
{standalone ? null : (
<SendouButton
variant="minimal"
size="miniscule"
icon={<ChevronUp size={22} />}
onPress={() => handleToggle(false)}
className={styles.collapseButton}
aria-label={t("q:match.actions.reportWeapons")}
/>
)}
{inputTargetMap ? (
<div className={styles.mapRow}>
<MapInfo map={inputTargetMap} />
<div className={styles.inputRow}>
<div className={styles.weaponSelectContainer}>
<WeaponSelect
label={`${t("q:match.weapon.yourWeapon")} #${nextMapIndex + 1}`}
value={selectedWeapon}
onChange={setSelectedWeapon}
quickSelectWeaponsIds={quickSelectWeaponIds}
/>
</div>
<SendouButton
variant="primary"
isDisabled={selectedWeapon === null || isSubmitting}
onPress={() => {
if (selectedWeapon === null) return;
onSubmit(selectedWeapon);
setSelectedWeapon(null);
}}
>
{t("common:actions.submit")}
</SendouButton>
</div>
</div>
) : null}
{pastReported.length > 0 ? (
<div className={styles.pastRow}>
{pastReported.map((weaponId, i) => (
<WeaponImage
key={i}
weaponSplId={weaponId}
variant="badge"
size={24}
/>
))}
<SendouButton
variant="minimal"
size="small"
isDisabled={isSubmitting}
onPress={onUndo}
>
{t("q:match.weapon.undoWeapon")}
</SendouButton>
</div>
) : null}
{unreportedCount > 0 ? (
<div className={styles.unreportedRow}>
{Array.from({ length: unreportedCount }, (_, i) => (
<Image
key={i}
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={24}
/>
))}
</div>
) : null}
</div>
);
}
function MapInfo({ map }: { map: WeaponReporterMap }) {
return (
<div className={styles.mapInfo}>
<StageImage
stageId={map.stageId}
width={100}
className={styles.stageImage}
/>
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useFetcher } from "react-router";
import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { WeaponReporterProps } from "./WeaponReporter";
/**
* Wires the `<WeaponReporter />` component to the standard
* `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the
* locally persisted recently-reported weapons list.
*
* `maps` is the play order of maps the viewer can report a weapon for and
* `pastReported` is the weapons the viewer has already reported, paired
* with the `mapIndex` they were reported for.
*/
export function useMatchWeaponReport({
maps,
pastReported,
}: {
maps: { stageId: StageId; mode: ModeShort }[];
pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[];
}): WeaponReporterProps {
const weaponFetcher = useFetcher();
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
const reportedMapIndexes = new Set(pastReported.map((w) => w.mapIndex));
const nextMapIndex = (() => {
for (let i = 0; i < maps.length; i++) {
if (!reportedMapIndexes.has(i)) return i;
}
return -1;
})();
const undoMapIndex = pastReported.reduce(
(max, w) => Math.max(max, w.mapIndex),
-1,
);
return {
maps,
pastReported: [...pastReported]
.sort((a, b) => a.mapIndex - b.mapIndex)
.map((w) => w.weaponSplId),
nextMapIndex,
quickSelectWeaponIds: recentlyReportedWeapons,
isSubmitting: weaponFetcher.state !== "idle",
onSubmit: (weaponSplId) => {
addRecentlyReportedWeapon(weaponSplId);
if (nextMapIndex < 0) return;
weaponFetcher.submit(
{
_action: "REPORT_WEAPON",
weaponSplId: String(weaponSplId),
mapIndex: String(nextMapIndex),
},
{ method: "post" },
);
},
onUndo: () => {
if (undoMapIndex < 0) return;
weaponFetcher.submit(
{
_action: "UNDO_WEAPON_REPORT",
mapIndex: String(undoMapIndex),
},
{ method: "post" },
);
},
};
}

View File

@ -0,0 +1,141 @@
import { describe, expect, it, test } from "vitest";
import type { CommonUser } from "~/utils/kysely.server";
import { inferSubstitutions, resolveRoomPass } from "./utils";
function user(id: number): CommonUser {
return {
id,
username: `user${id}`,
discordId: `discord${id}`,
discordAvatar: null,
customUrl: null,
};
}
describe("inferSubstitutions", () => {
it("returns an empty array when rosters are unchanged", () => {
const rosters = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6), user(7), user(8)],
};
expect(inferSubstitutions(rosters, rosters)).toEqual([]);
});
it("detects a single substitution on alpha", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6), user(7), user(8)],
};
const current = {
alpha: [user(1), user(2), user(3), user(9)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(4), playerIn: user(9) },
]);
});
it("detects substitutions on both sides in the same map transition", () => {
const previous = {
alpha: [user(1), user(2)],
bravo: [user(3), user(4)],
};
const current = {
alpha: [user(1), user(10)],
bravo: [user(11), user(4)],
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
{ side: "BRAVO", playerOut: user(3), playerIn: user(11) },
]);
});
it("pairs multiple substitutions on the same side by roster order", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(10), user(3), user(11)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
{ side: "ALPHA", playerOut: user(4), playerIn: user(11) },
]);
});
it("ignores unpaired leavers when no new player joined", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(2), user(3)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([]);
});
it("ignores unpaired joiners when no player left", () => {
const previous = {
alpha: [user(1), user(2), user(3)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(2), user(3), user(9)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([]);
});
it("treats players switching sides as separate substitutions on each side", () => {
const previous = {
alpha: [user(1), user(2)],
bravo: [user(3), user(4)],
};
const current = {
alpha: [user(3), user(4)],
bravo: [user(1), user(2)],
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(1), playerIn: user(3) },
{ side: "ALPHA", playerOut: user(2), playerIn: user(4) },
{ side: "BRAVO", playerOut: user(3), playerIn: user(1) },
{ side: "BRAVO", playerOut: user(4), playerIn: user(2) },
]);
});
});
describe("resolveRoomPass", () => {
test("returns a 4-digit password", () => {
const pass = resolveRoomPass(12345);
expect(pass).toMatch(/^\d{4}$/);
});
test("returns deterministic password for a given numeric seed", () => {
const pass1 = resolveRoomPass(12345);
const pass2 = resolveRoomPass(12345);
expect(pass1).toBe(pass2);
});
test("returns deterministic password for a given string seed", () => {
const pass1 = resolveRoomPass("test-seed");
const pass2 = resolveRoomPass("test-seed");
expect(pass1).toBe(pass2);
});
test("returns different passwords for different seeds", () => {
const pass1 = resolveRoomPass(1);
const pass2 = resolveRoomPass(2);
expect(pass1).not.toBe(pass2);
});
});

View File

@ -0,0 +1,83 @@
import * as R from "remeda";
import type { CommonUser } from "~/utils/kysely.server";
import { seededRandom } from "~/utils/random";
type MatchSide = "ALPHA" | "BRAVO";
type Rosters = {
alpha: CommonUser[];
bravo: CommonUser[];
};
export interface InferredSubstitution {
side: MatchSide;
playerOut: CommonUser;
playerIn: CommonUser;
}
/**
* Compares the rosters of two consecutive maps and pairs up any
* players that dropped from a side with new players that joined the same side.
* The pairs are returned in roster order, so the first player out is paired with
* the first new player in. When the counts don't match, unpaired players are ignored.
*/
export function inferSubstitutions(
previousRosters: Rosters,
currentRosters: Rosters,
): InferredSubstitution[] {
const result: InferredSubstitution[] = [];
for (const side of ["alpha", "bravo"] as const) {
const prevIds = new Set(previousRosters[side].map((u) => u.id));
const currIds = new Set(currentRosters[side].map((u) => u.id));
const out = previousRosters[side].filter((u) => !currIds.has(u.id));
const inn = currentRosters[side].filter((u) => !prevIds.has(u.id));
for (const [playerOut, playerIn] of R.zip(out, inn)) {
result.push({
side: side === "alpha" ? "ALPHA" : "BRAVO",
playerOut,
playerIn,
});
}
}
return result;
}
const NUM_MAP = {
"1": ["1", "2", "4"],
"2": ["2", "1", "3", "5"],
"3": ["3", "2", "6"],
"4": ["4", "1", "5", "7"],
"5": ["5", "2", "4", "6", "8"],
"6": ["6", "3", "5", "9"],
"7": ["7", "4", "8"],
"8": ["8", "7", "5", "9", "0"],
"9": ["9", "6", "8"],
"0": ["0", "8"],
};
/**
* Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed.
*
* Given the same seed, this function will always return the same password.
*/
export function resolveRoomPass(seed: number | string) {
let pass = "5";
for (let i = 0; i < 3; i++) {
const { seededShuffle } = seededRandom(`${seed}-${i}`);
const key = pass[i] as keyof typeof NUM_MAP;
const opts = NUM_MAP[key];
const next = seededShuffle(opts)[0];
pass += next;
}
// prevent 5555 since many use it as a default pass
// making it a bit more common guess
if (pass === "5555") return "5800";
return pass;
}

View File

@ -5,5 +5,8 @@ export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; // https://c
export const NZAP_TEST_ID = 2;
export const REGULAR_USER_TEST_ID = 2;
export const ORG_ADMIN_TEST_ID = 3;
// Matches STAFF_IDS[0] (Panda) so the seeded user is recognized as STAFF.
export const STAFF_TEST_ID = 11329;
export const STAFF_TEST_DISCORD_ID = "138757634500067328";
export const AMOUNT_OF_CALENDAR_EVENTS = 200;

View File

@ -9,6 +9,7 @@ import * as AssociationRepository from "~/features/associations/AssociationRepos
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { tags } from "~/features/calendar/calendar-constants";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
@ -22,17 +23,8 @@ import {
} from "~/features/plus-voting/core";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server";
import {
summarizeMaps,
summarizePlayerResults,
} from "~/features/sendouq-match/core/summarizer.server";
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SkillRepository from "~/features/sendouq-match/SkillRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
@ -70,14 +62,19 @@ import {
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { randomTeamName } from "~/utils/team-name";
import { mySlugify } from "~/utils/urls";
import { mySlugify, navIconUrl, sendouQMatchPage } from "~/utils/urls";
import {
getArtFilename,
SEED_ART_URLS,
SEED_TEAM_IMAGES,
SEED_TOURNAMENT_IMAGES,
} from "../../../scripts/seed-art-urls";
import type { QWeaponPool, Tables, UserMapModePreferences } from "../tables";
import type {
ParsedMemento,
QWeaponPool,
Tables,
UserMapModePreferences,
} from "../tables";
import {
ADMIN_TEST_AVATAR,
AMOUNT_OF_CALENDAR_EVENTS,
@ -85,6 +82,8 @@ import {
NZAP_TEST_DISCORD_ID,
NZAP_TEST_ID,
ORG_ADMIN_TEST_ID,
STAFF_TEST_DISCORD_ID,
STAFF_TEST_ID,
} from "./constants";
import placements from "./placements.json";
@ -173,7 +172,9 @@ const basicSeeds = (variation?: SeedVariation | null) => [
makeAdminTournamentOrganizer,
nzapUser,
users,
staffUser,
fixAdminId,
fixStaffUserId,
makeArtists,
adminUserWeaponPool,
adminUserWidgets,
@ -769,6 +770,26 @@ function nzapUser() {
});
}
function staffUser() {
return UserRepository.upsert({
discordId: STAFF_TEST_DISCORD_ID,
discordName: "Panda",
twitch: null,
youtubeId: null,
discordAvatar: null,
discordUniqueName: null,
});
}
function fixStaffUserId() {
sql.prepare(`delete from user where id = ${STAFF_TEST_ID}`).run();
sql
.prepare(
`update "User" set "id" = ${STAFF_TEST_ID} where "discordId" = '${STAFF_TEST_DISCORD_ID}'`,
)
.run();
}
async function users() {
const usedNames = new Set<string>();
for (let i = 0; i < 500; i++) {
@ -2577,12 +2598,21 @@ async function groups(variation?: SeedVariation | null) {
.filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID);
users.push(NZAP_TEST_ID);
let nzapGroupId = 0;
let sendouGroupId = 0;
const nzapGroupMemberIds: number[] = [];
const sendouGroupMemberIds: number[] = [];
for (let i = 0; i < 25; i++) {
const ownerId = users.pop()!;
const group = await SQGroupRepository.createGroup({
status: "ACTIVE",
userId: users.pop()!,
userId: ownerId,
});
if (i === 0) nzapGroupMemberIds.push(ownerId);
if (i === 1) sendouGroupMemberIds.push(ownerId);
const amountOfAdditionalMembers = () => {
if (SENDOU_IN_FULL_GROUP) {
if (i === 0) return 3;
@ -2593,6 +2623,7 @@ async function groups(variation?: SeedVariation | null) {
};
for (let j = 0; j < amountOfAdditionalMembers(); j++) {
const memberId = users.pop()!;
sql
.prepare(
/* sql */ `
@ -2602,15 +2633,100 @@ async function groups(variation?: SeedVariation | null) {
)
.run({
groupId: group.id,
userId: users.pop()!,
userId: memberId,
role: "REGULAR",
});
if (i === 0) nzapGroupMemberIds.push(memberId);
if (i === 1) sendouGroupMemberIds.push(memberId);
}
if (i === 0) nzapGroupId = group.id;
if (i === 1) sendouGroupId = group.id;
if (i === 0 && SENDOU_IN_FULL_GROUP) {
users.push(ADMIN_ID);
}
}
if (variation === "IN_SQ_MATCH") {
// Sendou's side tests the matchmade cascade vote flow, NZAP's side
// tests the trusted one-click flow.
sql
.prepare(
/* sql */ `update "Group" set "matchmade" = @matchmade where "id" = @id`,
)
.run({ matchmade: 1, id: sendouGroupId });
sql
.prepare(
/* sql */ `update "Group" set "matchmade" = @matchmade where "id" = @id`,
)
.run({ matchmade: 0, id: nzapGroupId });
const mapList = randomMapList(sendouGroupId, nzapGroupId);
const memento = buildSeedMemento({
mapList,
alphaGroupId: sendouGroupId,
bravoGroupId: nzapGroupId,
alphaMemberIds: sendouGroupMemberIds,
bravoMemberIds: nzapGroupMemberIds,
});
const createdMatch = await SQMatchRepository.create({
alphaGroupId: sendouGroupId,
bravoGroupId: nzapGroupId,
mapList,
memento,
});
const guaranteedWeaponPoolUserIds = [
sendouGroupMemberIds[1],
sendouGroupMemberIds[2],
nzapGroupMemberIds[1],
nzapGroupMemberIds[2],
].filter((id): id is number => typeof id === "number");
for (const userId of guaranteedWeaponPoolUserIds) {
const weapons: QWeaponPool[] = [
{ weaponSplId: 0, isFavorite: 1 },
{ weaponSplId: 2000, isFavorite: 0 },
{ weaponSplId: 4000, isFavorite: 0 },
];
await db
.updateTable("User")
.set({ qWeaponPool: JSON.stringify(weapons) })
.where("User.id", "=", userId)
.execute();
}
if (createdMatch.chatCode) {
await ChatSystemMessage.setMetadata({
chatCode: createdMatch.chatCode,
header: `Match #${createdMatch.id}`,
subtitle: "SendouQ",
url: sendouQMatchPage(createdMatch.id),
imageUrl: `${navIconUrl("sendouq")}.avif`,
participantUserIds: [...sendouGroupMemberIds, ...nzapGroupMemberIds],
expiresAfter: { hours: 2 },
});
}
const thirtyMinutesAgo = dateToDatabaseTimestamp(
sub(new Date(), { minutes: 30 }),
);
sql
.prepare(
/* sql */ `
insert into "RoomLink" ("userId", "url", "createdAt", "refreshedAt")
values (@userId, @url, @createdAt, @refreshedAt)
`,
)
.run({
userId: ADMIN_ID,
url: "https://example.com//private_battle/seed_room_123",
createdAt: thirtyMinutesAgo,
refreshedAt: thirtyMinutesAgo,
});
}
}
async function teamMapPrefsGroups() {
@ -2702,6 +2818,108 @@ const randomMapList = (
return mapList;
};
function buildSeedMemento({
mapList,
alphaGroupId,
bravoGroupId,
alphaMemberIds,
bravoMemberIds,
}: {
mapList: TournamentMapListMap[];
alphaGroupId: number;
bravoGroupId: number;
alphaMemberIds: number[];
bravoMemberIds: number[];
}): ParsedMemento {
const userPools = new Map<number, Map<ModeShort, Set<StageId>>>();
const addVote = (userId: number, mode: ModeShort, stageId: StageId) => {
let modes = userPools.get(userId);
if (!modes) {
modes = new Map();
userPools.set(userId, modes);
}
let stages = modes.get(mode);
if (!stages) {
stages = new Set();
modes.set(mode, stages);
}
stages.add(stageId);
};
for (const map of mapList) {
const candidates: number[] =
map.source === "BOTH"
? [...alphaMemberIds, ...bravoMemberIds]
: map.source === alphaGroupId
? alphaMemberIds
: map.source === bravoGroupId
? bravoMemberIds
: [];
if (candidates.length === 0) continue;
const voterCount = faker.number.int({ min: 1, max: candidates.length });
const voters = faker.helpers.arrayElements(candidates, voterCount);
for (const voterId of voters) {
addVote(voterId, map.mode, map.stageId);
}
}
const pools: ParsedMemento["pools"] = Array.from(userPools.entries()).map(
([userId, modes]) => ({
userId,
pool: Array.from(modes.entries()).map(([mode, stages]) => ({
mode,
stages: Array.from(stages),
})),
}),
);
const tierNames = [
"LEVIATHAN",
"DIAMOND",
"PLATINUM",
"GOLD",
"SILVER",
"BRONZE",
"IRON",
] as const;
const users: ParsedMemento["users"] = {};
for (const userId of [...alphaMemberIds, ...bravoMemberIds]) {
const tierName = faker.helpers.arrayElement(tierNames);
users[userId] = {
skill: {
ordinal: faker.number.float({ min: 1000, max: 3000 }),
tier: {
name: tierName,
isPlus: faker.datatype.boolean(),
},
approximate: false,
},
};
}
const groups: ParsedMemento["groups"] = {
[alphaGroupId]: {
tier: {
name: faker.helpers.arrayElement(tierNames),
isPlus: faker.datatype.boolean(),
},
},
[bravoGroupId]: {
tier: {
name: faker.helpers.arrayElement(tierNames),
isPlus: faker.datatype.boolean(),
},
},
};
return { users, groups, pools };
}
const MATCHES_COUNT = 500;
const AMOUNT_OF_USERS_WITH_SKILLS = 100;
@ -2805,57 +3023,25 @@ async function playedMatches() {
["ALPHA", "BRAVO", "BRAVO", "ALPHA", "ALPHA", "ALPHA"],
["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"],
]) as ("ALPHA" | "BRAVO")[];
const winner = winnersArrayToWinner(winners);
const finishedMatch = SendouQ.mapMatch(
(await SQMatchRepository.findById(match.id))!,
);
const { newSkills, differences } = calculateMatchSkills({
groupMatchId: match.id,
winner: winner === "ALPHA" ? groupAlphaMembers : groupBravoMembers,
loser: winner === "ALPHA" ? groupBravoMembers : groupAlphaMembers,
loserGroupId: winner === "ALPHA" ? groupBravo : groupAlpha,
winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo,
});
const members = [
...finishedMatch.groupAlpha.members.map((m) => ({
...m,
groupId: match.alphaGroupId,
})),
...finishedMatch.groupBravo.members.map((m) => ({
...m,
groupId: match.bravoGroupId,
})),
];
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId:
faker.number.float(1) > 0.5
? groupAlphaMembers[0]
: groupBravoMembers[0],
winners,
});
await SkillRepository.createMatchSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: { users: {}, groups: {}, pools: [] },
});
await SQGroupRepository.setAsInactive(groupAlpha);
await SQGroupRepository.setAsInactive(groupBravo);
await PlayerStatRepository.upsertMapResults(
summarizeMaps({ match: finishedMatch, members, winners }),
);
await PlayerStatRepository.upsertPlayerResults(
summarizePlayerResults({ match: finishedMatch, members, winners }),
);
const reporterUserId =
faker.number.float(1) > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0];
for (const [mapIndex, winner] of winners.entries()) {
await SQMatchRepository.reportMapWinner({
matchId: match.id,
winnerId: winner === "ALPHA" ? groupAlpha : groupBravo,
reportedByUserId: reporterUserId,
reportedCount: mapIndex,
isStaffReport: true,
});
}
// -> add weapons for 90% of matches
if (faker.number.float(1) > 0.9) continue;
const finishedMatch = (await SQMatchRepository.findById(match.id))!;
const users = [...groupAlphaMembers, ...groupBravoMembers];
const mapsWithUsers = users.flatMap((u) =>
finishedMatch.mapList.map((m) => ({ map: m, user: u })),
finishedMatch.mapList.map((_, mapIndex) => ({ mapIndex, user: u })),
);
await ReportedWeaponRepository.createMany(
@ -2873,7 +3059,8 @@ async function playedMatches() {
};
return {
groupMatchMapId: mu.map.id,
groupMatchId: match.id,
mapIndex: mu.mapIndex,
userId: mu.user,
weaponSplId: weapon(),
};

View File

@ -237,6 +237,8 @@ export interface Group {
id: GeneratedAlways<number>;
inviteCode: string;
latestActionAt: Generated<number>;
/** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */
matchmade: Generated<DBBoolean>;
status: "PREPARING" | "ACTIVE" | "INACTIVE";
teamId: number | null;
}
@ -259,6 +261,8 @@ export type UserSkillDifference =
| {
calculated: true;
spDiff: number;
oldSp?: number;
newSp?: number;
}
| CalculatingSkill;
export type GroupSkillDifference =
@ -301,11 +305,21 @@ export interface GroupMatch {
alphaGroupId: number;
bravoGroupId: number;
chatCode: string | null;
confirmedAt: number | null;
confirmedByUserId: number | null;
createdAt: Generated<number>;
id: GeneratedAlways<number>;
memento: JSONColumnTypeNullable<ParsedMemento>;
reportedAt: number | null;
reportedByUserId: number | null;
cancelRequestedByUserId: number | null;
cancelAcceptedByUserId: number | null;
}
export interface GroupMatchContinueVote {
id: GeneratedAlways<number>;
groupId: number;
userId: number;
isContinuing: DBBoolean;
votedAt: Generated<number>;
}
export interface GroupMatchMap {
@ -313,6 +327,8 @@ export interface GroupMatchMap {
index: number;
matchId: number;
mode: ModeShort;
reportedAt: number | null;
reportedByUserId: number | null;
source: string;
stageId: StageId;
winnerGroupId: number | null;
@ -438,7 +454,9 @@ export interface PlusVotingResult {
}
export interface ReportedWeapon {
groupMatchMapId: number | null;
groupMatchId: number | null;
tournamentMatchId: number | null;
mapIndex: number;
userId: number;
weaponSplId: MainWeaponId;
}
@ -986,10 +1004,20 @@ export interface UserPreferences {
* "12h" = 12 hour format (e.g. 2:00 PM)
* */
clockFormat?: "24h" | "12h" | "auto";
/**
* What numeric date format the user prefers?
*
* "auto" = use the format the active language defaults to (default value)
* "MDY" = month/day/year (e.g. 4/27/2026)
* "DMY" = day/month/year (e.g. 27/04/2026)
* "YMD" = ISO year-month-day (e.g. 2026-04-27)
* */
dateFormat?: "auto" | "MDY" | "DMY" | "YMD";
/** Is the new widget based user page enabled? (Supporter early preview) */
newProfileEnabled?: boolean;
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
spoilerFreeMode?: boolean;
weaponReportDefaultOpen?: boolean;
}
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
@ -1050,6 +1078,8 @@ export interface User {
qWeaponPool: JSONColumnTypeNullable<QWeaponPool[]>;
plusSkippedForSeasonNth: number | null;
noScreen: Generated<DBBoolean>;
/** User doesn't have access to SplatNet 3 to join rooms made by others */
noSplatnet: Generated<DBBoolean>;
buildSorting: JSONColumnTypeNullable<BuildSort[]>;
preferences: JSONColumnTypeNullable<UserPreferences>;
/** User creation date. Can be null because we did not always save this. */
@ -1297,6 +1327,13 @@ export interface NotificationUserSubscription {
subscription: JSONColumnType<NotificationSubscription>;
}
export interface RoomLink {
userId: number;
url: string;
createdAt: Generated<number>;
refreshedAt: Generated<number>;
}
export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number];
@ -1341,6 +1378,7 @@ export interface DB {
Group: Group;
GroupLike: GroupLike;
GroupMatch: GroupMatch;
GroupMatchContinueVote: GroupMatchContinueVote;
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
PrivateUserNote: PrivateUserNote;
@ -1353,6 +1391,7 @@ export interface DB {
PlusTier: PlusTier;
PlusVote: PlusVote;
PlusVotingResult: PlusVotingResult;
RoomLink: RoomLink;
ReportedWeapon: ReportedWeapon;
Skill: Skill;
SkillTeamUser: SkillTeamUser;

View File

@ -42,7 +42,6 @@ export default async function handleRequest(
lng, // The locale we detected above
ns, // The namespaces the routes about to render wants to use
resources,
showSupportNotice: false,
});
return new Promise((resolve, reject) => {

View File

@ -39,6 +39,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
errorToast(`Migration failed. Reason: ${errorMessage}`);
}
await refreshBannedCache();
message = "Account migrated";
break;
} catch (err) {

View File

@ -9,4 +9,5 @@ export const SEED_VARIATIONS = [
"TEAM_MAP_PREFS",
"FINALIZED_BRACKET",
"AB_RR",
"IN_SQ_MATCH",
] as const;

View File

@ -4,8 +4,8 @@ import { z } from "zod";
import { db } from "~/db/sql";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { resolveMapList } from "~/features/tournament-bracket/core/mapList.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import { resolveMapList } from "~/features/tournament-match/core/mapList.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";

View File

@ -1,6 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentPlayersResponse } from "../schema";

View File

@ -95,7 +95,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
<SendouDialog
heading={formatDate(databaseTimestampToDate(art.createdAt), {
year: "numeric",
month: "long",
month: "numeric",
day: "numeric",
})}
onClose={close}

View File

@ -65,7 +65,7 @@ export default function NewArtPage() {
const submitButtonDisabled = () => {
if (fetcher.state !== "idle") return true;
return !img && !data.art;
return (!img || !smallImg) && !data.art;
};
if (!isArtist) {
@ -121,7 +121,6 @@ function ImageUpload({
<input
id={id}
type="file"
name="img"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={(e) => {
const uploadedFile = e.target.files?.[0];

View File

@ -68,7 +68,7 @@ export const meta: MetaFunction = (args) => {
};
export default function ArtPage() {
const { t } = useTranslation(["art", "common"]);
const { t } = useTranslation(["art", "common", "forms"]);
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const switchId = React.useId();
@ -100,7 +100,7 @@ export default function ArtPage() {
id={switchId}
/>
<Label htmlFor={switchId} className="m-auto-0">
{t("art:openCommissionsOnly")}
{t("forms:labels.profileCommissionsOpen")}
</Label>
</div>
<div

View File

@ -23,11 +23,6 @@ export function articleBySlug(slug: string) {
return {
content,
date,
dateString: date.toLocaleDateString("en-US", {
day: "2-digit",
month: "long",
year: "numeric",
}),
authors: normalizeAuthors(restParsed.author),
title: restParsed.title,
};

View File

@ -11,7 +11,6 @@ export async function mostRecentArticles(count: number) {
const articles: Array<
Omit<NonNullable<ReturnType<typeof articleBySlug>>, "content"> & {
slug: string;
dateString: string;
}
> = [];
for (const file of files) {
@ -25,11 +24,6 @@ export async function mostRecentArticles(count: number) {
articles.push({
date,
slug: file.replace(".md", ""),
dateString: date.toLocaleDateString("en-US", {
day: "2-digit",
month: "long",
year: "numeric",
}),
authors: normalizeAuthors(restParsed.author),
title: restParsed.title,
});
@ -37,6 +31,5 @@ export async function mostRecentArticles(count: number) {
return articles
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, count)
.map(({ date: _date, ...rest }) => rest);
.slice(0, count);
}

View File

@ -3,6 +3,7 @@ import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@ -52,12 +53,20 @@ export const meta: MetaFunction = (args) => {
export default function ArticlePage() {
const data = useLoaderData<typeof loader>();
const { formatDate } = useTimeFormat();
return (
<Main>
<article className="article">
<h1>{data.title}</h1>
<div className="text-sm text-lighter">
by <Author /> <time>{data.dateString}</time>
by <Author /> {" "}
<time>
{formatDate(new Date(data.date), {
day: "numeric",
month: "numeric",
year: "numeric",
})}
</time>
</div>
<Markdown>
{contentWithoutLeadingTitle(data.content, data.title)}

View File

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
@ -30,6 +31,7 @@ export const meta: MetaFunction = (args) => {
export default function ArticlesMainPage() {
const { t, i18n } = useTranslation(["common"]);
const { formatDate } = useTimeFormat();
const data = useLoaderData<typeof loader>();
return (
@ -46,7 +48,14 @@ export default function ArticlesMainPage() {
style: "short",
}).format(article.authors.map((a) => a.name)),
})}{" "}
<time>{article.dateString}</time>
{" "}
<time>
{formatDate(new Date(article.date), {
day: "numeric",
month: "numeric",
year: "numeric",
})}
</time>
</div>
</li>
))}

View File

@ -767,6 +767,10 @@
"displayName": "Minus One Open",
"authorDiscordId": "569271521776762896"
},
"moneycat": {
"displayName": "Cats Have 9 Lives",
"authorDiscordId": "781999824765452310"
},
"mrgrizz": {
"displayName": "Salmon Workers",
"authorDiscordId": "345996252032401408"
@ -1227,6 +1231,10 @@
"displayName": "Squidforce Cup",
"authorDiscordId": "1320944066002681876"
},
"sssge": {
"displayName": "Solo Squid Showdown: Golden Edition",
"authorDiscordId": "824681527815438337"
},
"stellatedzone": {
"displayName": "Stellated Zone",
"authorDiscordId": "309327923129745409"
@ -1467,6 +1475,14 @@
"displayName": "Weapon Lockdown Special Edition",
"authorDiscordId": "338806780446638082"
},
"wellstringcustom": {
"displayName": "Wellstring Propoganda: Low Cut",
"authorDiscordId": "338806780446638082"
},
"wellstringregular": {
"displayName": "Wellstring Propoganda: Top Cut",
"authorDiscordId": "338806780446638082"
},
"whitecat": {
"displayName": "White Cat Achievement",
"authorDiscordId": "631246535560265749"
@ -1475,6 +1491,10 @@
"displayName": "Wi Wi Wi Cat",
"authorDiscordId": "530722502603833346"
},
"wings": {
"displayName": "Wings Up!",
"authorDiscordId": "752582395076673577"
},
"wiper": {
"displayName": "Squeaky Clean Scrap",
"authorDiscordId": "528851510222782474"

View File

@ -6,14 +6,19 @@ import {
} from "~/features/auth/core/authenticator.server";
import { authSessionStorage } from "~/features/auth/core/session.server";
import type { Nullish } from "~/utils/types";
import { userIsBanned } from "../core/banned.server";
import { refreshBannedCache, userIsBanned } from "../core/banned.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserIdEvenIfBanned(request);
if (!userId || !userIsBanned(userId)) return redirect("/");
const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId)!;
const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId);
if (!bannedStatus) {
await refreshBannedCache();
return redirect("/");
}
return {
banned: bannedStatus.banned,

View File

@ -25,7 +25,7 @@ export default function SuspendedPage() {
<div suppressHydrationWarning>
Ends:{" "}
{formatDateTime(ends, {
month: "long",
month: "numeric",
day: "numeric",
year: "numeric",
hour: "numeric",

View File

@ -13,6 +13,7 @@ import {
mainWeaponIds,
nonBombSubWeaponIds,
nonDamagingSpecialWeaponIds,
specialWeaponIds,
subWeaponIds,
weaponCategories,
weaponIdToBaseWeaponId,
@ -216,7 +217,10 @@ export function validatedAnyWeaponFromSearchParams(
if (rawWeapon?.startsWith("SPECIAL_")) {
const id = Number(rawWeapon.replace("SPECIAL_", ""));
if (nonDamagingSpecialWeaponIds.includes(id)) {
if (
!specialWeaponIds.includes(id as any) ||
nonDamagingSpecialWeaponIds.includes(id)
) {
return DEFAULT_ANY_WEAPON;
}

View File

@ -257,9 +257,9 @@ export async function ownerIdById(buildId: number) {
.selectFrom("Build")
.select("ownerId")
.where("id", "=", buildId)
.executeTakeFirstOrThrow();
.executeTakeFirst();
return result.ownerId;
return result?.ownerId ?? null;
}
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {

View File

@ -236,7 +236,7 @@ function DateFilter({
{patch} (
{formatDate(date, {
day: "numeric",
month: "long",
month: "numeric",
year: "numeric",
})}
)

View File

@ -6,7 +6,7 @@ import type {
} from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { assertUnreachable } from "~/utils/types";
import type { BuildFiltersFromSearchParams } from "../builds-schemas.server";
import type { BuildFiltersFromSearchParams } from "../builds-schemas";
import type {
AbilityBuildFilter,
DateBuildFilter,

View File

@ -12,7 +12,7 @@ import {
import {
buildFiltersSearchParams,
buildsLimitSearchParam,
} from "../builds-schemas.server";
} from "../builds-schemas";
import { filterBuilds } from "../core/filter.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {

View File

@ -18,7 +18,6 @@ import { BuildCard } from "~/components/BuildCard";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { Main } from "~/components/Main";
import { safeJSONParse } from "~/utils/json";
import { isRevalidation, metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
@ -37,7 +36,10 @@ import {
MAX_BUILD_FILTERS,
PATCHES,
} from "../builds-constants";
import type { BuildFiltersFromSearchParams } from "../builds-schemas.server";
import {
type BuildFiltersFromSearchParams,
buildFiltersSearchParams,
} from "../builds-schemas";
import type { AbilityBuildFilter, BuildFilter } from "../builds-types";
import { FilterSection } from "../components/FilterSection";
@ -94,7 +96,10 @@ function parseFiltersFromSearchParams(
const raw = searchParams.get(FILTER_SEARCH_PARAM_KEY);
if (!raw) return [];
return safeJSONParse<BuildFilter[]>(raw, []);
const parsed = buildFiltersSearchParams.safeParse(raw);
if (!parsed.success || !parsed.data) return [];
return parsed.data;
}
function extractMeaningfulFilters(

View File

@ -28,12 +28,6 @@ export const tags = {
QUALIFIER: {
color: "#FFC0CB",
},
SZ: {
color: "#F44336",
},
TW: {
color: "#D50000",
},
ONES: {
color: "#FAEC25",
},
@ -97,12 +91,7 @@ export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number];
export const DAYS_SHOWN_AT_A_TIME = 4;
/** Tags not shown on the tournament cards */
export const EXCLUDED_TAGS: Array<CalendarEventTag> = [
"CARDS",
"SR",
"SZ",
"TW",
];
export const EXCLUDED_TAGS: Array<CalendarEventTag> = ["CARDS", "SR"];
export const CALENDAR_EVENT_RESULT = {
MAX_PARTICIPANTS_COUNT: 1000,

View File

@ -63,8 +63,6 @@ const TAGS_TO_OMIT: CalendarEventTag[] = [
"SR",
"S1",
"S2",
"SZ",
"TW",
"ONES",
"DUOS",
"TRIOS",

View File

@ -46,13 +46,13 @@ export interface ShowcaseCalendarEvent extends CommonEvent {
hidden: boolean;
isFinalized: boolean;
minMembersPerTeam: number;
firstPlacer: {
firstPlacers: Array<{
teamName: string;
logoUrl: string | null;
members: (CommonUser & { country: Tables["User"]["country"] })[];
notShownMembersCount: number;
div: string | null;
} | null;
}>;
hasVods?: boolean;
}

View File

@ -32,6 +32,10 @@
color: var(--color-text-inverse);
}
:global(html.light) .tag {
color: var(--color-text);
}
.tagDeleteButton {
margin-left: auto;
@ -41,3 +45,7 @@
margin-inline: var(--s-1) 0 !important;
}
}
:global(html.light) .tagDeleteButton > svg {
color: var(--color-text);
}

View File

@ -47,7 +47,7 @@ export function TournamentCard({
}
return formatDateTimeSmartMinutes(date, {
month: "short",
month: "numeric",
day: "numeric",
hour: "numeric",
weekday: "short",
@ -57,7 +57,8 @@ export function TournamentCard({
return (
<div
className={clsx(className, styles.container, {
[styles.containerTall]: isShowcase && tournament.firstPlacer,
[styles.containerTall]:
isShowcase && tournament.firstPlacers.length > 0,
})}
data-testid="tournament-card"
>
@ -117,15 +118,17 @@ export function TournamentCard({
<Tags tags={tournament.tags} small centered />
</div>
) : null}
{isShowcase && tournament.firstPlacer ? (
{isShowcase && tournament.firstPlacers.length > 0 ? (
<TournamentFirstPlacers
firstPlacer={tournament.firstPlacer}
firstPlacers={tournament.firstPlacers}
censored={isCensored(tournament.id)}
/>
) : null}
</Link>
<div className="stack horizontal justify-between items-center">
{isShowcase && tournament.firstPlacer && isCensored(tournament.id) ? (
{isShowcase &&
tournament.firstPlacers.length > 0 &&
isCensored(tournament.id) ? (
<SpoilerRevealPill onReveal={() => reveal(tournament.id)} />
) : null}
{isShowcase && "hasVods" in tournament && tournament.hasVods ? (
@ -157,20 +160,52 @@ export function TournamentCard({
}
function TournamentFirstPlacers({
firstPlacer,
firstPlacers,
censored,
}: {
firstPlacer: NonNullable<ShowcaseCalendarEvent["firstPlacer"]>;
firstPlacers: ShowcaseCalendarEvent["firstPlacers"];
censored: boolean;
}) {
if (firstPlacers.length > 1) {
return (
<div className={styles.firstPlacers}>
<div className="stack md items-start">
{firstPlacers.map((placer) => (
<TournamentFirstPlacerTeamNameOnly
key={placer.div ?? placer.teamName}
placer={placer}
censored={censored}
/>
))}
</div>
</div>
);
}
const placer = firstPlacers[0];
return (
<div className={styles.firstPlacers}>
<TournamentFirstPlacerWithMembers placer={placer} censored={censored} />
</div>
);
}
function TournamentFirstPlacerWithMembers({
placer,
censored,
}: {
placer: ShowcaseCalendarEvent["firstPlacers"][number];
censored: boolean;
}) {
const { t } = useTranslation(["front"]);
return (
<div className={styles.firstPlacers}>
<>
<div className="stack xs horizontal items-center text-xs">
{!censored && firstPlacer.logoUrl ? (
{!censored && placer.logoUrl ? (
<img
src={firstPlacer.logoUrl}
src={placer.logoUrl}
alt=""
width={24}
className="rounded-full"
@ -178,16 +213,16 @@ function TournamentFirstPlacers({
) : null}{" "}
<div className="stack items-start">
<span className={styles.firstPlacersTeamName}>
{censored ? "???" : firstPlacer.teamName}
{censored ? "???" : placer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
{firstPlacer.div ? ` (${firstPlacer.div})` : null}
{placer.div ? ` (${placer.div})` : null}
</div>
</div>
</div>
<div className="text-xxs stack items-start mt-1">
{firstPlacer.members.map((member) => (
{placer.members.map((member) => (
<div key={member.id} className="stack horizontal xs items-center">
{!censored && member.country ? (
<Flag tiny countryCode={member.country} />
@ -195,12 +230,34 @@ function TournamentFirstPlacers({
{censored ? "???" : member.username}{" "}
</div>
))}
{!censored && firstPlacer.notShownMembersCount > 0 ? (
{!censored && placer.notShownMembersCount > 0 ? (
<div className="font-bold text-lighter">
+{firstPlacer.notShownMembersCount}
+{placer.notShownMembersCount}
</div>
) : null}
</div>
</>
);
}
function TournamentFirstPlacerTeamNameOnly({
placer,
censored,
}: {
placer: ShowcaseCalendarEvent["firstPlacers"][number];
censored: boolean;
}) {
const { t } = useTranslation(["front"]);
return (
<div className="stack items-start">
<span className={styles.firstPlacersTeamName}>
{censored ? "???" : placer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
{placer.div ? ` (${placer.div})` : null}
</div>
</div>
);
}

View File

@ -105,7 +105,7 @@ export default function CalendarEventPage() {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
month: "numeric",
weekday: "long",
year: "numeric",
})

View File

@ -8,6 +8,7 @@
.navigateButtonsContainer {
display: flex;
flex-wrap: wrap;
gap: var(--s-4);
width: 100%;
}
@ -31,6 +32,9 @@
&:not(.navigateArrowButton) {
justify-content: center;
flex-basis: 100%;
background-color: var(--color-bg);
min-height: 30px;
}
& svg {
@ -50,7 +54,26 @@
}
}
.navigateArrowButtonRange {
font-variant-numeric: tabular-nums;
color: var(--color-text-high);
font-size: var(--font-2xs);
}
@container (width >= 640px) {
.navigateArrowButton {
min-width: 7.25rem;
flex-basis: auto;
}
.navigateButton:not(.navigateArrowButton) {
flex-basis: auto;
}
}
.navigateArrowButton {
gap: var(--s-1);
& svg {
margin-inline-start: -5px;
}

View File

@ -598,7 +598,7 @@ function TagsAdder() {
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
(tag) => !tags.includes(tag),
).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration?
);
return (
<div className="stack sm">

View File

@ -144,17 +144,16 @@ function NavigateButton({
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
filters?: CalendarLoaderData["filters"];
}) {
const { formatDate } = useTimeFormat();
const { formatDateRange } = useTimeFormat();
const lowestDate = daysInterval[0];
const highestDate = daysInterval[daysInterval.length - 1];
const dateToString = (
day: ReturnType<typeof daysForCalendar>["shown"][number],
) =>
formatDate(new Date(new Date().getFullYear(), day.month, day.day), {
day: "numeric",
month: "short",
});
const year = new Date().getFullYear();
const rangeString = formatDateRange(
new Date(year, lowestDate.month, lowestDate.day),
new Date(year, highestDate.month, highestDate.day),
{ day: "numeric", month: "numeric" },
);
return (
<Link
@ -165,9 +164,7 @@ function NavigateButton({
{icon}
<div>
<div>{children}</div>
<div className="text-xxs text-lighter">
{dateToString(lowestDate)} - {dateToString(highestDate)}
</div>
<div className={styles.navigateArrowButtonRange}>{rangeString}</div>
</div>
</Link>
);
@ -264,7 +261,7 @@ function DayHeader(props: { date: number; month: number; year: number }) {
>
{formatDate(date, {
day: "numeric",
month: "long",
month: "numeric",
})}
<div className={styles.dayHeaderWeekday}>
{formatDate(date, {

View File

@ -276,7 +276,7 @@ function ChatProviderInner({
"system:",
isSystemMessage,
);
if (isSystemMessage) {
if (isSystemMessage || messageArr[0].revalidateOnly) {
revalidate();
}
@ -684,7 +684,7 @@ function useChatRouteSync({
]);
}
function useCurrentRouteChatCode(): string | string[] | null {
export function useCurrentRouteChatCode(): string | string[] | null {
const matches = useMatches();
for (const match of matches) {

View File

@ -141,6 +141,10 @@ export async function setMetadata(args: SetMetadataArgs) {
args.participantUserIds,
);
logger.debug(
`Setting chat room metadata for ${args.chatCode} (participants: ${participantsKey})`,
);
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, {
method: "POST",
body: JSON.stringify({

View File

@ -0,0 +1,58 @@
import { sub } from "date-fns";
import { db } from "~/db/sql";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
export function upsert(args: { userId: number; url: string }) {
return db
.insertInto("RoomLink")
.values({
userId: args.userId,
url: args.url,
})
.onConflict((oc) =>
oc.column("userId").doUpdateSet({
url: args.url,
createdAt: databaseTimestampNow(),
refreshedAt: databaseTimestampNow(),
}),
)
.execute();
}
export function findByUserIds(userIds: number[], maxAgeHours: number) {
return db
.selectFrom("RoomLink")
.select([
"RoomLink.userId",
"RoomLink.url",
"RoomLink.createdAt",
"RoomLink.refreshedAt",
])
.where("RoomLink.userId", "in", userIds)
.where(
"RoomLink.createdAt",
">=",
dateToDatabaseTimestamp(sub(new Date(), { hours: maxAgeHours })),
)
.orderBy("RoomLink.refreshedAt", "asc")
.execute();
}
export function refreshTimestamp(userId: number) {
return db
.updateTable("RoomLink")
.set({ refreshedAt: databaseTimestampNow() })
.where("userId", "=", userId)
.execute();
}
export function deleteOld() {
return db
.deleteFrom("RoomLink")
.where(
"refreshedAt",
"<",
dateToDatabaseTimestamp(sub(new Date(), { hours: 2 })),
)
.executeTakeFirst();
}

View File

@ -0,0 +1,117 @@
import { describe, expect, test } from "vitest";
import {
extractRoomLink,
findRoomLinks,
isSplatnetRoomUrl,
} from "./chat-constants";
describe("isSplatnetRoomUrl", () => {
test("accepts canonical SplatNet share path", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com/av5ja-lp1/abc123")).toBe(
true,
);
});
test("accepts a simple alphanumeric path", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com/abcdef")).toBe(true);
});
test("rejects http (non-https)", () => {
expect(isSplatnetRoomUrl("http://s.nintendo.com/abc")).toBe(false);
});
test("rejects unescaped-dot lookalike host (sanintendoacom.evil.tld)", () => {
expect(isSplatnetRoomUrl("https://sanintendoacom.evil.tld/lobby")).toBe(
false,
);
});
test("rejects dash variant host (s-nintendo-com.evil.tld)", () => {
expect(isSplatnetRoomUrl("https://s-nintendo-com.evil.tld/lobby")).toBe(
false,
);
});
test("rejects userinfo in URL (s.nintendo.com@evil.com)", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com@evil.com/abc")).toBe(
false,
);
});
test("rejects custom port", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com:8080/abc")).toBe(false);
});
test("rejects query string", () => {
expect(
isSplatnetRoomUrl("https://s.nintendo.com/abc?redirect=evil.com"),
).toBe(false);
});
test("rejects fragment", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com/abc#@evil.com")).toBe(
false,
);
});
test("rejects trailing dot in hostname", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com./abc")).toBe(false);
});
test("rejects empty path", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com/")).toBe(false);
});
test("rejects path with disallowed characters", () => {
expect(isSplatnetRoomUrl("https://s.nintendo.com/abc!def")).toBe(false);
});
test("rejects malformed URL", () => {
expect(isSplatnetRoomUrl("not a url")).toBe(false);
});
});
describe("findRoomLinks", () => {
test("returns empty array when no links", () => {
expect(findRoomLinks("just chatting here")).toEqual([]);
});
test("finds a valid link with its index", () => {
const text = "join: https://s.nintendo.com/abc123 thanks";
expect(findRoomLinks(text)).toEqual([
{ url: "https://s.nintendo.com/abc123", index: 6 },
]);
});
test("ignores spoofed lookalike hosts even when surrounding text matches the candidate regex", () => {
const text = "join here https://sanintendoacom.evil.tld/lobby right now";
expect(findRoomLinks(text)).toEqual([]);
});
test("ignores links with query strings", () => {
const text =
"https://s.nintendo.com/abc?redirect=https://evil.com legitimate?";
expect(findRoomLinks(text)).toEqual([]);
});
test("returns multiple valid links", () => {
const text =
"https://s.nintendo.com/aaa and also https://s.nintendo.com/bbb";
expect(findRoomLinks(text)).toEqual([
{ url: "https://s.nintendo.com/aaa", index: 0 },
{ url: "https://s.nintendo.com/bbb", index: 36 },
]);
});
});
describe("extractRoomLink", () => {
test("returns first valid link", () => {
expect(extractRoomLink("hi https://s.nintendo.com/abc see you")).toBe(
"https://s.nintendo.com/abc",
);
});
test("returns null when no valid link present", () => {
expect(extractRoomLink("https://sanintendoacom.evil.tld/abc")).toBeNull();
});
});

View File

@ -1 +1,44 @@
export const MESSAGE_MAX_LENGTH = 200;
const SPLATNET_ROOM_HOST = "s.nintendo.com";
const SPLATNET_ROOM_PATH_PATTERN = /^\/[A-Za-z0-9/_-]+$/;
const SPLATNET_ROOM_CANDIDATE_PATTERN = /https:\/\/s\.nintendo\.com\/\S+/g;
export function isSplatnetRoomUrl(url: string): boolean {
if (!URL.canParse(url)) return false;
const parsed = new URL(url);
return (
parsed.protocol === "https:" &&
parsed.hostname === SPLATNET_ROOM_HOST &&
parsed.username === "" &&
parsed.password === "" &&
parsed.port === "" &&
parsed.search === "" &&
parsed.hash === "" &&
SPLATNET_ROOM_PATH_PATTERN.test(parsed.pathname)
);
}
export function findRoomLinks(
text: string,
): Array<{ url: string; index: number }> {
const results: Array<{ url: string; index: number }> = [];
for (const match of text.matchAll(SPLATNET_ROOM_CANDIDATE_PATTERN)) {
if (isSplatnetRoomUrl(match[0])) {
results.push({ url: match[0], index: match.index });
}
}
return results;
}
export function extractRoomLink(text: string): string | null {
return findRoomLinks(text)[0]?.url ?? null;
}
const MATCH_ROOM_URL_PATTERN =
/^\/q\/match\/\d+$|^\/to\/\d+\/matches\/\d+$|^\/scrims\/\d+$/;
export function isMatchRoomUrl(url: string) {
const pathname = URL.canParse(url) ? new URL(url).pathname : url;
return MATCH_ROOM_URL_PATTERN.test(pathname);
}

View File

@ -9,6 +9,7 @@ export type SystemMessageType =
| "SCORE_CONFIRMED"
| "CANCEL_REPORTED"
| "CANCEL_CONFIRMED"
| "CANCEL_REFUSED"
| "TOURNAMENT_UPDATED"
| "TOURNAMENT_MATCH_UPDATED";

View File

@ -107,6 +107,12 @@
opacity: 0.7;
}
.roomLink {
color: var(--color-text-accent);
text-decoration: underline;
word-break: break-all;
}
.roomButton {
border: 0;
border-bottom: var(--border-style);

View File

@ -8,7 +8,7 @@ import { Avatar } from "../../../components/Avatar";
import { SendouButton } from "../../../components/elements/Button";
import { SubmitButton } from "../../../components/SubmitButton";
import { useTimeFormat } from "../../../hooks/useTimeFormat";
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
import { findRoomLinks, MESSAGE_MAX_LENGTH } from "../chat-constants";
import { useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
import styles from "./Chat.module.css";
@ -95,6 +95,9 @@ export function Chat({
case "CANCEL_CONFIRMED": {
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
}
case "CANCEL_REFUSED": {
return t("common:chat.systemMsg.cancelRefused", { name: name() });
}
case "USER_LEFT": {
return t("common:chat.systemMsg.userLeft", { name: name() });
}
@ -268,7 +271,9 @@ function Message({
[styles.messageContentsPending]: message.pending,
})}
>
{message.contents}
{message.contents ? (
<MessageContents text={message.contents} />
) : null}
</div>
</div>
</li>
@ -301,6 +306,39 @@ function SystemMessage({
);
}
function MessageContents({ text }: { text: string }) {
const matches = findRoomLinks(text);
if (matches.length === 0) return <>{text}</>;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
for (const [i, match] of matches.entries()) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push(
<a
key={i}
href={match.url}
target="_blank"
rel="noopener noreferrer"
className={styles.roomLink}
>
{match.url}
</a>,
);
lastIndex = match.index + match.url.length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return <>{parts}</>;
}
function MessageTimestamp({ timestamp }: { timestamp: number }) {
const { formatDateTime, formatTime } = useTimeFormat();
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);

View File

@ -0,0 +1,81 @@
import { differenceInMinutes } from "date-fns";
import { useFetcher } from "react-router";
import { databaseTimestampToDate } from "~/utils/dates";
interface RoomLink {
userId: number;
url: string;
refreshedAt: number;
}
interface ResolveActiveRoomLinkArgs {
/** Room links for all match participants, sorted by `refreshedAt` ascending. */
roomLinks: ReadonlyArray<RoomLink>;
/** Database timestamp before which a link is considered stale (e.g. match start time). */
freshnessCutoff: number;
/** Viewer user id, used as fallback to surface the viewer's own stale link. */
viewerUserId?: number;
/** Members shown to resolve `hostedBy`. */
members: ReadonlyArray<{ id: number; username: string }>;
}
interface ActiveRoomLink {
joinLink?: string;
hostedBy?: string;
isStale?: boolean;
staleMinutesAgo: number;
refreshedAt?: Date;
}
/**
* Selects the room link to display for a match. Prefers the oldest link refreshed
* after the freshness cutoff (the host's confirmed room). Falls back to the
* viewer's own stale link so they can refresh it themselves.
*/
export function resolveActiveRoomLink({
roomLinks,
freshnessCutoff,
viewerUserId,
members,
}: ResolveActiveRoomLinkArgs): ActiveRoomLink {
const validRoomLink = roomLinks.find(
(rl) => rl.refreshedAt >= freshnessCutoff,
);
const ownStaleRoomLink = validRoomLink
? undefined
: roomLinks.find((rl) => rl.userId === viewerUserId);
const activeRoomLink = validRoomLink ?? ownStaleRoomLink;
return {
joinLink: activeRoomLink?.url,
hostedBy: activeRoomLink
? members.find((m) => m.id === activeRoomLink.userId)?.username
: undefined,
isStale: activeRoomLink ? !validRoomLink : undefined,
staleMinutesAgo: ownStaleRoomLink
? differenceInMinutes(
new Date(),
databaseTimestampToDate(ownStaleRoomLink.refreshedAt),
)
: 0,
refreshedAt: validRoomLink
? databaseTimestampToDate(validRoomLink.refreshedAt)
: undefined,
};
}
/** Confirms the viewer's room link by refreshing its timestamp via the central `/room` action. */
export function useConfirmRoom() {
const fetcher = useFetcher();
return {
onConfirmRoom: () => {
fetcher.submit(
{ _action: "CONFIRM" },
{ method: "post", action: "/room", encType: "application/json" },
);
},
isConfirming: fetcher.state !== "idle",
};
}

View File

@ -0,0 +1,37 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload } from "~/utils/remix.server";
import { isSplatnetRoomUrl } from "../chat-constants";
import * as RoomLinkRepository from "../RoomLinkRepository.server";
const roomLinkSchema = z.discriminatedUnion("_action", [
z.object({
_action: z.literal("UPSERT"),
url: z.string().refine(isSplatnetRoomUrl, "Not a SplatNet room URL"),
}),
z.object({
_action: z.literal("CONFIRM"),
}),
]);
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: roomLinkSchema,
});
switch (data._action) {
case "UPSERT": {
await RoomLinkRepository.upsert({ userId: user.id, url: data.url });
break;
}
case "CONFIRM": {
await RoomLinkRepository.refreshTimestamp(user.id);
break;
}
}
return null;
};

View File

@ -49,6 +49,21 @@ export function getLiveTournamentStreams(): SidebarStream[] {
return streams;
}
/** Lowercased Twitch usernames of all members and casters streaming a currently live tournament. */
export function getLiveTournamentStreamerTwitchNames(): string[] {
const names: string[] = [];
for (const tournament of RunningTournaments.all) {
if (tournament.isLeagueDivision) continue;
for (const stream of tournament.streams) {
names.push(stream.twitchUserName.toLowerCase());
}
}
return names;
}
function deriveCurrentRound(tournament: Tournament): string {
for (const bracket of tournament.brackets.toReversed()) {
if (bracket.preview) continue;

View File

@ -11,6 +11,7 @@ import {
} from "~/components/elements/Menu";
import { ListButton } from "~/components/SideNav";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls";
@ -38,12 +39,17 @@ export function FriendMenu({
onNavigate?: () => void;
}) {
const { t } = useTranslation(["common", "friends"]);
const { formatDate } = useTimeFormat();
const fetcher = useFetcher();
const [confirmOpen, setConfirmOpen] = React.useState(false);
const friendSinceText = friendshipCreatedAt
? t("friends:friendsList.friendSince", {
date: databaseTimestampToDate(friendshipCreatedAt).toLocaleDateString(),
date: formatDate(databaseTimestampToDate(friendshipCreatedAt), {
day: "numeric",
month: "numeric",
year: "numeric",
}),
})
: null;

View File

@ -1,10 +1,12 @@
import cachified from "@epic-web/cachified";
import * as R from "remeda";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
getBracketProgressionLabel,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import {
@ -186,16 +188,21 @@ function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
const threeDaysAgo = databaseTimestampThreeDaysAgo();
const nonResults = tournaments.filter(
(tournament) =>
!tournament.firstPlacer &&
tournament.firstPlacers.length === 0 &&
!tournament.isFinalized &&
tournament.startTime > threeDaysAgo,
);
const rankedResults = tournaments
.filter((tournament) => tournament.firstPlacer && tournament.isRanked)
.filter(
(tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked,
)
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
const nonRankedResults = tournaments
.filter((tournament) => tournament.firstPlacer && !tournament.isRanked)
.filter(
(tournament) =>
tournament.firstPlacers.length > 0 && !tournament.isRanked,
)
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
const rankedResultsToKeep = rankedResults.slice(0, 4);
@ -283,7 +290,7 @@ const MEMBERS_TO_SHOW = 5;
function mapTournamentFromDB(
tournament: TournamentRepository.ForShowcase,
): ShowcaseCalendarEvent {
const highestDivWinners = resolveHighestDivisionWinners(tournament);
const firstPlacers = resolveFirstPlacers(tournament);
const tentativeTier =
tournament.tier === null &&
@ -320,41 +327,35 @@ function mapTournamentFromDB(
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
modes: null,
hasVods: (tournament.vodCount ?? 0) > 0,
firstPlacer:
highestDivWinners.length > 0
? {
teamName: highestDivWinners[0].teamName,
logoUrl:
highestDivWinners[0].teamLogoUrl ??
highestDivWinners[0].pickupAvatarUrl,
div: highestDivWinners[0].div,
members: highestDivWinners
.slice(0, MEMBERS_TO_SHOW)
.map((firstPlacer) => ({
customUrl: firstPlacer.customUrl,
discordAvatar: firstPlacer.discordAvatar,
discordId: firstPlacer.discordId,
id: firstPlacer.id,
username: firstPlacer.username,
country: firstPlacer.country,
})),
notShownMembersCount:
highestDivWinners.length > MEMBERS_TO_SHOW
? highestDivWinners.length - MEMBERS_TO_SHOW
: 0,
}
: null,
firstPlacers,
};
}
function resolveHighestDivisionWinners(
type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number];
function resolveFirstPlacers(
tournament: TournamentRepository.ForShowcase,
) {
): ShowcaseCalendarEvent["firstPlacers"] {
if (tournament.firstPlacers.length === 0) {
return [];
}
// not a "many starting brackets" tournament
if (
Progression.hasAbDivisionsFinals(tournament.settings.bracketProgression)
) {
const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? "");
return Object.values(byDiv)
.map((rows) => buildFirstPlacerEntry(rows, { withMembers: false }))
.sort((a, b) => (a.div ?? "").localeCompare(b.div ?? ""));
}
const winnerRows = winnersOfHighestDivision(tournament);
return [buildFirstPlacerEntry(winnerRows, { withMembers: true })];
}
function winnersOfHighestDivision(
tournament: TournamentRepository.ForShowcase,
): FirstPlacerRow[] {
if (tournament.firstPlacers.every((p) => p.div === null)) {
return tournament.firstPlacers;
}
@ -363,8 +364,6 @@ function resolveHighestDivisionWinners(
0,
tournament.settings.bracketProgression,
);
// Filter to only include winners from the highest division
const highestDivWinners = tournament.firstPlacers.filter(
(p) => p.div === highestDivName,
);
@ -374,6 +373,34 @@ function resolveHighestDivisionWinners(
: tournament.firstPlacers;
}
function buildFirstPlacerEntry(
rows: FirstPlacerRow[],
{ withMembers }: { withMembers: boolean },
): ShowcaseCalendarEvent["firstPlacers"][number] {
const first = rows[0];
const members = withMembers
? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({
customUrl: row.customUrl,
discordAvatar: row.discordAvatar,
discordId: row.discordId,
id: row.id,
username: row.username,
country: row.country,
}))
: [];
return {
teamName: first.teamName,
logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl,
div: first.div,
members,
notShownMembersCount:
withMembers && rows.length > MEMBERS_TO_SHOW
? rows.length - MEMBERS_TO_SHOW
: 0,
};
}
function databaseTimestampWeekFromNow() {
const now = new Date();

View File

@ -71,8 +71,8 @@ function SeasonDates({
return isHydrated ? (
<div className={className}>
{formatDate(season.starts, { month: "long", day: "numeric" })} -{" "}
{formatDate(season.ends, { month: "long", day: "numeric" })}
{formatDate(season.starts, { month: "numeric", day: "numeric" })} -{" "}
{formatDate(season.ends, { month: "numeric", day: "numeric" })}
</div>
) : (
<div className={clsx(className, "invisible")}>X</div>

View File

@ -410,12 +410,7 @@ export async function seasonPopularUsersWeapon(
.with("q1", (db) =>
db
.selectFrom("ReportedWeapon")
.innerJoin(
"GroupMatchMap",
"ReportedWeapon.groupMatchMapId",
"GroupMatchMap.id",
)
.innerJoin("GroupMatch", "GroupMatchMap.matchId", "GroupMatch.id")
.innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id")
.select(({ fn }) => [
"ReportedWeapon.userId",
"ReportedWeapon.weaponSplId",

View File

@ -272,7 +272,7 @@ function PostTime({
return (
<div className="text-lighter text-xs font-bold">
{formatDate(createdAtDate, {
month: "long",
month: "numeric",
day: "numeric",
})}{" "}
{overDayDifferenceBetween ? (

View File

@ -0,0 +1,732 @@
import { ArrowLeft, Ban, Undo2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import {
SendouTab,
SendouTabList,
SendouTabs,
} from "~/components/elements/Tabs";
import { Main } from "~/components/Main";
import { MatchActionPickBanTab } from "~/components/match-page/MatchActionPickBanTab";
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
import {
IconBanner,
MatchBannerContainer,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchPage } from "~/components/match-page/MatchPage";
import { MatchPageHeader } from "~/components/match-page/MatchPageHeader";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs } from "~/components/match-page/MatchTabs";
import { logger } from "~/utils/logger";
import type { SendouRouteHandle } from "~/utils/remix.server";
type ActionVariant =
| "winner"
| "counterpick-stage"
| "ban-stage"
| "ban-stage-only"
| "pick-mode"
| "ban-mode";
export const handle: SendouRouteHandle = {
i18n: ["q"],
};
export default function MatchPageTestRoute() {
const { t } = useTranslation(["q"]);
const [actionVariant, setActionVariant] = useState<ActionVariant>("winner");
return (
<Main>
<MatchPage>
<MatchPageHeader
subtitle="Mola Mola"
topRight={
<SendouButton variant="outlined" size="small" icon={<ArrowLeft />}>
Back to bracket
</SendouButton>
}
>
Round 2.1
</MatchPageHeader>
<SendouTabs
selectedKey={actionVariant}
onSelectionChange={(key) => setActionVariant(key as ActionVariant)}
disappearing={false}
padded={false}
>
<SendouTabList>
<SendouTab id="winner">Winner</SendouTab>
<SendouTab id="counterpick-stage">Counterpick</SendouTab>
<SendouTab id="ban-stage">Ban stage</SendouTab>
<SendouTab id="ban-stage-only">Ban stage (any mode)</SendouTab>
<SendouTab id="pick-mode">Pick mode</SendouTab>
<SendouTab id="ban-mode">Ban mode</SendouTab>
</SendouTabList>
</SendouTabs>
<MatchBannerContainer>
<MatchBannerTopRow
score={{
alpha: 1,
bravo: 2,
isFinal: false,
count: 5,
bestOf: true,
}}
time={{
currentMinutes: 3,
totalMinutes: 1,
}}
/>
<IconBanner
icon={<Ban size={32} />}
header={t("q:match.cancelRequested")}
subtitle={t("q:match.cancelRequested.subtitle", {
teamName: "Chimera",
})}
screenLegal={false}
/>
<MatchBannerBottomRow
games={[
{ mode: "SZ", winner: "ALPHA" },
{ mode: "TC", winner: "BRAVO" },
{ mode: "RM", winner: "ALPHA" },
]}
activeRosters={{
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
}}
/>
</MatchBannerContainer>
<MatchTabs tabs={["join", "rosters", "action", "result"]}>
<MatchJoinTab
joinLink="https://app.nintendo.net/private_battle/abc123"
pool="SQ7"
pass="8430"
showNoSplatnetAlert
/>
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[true, false]}
onSubbedOutChange={(teamId, subbedOut) => {
logger.info("onSubbedOutChange", { teamId, subbedOut });
}}
teams={[
{
team: {
id: 1,
name: "me in japan",
url: "/t/me-in-japan",
},
tier: { name: "DIAMOND", isPlus: true },
members: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
tier: { name: "LEVIATHAN", isPlus: true },
plusTier: 1,
weaponPool: [0, 2000, 4000],
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
tier: { name: "DIAMOND", isPlus: false },
plusTier: 2,
weaponPool: [20, 1100],
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
tier: "CALCULATING",
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
{
id: 9,
username: "Poppy",
discordId: "567",
discordAvatar: null,
customUrl: null,
tier: { name: "GOLD", isPlus: true },
},
],
subbedOut: [9],
},
{
defaultName: "Group Bravo",
members: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
tier: { name: "PLATINUM", isPlus: false },
plusTier: 3,
weaponPool: [40, 3000],
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
tier: { name: "SILVER", isPlus: true },
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
tier: { name: "BRONZE", isPlus: false },
},
],
},
]}
/>
{actionVariant === "winner" ? (
<MatchActionTab
teams={[
{ id: 1, name: "Chimera" },
{ id: 2, name: "Koopa Clan" },
]}
ownTeamId={1}
stageId={4}
mode="SZ"
withPoints={true}
actionButtons={
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
>
{t("q:match.undoReport")}
</SendouButton>
}
/>
) : actionVariant === "counterpick-stage" ? (
<MatchActionPickBanTab
type="PICK"
options={[
{ stageId: 1, mode: "SZ", picker: "US" },
{ stageId: 2, mode: "SZ", picker: "BOTH" },
{ stageId: 3, mode: "SZ", picker: "THEM" },
{ stageId: 4, mode: "TC", picker: "US" },
{ stageId: 5, mode: "TC", picker: "THEM" },
{ stageId: 6, mode: "RM", picker: "BOTH" },
{ stageId: 7, mode: "RM", picker: "US" },
]}
onSubmit={(data) => logger.info("pick submit", data)}
/>
) : actionVariant === "ban-stage" ? (
<MatchActionPickBanTab
type="BAN"
options={[
{ stageId: 1, mode: "SZ", nth: 1 },
{ stageId: 2, mode: "SZ", nth: 2 },
{ stageId: 4, mode: "TC", nth: 3 },
{ stageId: 5, mode: "TC", nth: 4 },
{ stageId: 6, mode: "RM", nth: 5 },
{ stageId: 7, mode: "RM", nth: 6 },
{ stageId: 8, mode: "CB", nth: 7 },
{ stageId: 9, mode: "CB", nth: 8 },
]}
onSubmit={(data) => logger.info("ban submit", data)}
/>
) : actionVariant === "ban-stage-only" ? (
<MatchActionPickBanTab
type="BAN"
options={[
{ stageId: 1 },
{ stageId: 2 },
{ stageId: 3 },
{ stageId: 4 },
{ stageId: 5 },
{ stageId: 6 },
{ stageId: 7 },
{ stageId: 8 },
{ stageId: 9 },
]}
onSubmit={(data) => logger.info("ban stage-only submit", data)}
/>
) : actionVariant === "pick-mode" ? (
<MatchActionPickBanTab
type="PICK"
options={[
{ mode: "SZ" },
{ mode: "TC" },
{ mode: "RM" },
{ mode: "CB" },
]}
onSubmit={(data) => logger.info("pick mode submit", data)}
/>
) : (
<MatchActionPickBanTab
type="BAN"
options={[
{ mode: "SZ" },
{ mode: "TC" },
{ mode: "RM" },
{ mode: "CB" },
]}
onSubmit={(data) => logger.info("ban mode submit", data)}
/>
)}
<MatchResultTab
teams={{
alpha: { name: "me in japan" },
bravo: { name: "Group Bravo" },
}}
score={{ alpha: 3, bravo: 0 }}
spChanges={{
alpha: {
members: [
{
user: {
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
skillDifference: {
calculated: true,
spDiff: 12.3,
oldSp: 1402.43,
newSp: 1414.73,
},
},
{
user: {
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: 8.7,
oldSp: 1521.18,
newSp: 1529.88,
},
},
{
user: {
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: false,
matchesCount: 3,
matchesCountNeeded: 7,
},
},
{
user: {
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: false,
matchesCount: 7,
matchesCountNeeded: 7,
newSp: 1850,
},
},
],
skillDifference: {
calculated: false,
matchesCount: 5,
matchesCountNeeded: 7,
},
},
bravo: {
members: [
{
user: {
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -11.2,
oldSp: 1612.55,
newSp: 1601.35,
},
},
{
user: {
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -9.4,
oldSp: 1488.62,
newSp: 1479.22,
},
},
{
user: {
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -13.8,
oldSp: 1730.91,
newSp: 1717.11,
},
},
{
user: {
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -7.6,
oldSp: 1555.04,
newSp: 1547.44,
},
},
],
skillDifference: {
calculated: true,
oldSp: 1980,
newSp: 1968,
},
},
}}
maps={[
{
stageId: 1,
mode: "SZ",
timestamp: 1712855000,
winner: "ALPHA",
weapons: {
alpha: [40, 10, 1100, 3040],
bravo: [50, 210, 2010, 4010],
},
rosters: {
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
},
},
{
stageId: 4,
mode: "TC",
timestamp: 1712855600,
winner: "ALPHA",
weapons: {
alpha: [40, 10, 1100, 3040],
bravo: [50, 210, 2010, 4010],
},
rosters: {
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
},
},
{
stageId: 2,
mode: "RM",
timestamp: 1712856200,
winner: "ALPHA",
points: [100, 42],
weapons: {
alpha: [40, null, 1100, 3040],
bravo: [null, 210, null, 4010],
},
rosters: {
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 9,
username: "Poppy",
discordId: "567",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
},
},
]}
/>
</MatchTabs>
</MatchPage>
</Main>
);
}

View File

@ -1,7 +1,38 @@
import { sql } from "kysely";
import { sql, type Transaction } from "kysely";
import { ordinal } from "openskill";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants";
export async function addInitialSkill(
{
mu,
sigma,
season,
userId,
}: {
mu: number;
sigma: number;
season: number;
userId: number;
},
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
await executor
.insertInto("Skill")
.values({
mu,
sigma,
season,
ordinal: ordinal({ mu, sigma }),
userId,
matchesCount: 0,
})
.execute();
}
export async function seasonProgressionByUserId({
userId,
season,

Some files were not shown because too many files have changed in this diff Show More