sendou.ink/app/features/leaderboards/routes/leaderboards.tsx
Kalle e7bbb565be
SendouQ (#1455)
* Tables

* Clocks

* Maplist preference selector

* Fix SSR

* Nav icon

* RankedOrScrim

* Map pool

* Create group

* Redirect logic

* Persist map pool

* Advance from preparing page

* Rename query

* Fix merge

* Fix migration order

* Seed groups

* Find looking groups SQL

* Renders something

* More UI work

* Back to 30min

* Likes/dislikes

* Always return own group

* Fix like order

* 3 tc/rm/cb -> 2

* Show only 3 weapons

* Pass group size

* Handle both liked and liked by same group

* Fix SQL

* Group preference frontend work

* Morphing

* Styling

* Don't show group controls if not manager

* Give/remove manager

* Leave group

* Leave with confirm

* Delete likes when morphing groups

* Clocks consistency

* Remove bad invariant

* Persist settings to local storage

* Fix initial value flashing

* Fix never resolving loading indicator

* REFRESH_GROUP

* Flip animations

* Tweaks

* Auto refresh logic

* Groups of 4 seed

* Reduce throwing

* Load full groups initial

* Create match

* Match UI initial

* Score reporter initial

* Push footer down on match page

* Score reporter knows when set ended

* Score reporting untested

* Show score after report

* Align better

* Look again with same group functionality

* More migrations

* Team on match page

* Show confirmer before reporting score

* Report weapons

* Report weapos again by admin + skill changing

* Handle no tiebreaker given to MapPool

* Remove unranked

* Remove support for "team id skill"

* no-wrap -> nowrap

* Preparing page work

* Use common GroupCard component

* Add some metas

* MemberAdder in looking page

* Fix GroupCard actions

* Fix SZ only map list including other modes

* Add season info

* Prompt login

* Joining team

* Manage group on preparing page

* Manage group on preparing page

* Seed past matches

* Add to seed

* No map list preference when full group + fix expiry

* Fix skill matchesCount calculation

* Tiers initial work

* Some progress on tiers

* Tiering logic

* MMR in group cards

* Name to challenge

* Team MMR

* Big team rank icons

* Adjust todos

* Match score report with confirm

* Allow regular members to report score

* Handle reporting weapons edge cases

* Add tier images

* Improve GroupCard spacing

* Refactor looking page

* Looking mobile UI

* Calculate skill only for current season

* Divide groups visually when reporting weapons

* Fix match page weapons sorting

* Add cache to user skills+tier calculation

* Admin report match score

* Initial leaderboard

* Cached leaderboard

* Weapon category lb's

* Populate SkillTeamUser in SendouQ

* Team leaderboard filtered down

* Add TODOs

* Seasons initlal

* Season weapons initial

* Weapons stylized

* Show rest weapons as +

* Hide peak if same as current

* Load matches SQL initial

* Season matches UI initial

* Take user id in account

* Add weapons

* Paginated matches

* Fix pages count logic

* Scroll top on data change

* Day headers for matches

* Link from user page to user seasons page

* Summarize maps + ui initial

* Map stats

* Player info tabs

* MMR chart

* Chart adjustments

* Handle basing team MMR on player MMR

* Set initial MMR

* Add info about discord to match page

* Season support to tournaments

* Get tournament skills as well for the graph

* WIP

* New team rating logic + misc other

* tiered -> tiered.server

* Update season starting time

* TODOs

* Add rules page

* Hide elements correctly when off-season

* Fix crash when only one player with skill

* How-to video

* Fix StartRank showing when not logged in

* Make user leaderboard the default

* Make Skill season non-nullable

* Add suggested pass to match

* Add rule

* identifierToUserIds helper

* Fix tiers not showing
2023-08-12 22:42:54 +03:00

368 lines
11 KiB
TypeScript

import type {
LinksFunction,
LoaderArgs,
SerializeFrom,
V2_MetaFunction,
} from "@remix-run/node";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import { Avatar } from "~/components/Avatar";
import { Main } from "~/components/Main";
import { makeTitle } from "~/utils/strings";
import {
LEADERBOARDS_PAGE,
navIconUrl,
teamPage,
topSearchPlayerPage,
userPage,
userSeasonsPage,
userSubmittedImage,
} from "~/utils/urls";
import styles from "../../top-search/top-search.css";
import { userSPLeaderboard } from "../queries/userSPLeaderboard.server";
import type { SendouRouteHandle } from "~/utils/remix";
import {
type TeamSPLeaderboardItem,
teamSPLeaderboard,
} from "../queries/teamSPLeaderboard.server";
import React from "react";
import { LEADERBOARD_TYPES } from "../leaderboards-constants";
import { useTranslation } from "~/hooks/useTranslation";
import { i18next } from "~/modules/i18n";
import {
type XPLeaderboardItem,
allXPLeaderboard,
modeXPLeaderboard,
weaponXPLeaderboard,
} from "../queries/XPLeaderboard.server";
import { TierImage, WeaponImage } from "~/components/Image";
import {
weaponCategories,
type MainWeaponId,
type RankedModeShort,
} from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { allSeasons } from "~/features/mmr/season";
import {
addPlacementRank,
addTiers,
addWeapons,
filterByWeaponCategory,
oneEntryPerUser,
} from "../core/leaderboards.server";
import { seasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server";
import { cachified } from "cachified";
import { cache, ttl } from "~/utils/cache.server";
import { HALF_HOUR_IN_MS } from "~/constants";
export const handle: SendouRouteHandle = {
i18n: ["vods"],
breadcrumb: () => ({
imgPath: navIconUrl("leaderboards"),
href: LEADERBOARDS_PAGE,
type: "IMAGE",
}),
};
export const meta: V2_MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [
{ title: data.title },
{
name: "description",
content:
"Leaderboards of top Splatoon players ranked by their X Power and tournament results",
},
];
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
const TYPE_SEARCH_PARAM_KEY = "type";
export const loader = async ({ request }: LoaderArgs) => {
const t = await i18next.getFixedT(request);
const unvalidatedType = new URL(request.url).searchParams.get(
TYPE_SEARCH_PARAM_KEY
);
const type =
LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ??
LEADERBOARD_TYPES[0];
const userLeaderboard = type.includes("USER")
? await cachified({
// TODO: add season here
key: `user-leaderboard-season-${0}`,
cache,
ttl: ttl(HALF_HOUR_IN_MS),
// eslint-disable-next-line @typescript-eslint/require-await
async getFreshValue() {
const leaderboard = userSPLeaderboard(0);
const withTiers = addTiers(leaderboard);
return addWeapons(withTiers, seasonPopularUsersWeapon(0));
},
})
: null;
const teamLeaderboard =
type === "TEAM"
? await cachified({
// TODO: add season here
key: `team-leaderboard-season-${0}`,
cache,
ttl: ttl(HALF_HOUR_IN_MS),
// eslint-disable-next-line @typescript-eslint/require-await
async getFreshValue() {
const leaderboard = teamSPLeaderboard(0);
const filteredByUser = oneEntryPerUser(leaderboard);
return addPlacementRank(filteredByUser);
},
})
: null;
const filteredLeaderboard =
userLeaderboard && type !== "USER"
? filterByWeaponCategory(
userLeaderboard,
type.split("-")[1] as (typeof weaponCategories)[number]["name"]
)
: userLeaderboard;
// TODO: season selection logic
return {
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
teamLeaderboard,
xpLeaderboard:
type === "XP-ALL"
? allXPLeaderboard()
: type.startsWith("XP-MODE")
? modeXPLeaderboard(type.split("-")[2] as RankedModeShort)
: type.startsWith("XP-WEAPON")
? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId)
: null,
title: makeTitle(t("pages.leaderboards")),
};
};
export default function LeaderboardsPage() {
const { t } = useTranslation(["common", "game-misc", "weapons"]);
const [searchParams, setSearchParams] = useSearchParams();
const data = useLoaderData<typeof loader>();
return (
<Main halfWidth className="stack lg">
<select
className="text-sm"
value={searchParams.get(TYPE_SEARCH_PARAM_KEY) ?? LEADERBOARD_TYPES[0]}
onChange={(e) =>
setSearchParams({ [TYPE_SEARCH_PARAM_KEY]: e.target.value })
}
>
{allSeasons(new Date()).map((season) => {
return (
<optgroup label={`SP - Season ${season}`} key={season}>
{LEADERBOARD_TYPES.filter((type) => !type.includes("XP")).map(
(type) => {
const userOrTeam = type.includes("USER") ? "USER" : "TEAM";
const category = weaponCategories.find((c) =>
type.includes(c.name)
)?.name;
return (
<option key={type} value={type}>
{t(`common:leaderboard.type.${userOrTeam}`)}
{category
? ` (${t(`common:weapon.category.${category}`)})`
: ""}
</option>
);
}
)}
</optgroup>
);
})}
<optgroup label="XP">
<option value="XP-ALL">{t(`common:leaderboard.type.XP-ALL`)}</option>
{rankedModesShort.map((mode) => {
return (
<option key={mode} value={`XP-MODE-${mode}`}>
{t(`game-misc:MODE_LONG_${mode}`)}
</option>
);
})}
</optgroup>
{weaponCategories.map((category) => {
return (
<optgroup
key={category.name}
label={`XP (${t(`common:weapon.category.${category.name}`)})`}
>
{category.weaponIds.map((weaponId) => {
return (
<option key={weaponId} value={`XP-WEAPON-${weaponId}`}>
{t(`weapons:MAIN_${weaponId}`)}
</option>
);
})}
</optgroup>
);
})}
</select>
{data.userLeaderboard ? (
<PlayersTable
entries={data.userLeaderboard}
showTiers={
!searchParams.get(TYPE_SEARCH_PARAM_KEY) ||
searchParams.get(TYPE_SEARCH_PARAM_KEY) === "USER"
}
/>
) : null}
{data.teamLeaderboard ? (
<TeamTable entries={data.teamLeaderboard} />
) : null}
{data.xpLeaderboard ? <XPTable entries={data.xpLeaderboard} /> : null}
{/* TODO: only when viewing current season */}
{!data.xpLeaderboard ? (
<div className="text-xs text-lighter">
Leaderboard is updated once every 30 minutes.
</div>
) : null}
</Main>
);
}
function PlayersTable({
entries,
showTiers,
}: {
entries: NonNullable<SerializeFrom<typeof loader>["userLeaderboard"]>;
showTiers?: boolean;
}) {
return (
<div className="placements__table">
{entries.map((entry) => {
return (
<React.Fragment key={entry.entryId}>
{entry.tier && showTiers ? (
<div className="placements__tier-header">
<TierImage tier={entry.tier} width={32} />
{entry.tier.name}
{entry.tier.isPlus ? "+" : ""}
</div>
) : null}
{/* TODO: dynamic season */}
<Link
to={userSeasonsPage({ user: entry, season: 0 })}
className="placements__table__row"
>
<div className="placements__table__inner-row">
<div className="placements__table__rank">
{entry.placementRank}
</div>
<div>
<Avatar size="xxs" user={entry} />
</div>
{entry.weaponSplId ? (
<WeaponImage
className="placements__table__weapon"
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
) : null}
<div className="placements__table__name">
{entry.discordName}
</div>
<div className="placements__table__power">{entry.power}</div>
</div>
</Link>
</React.Fragment>
);
})}
</div>
);
}
function TeamTable({ entries }: { entries: TeamSPLeaderboardItem[] }) {
return (
<div className="placements__table">
{entries.map((entry) => {
return (
<div key={entry.entryId} className="placements__table__row">
<div className="placements__table__inner-row">
<div className="placements__table__rank">
{entry.placementRank}
</div>
{entry.team?.avatarImgUrl ? (
<Link
to={teamPage(entry.team.customUrl)}
title={entry.team.name}
>
<Avatar
size="xxs"
url={userSubmittedImage(entry.team.avatarImgUrl)}
className="placements__avatar"
/>
</Link>
) : null}
<div className="text-xs">
{entry.members.map((member, i) => {
return (
<React.Fragment key={member.id}>
<Link to={userPage(member)}>{member.discordName}</Link>
{i !== entry.members.length - 1 ? ", " : null}
</React.Fragment>
);
})}
</div>
<div className="placements__table__power">{entry.power}</div>
</div>
</div>
);
})}
</div>
);
}
function XPTable({ entries }: { entries: XPLeaderboardItem[] }) {
return (
<div className="placements__table">
{entries.map((entry) => {
return (
<Link
to={topSearchPlayerPage(entry.playerId)}
key={entry.entryId}
className="placements__table__row"
>
<div className="placements__table__inner-row">
<div className="placements__table__rank">
{entry.placementRank}
</div>
{entry.discordId ? (
<Avatar size="xxs" user={entry as any} />
) : null}
<WeaponImage
className="placements__table__weapon"
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
<div>{entry.name}</div>
<div className="placements__table__power">{entry.power}</div>
</div>
</Link>
);
})}
</div>
);
}