Exported images w/ SQ season summary downloadable (#3272)

This commit is contained in:
Kalle 2026-07-31 17:34:49 +03:00 committed by GitHub
parent 83660a5b2f
commit 97619bdc05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
114 changed files with 5780 additions and 292 deletions

View File

@ -7,6 +7,7 @@ import styles from "./Ability.module.css";
import { Image } from "./Image";
const sizeMap = {
HUGE: 64,
MAIN: 42,
SUB: 32,
SUBTINY: 26,

View File

@ -3,7 +3,7 @@ import * as React from "react";
import type { Tables } from "~/db/tables";
import { useHydrated } from "~/hooks/useHydrated";
import { LRUCache } from "~/modules/cache";
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
import { BLANK_IMAGE_URL, resolveAvatarUrl } from "~/utils/urls";
import styles from "./Avatar.module.css";
const dimensions = {
@ -132,19 +132,23 @@ export function Avatar({
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
const src = url
? url
: user?.customAvatarUrl && !isErrored
? user.customAvatarUrl
: user?.discordAvatar && !isErrored
? discordAvatarUrl({
discordAvatar: user.discordAvatar,
discordId: user.discordId,
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: isClient
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL;
const userAvatarUrl = user
? resolveAvatarUrl({
customAvatarUrl: user.customAvatarUrl,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: undefined;
const avatarUrl = url ?? userAvatarUrl;
const src =
avatarUrl && !isErrored
? avatarUrl
: isClient
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL;
return (
<div className={clsx(styles.avatarWrapper, className)}>

View File

@ -1,10 +1,22 @@
import clsx from "clsx";
import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react";
import {
HardDriveDownload,
Lock,
MessageCircleMore,
SquarePen,
Trash,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import {
BuildGraphic,
type BuildGraphicOwner,
} from "~/features/img-export/components/BuildGraphic";
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
import type {
Ability as AbilityType,
BuildAbilitiesTuple,
@ -28,6 +40,7 @@ import { Ability } from "./Ability";
import styles from "./BuildCard.module.css";
import { LinkButton, SendouButton } from "./elements/Button";
import { SendouPopover } from "./elements/Popover";
import { SendouSwitch } from "./elements/Switch";
import { FormWithConfirm } from "./FormWithConfirm";
import { Image } from "./Image";
import { LocaleTime } from "./LocaleTime";
@ -48,11 +61,21 @@ interface BuildProps {
modes: ModeShort[] | null;
weapons: Array<BuildWeaponWithTop500Info>;
};
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier"> &
Partial<
Pick<BuildGraphicOwner, "customUrl" | "discordAvatar" | "customAvatarUrl">
>;
/** Set to false when the page context already shows the owner (e.g. their own builds page) */
showOwner?: boolean;
canEdit?: boolean;
}
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
export function BuildCard({
build,
owner,
showOwner = true,
canEdit = false,
}: BuildProps) {
const user = useUser();
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
@ -100,15 +123,15 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
</h2>
</div>
<div className={styles.dateAuthorRow}>
{owner && (
{owner && showOwner ? (
<>
<Link to={userBuildsPage(owner)} className={styles.ownerLink}>
{owner.username}
</Link>
<div></div>
</>
)}
{owner?.plusTier ? (
) : null}
{owner?.plusTier && showOwner ? (
<>
<span>+{owner.plusTier}</span>
<div></div>
@ -180,6 +203,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
path={navIconUrl("analyzer")}
/>
</LinkButton>
{owner ? <BuildImageExportDialog build={build} owner={owner} /> : null}
{description ? (
<SendouPopover
trigger={
@ -229,6 +253,67 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
);
}
function BuildImageExportDialog({
build,
owner,
}: {
build: BuildProps["build"];
owner: BuildGraphicOwner;
}) {
const { t } = useTranslation(["common"]);
const [showTitle, setShowTitle] = React.useState(true);
const [showAbilityPoints, setShowAbilityPoints] = React.useState(true);
const [showAbilityChunks, setShowAbilityChunks] = React.useState(false);
return (
<ImageExportDialog
trigger={
<SendouButton
shape="circle"
size="small"
variant="minimal"
icon={<HardDriveDownload />}
className={styles.smallText}
aria-label={t("common:imageExport.export")}
/>
}
heading={t("common:imageExport.export")}
filename={`build-${mySlugify(build.title)}`}
qrCodePath={analyzerPage({
weaponId: build.weapons[0].weaponSplId,
abilities: build.abilities.flat(),
})}
settings={
<>
<SendouSwitch isSelected={showTitle} onChange={setShowTitle}>
{t("common:imageExport.buildTitle")}
</SendouSwitch>
<SendouSwitch
isSelected={showAbilityPoints}
onChange={setShowAbilityPoints}
>
{t("common:imageExport.abilityPoints")}
</SendouSwitch>
<SendouSwitch
isSelected={showAbilityChunks}
onChange={setShowAbilityChunks}
>
{t("common:imageExport.abilityChunks")}
</SendouSwitch>
</>
}
>
<BuildGraphic
build={build}
owner={owner}
showTitle={showTitle}
showAbilityPoints={showAbilityPoints}
showAbilityChunks={showAbilityChunks}
/>
</ImageExportDialog>
);
}
function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId);

View File

@ -12,7 +12,7 @@ export function Flag({
const { i18n } = useTranslation();
return (
<div
<span
className={clsx(`twf twf-${countryCode.toLowerCase()}`, {
"twf-s": tiny,
})}

View File

@ -0,0 +1,14 @@
import { db } from "~/db/sql";
/**
* Moves every season stamped row (skills and the aggregated stats keyed off them)
* to the given season. Concluding a match stamps its results with the season that
* is current then, so matches backdated into an older season still leave their
* results in the ongoing one a seed that needs a season looking played out and
* over has no other way to ask for one.
*/
export async function reseason(season: number) {
await db.updateTable("Skill").set({ season }).execute();
await db.updateTable("MapResult").set({ season }).execute();
await db.updateTable("PlayerResult").set({ season }).execute();
}

View File

@ -169,11 +169,7 @@ export function BuildCards({ data }: { data: SerializeFrom<typeof loader> }) {
<BuildCard
key={build.id}
build={build}
owner={
build.owner
? { ...build.owner, plusTier: build.plusTier }
: undefined
}
owner={{ ...build.owner, plusTier: build.plusTier }}
canEdit={false}
/>
);

View File

@ -51,6 +51,20 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
import { Table } from "~/components/Table";
import { TierPill } from "~/components/TierPill";
import { WeaponSelect } from "~/components/WeaponSelect";
import {
SeasonSummaryGraphic,
type SeasonSummaryGraphicActivity,
type SeasonSummaryGraphicBestSet,
type SeasonSummaryGraphicStats,
} from "~/features/img-export/components/SeasonSummaryGraphic";
import {
TournamentResultsGraphic,
type TournamentResultsGraphicTeam,
} from "~/features/img-export/components/TournamentResultsGraphic";
import {
TournamentRunGraphic,
type TournamentRunGraphicMatch,
} from "~/features/img-export/components/TournamentRunGraphic";
import {
Trophy,
TrophyContextProvider,
@ -66,7 +80,7 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
import { formFieldsShowcaseSchema } from "../form-examples-schema";
export const handle: SendouRouteHandle = {
i18n: ["user", "q"],
i18n: ["user", "q", "calendar", "tournament"],
};
export const SECTIONS = [
@ -93,6 +107,21 @@ export const SECTIONS = [
{ title: "Pagination", id: "pagination", component: PaginationSection },
{ title: "Avatar", id: "avatar", component: AvatarSection },
{ title: "User Card", id: "user-card", component: UserCardSection },
{
title: "Tournament Results Graphic",
id: "tournament-results-graphic",
component: TournamentResultsGraphicSection,
},
{
title: "Tournament Run Graphic",
id: "tournament-run-graphic",
component: TournamentRunGraphicSection,
},
{
title: "Season Summary Graphic",
id: "season-summary-graphic",
component: SeasonSummaryGraphicSection,
},
{
title: "Form Messages",
id: "form-messages",
@ -1634,6 +1663,432 @@ function UserCardSection({ id }: { id: string }) {
);
}
const RESULTS_GRAPHIC_IMG_ROOT =
"https://sendou.nyc3.cdn.digitaloceanspaces.com";
const RESULTS_GRAPHIC_TEAMS: TournamentResultsGraphicTeam[] = [
{
placement: 1,
name: "Besto Friendo",
players: [
{ name: "Yeti" },
{ name: "まるお", countryCode: "JP" },
{ name: "🪄", countryCode: "FR" },
{ name: "Grey", countryCode: "FR" },
],
weapons: [40, 2070, 8010, 5030],
},
{
placement: 2,
name: "輝く",
players: [
{ name: "Ali", countryCode: "CA" },
{ name: "w" },
{ name: "へでる" },
{ name: "メガチャーレム" },
],
weapons: [210, 40, 2010, 1120],
},
{
placement: 3,
name: "Small Bubbler",
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/pickup-logo-867qQb75XxqndbJDbJzk3-1774655417856.webp`,
players: [
{ name: "Omegα" },
{ name: "shadowind", countryCode: "US" },
{ name: "Max", countryCode: "US" },
{ name: "y0shell", countryCode: "GB" },
],
weapons: [5010, 2030, 1010, 0],
},
{
placement: 4,
name: "For fun.",
players: [
{ name: "Len. 🕷", countryCode: "VN" },
{ name: "Jovan", countryCode: "NG" },
{ name: "swish", countryCode: "US" },
{ name: "prosper", countryCode: "NG" },
],
weapons: [240, 5040, 1030, 2070],
},
{
placement: 5,
name: "Its too much",
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/pickup-logo-1R_1LhuEk6jecMsRWwCl--1774679643780.webp`,
players: [
{ name: "Coolaceeeee ^_^", countryCode: "JP" },
{ name: "illusion" },
{ name: "sunni" },
{ name: "Chiva", countryCode: "MX" },
],
weapons: [1001, 220, 40, 2010],
},
{
placement: 5,
name: "ezmd",
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/Yu_tgElCa5D48CcyFPF3Y-1756077173431.webp`,
players: [
{ name: "Silver", countryCode: "FR" },
{ name: "Devin", countryCode: "DE" },
{ name: "kiki", countryCode: "IE" },
{ name: "Reiyu", countryCode: "DO" },
],
weapons: [5011, 2000, 8020, 260],
},
{
placement: 7,
name: "healthy diet food groups",
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/OYhoGpd7lIRyyBawAfRx9-1673646086021.webp`,
players: [
{ name: "zyf", countryCode: "US" },
{ name: "Blopwher", countryCode: "US" },
{ name: "Stans", countryCode: "US" },
{ name: "Miner", countryCode: "ET" },
],
weapons: [1100, 2040, 10, 5000],
},
{
placement: 7,
name: "WIT CHECK",
players: [
{ name: "Andre", countryCode: "TT" },
{ name: "Isabel T.J.", countryCode: "ES" },
{ name: "Chara", countryCode: "US" },
{ name: "Basil", countryCode: "US" },
],
weapons: [200, 1020, 2060, 5020],
},
];
function TournamentResultsGraphicSection({ id }: { id: string }) {
return (
<Section>
<SectionTitle id={id}>Tournament Results Graphic</SectionTitle>
<div className="stack md">
<ComponentRow label="Top 8 export image">
<TournamentResultsGraphic
tournamentId={3435}
tournamentName="In The Zone 50"
startTime={new Date(1774720800 * 1000)}
tier={2}
logoUrl={`${RESULTS_GRAPHIC_IMG_ROOT}/tournament-logo-itz.png`}
organization={{
name: "sendou.ink",
avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/dBYwiLjlhVBwW-oyyNJkC-1721997877357.webp`,
}}
teams={RESULTS_GRAPHIC_TEAMS}
teamsCount={32}
playersCount={130}
/>
</ComponentRow>
</div>
</Section>
);
}
const RUN_GRAPHIC_SWISS = "Day 1 - Swiss";
const RUN_GRAPHIC_ALPHA = "Day 2 - Alpha Bracket";
const RUN_GRAPHIC_MATCHES: TournamentRunGraphicMatch[] = [
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[7], seed: 48 },
ownScore: 2,
opponentScore: 0,
roundName: "Swiss 1",
bracketName: RUN_GRAPHIC_SWISS,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[5], seed: 3 },
ownScore: 2,
opponentScore: 1,
roundName: "Swiss 2",
bracketName: RUN_GRAPHIC_SWISS,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[1], seed: 6 },
ownScore: 1,
opponentScore: 2,
roundName: "Swiss 3",
bracketName: RUN_GRAPHIC_SWISS,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[3], seed: 15 },
ownScore: 2,
opponentScore: 0,
roundName: "Swiss 4",
bracketName: RUN_GRAPHIC_SWISS,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[6], seed: 19 },
ownScore: 2,
opponentScore: 1,
roundName: "Swiss 5",
bracketName: RUN_GRAPHIC_SWISS,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[4], seed: 26 },
ownScore: 2,
opponentScore: 0,
roundName: "Swiss 6",
bracketName: RUN_GRAPHIC_SWISS,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[2], seed: 22 },
ownScore: 2,
opponentScore: 0,
roundName: "WB Round 1",
bracketName: RUN_GRAPHIC_ALPHA,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[5], seed: 3 },
ownScore: 2,
opponentScore: 1,
roundName: "WB Round 2",
bracketName: RUN_GRAPHIC_ALPHA,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[6], seed: 19 },
ownScore: 2,
opponentScore: 0,
roundName: "WB Semis",
bracketName: RUN_GRAPHIC_ALPHA,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[3], seed: 15 },
ownScore: 2,
opponentScore: 1,
roundName: "WB Finals",
bracketName: RUN_GRAPHIC_ALPHA,
},
{
opponent: { ...RESULTS_GRAPHIC_TEAMS[1], seed: 6 },
ownScore: 3,
opponentScore: 1,
roundName: "Grand Finals",
bracketName: RUN_GRAPHIC_ALPHA,
},
];
function TournamentRunGraphicSection({ id }: { id: string }) {
return (
<Section>
<SectionTitle id={id}>Tournament Run Graphic</SectionTitle>
<div className="stack md">
<ComponentRow label="Team run export image">
<TournamentRunGraphic
tournamentId={3938}
tournamentTeamId={73362}
tournamentName="Low Ink June 2026"
startTime={new Date(1781974800 * 1000)}
tier={5}
logoUrl={`${RESULTS_GRAPHIC_IMG_ROOT}/tournament-logo-zWk5C1kvQtEWrd7d2c_KS-1735836299376.webp`}
organization={{
name: "Inkling Performance Labs",
avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp`,
}}
team={RESULTS_GRAPHIC_TEAMS[0]}
seed={11}
matches={RUN_GRAPHIC_MATCHES}
teamsCount={74}
playersCount={349}
seriesWins={{
totalCount: 4,
first: {
name: "Low Ink May 2023",
startTime: new Date("2023-05-20T18:00:00Z"),
},
latest: {
name: "Low Ink February 2026",
startTime: new Date("2026-02-21T18:00:00Z"),
},
}}
/>
</ComponentRow>
</div>
</Section>
);
}
const SEASON_SUMMARY_DAYS: Array<
[date: string, sp: number, activity: SeasonSummaryGraphicActivity]
> = [
["2026-03-02", 1875.2, "sq"],
["2026-03-03", 1922.7, "sq"],
["2026-03-06", 1898.4, "sq"],
["2026-03-08", 1961.3, "tournament"],
["2026-03-10", 2004.9, "sq"],
["2026-03-12", 1987.1, "sq"],
["2026-03-14", 2043.6, "sq"],
["2026-03-17", 2071.2, "sq"],
["2026-03-19", 2055.8, "sq"],
["2026-03-22", 2102.4, "both"],
["2026-03-25", 2138.9, "sq"],
["2026-03-26", 2117.3, "sq"],
["2026-03-29", 2164.0, "tournament"],
["2026-04-02", 2189.5, "sq"],
["2026-04-05", 2151.2, "tournament"],
["2026-04-06", 2208.8, "sq"],
["2026-04-09", 2247.3, "sq"],
["2026-04-12", 2231.6, "both"],
["2026-04-15", 2278.1, "sq"],
["2026-04-16", 2296.4, "sq"],
["2026-04-19", 2263.7, "tournament"],
["2026-04-21", 2312.9, "sq"],
["2026-04-24", 2340.2, "sq"],
["2026-04-26", 2371.8, "both"],
["2026-04-28", 2355.4, "sq"],
["2026-05-01", 2389.7, "sq"],
["2026-05-03", 2410.8, "tournament"],
["2026-05-05", 2384.2, "sq"],
["2026-05-08", 2401.5, "sq"],
["2026-05-10", 2368.9, "both"],
["2026-05-11", 2352.6, "sq"],
["2026-05-14", 2377.3, "sq"],
["2026-05-16", 2341.5, "sq"],
];
const SEASON_SUMMARY_BEST_SETS: SeasonSummaryGraphicBestSet[] = [
{
context: "SendouQ",
ownScore: 4,
opponentScore: 2,
opponentSp: 2489.3,
opponentPlayers: [
{ name: "Yeti" },
{ name: "まるお", countryCode: "JP" },
{ name: "Silver", countryCode: "FR" },
{ name: "Chara", countryCode: "US" },
],
},
{
context: "In The Zone 50",
ownScore: 3,
opponentScore: 2,
opponentSp: 2451.0,
opponentPlayers: [
{ name: "zyf", countryCode: "US" },
{ name: "Blopwher", countryCode: "US" },
{ name: "Stans", countryCode: "US" },
{ name: "Miner", countryCode: "ET" },
],
},
{
context: "SendouQ",
ownScore: 4,
opponentScore: 3,
opponentSp: 2413.7,
opponentPlayers: [
{ name: "Andre", countryCode: "TT" },
{ name: "Devin", countryCode: "DE" },
{ name: "kiki", countryCode: "IE" },
{ name: "Reiyu", countryCode: "DO" },
],
},
];
const SEASON_SUMMARY_STATS: SeasonSummaryGraphicStats = {
tier: { name: "DIAMOND", isPlus: true },
sp: 2341.5,
setsWon: 41,
setsLost: 18,
mapsWon: 132,
mapsLost: 77,
longestWinStreak: 9,
clutch: { won: 8, total: 12 },
soloRank: 14,
teamRank: {
rank: 12,
sp: 2502.4,
mates: [
{ name: "Grey", countryCode: "FR" },
{ name: "sunni" },
{ name: "Isabel T.J.", countryCode: "ES" },
],
team: {
name: "Alliance Rogue",
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/Yu_tgElCa5D48CcyFPF3Y-1756077173431.webp`,
},
},
topMates: [
{
player: { name: "Grey", countryCode: "FR" },
discordId: "289132480999030784",
setsCount: 23,
},
{
player: { name: "sunni" },
discordId: "437750362188120075",
setsCount: 17,
},
{
player: { name: "まるお", countryCode: "JP" },
discordId: "196628310288564224",
setsCount: 9,
},
{
player: { name: "Chara", countryCode: "US" },
discordId: "455039198672814090",
setsCount: 8,
},
{
player: { name: "Silver", countryCode: "FR" },
discordId: "224378380316540929",
setsCount: 6,
},
{ player: { name: "Yeti" }, discordId: "153113232128507904", setsCount: 4 },
],
bestStage: { stageId: 14, winratePercentage: 78 },
spProgression: SEASON_SUMMARY_DAYS.map(([date, sp]) => ({ date, sp })),
activeDays: SEASON_SUMMARY_DAYS.map(([date, , activity]) => ({
date,
activity,
})),
bestSets: SEASON_SUMMARY_BEST_SETS,
bestTournament: {
name: "In The Zone 50",
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/tournament-logo-itz.png`,
tier: 2,
placement: 2,
teamsCount: 32,
},
topWeapons: [
{ weaponSplId: 40, usagePercentage: 46 },
{ weaponSplId: 1001, usagePercentage: 31 },
{ weaponSplId: 2070, usagePercentage: 12 },
],
};
function SeasonSummaryGraphicSection({ id }: { id: string }) {
return (
<Section>
<SectionTitle id={id}>Season Summary Graphic</SectionTitle>
<div className="stack md">
<ComponentRow label="Season summary export image">
<SeasonSummaryGraphic
user={{
name: "Sendou",
discordId: "79237403620945920",
customUrl: "sendou",
countryCode: "FI",
avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/dBYwiLjlhVBwW-oyyNJkC-1721997877357.webp`,
}}
season={8}
seasonDateRange={{
starts: new Date("2026-03-02T17:00:00.000Z"),
ends: new Date("2026-05-17T20:59:59.999Z"),
}}
stats={SEASON_SUMMARY_STATS}
/>
</ComponentRow>
</div>
</Section>
);
}
function FormMessageSection({ id }: { id: string }) {
return (
<Section>

View File

@ -0,0 +1,122 @@
.plusTier {
font-size: var(--font-xs);
font-weight: var(--weight-extra);
color: var(--graphic-accent);
}
.buildTitle {
overflow: hidden;
font-size: var(--font-lg);
font-weight: var(--weight-extra);
line-height: 1.2;
text-align: center;
text-overflow: ellipsis;
}
.weaponsRow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-2);
}
.weapon {
position: relative;
padding: var(--s-0-5);
border-radius: 50%;
background-color: var(--graphic-row-bg);
}
.top500 {
position: absolute;
top: -3px;
right: 16px;
z-index: 1;
}
.weaponName {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
}
.gearGrid {
display: grid;
grid-template-columns: repeat(3, max-content);
gap: var(--s-2) var(--s-3);
place-items: center;
justify-content: center;
padding-block-end: var(--s-3);
}
.noGear {
grid-template-columns: repeat(2, max-content);
}
.noAbilityPoints {
padding-block-end: 0;
}
.gear {
background-color: var(--graphic-row-bg);
border-radius: 50%;
}
.subAbilities {
display: flex;
align-items: flex-start;
gap: var(--s-3);
}
.subAbilitySegment {
position: relative;
}
.subAbilitySegmentIcons {
display: flex;
gap: var(--s-3);
}
.abilityPointsLabel {
position: absolute;
top: 100%;
inset-inline: 0;
margin-block-start: 5px;
border-top: 1px solid
color-mix(in oklch, var(--graphic-text-dim) 40%, transparent);
font-size: 0.7rem;
font-weight: var(--weight-semi);
line-height: 1.8;
text-align: center;
color: color-mix(in oklch, var(--graphic-text-dim) 70%, transparent);
}
.abilityChunks {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-3);
}
.abilityChunk {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.abilityChunkCount {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.description {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
line-height: 1.4;
text-align: center;
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@ -0,0 +1,251 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Ability } from "~/components/Ability";
import { Image, ModeImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import type { Tables } from "~/db/tables";
import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types";
import { getAbilityChunksMapAsArray } from "~/features/build-analyzer/core/abilityChunksCalc";
import {
apFromMap,
buildToAbilityPoints,
} from "~/features/build-analyzer/core/utils";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import type {
Ability as AbilityType,
BuildAbilitiesTuple,
GearType,
ModeShort,
} from "~/modules/in-game-lists/types";
import { gearImageUrl, navIconUrl, resolveAvatarUrl } from "~/utils/urls";
import styles from "./BuildGraphic.module.css";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicHeader,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
const BUILD_GRAPHIC_WIDTH = 380;
export interface BuildGraphicOwner {
username: string;
discordId: string;
plusTier: number | null;
customUrl?: Tables["User"]["customUrl"];
discordAvatar?: Tables["User"]["discordAvatar"];
customAvatarUrl?: string | null;
}
export function BuildGraphic({
build,
owner,
showTitle = true,
showAbilityPoints = true,
showAbilityChunks = false,
}: {
build: Pick<
Tables["Build"],
| "title"
| "description"
| "clothesGearSplId"
| "headGearSplId"
| "shoesGearSplId"
| "updatedAt"
> & {
abilities: BuildAbilitiesTuple;
modes: ModeShort[] | null;
weapons: Array<BuildWeaponWithTop500Info>;
};
owner: BuildGraphicOwner;
showTitle?: boolean;
showAbilityPoints?: boolean;
showAbilityChunks?: boolean;
}) {
const { t } = useTranslation(["weapons"]);
const abilityPoints = buildToAbilityPoints(build.abilities);
const isNoGear = [
build.headGearSplId,
build.clothesGearSplId,
build.shoesGearSplId,
].some((id) => typeof id !== "number");
return (
<GraphicContainer width={BUILD_GRAPHIC_WIDTH}>
<GraphicHeader
avatarUrl={resolveAvatarUrl({
customAvatarUrl: owner.customAvatarUrl,
discordId: owner.discordId,
discordAvatar: owner.discordAvatar,
size: "lg",
})}
identiconInput={owner.discordId}
titleRow={
<span className={graphicStyles.headerTitle}>
{owner.username}
{owner.plusTier ? (
<span className={styles.plusTier}> +{owner.plusTier}</span>
) : null}
</span>
}
subtitle={
<LocaleTime
date={build.updatedAt}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
build.modes && build.modes.length > 0
? build.modes.map((mode) => (
<ModeImage key={mode} mode={mode} size={24} />
))
: undefined
}
alignTrailingWithTitle
/>
{showTitle ? (
<div className={styles.buildTitle}>{build.title}</div>
) : null}
<div className={styles.weaponsRow}>
{build.weapons.map((weapon) => (
<div key={weapon.weaponSplId} className={styles.weapon}>
{weapon.isTop500 ? (
<Image
className={styles.top500}
path={navIconUrl("xsearch")}
alt=""
height={26}
width={26}
/>
) : null}
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={50}
/>
</div>
))}
{build.weapons.length === 1 ? (
<div className={styles.weaponName}>
{t(`weapons:MAIN_${build.weapons[0].weaponSplId}`)}
</div>
) : null}
</div>
<div
className={clsx(styles.gearGrid, {
[styles.noGear]: isNoGear,
[styles.noAbilityPoints]: !showAbilityPoints,
})}
>
<GearRow
gearType="HEAD"
abilities={build.abilities[0]}
gearId={build.headGearSplId}
abilityPoints={showAbilityPoints ? abilityPoints : null}
/>
<GearRow
gearType="CLOTHES"
abilities={build.abilities[1]}
gearId={build.clothesGearSplId}
abilityPoints={showAbilityPoints ? abilityPoints : null}
/>
<GearRow
gearType="SHOES"
abilities={build.abilities[2]}
gearId={build.shoesGearSplId}
abilityPoints={showAbilityPoints ? abilityPoints : null}
/>
</div>
{showAbilityChunks ? (
<AbilityChunksRow abilities={build.abilities} />
) : null}
{build.description ? (
<div className={styles.description}>{build.description}</div>
) : null}
</GraphicContainer>
);
}
function GearRow({
gearType,
abilities,
gearId,
abilityPoints,
}: {
gearType: GearType;
abilities: AbilityType[];
gearId: number | null;
/** When null the ability point labels are hidden */
abilityPoints: AbilityPoints | null;
}) {
const { t } = useTranslation(["analyzer"]);
const [mainAbility, ...subAbilities] = abilities;
return (
<>
{typeof gearId === "number" ? (
<Image
height={80}
width={80}
alt=""
path={gearImageUrl(gearType, gearId)}
className={styles.gear}
/>
) : null}
<Ability ability={mainAbility} size="HUGE" />
<div className={styles.subAbilities}>
{subAbilitySegments(subAbilities).map((segment, segmentIndex) => (
<div key={segmentIndex} className={styles.subAbilitySegment}>
<div className={styles.subAbilitySegmentIcons}>
{Array.from({ length: segment.count }, (_, index) => (
<Ability key={index} ability={segment.ability} size="MAIN" />
))}
</div>
{abilityPoints ? (
<div className={styles.abilityPointsLabel}>
{apFromMap({ abilityPoints, ability: segment.ability })}
{t("analyzer:abilityPoints.short")}
</div>
) : null}
</div>
))}
</div>
</>
);
}
function AbilityChunksRow({ abilities }: { abilities: BuildAbilitiesTuple }) {
const abilityChunks = getAbilityChunksMapAsArray(abilities);
if (abilityChunks.length === 0) return null;
return (
<div className={styles.abilityChunks}>
{abilityChunks.map(([ability, count]) => (
<div key={ability} className={styles.abilityChunk}>
<Ability ability={ability} size="SUB" />
<div className={styles.abilityChunkCount}>{count}</div>
</div>
))}
</div>
);
}
function subAbilitySegments(subAbilities: AbilityType[]) {
const segments: Array<{ ability: AbilityType; count: number }> = [];
for (const ability of subAbilities) {
const previousSegment = segments.at(-1);
if (previousSegment?.ability === ability) {
previousSegment.count++;
} else {
segments.push({ ability, count: 1 });
}
}
return segments;
}

View File

@ -0,0 +1,335 @@
.container {
--graphic-row-bg: var(--color-bg-high);
--graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent);
--graphic-text-dim: var(--color-text-high);
--graphic-accent: var(--color-text-accent);
--graphic-first: oklch(87% 0.13 85);
--graphic-second: oklch(83% 0.015 268);
--graphic-third: oklch(74% 0.09 55);
--graphic-win: var(--color-success-high);
--graphic-loss: var(--color-error-high);
display: flex;
flex-direction: column;
gap: var(--s-4);
width: 720px;
padding: var(--s-6);
background:
radial-gradient(
ellipse 90% 45% at 50% -5%,
color-mix(in oklch, var(--color-accent) 25%, transparent),
transparent 70%
),
var(--color-bg);
color: var(--color-text);
/* the exported image resolves fonts slightly differently, so any text allowed to wrap eventually does */
white-space: nowrap;
border: 1.5px solid var(--color-border-high);
border-radius: var(--radius-box);
}
/*
Zero-specificity theme scoping so that a forced [data-theme] wrapper always
beats the html class regardless of which one is in effect
*/
:global(:where(html.light)) .container,
:global(:where([data-theme="light"])) .container {
--graphic-first: oklch(58% 0.12 85);
--graphic-second: oklch(48% 0.02 268);
--graphic-third: oklch(50% 0.1 55);
}
:global(:where([data-theme="dark"])) .container {
--graphic-first: oklch(87% 0.13 85);
--graphic-second: oklch(83% 0.015 268);
--graphic-third: oklch(74% 0.09 55);
}
.header {
display: flex;
align-items: center;
gap: var(--s-3);
}
/*
The image export freezes each element's width, so a shrink-to-fit box would clip its own
text. Both sides of the header grow into the free space instead, which keeps them at the
same place as an auto margin would but leaves their text room to breathe.
*/
.headerText {
flex: 1 1 auto;
min-width: 0;
}
.headerTitleRow {
display: flex;
align-items: center;
gap: var(--s-2);
& > :not(.headerTitle) {
flex-shrink: 0;
}
}
.headerTitle {
min-width: 0;
overflow: hidden;
font-size: var(--font-lg);
font-weight: var(--weight-extra);
line-height: 1.15;
white-space: nowrap;
text-overflow: ellipsis;
}
.headerSubtitle {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.headerTrailing {
display: flex;
flex-grow: 1;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: var(--s-2);
}
.box {
padding: var(--s-2) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.boxLabel {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--graphic-text-dim);
}
.teamsList {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.teamRow {
display: grid;
grid-template-columns: 3rem 2.75rem 1fr auto;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
&.teamRowFirst {
background:
linear-gradient(
to right,
color-mix(in oklch, var(--graphic-first) 18%, transparent),
transparent 40%
),
var(--graphic-row-bg);
border-color: color-mix(in oklch, var(--graphic-first) 40%, transparent);
}
}
.placement {
font-size: var(--font-lg);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--graphic-text-dim);
& sup {
font-size: var(--font-2xs);
font-weight: var(--weight-extra);
}
}
.placementFirst {
color: var(--graphic-first);
}
.placementSecond {
color: var(--graphic-second);
}
.placementThird {
color: var(--graphic-third);
}
.teamInfo {
min-width: 0;
}
.teamName {
overflow: hidden;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
white-space: nowrap;
text-overflow: ellipsis;
}
.avatarCell {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.teamSeed {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
white-space: nowrap;
color: var(--graphic-text-dim);
}
.playersList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--s-0-5) var(--s-3);
margin-top: var(--s-1);
}
.player {
display: flex;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.playerName {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.weapons {
display: flex;
gap: var(--s-1);
padding: var(--s-1-5);
background-color: oklch(0% 0 0 / 0.3);
border-radius: var(--radius-field);
}
.weaponKit {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.statsRow {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: var(--s-2);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
min-width: 0;
padding: var(--s-1-5) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.statValue {
/* full width rather than shrink-to-fit so the image export cannot clip the value */
width: 100%;
font-size: var(--font-lg);
text-align: center;
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
}
.statSeparator {
color: var(--graphic-text-dim);
}
.statWin {
color: var(--graphic-win);
}
.statLoss {
color: var(--graphic-loss);
}
.score {
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
text-align: center;
}
.scoreWin {
color: var(--graphic-win);
}
.scoreLoss {
color: var(--graphic-loss);
}
.sectionDivider {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
font-weight: var(--weight-extra);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--graphic-accent);
&::before,
&::after {
content: "";
flex: 1;
height: 1px;
background-color: var(--graphic-row-border);
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-xs);
font-weight: var(--weight-extra);
color: var(--graphic-text-dim);
}
.footerAccent {
color: var(--graphic-accent);
}
.qrCodeRow {
display: flex;
justify-content: center;
}
.qrCode {
padding: var(--s-1-5);
background-color: #fff;
border-radius: var(--radius-field);
& svg {
display: block;
}
}

View File

@ -0,0 +1,283 @@
import clsx from "clsx";
import { QRCodeSVG } from "qrcode.react";
import * as React from "react";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { SpecialWeaponImage, WeaponImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
import { weaponParams } from "~/features/build-analyzer/core/utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import styles from "./Graphic.module.css";
export const GRAPHIC_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
/**
* Sizes that keep the code at roughly two pixels per module. The lengths are the byte
* capacities of the QR versions used at the default "L" error correction level.
*/
const QR_CODE_SIZE_BREAKPOINTS = [
{ maxUrlLength: 106, size: 56 },
{ maxUrlLength: 271, size: 84 },
{ maxUrlLength: 523, size: 116 },
] as const;
const QR_CODE_SIZE_MAX = 144;
/** Full URL the graphic's QR code should link to, provided by `ImageExportDialog` (null = no QR code) */
export const GraphicQrCodeContext = React.createContext<string | null>(null);
export interface GraphicPlayer {
name: string;
countryCode?: string;
}
export interface GraphicTeam {
name: string;
logoUrl?: string;
seed?: number;
players: GraphicPlayer[];
weapons: MainWeaponId[];
}
export function GraphicContainer({
children,
width,
}: {
children: React.ReactNode;
width?: number;
}) {
const qrCodeUrl = React.useContext(GraphicQrCodeContext);
return (
<div className={styles.container} style={width ? { width } : undefined}>
{children}
{qrCodeUrl ? (
<div className={styles.qrCodeRow}>
<div className={styles.qrCode}>
<QRCodeSVG value={qrCodeUrl} size={qrCodeSize(qrCodeUrl)} />
</div>
</div>
) : null}
</div>
);
}
export function GraphicHeader({
avatarUrl,
identiconInput,
titleRow,
subtitle,
trailing,
alignTrailingWithTitle = false,
}: {
avatarUrl?: string;
identiconInput: string;
titleRow: React.ReactNode;
subtitle: React.ReactNode;
trailing?: React.ReactNode;
/** Line the trailing content up with the title instead of centering it against the title and subtitle together */
alignTrailingWithTitle?: boolean;
}) {
const trailingContent = trailing ? (
<div className={styles.headerTrailing}>{trailing}</div>
) : null;
return (
<header className={styles.header}>
<Avatar
url={avatarUrl}
identiconInput={identiconInput}
size="sm"
alt=""
/>
<div className={styles.headerText}>
<div className={styles.headerTitleRow}>
{titleRow}
{alignTrailingWithTitle ? trailingContent : null}
</div>
{subtitle}
</div>
{alignTrailingWithTitle ? null : trailingContent}
</header>
);
}
export function GraphicTeamsList({ children }: { children: React.ReactNode }) {
return <ol className={styles.teamsList}>{children}</ol>;
}
export function GraphicTeamRow({
team,
leading,
highlighted = false,
className,
as: Element = "li",
}: {
team: GraphicTeam;
leading?: React.ReactNode;
highlighted?: boolean;
className?: string;
as?: "li" | "div";
}) {
return (
<Element
className={clsx(styles.teamRow, className, {
[styles.teamRowFirst]: highlighted,
})}
>
{leading}
<div className={styles.avatarCell}>
<Avatar
url={team.logoUrl}
identiconInput={team.name}
size="sm"
alt=""
/>
{typeof team.seed === "number" ? (
<div className={styles.teamSeed}>#{team.seed}</div>
) : null}
</div>
<div className={styles.teamInfo}>
<div className={styles.teamName}>{team.name}</div>
<div className={styles.playersList}>
{team.players.map((player) => (
<GraphicPlayerChip key={player.name} player={player} />
))}
</div>
</div>
<div className={styles.weapons}>
{team.weapons.map((weaponSplId, index) => (
<div key={`${weaponSplId}-${index}`} className={styles.weaponKit}>
<WeaponImage weaponSplId={weaponSplId} variant="badge" size={38} />
<SpecialWeaponImage
specialWeaponId={
weaponParams().weaponKits[weaponSplId].specialWeaponId
}
size={24}
/>
</div>
))}
</div>
</Element>
);
}
/**
* A chip is sized by its content, and the image export freezes each element's width. Inline
* elements are exempt from that, so the chip must stay a `span` or its name gets clipped.
*/
export function GraphicPlayerChip({ player }: { player: GraphicPlayer }) {
return (
<span className={styles.player}>
{player.countryCode ? (
<Flag countryCode={player.countryCode} tiny />
) : null}
<span className={styles.playerName}>{player.name}</span>
</span>
);
}
export function GraphicPlacementCell({ placement }: { placement: number }) {
return (
<div className={clsx(styles.placement, placementAccentClass(placement))}>
<Placement placement={placement} textOnly />
</div>
);
}
export function GraphicStatsRow({ children }: { children: React.ReactNode }) {
return <div className={styles.statsRow}>{children}</div>;
}
export function GraphicStat({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className={styles.stat}>
<div className={styles.boxLabel}>{label}</div>
<div className={styles.statValue}>{children}</div>
</div>
);
}
export function GraphicWonLost({ won, lost }: { won: number; lost: number }) {
return (
<>
<span className={styles.statWin}>{won}</span>
<span className={styles.statSeparator}>-</span>
<span className={styles.statLoss}>{lost}</span>
</>
);
}
export function GraphicScore({
ownScore,
opponentScore,
}: {
ownScore: number;
opponentScore: number;
}) {
return (
<div
className={clsx(
styles.score,
ownScore > opponentScore ? styles.scoreWin : styles.scoreLoss,
)}
>
{ownScore}-{opponentScore}
</div>
);
}
export function GraphicSectionDivider({
children,
as: Element = "div",
}: {
children: React.ReactNode;
as?: "div" | "li";
}) {
return <Element className={styles.sectionDivider}>{children}</Element>;
}
export function GraphicFooter({ children }: { children: React.ReactNode }) {
return <footer className={styles.footer}>{children}</footer>;
}
export function GraphicSiteUrl({ path }: { path: string }) {
return (
<div>
sendou<span className={styles.footerAccent}>.ink</span>
{path}
</div>
);
}
function qrCodeSize(url: string) {
for (const breakpoint of QR_CODE_SIZE_BREAKPOINTS) {
if (url.length <= breakpoint.maxUrlLength) {
return breakpoint.size;
}
}
return QR_CODE_SIZE_MAX;
}
function placementAccentClass(placement: number) {
switch (placement) {
case 1:
return styles.placementFirst;
case 2:
return styles.placementSecond;
case 3:
return styles.placementThird;
default:
return undefined;
}
}

View File

@ -0,0 +1,24 @@
/* doubled selector so it wins over the base modal max-width regardless of CSS module order */
.dialog.dialog {
max-width: fit-content;
}
.settings {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-3) var(--s-4);
}
.scroller {
max-width: 100%;
overflow-x: auto;
}
/* auto margins center the frame when the settings make the dialog wider than the graphic, but
never clip it when the graphic itself is the wider one (unlike flex centering) */
.frame {
width: fit-content;
margin-inline: auto;
}

View File

@ -0,0 +1,178 @@
import { HardDriveDownload } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useMatches } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouSwitch } from "~/components/elements/Switch";
import { useTheme } from "~/features/theme/core/provider";
import { SENDOU_INK_BASE_URL } from "~/utils/urls";
import { GraphicQrCodeContext } from "./Graphic";
import styles from "./ImageExportDialog.module.css";
const EXPORT_SCALE = 1.75;
type ThemeSelection = "light" | "dark" | "light-custom" | "dark-custom";
const THEME_SELECTIONS = [
{
value: "light",
translationKey: "common:imageExport.theme.light",
needsCustomTheme: false,
},
{
value: "dark",
translationKey: "common:imageExport.theme.dark",
needsCustomTheme: false,
},
{
value: "light-custom",
translationKey: "common:imageExport.theme.lightCustom",
needsCustomTheme: true,
},
{
value: "dark-custom",
translationKey: "common:imageExport.theme.darkCustom",
needsCustomTheme: true,
},
] as const;
interface ImageExportDialogProps {
/** Button that opens the dialog, e.g. a `SendouButton` (its own `onPress` also runs, useful for lazy loading the graphic's data) */
trigger: React.ReactNode;
heading: string;
/** Name of the downloaded file without the extension */
filename: string;
/** Path the QR code links to, defaults to the current page */
qrCodePath?: string;
/** Extra settings controls specific to the use case */
settings?: React.ReactNode;
/** The graphic to preview and export */
children: React.ReactNode;
}
/**
* Dialog for exporting a graphic component as a .png image. Renders the given graphic
* as a preview with generic settings (color scheme, custom theme, QR code) and downloads
* a screenshot of it via snapdom. Graphics render their QR code via {@link GraphicQrCodeContext}.
*/
export function ImageExportDialog({
trigger,
heading,
...contentProps
}: ImageExportDialogProps) {
return (
<SendouDialog
trigger={trigger}
heading={heading}
showCloseButton
className={styles.dialog}
>
<ImageExportDialogContent {...contentProps} />
</SendouDialog>
);
}
function ImageExportDialogContent({
filename,
qrCodePath,
settings,
children,
}: Omit<ImageExportDialogProps, "trigger" | "heading">) {
const { t } = useTranslation(["common"]);
const { htmlThemeClass } = useTheme();
const location = useLocation();
const pageHasCustomTheme = usePageHasCustomTheme();
const [themeSelection, setThemeSelection] = React.useState<ThemeSelection>(
() => {
const mode = htmlThemeClass === "light" ? "light" : "dark";
return pageHasCustomTheme ? `${mode}-custom` : mode;
},
);
const [withQrCode, setWithQrCode] = React.useState(true);
const frameRef = React.useRef<HTMLDivElement>(null);
const theme = themeSelection.startsWith("light") ? "light" : "dark";
const useCustomTheme = themeSelection.endsWith("-custom");
const qrCodeUrl = `${SENDOU_INK_BASE_URL}${qrCodePath ?? `${location.pathname}${location.search}`}`;
const handleDownload = async () => {
if (!frameRef.current) return;
const { snapdom } = await import("@zumer/snapdom");
await snapdom.download(frameRef.current, {
type: "png",
filename,
quality: 1,
scale: EXPORT_SCALE,
embedFonts: true,
// without this snapdom re-encodes images down to their rendered size, making e.g. the tier image look rough
compress: false,
});
};
return (
<div className="stack md">
<div className={styles.settings}>
<SendouChipRadioGroup wrap>
{THEME_SELECTIONS.filter(
(selection) => pageHasCustomTheme || !selection.needsCustomTheme,
).map((selection) => (
<SendouChipRadio
key={selection.value}
name="image-export-theme"
value={selection.value}
checked={themeSelection === selection.value}
onChange={() => setThemeSelection(selection.value)}
>
{t(selection.translationKey)}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
<SendouSwitch isSelected={withQrCode} onChange={setWithQrCode}>
{t("common:imageExport.qrCode")}
</SendouSwitch>
{settings}
</div>
<SendouButton
icon={<HardDriveDownload />}
onPress={handleDownload}
className="mx-auto"
>
{t("common:imageExport.download")}
</SendouButton>
<div className={styles.scroller}>
<div
ref={frameRef}
className={styles.frame}
data-theme={theme}
data-default-theme={
pageHasCustomTheme && !useCustomTheme ? true : undefined
}
data-testid="image-export-frame"
>
<GraphicQrCodeContext.Provider value={withQrCode ? qrCodeUrl : null}>
{children}
</GraphicQrCodeContext.Provider>
</div>
</div>
</div>
);
}
function usePageHasCustomTheme() {
const matches = useMatches();
return matches.some((match) =>
Boolean(
(match.loaderData as { customTheme?: unknown } | undefined)?.customTheme,
),
);
}

View File

@ -0,0 +1,387 @@
.seasonBadge {
font-size: var(--font-md);
font-weight: var(--weight-extra);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--graphic-accent);
}
.hero {
display: flex;
align-items: center;
gap: var(--s-4);
background:
linear-gradient(
to right,
color-mix(in oklch, var(--graphic-accent) 16%, transparent),
transparent 40%
),
var(--graphic-row-bg);
border-color: color-mix(in oklch, var(--graphic-accent) 35%, transparent);
}
.heroTierName {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
line-height: 1.1;
}
.heroSp {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
font-variant-numeric: tabular-nums;
}
.heroPeak {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.rankBlock {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
margin-inline-start: auto;
padding-inline-end: var(--s-2);
}
.rankValue {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
color: var(--graphic-accent);
}
.bestStageRow {
background-image:
linear-gradient(to right, var(--graphic-row-bg) 35%, transparent 80%),
var(--best-stage-banner);
background-origin: border-box;
background-position: right center;
background-size: cover;
background-repeat: no-repeat;
}
.bestStageName {
font-size: var(--font-md);
font-weight: var(--weight-extra);
}
.bestStageWinrate {
font-weight: var(--weight-extra);
color: var(--graphic-accent);
}
.chart {
display: block;
width: 100%;
/* the area gradient's stops resolve their color from here via currentColor */
color: var(--graphic-accent);
}
.chartLine {
fill: none;
stroke: var(--graphic-accent);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.chartAreaTop {
stop-color: var(--graphic-accent);
stop-opacity: 0.3;
}
.chartAreaBottom {
stop-color: var(--graphic-accent);
stop-opacity: 0;
}
.chartGridLine {
stroke: var(--graphic-row-border);
stroke-width: 1;
}
.chartLabel {
font-size: 10px;
font-weight: var(--weight-semi);
fill: var(--graphic-text-dim);
}
.chartPeakLabel {
font-size: 11px;
font-weight: var(--weight-extra);
fill: var(--color-text);
}
.chartDot {
fill: var(--graphic-accent);
stroke: var(--graphic-row-bg);
stroke-width: 2;
}
.middleGrid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: var(--s-2);
}
.sideStack {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.activityBox {
/* categorical palette, all-pairs CVD separation validated against the dark surface */
--activity-none: color-mix(in oklch, var(--color-text) 8%, transparent);
--activity-sq: oklch(70% 0.11 253);
--activity-tournament: oklch(75% 0.12 85);
--activity-both: oklch(94% 0.02 253);
}
:global(:where(html.light)) .activityBox,
:global(:where([data-theme="light"])) .activityBox {
--activity-sq: oklch(56% 0.13 253);
--activity-tournament: oklch(66% 0.13 85);
--activity-both: oklch(30% 0.04 253);
}
:global(:where([data-theme="dark"])) .activityBox {
--activity-sq: oklch(70% 0.11 253);
--activity-tournament: oklch(75% 0.12 85);
--activity-both: oklch(94% 0.02 253);
}
.calendar {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, auto);
justify-content: center;
gap: 4px;
margin-top: var(--s-4);
}
.calendarCell {
width: 18px;
height: 18px;
border-radius: 3px;
background-color: var(--activity-none);
&.calendarCellHidden {
visibility: hidden;
}
&.calendarSq {
background-color: var(--activity-sq);
}
&.calendarTournament {
background-color: var(--activity-tournament);
}
&.calendarBoth {
background-color: var(--activity-both);
}
}
.calendarLegend {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2-5);
margin-top: var(--s-3);
}
.calendarLegendItem {
display: flex;
align-items: center;
gap: var(--s-1-5);
& .calendarCell {
width: 10px;
height: 10px;
}
}
.weaponsRow {
display: flex;
justify-content: space-evenly;
gap: var(--s-3);
margin-top: var(--s-1-5);
}
.weaponUsage {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.matesList {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
margin-top: var(--s-1-5);
}
.matesBoxExpanded {
display: flex;
flex: 1;
flex-direction: column;
& .matesList {
flex: 1;
justify-content: space-evenly;
}
}
.mateRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.mateName {
display: flex;
/* the image export freezes each element's width, so a shrink-to-fit box would clip its own text */
flex: 1;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
}
.mateNameText {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mateSets {
margin-inline-start: auto;
white-space: nowrap;
}
.teamRankRow {
display: flex;
align-items: center;
gap: var(--s-3);
/* the info column takes the free space here, so an auto margin would leave it nothing to grow into */
& .rankBlock {
margin-inline-start: 0;
}
}
.teamRankInfo {
/* the image export freezes each element's width, so a shrink-to-fit box would clip its own text */
flex: 1;
min-width: 0;
}
.teamRankTitle {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.teamRankName {
overflow: hidden;
font-size: var(--font-md);
font-weight: var(--weight-extra);
white-space: nowrap;
text-overflow: ellipsis;
}
.teamRankSp {
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
&.teamRankSpSecondary {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
}
.playersInline {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--s-0-5) var(--s-3);
}
.bestSetsList {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.bestSetRow {
display: grid;
grid-template-columns: 3.25rem 1fr auto;
align-items: center;
gap: var(--s-3);
}
.playersInlineStart {
justify-content: flex-start;
margin-top: var(--s-1);
}
.setInfo {
min-width: 0;
}
.setContext {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.setSp {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
padding: 0 var(--s-2);
}
.setSpValue {
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
}
.tournamentRow {
display: grid;
grid-template-columns: 3.25rem 2.75rem 1fr auto;
align-items: center;
gap: var(--s-3);
}
.tournamentInfo {
min-width: 0;
}
.tournamentName {
overflow: hidden;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
white-space: nowrap;
text-overflow: ellipsis;
}
.tournamentMeta {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}

View File

@ -0,0 +1,605 @@
import clsx from "clsx";
import {
eachDayOfInterval,
format,
parseISO,
startOfDay,
startOfWeek,
} from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { TierImage, WeaponImage } from "~/components/Image";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { TierPill } from "~/components/TierPill";
import type { TierName } from "~/features/mmr/mmr-constants";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { stageBannerImageUrl, userSeasonsPage } from "~/utils/urls";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
type GraphicPlayer,
GraphicPlayerChip,
GraphicQrCodeContext,
GraphicScore,
GraphicSectionDivider,
GraphicSiteUrl,
GraphicStat,
GraphicStatsRow,
GraphicWonLost,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import styles from "./SeasonSummaryGraphic.module.css";
const CHART_WIDTH = 672;
const CHART_HEIGHT = 170;
const CHART_MARGIN = { top: 26, right: 14, bottom: 22, left: 14 };
const CHART_POINTS_NEEDED = 2;
const CHART_PEAK_LABEL_CLAMP = 48;
const TOP_MATES_COUNT = 3;
/** Without weapons the teammates box is alone next to the activity calendar, so it has room for more */
const TOP_MATES_COUNT_WITHOUT_WEAPONS = 6;
export type SeasonSummaryGraphicActivity = "sq" | "tournament" | "both";
export interface SeasonSummaryGraphicBestSet {
opponentPlayers: GraphicPlayer[];
ownScore: number;
opponentScore: number;
/** Average SP of the opposing players at the time the set was played */
opponentSp: number;
/** Where the set was played e.g. "SendouQ" or a tournament name */
context: string;
}
export interface SeasonSummaryGraphicStats {
tier: { name: TierName; isPlus: boolean };
sp: number;
setsWon: number;
setsLost: number;
mapsWon: number;
mapsLost: number;
longestWinStreak: number;
/** Sets that went to a deciding map: how many of those were won */
clutch?: { won: number; total: number };
soloRank?: number;
teamRank?: {
/** Omitted when the roster is not on the main team leaderboard */
rank?: number;
sp: number;
mates: GraphicPlayer[];
team?: {
name: string;
logoUrl?: string;
};
};
topMates: Array<{
player: GraphicPlayer;
discordId: string;
avatarUrl?: string;
setsCount: number;
}>;
bestStage?: { stageId: StageId; winratePercentage: number };
/** Per day peak SP, dates in "yyyy-MM-dd" format */
spProgression: Array<{ date: string; sp: number }>;
/** Days with at least one set played, dates in "yyyy-MM-dd" format */
activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>;
bestSets: SeasonSummaryGraphicBestSet[];
bestTournament?: {
name: string;
logoUrl?: string;
tier?: number;
placement: number;
teamsCount: number;
};
topWeapons: Array<{ weaponSplId: MainWeaponId; usagePercentage: number }>;
}
export function SeasonSummaryGraphic({
user,
season,
seasonDateRange,
stats,
}: {
user: {
name: string;
discordId: string;
customUrl?: string;
countryCode?: string;
avatarUrl?: string;
};
season: number;
seasonDateRange: { starts: Date; ends: Date };
stats: SeasonSummaryGraphicStats;
}) {
const { t } = useTranslation(["user", "calendar", "game-misc"]);
const qrCodeUrl = React.useContext(GraphicQrCodeContext);
const {
tier,
sp,
setsWon,
setsLost,
mapsWon,
mapsLost,
longestWinStreak,
clutch,
soloRank,
teamRank,
topMates,
bestStage,
spProgression,
activeDays,
bestSets,
bestTournament,
topWeapons,
} = stats;
const peakSp =
spProgression.length > 0
? Math.max(...spProgression.map((point) => point.sp))
: sp;
const shownMates = topMates.slice(
0,
topWeapons.length > 0 ? TOP_MATES_COUNT : TOP_MATES_COUNT_WITHOUT_WEAPONS,
);
return (
<GraphicContainer>
<GraphicHeader
avatarUrl={user.avatarUrl}
identiconInput={user.discordId}
titleRow={
<>
{user.countryCode ? (
<Flag countryCode={user.countryCode} tiny />
) : null}
<span className={graphicStyles.headerTitle}>{user.name}</span>
</>
}
subtitle={
<LocaleTimeRange
from={seasonDateRange.starts}
to={seasonDateRange.ends}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
<div className={styles.seasonBadge}>
{t("user:seasons.season")} {season}
</div>
}
/>
<div className={clsx(graphicStyles.box, styles.hero)}>
<TierImage tier={tier} width={92} />
<div>
<div className={styles.heroTierName}>
{tier.name}
{tier.isPlus ? "+" : ""}
</div>
<div className={styles.heroSp}>{sp.toFixed(1)}SP</div>
<div className={styles.heroPeak}>
{t("user:seasons.peak")} {peakSp.toFixed(1)}SP
</div>
</div>
{typeof soloRank === "number" ? (
<div className={styles.rankBlock}>
<div className={styles.rankValue}>#{soloRank}</div>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.soloRank")}
</div>
</div>
) : null}
</div>
{teamRank ? (
<div className={clsx(graphicStyles.box, styles.teamRankRow)}>
{teamRank.team ? (
<Avatar
url={teamRank.team.logoUrl}
identiconInput={teamRank.team.name}
size="sm"
alt=""
/>
) : null}
<div className={styles.teamRankInfo}>
<div className={styles.teamRankTitle}>
{teamRank.team ? (
<span className={styles.teamRankName}>
{teamRank.team.name}
</span>
) : null}
<div
className={clsx(styles.teamRankSp, {
[styles.teamRankSpSecondary]: Boolean(teamRank.team),
})}
>
{teamRank.sp.toFixed(1)}SP
</div>
</div>
<div
className={clsx(styles.playersInline, styles.playersInlineStart)}
>
{teamRank.mates.map((mate) => (
<GraphicPlayerChip key={mate.name} player={mate} />
))}
</div>
</div>
{typeof teamRank.rank === "number" ? (
<div className={styles.rankBlock}>
<div className={styles.rankValue}>#{teamRank.rank}</div>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.teamRank")}
</div>
</div>
) : null}
</div>
) : null}
<GraphicStatsRow>
<GraphicStat label={t("user:seasons.summary.sets")}>
<GraphicWonLost won={setsWon} lost={setsLost} />
</GraphicStat>
<GraphicStat label={t("user:seasons.summary.maps")}>
<GraphicWonLost won={mapsWon} lost={mapsLost} />
</GraphicStat>
{clutch && clutch.total > 0 ? (
<GraphicStat label={t("user:seasons.summary.clutch")}>
<GraphicWonLost won={clutch.won} lost={clutch.total - clutch.won} />
</GraphicStat>
) : null}
<GraphicStat label={t("user:seasons.summary.winStreak")}>
{longestWinStreak}
</GraphicStat>
</GraphicStatsRow>
{spProgression.length >= CHART_POINTS_NEEDED ? (
<div className={graphicStyles.box}>
<SpChart points={spProgression} />
</div>
) : null}
{bestStage ? (
<div
className={clsx(graphicStyles.box, styles.bestStageRow)}
style={
{
"--best-stage-banner": `url(${stageBannerImageUrl(bestStage.stageId)})`,
} as React.CSSProperties
}
>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.bestStage")}
</div>
<div className={styles.bestStageName}>
{t(`game-misc:STAGE_${bestStage.stageId}`)}{" "}
<span className={styles.bestStageWinrate}>
{Math.round(bestStage.winratePercentage)}%
</span>
</div>
</div>
) : null}
<div className={styles.middleGrid}>
<div className={clsx(graphicStyles.box, styles.activityBox)}>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.activity")}
</div>
<ActivityCalendar
seasonDateRange={seasonDateRange}
activeDays={activeDays}
/>
<ActivityLegend />
</div>
<div className={styles.sideStack}>
{topWeapons.length > 0 ? (
<div className={graphicStyles.box}>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.topWeapons")}
</div>
<div className={styles.weaponsRow}>
{topWeapons.map((weapon) => (
<div key={weapon.weaponSplId} className={styles.weaponUsage}>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={52}
/>
<div className={graphicStyles.boxLabel}>
{Math.round(weapon.usagePercentage)}%
</div>
</div>
))}
</div>
</div>
) : null}
{shownMates.length > 0 ? (
<div
className={clsx(graphicStyles.box, {
[styles.matesBoxExpanded]: topWeapons.length === 0,
})}
>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.topMates")}
</div>
<div className={styles.matesList}>
{shownMates.map((mate) => (
<div key={mate.player.name} className={styles.mateRow}>
<Avatar
url={mate.avatarUrl}
identiconInput={mate.discordId}
size="xxs"
alt=""
/>
<div className={styles.mateName}>
{mate.player.countryCode ? (
<Flag countryCode={mate.player.countryCode} tiny />
) : null}
<span className={styles.mateNameText}>
{mate.player.name}
</span>
</div>
<div
className={clsx(graphicStyles.boxLabel, styles.mateSets)}
>
{t("user:seasons.summary.count.sets", {
count: mate.setsCount,
})}
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
{bestSets.length > 0 ? (
<>
<GraphicSectionDivider>
{t("user:seasons.summary.bestWins")}
</GraphicSectionDivider>
<ol className={styles.bestSetsList}>
{bestSets.map((set, index) => (
<li
key={`${index}-${set.context}`}
className={clsx(graphicStyles.box, styles.bestSetRow)}
>
<GraphicScore
ownScore={set.ownScore}
opponentScore={set.opponentScore}
/>
<div className={styles.setInfo}>
<div
className={clsx(graphicStyles.boxLabel, styles.setContext)}
>
{set.context}
</div>
<div
className={clsx(
styles.playersInline,
styles.playersInlineStart,
)}
>
{set.opponentPlayers.map((player) => (
<GraphicPlayerChip key={player.name} player={player} />
))}
</div>
</div>
<div className={styles.setSp}>
<div className={styles.setSpValue}>
{set.opponentSp.toFixed(1)}
</div>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.opponentSp")}
</div>
</div>
</li>
))}
</ol>
</>
) : null}
{bestTournament ? (
<>
<GraphicSectionDivider>
{t("user:seasons.summary.bestTournament")}
</GraphicSectionDivider>
<div className={clsx(graphicStyles.box, styles.tournamentRow)}>
<GraphicPlacementCell placement={bestTournament.placement} />
<Avatar
url={bestTournament.logoUrl}
identiconInput={bestTournament.name}
size="sm"
alt=""
/>
<div className={styles.tournamentInfo}>
<div className={styles.tournamentName}>{bestTournament.name}</div>
<div className={styles.tournamentMeta}>
{t("calendar:count.teams", {
count: bestTournament.teamsCount,
})}
</div>
</div>
{typeof bestTournament.tier === "number" ? (
<TierPill tier={bestTournament.tier} />
) : null}
</div>
</>
) : null}
{qrCodeUrl ? null : (
<GraphicFooter>
<div>
{t("user:seasons.summary.count.sets", {
count: setsWon + setsLost,
})}{" "}
·{" "}
{t("user:seasons.summary.count.maps", {
count: mapsWon + mapsLost,
})}
</div>
<GraphicSiteUrl path={userSeasonsPage({ user, season })} />
</GraphicFooter>
)}
</GraphicContainer>
);
}
function SpChart({ points }: { points: Array<{ date: string; sp: number }> }) {
const { formatter } = useDateTimeFormat({ month: "short", day: "numeric" });
const gradientId = React.useId();
const times = points.map((point) => parseISO(point.date).getTime());
const minTime = times[0];
const maxTime = times[times.length - 1];
const sps = points.map((point) => point.sp);
const minSp = Math.min(...sps);
const maxSp = Math.max(...sps);
const innerWidth = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right;
const innerHeight = CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom;
const bottomY = CHART_HEIGHT - CHART_MARGIN.bottom;
const xAt = (time: number) =>
CHART_MARGIN.left +
((time - minTime) / Math.max(maxTime - minTime, 1)) * innerWidth;
const yAt = (spValue: number) =>
CHART_MARGIN.top +
(1 - (spValue - minSp) / Math.max(maxSp - minSp, 1)) * innerHeight;
const linePath = points
.map(
(point, index) =>
`${index === 0 ? "M" : "L"}${xAt(times[index]).toFixed(1)} ${yAt(point.sp).toFixed(1)}`,
)
.join(" ");
const areaPath = `${linePath} L${xAt(maxTime).toFixed(1)} ${bottomY} L${xAt(minTime).toFixed(1)} ${bottomY} Z`;
const peakIndex = sps.indexOf(maxSp);
const peakX = xAt(times[peakIndex]);
const peakY = yAt(maxSp);
const peakLabelX = Math.min(
Math.max(peakX, CHART_PEAK_LABEL_CLAMP),
CHART_WIDTH - CHART_PEAK_LABEL_CLAMP,
);
return (
<svg
className={styles.chart}
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
role="img"
aria-label="SP"
>
<defs>
{/* presentation attributes, not CSS: the image export does not style elements inside defs */}
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity={0.3} />
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<line
className={styles.chartGridLine}
x1={CHART_MARGIN.left}
y1={yAt(minSp)}
x2={CHART_WIDTH - CHART_MARGIN.right}
y2={yAt(minSp)}
/>
<path d={areaPath} fill={`url(#${gradientId})`} />
<path className={styles.chartLine} d={linePath} />
<circle className={styles.chartDot} cx={peakX} cy={peakY} r={4.5} />
<text
className={styles.chartPeakLabel}
x={peakLabelX}
y={peakY - 10}
textAnchor="middle"
>
{maxSp.toFixed(1)}SP
</text>
<text
className={styles.chartLabel}
x={CHART_MARGIN.left}
y={CHART_HEIGHT - 6}
>
{formatter.format(parseISO(points[0].date))}
</text>
<text
className={styles.chartLabel}
x={CHART_WIDTH - CHART_MARGIN.right}
y={CHART_HEIGHT - 6}
textAnchor="end"
>
{formatter.format(parseISO(points[points.length - 1].date))}
</text>
</svg>
);
}
function ActivityCalendar({
seasonDateRange,
activeDays,
}: {
seasonDateRange: { starts: Date; ends: Date };
activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>;
}) {
const activityByDay = new Map(
activeDays.map((day) => [day.date, day.activity]),
);
const seasonFirstDay = startOfDay(seasonDateRange.starts);
const days = eachDayOfInterval({
start: startOfWeek(seasonFirstDay, { weekStartsOn: 1 }),
end: seasonDateRange.ends,
});
return (
<div className={styles.calendar}>
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const beforeSeason = day.getTime() < seasonFirstDay.getTime();
return (
<div
key={key}
className={clsx(
styles.calendarCell,
activityClass(activityByDay.get(key)),
{ [styles.calendarCellHidden]: beforeSeason },
)}
/>
);
})}
</div>
);
}
function ActivityLegend() {
const { t } = useTranslation(["user"]);
return (
<div className={clsx(styles.calendarLegend, graphicStyles.boxLabel)}>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarSq)} />
SendouQ
</div>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarTournament)} />
{t("user:seasons.summary.activity.tournament")}
</div>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarBoth)} />
{t("user:seasons.summary.activity.both")}
</div>
</div>
);
}
function activityClass(activity?: SeasonSummaryGraphicActivity) {
if (!activity) return undefined;
switch (activity) {
case "sq":
return styles.calendarSq;
case "tournament":
return styles.calendarTournament;
case "both":
return styles.calendarBoth;
}
}

View File

@ -0,0 +1,9 @@
.organizationName {
max-width: 12rem;
overflow: hidden;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
white-space: nowrap;
text-overflow: ellipsis;
color: var(--graphic-text-dim);
}

View File

@ -0,0 +1,140 @@
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { LocaleTime } from "~/components/LocaleTime";
import { TierPill } from "~/components/TierPill";
import { tournamentPage } from "~/utils/urls";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
GraphicSiteUrl,
type GraphicTeam,
GraphicTeamRow,
GraphicTeamsList,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import styles from "./TournamentResultsGraphic.module.css";
export interface TournamentResultsGraphicTeam extends GraphicTeam {
placement: number;
}
export function TournamentResultsGraphic({
tournamentId,
tournamentName,
startTime,
logoUrl,
tier,
organization,
teams,
teamsCount,
playersCount,
}: {
tournamentId: number;
tournamentName: string;
startTime: Date;
logoUrl?: string;
tier?: number;
organization?: { name: string; avatarUrl?: string };
teams: TournamentResultsGraphicTeam[];
teamsCount: number;
playersCount: number;
}) {
return (
<GraphicContainer>
<TournamentGraphicHeader
tournamentName={tournamentName}
startTime={startTime}
logoUrl={logoUrl}
tier={tier}
organization={organization}
/>
<GraphicTeamsList>
{teams.map((team) => (
<GraphicTeamRow
key={`${team.placement}-${team.name}`}
team={team}
highlighted={team.placement === 1}
leading={<GraphicPlacementCell placement={team.placement} />}
/>
))}
</GraphicTeamsList>
<TournamentGraphicFooter
teamsCount={teamsCount}
playersCount={playersCount}
path={tournamentPage(tournamentId)}
/>
</GraphicContainer>
);
}
export function TournamentGraphicHeader({
tournamentName,
startTime,
logoUrl,
tier,
organization,
}: {
tournamentName: string;
startTime: Date;
logoUrl?: string;
tier?: number;
organization?: { name: string; avatarUrl?: string };
}) {
return (
<GraphicHeader
avatarUrl={logoUrl}
identiconInput={tournamentName}
titleRow={
<>
<span className={graphicStyles.headerTitle}>{tournamentName}</span>
{typeof tier === "number" ? <TierPill tier={tier} /> : null}
</>
}
subtitle={
<LocaleTime
date={startTime}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
organization ? (
<>
<span className={styles.organizationName}>{organization.name}</span>
<Avatar
url={organization.avatarUrl}
identiconInput={organization.name}
size="xs"
alt=""
/>
</>
) : null
}
/>
);
}
export function TournamentGraphicFooter({
teamsCount,
playersCount,
path,
}: {
teamsCount: number;
playersCount: number;
path: string;
}) {
const { t } = useTranslation(["calendar"]);
return (
<GraphicFooter>
<div>
{t("calendar:count.teams", { count: teamsCount })} ·{" "}
{t("calendar:count.players", { count: playersCount })}
</div>
<GraphicSiteUrl path={path} />
</GraphicFooter>
);
}

View File

@ -0,0 +1,54 @@
.ownTeamRow {
grid-template-columns: 2.75rem 1fr auto;
background:
linear-gradient(
to right,
color-mix(in oklch, var(--graphic-accent) 16%, transparent),
transparent 40%
),
var(--graphic-row-bg);
border-color: color-mix(in oklch, var(--graphic-accent) 35%, transparent);
}
.matchRow {
grid-template-columns: 5.5rem 2.75rem 1fr auto;
}
.matchLeading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.roundName {
text-align: center;
}
.seriesWin {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
width: 100%;
text-align: center;
}
.seriesWinName {
width: 100%;
overflow: hidden;
font-size: var(--font-sm);
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
}
.seriesWinDate {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.seriesTitlesCount {
color: var(--graphic-first);
}

View File

@ -0,0 +1,197 @@
import clsx from "clsx";
import { ArrowDown } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { LocaleTime } from "~/components/LocaleTime";
import { tournamentTeamPage } from "~/utils/urls";
import {
GraphicContainer,
GraphicPlacementCell,
GraphicScore,
GraphicSectionDivider,
GraphicStat,
GraphicStatsRow,
type GraphicTeam,
GraphicTeamRow,
GraphicTeamsList,
GraphicWonLost,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import {
TournamentGraphicFooter,
TournamentGraphicHeader,
type TournamentResultsGraphicTeam,
} from "./TournamentResultsGraphic";
import styles from "./TournamentRunGraphic.module.css";
const SERIES_WIN_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
export interface TournamentRunGraphicSeriesWin {
name: string;
startTime: Date;
}
export interface TournamentRunGraphicMatch {
opponent: GraphicTeam;
ownScore: number;
opponentScore: number;
roundName: string;
bracketName: string;
}
export function TournamentRunGraphic({
tournamentId,
tournamentTeamId,
tournamentName,
startTime,
logoUrl,
tier,
organization,
team,
seed,
matches,
teamsCount,
playersCount,
seriesWins,
}: {
tournamentId: number;
tournamentTeamId: number;
tournamentName: string;
startTime: Date;
logoUrl?: string;
tier?: number;
organization?: { name: string; avatarUrl?: string };
team: TournamentResultsGraphicTeam;
seed?: number;
matches: TournamentRunGraphicMatch[];
teamsCount: number;
playersCount: number;
seriesWins?: {
totalCount: number;
first: TournamentRunGraphicSeriesWin;
latest?: TournamentRunGraphicSeriesWin;
};
}) {
const { t } = useTranslation(["tournament"]);
const setsWon = matches.filter(
(match) => match.ownScore > match.opponentScore,
).length;
const setsLost = matches.length - setsWon;
const mapsWon = R.sumBy(matches, (match) => match.ownScore);
const mapsLost = R.sumBy(matches, (match) => match.opponentScore);
return (
<GraphicContainer>
<TournamentGraphicHeader
tournamentName={tournamentName}
startTime={startTime}
logoUrl={logoUrl}
tier={tier}
organization={organization}
/>
<GraphicTeamRow as="div" team={team} className={styles.ownTeamRow} />
<GraphicStatsRow>
<GraphicStat label={t("tournament:team.placement")}>
<GraphicPlacementCell placement={team.placement} />
</GraphicStat>
{typeof seed === "number" ? (
<GraphicStat label={t("tournament:team.seed")}>{seed}</GraphicStat>
) : null}
<GraphicStat label={t("tournament:run.sets")}>
<GraphicWonLost won={setsWon} lost={setsLost} />
</GraphicStat>
<GraphicStat label={t("tournament:run.maps")}>
<GraphicWonLost won={mapsWon} lost={mapsLost} />
</GraphicStat>
</GraphicStatsRow>
{team.placement === 1 && seriesWins ? (
<GraphicStatsRow>
<SeriesWinStat
label={t("tournament:run.firstTitle")}
win={seriesWins.first}
/>
{seriesWins.latest ? (
<SeriesWinStat
label={t("tournament:run.latestTitle")}
win={seriesWins.latest}
/>
) : null}
<GraphicStat label={t("tournament:run.seriesTitles")}>
<span className={styles.seriesTitlesCount}>
{seriesWins.totalCount}
</span>
</GraphicStat>
</GraphicStatsRow>
) : null}
<GraphicTeamsList>
{matches.map((match, index) => {
const previousMatch = matches[index - 1];
const qualifiedForBracket =
previousMatch && previousMatch.bracketName !== match.bracketName;
return (
<React.Fragment key={`${index}-${match.opponent.name}`}>
{qualifiedForBracket ? (
<GraphicSectionDivider as="li">
<ArrowDown size={14} />
{t("tournament:run.qualifiedFor", {
bracket: match.bracketName,
})}
</GraphicSectionDivider>
) : null}
<GraphicTeamRow
className={styles.matchRow}
team={match.opponent}
leading={
<div className={styles.matchLeading}>
<div
className={clsx(graphicStyles.boxLabel, styles.roundName)}
>
{match.roundName}
</div>
<GraphicScore
ownScore={match.ownScore}
opponentScore={match.opponentScore}
/>
</div>
}
/>
</React.Fragment>
);
})}
</GraphicTeamsList>
<TournamentGraphicFooter
teamsCount={teamsCount}
playersCount={playersCount}
path={tournamentTeamPage({ tournamentId, tournamentTeamId })}
/>
</GraphicContainer>
);
}
function SeriesWinStat({
label,
win,
}: {
label: string;
win: TournamentRunGraphicSeriesWin;
}) {
return (
<GraphicStat label={label}>
<div className={styles.seriesWin}>
<div className={styles.seriesWinName}>{win.name}</div>
<LocaleTime
date={win.startTime}
options={SERIES_WIN_DATE_FORMAT_OPTIONS}
className={styles.seriesWinDate}
/>
</div>
</GraphicStat>
);
}

View File

@ -0,0 +1,326 @@
import { describe, expect, it } from "vitest";
import * as SeasonSummary from "./SeasonSummary";
const win = { ownScore: 4, opponentScore: 2 };
const loss = { ownScore: 1, opponentScore: 4 };
describe("longestWinStreak", () => {
it("returns 0 for no sets", () => {
expect(SeasonSummary.longestWinStreak([])).toBe(0);
});
it("returns 0 when every set was lost", () => {
expect(SeasonSummary.longestWinStreak([loss, loss])).toBe(0);
});
it("counts consecutive wins only", () => {
expect(
SeasonSummary.longestWinStreak([win, win, loss, win, win, win, loss]),
).toBe(3);
});
it("counts a streak lasting until the end", () => {
expect(SeasonSummary.longestWinStreak([loss, win, win])).toBe(2);
});
});
describe("clutchRecord", () => {
it("returns zeros for no sets", () => {
expect(SeasonSummary.clutchRecord([])).toEqual({ won: 0, total: 0 });
});
it("only counts sets decided by one map", () => {
expect(
SeasonSummary.clutchRecord([
{ ownScore: 4, opponentScore: 3 },
{ ownScore: 2, opponentScore: 3 },
{ ownScore: 4, opponentScore: 2 },
{ ownScore: 0, opponentScore: 4 },
{ ownScore: 2, opponentScore: 1 },
]),
).toEqual({ won: 2, total: 3 });
});
});
describe("bestStage", () => {
it("returns undefined when no stage has enough maps played", () => {
expect(
SeasonSummary.bestStage({
1: { SZ: { wins: 2, losses: 0 } },
}),
).toBeUndefined();
});
it("aggregates winrate across modes", () => {
expect(
SeasonSummary.bestStage({
1: { SZ: { wins: 4, losses: 2 }, TC: { wins: 2, losses: 2 } },
}),
).toEqual({ stageId: 1, winratePercentage: 60 });
});
it("picks the stage with the highest winrate among qualified ones", () => {
expect(
SeasonSummary.bestStage({
1: { SZ: { wins: 9, losses: 1 } },
2: { SZ: { wins: 5, losses: 5 } },
3: { SZ: { wins: 8, losses: 2 } },
}),
).toEqual({ stageId: 1, winratePercentage: 90 });
});
it("does not let a low sample size stage win over a qualified one", () => {
expect(
SeasonSummary.bestStage({
1: { SZ: { wins: 3, losses: 0 } },
2: { SZ: { wins: 7, losses: 3 } },
}),
).toEqual({ stageId: 2, winratePercentage: 70 });
});
});
describe("tournamentRunScore", () => {
it("lets tier dominate over placement quality", () => {
const higherTierRun = SeasonSummary.tournamentRunScore({
tier: 2,
placement: 2,
teamsCount: 32,
topEightAvgSp: null,
});
const lowerTierWin = SeasonSummary.tournamentRunScore({
tier: 3,
placement: 1,
teamsCount: 64,
topEightAvgSp: null,
});
expect(higherTierRun).toBeGreaterThan(lowerTierWin);
});
it("rewards better placement within the same tier", () => {
const winner = SeasonSummary.tournamentRunScore({
tier: 5,
placement: 1,
teamsCount: 16,
topEightAvgSp: null,
});
const runnerUp = SeasonSummary.tournamentRunScore({
tier: 5,
placement: 2,
teamsCount: 16,
topEightAvgSp: null,
});
expect(winner).toBeGreaterThan(runnerUp);
});
it("scores an untiered tournament below a tiered one with a similar run", () => {
const untiered = SeasonSummary.tournamentRunScore({
tier: null,
placement: 1,
teamsCount: 16,
topEightAvgSp: null,
});
const tiered = SeasonSummary.tournamentRunScore({
tier: 9,
placement: 1,
teamsCount: 16,
topEightAvgSp: null,
});
expect(untiered).toBeLessThan(tiered);
});
it("breaks a tie between identical runs of the same tier by field strength", () => {
const strongField = SeasonSummary.tournamentRunScore({
tier: 4,
placement: 3,
teamsCount: 24,
topEightAvgSp: 2600,
});
const weakField = SeasonSummary.tournamentRunScore({
tier: 4,
placement: 3,
teamsCount: 24,
topEightAvgSp: 1400,
});
expect(strongField).toBeGreaterThan(weakField);
});
it("does not let field strength outweigh a tier step", () => {
const strongerField = SeasonSummary.tournamentRunScore({
tier: 4,
placement: 3,
teamsCount: 24,
topEightAvgSp: 3000,
});
const higherTier = SeasonSummary.tournamentRunScore({
tier: 3,
placement: 3,
teamsCount: 24,
topEightAvgSp: 1200,
});
expect(strongerField).toBeLessThan(higherTier);
});
});
describe("bestTournamentRun", () => {
it("returns undefined for no runs", () => {
expect(SeasonSummary.bestTournamentRun([])).toBeUndefined();
});
it("picks the run with the highest score", () => {
const runs = [
{ tier: 6, placement: 1, teamsCount: 32, topEightAvgSp: 2400 },
{ tier: 2, placement: 10, teamsCount: 32, topEightAvgSp: 1800 },
{ tier: null, placement: 1, teamsCount: 100, topEightAvgSp: 2900 },
];
expect(SeasonSummary.bestTournamentRun(runs)).toBe(runs[1]);
});
it("picks the stronger field among runs tied by tier and placement", () => {
const runs = [
{ tier: 3, placement: 5, teamsCount: 32, topEightAvgSp: 1900 },
{ tier: 3, placement: 5, teamsCount: 32, topEightAvgSp: 2500 },
{ tier: 3, placement: 5, teamsCount: 32, topEightAvgSp: null },
];
expect(SeasonSummary.bestTournamentRun(runs)).toBe(runs[1]);
});
});
describe("topWeaponUsages", () => {
it("returns empty array for no reported weapons", () => {
expect(SeasonSummary.topWeaponUsages([])).toEqual([]);
});
it("returns the most used weapons with their usage share", () => {
expect(
SeasonSummary.topWeaponUsages([
{ weaponSplId: 40, count: 10 },
{ weaponSplId: 1001, count: 5 },
{ weaponSplId: 2070, count: 4 },
{ weaponSplId: 0, count: 1 },
]),
).toEqual([
{ weaponSplId: 40, usagePercentage: 50 },
{ weaponSplId: 1001, usagePercentage: 25 },
{ weaponSplId: 2070, usagePercentage: 20 },
]);
});
});
// season 11 ended 2026-05-17, season 12 started 2026-06-01
const OFF_SEASON_DATE = new Date("2026-05-20T12:00:00Z");
const MID_SEASON_12_DATE = new Date("2026-06-10T12:00:00Z");
describe("isSeasonExportableByAll", () => {
it("latest finished season is exportable during off-season", () => {
expect(SeasonSummary.isSeasonExportableByAll(11, OFF_SEASON_DATE)).toBe(
true,
);
});
it("older seasons are not exportable during off-season", () => {
expect(SeasonSummary.isSeasonExportableByAll(10, OFF_SEASON_DATE)).toBe(
false,
);
});
it("nothing is exportable while a season is in progress", () => {
expect(SeasonSummary.isSeasonExportableByAll(11, MID_SEASON_12_DATE)).toBe(
false,
);
});
});
describe("canExportSeasonSummary", () => {
const baseArgs = {
loggedInUser: { id: 1, roles: [] },
profileUserId: 1,
season: 11,
seasonsParticipatedIn: [11, 10],
hasCalculatedSkill: true,
date: OFF_SEASON_DATE,
};
it("allows the profile owner to export the latest finished season during off-season", () => {
expect(SeasonSummary.canExportSeasonSummary(baseArgs)).toBe(true);
});
it("disallows exporting someone else's profile", () => {
expect(
SeasonSummary.canExportSeasonSummary({ ...baseArgs, profileUserId: 2 }),
).toBe(false);
});
it("disallows without being logged in", () => {
expect(
SeasonSummary.canExportSeasonSummary({
...baseArgs,
loggedInUser: undefined,
}),
).toBe(false);
});
it("disallows a season not participated in", () => {
expect(
SeasonSummary.canExportSeasonSummary({
...baseArgs,
seasonsParticipatedIn: [10],
}),
).toBe(false);
});
it("disallows without a calculated skill", () => {
expect(
SeasonSummary.canExportSeasonSummary({
...baseArgs,
hasCalculatedSkill: false,
}),
).toBe(false);
});
it("disallows a non-supporter exporting an older season", () => {
expect(
SeasonSummary.canExportSeasonSummary({ ...baseArgs, season: 10 }),
).toBe(false);
});
it("allows a supporter to export any finished participated season, also mid-season", () => {
expect(
SeasonSummary.canExportSeasonSummary({
...baseArgs,
loggedInUser: { id: 1, roles: ["SUPPORTER" as const] },
season: 10,
date: MID_SEASON_12_DATE,
}),
).toBe(true);
});
it("disallows exporting an ongoing season", () => {
expect(
SeasonSummary.canExportSeasonSummary({
...baseArgs,
season: 12,
seasonsParticipatedIn: [12, 11, 10],
date: MID_SEASON_12_DATE,
}),
).toBe(false);
});
it("disallows a supporter exporting an ongoing season", () => {
expect(
SeasonSummary.canExportSeasonSummary({
...baseArgs,
loggedInUser: { id: 1, roles: ["SUPPORTER" as const] },
season: 12,
seasonsParticipatedIn: [12, 11, 10],
date: MID_SEASON_12_DATE,
}),
).toBe(false);
});
});

View File

@ -0,0 +1,195 @@
import * as R from "remeda";
import * as Seasons from "~/features/mmr/core/Seasons";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { Role } from "~/modules/permissions/types";
const BEST_STAGE_MIN_MAPS_NEEDED = 10;
const UNTIERED_TOURNAMENT_TIER = 10;
const TOP_WEAPONS_COUNT = 3;
const FIELD_STRENGTH_BASELINE_SP = 1200;
const FIELD_STRENGTH_SP_PER_POINT = 800;
export interface SetScore {
ownScore: number;
opponentScore: number;
}
/**
* Length of the longest run of consecutive set wins in the given
* chronologically ordered list of sets.
*/
export function longestWinStreak(sets: SetScore[]): number {
let longest = 0;
let current = 0;
for (const set of sets) {
if (set.ownScore > set.opponentScore) {
current += 1;
longest = Math.max(longest, current);
} else {
current = 0;
}
}
return longest;
}
/**
* Win record in sets that went to a deciding map, i.e. the score margin was
* exactly one (4-3 in SendouQ, 2-1/3-2 in tournaments).
*/
export function clutchRecord(sets: SetScore[]): { won: number; total: number } {
const decidingMapSets = sets.filter(
(set) => Math.abs(set.ownScore - set.opponentScore) === 1,
);
return {
won: decidingMapSets.filter((set) => set.ownScore > set.opponentScore)
.length,
total: decidingMapSets.length,
};
}
/**
* Stage with the best winrate aggregated across modes. Stages with less than
* a minimum threshold of maps played are excluded so a lucky 2-0 stage can't
* win. Returns undefined when no stage passes the threshold.
*/
export function bestStage(
stages: Partial<
Record<
StageId,
Partial<Record<ModeShort, { wins: number; losses: number }>>
>
>,
): { stageId: StageId; winratePercentage: number } | undefined {
let best: { stageId: StageId; winratePercentage: number } | undefined;
for (const [stageId, modes] of Object.entries(stages)) {
let wins = 0;
let losses = 0;
for (const record of Object.values(modes)) {
wins += record.wins;
losses += record.losses;
}
const mapsPlayed = wins + losses;
if (mapsPlayed < BEST_STAGE_MIN_MAPS_NEEDED) continue;
const winratePercentage = (wins / mapsPlayed) * 100;
if (!best || winratePercentage > best.winratePercentage) {
best = { stageId: Number(stageId) as StageId, winratePercentage };
}
}
return best;
}
export interface TournamentRun {
/** Tournament tier, 1 = X (best) … 9 = C. Null when the tournament has no calculated tier (treated as below every tiered tournament). */
tier: number | null;
placement: number;
teamsCount: number;
/** Average end of season SP of the players who placed in the tournament's top 8. Null when none of them had a calculated skill. */
topEightAvgSp: number | null;
}
/**
* Composite score for ranking a user's tournament runs of a season. The
* tournament's tier dominates; within a tier both the user's placement quality
* relative to the field size and the strength of that field contribute.
*/
export function tournamentRunScore(run: TournamentRun): number {
const tier = run.tier ?? UNTIERED_TOURNAMENT_TIER;
return (
(10 - tier) * 3 +
Math.log2(run.teamsCount / run.placement) +
fieldStrengthScore(run.topEightAvgSp)
);
}
/** The best tournament run by {@link tournamentRunScore}. */
export function bestTournamentRun<T extends TournamentRun>(
runs: T[],
): T | undefined {
return R.firstBy(runs, [tournamentRunScore, "desc"]);
}
/**
* The user's most used weapons with their share of all reported weapon
* occurrences, most used first.
*/
export function topWeaponUsages(
reportedWeapons: Array<{ weaponSplId: MainWeaponId; count: number }>,
): Array<{ weaponSplId: MainWeaponId; usagePercentage: number }> {
const totalCount = R.sumBy(reportedWeapons, (weapon) => weapon.count);
if (totalCount === 0) return [];
return reportedWeapons
.toSorted((a, b) => b.count - a.count)
.slice(0, TOP_WEAPONS_COUNT)
.map((weapon) => ({
weaponSplId: weapon.weaponSplId,
usagePercentage: (weapon.count / totalCount) * 100,
}));
}
/** Whether the season has ended, a prerequisite for exporting its summary image. */
export function isSeasonFinished(season: number, date = new Date()) {
return Seasons.allFinished(date).some((nth) => nth === season);
}
/**
* Whether a season's summary image is exportable without the supporter perk:
* only the latest finished season is, and only while no season is in progress.
*/
export function isSeasonExportableByAll(season: number, date = new Date()) {
return (
Seasons.current(date) === null && Seasons.allFinished(date)[0] === season
);
}
/**
* Whether the logged in user can export the season summary image from the
* given profile. Only the profile owner can export, they must have
* participated in the season and have a calculated (non-approximate) skill
* for it. An ongoing season is never exportable. Supporters can export any of
* their finished seasons, others only per {@link isSeasonExportableByAll}.
*/
export function canExportSeasonSummary({
loggedInUser,
profileUserId,
season,
seasonsParticipatedIn,
hasCalculatedSkill,
date = new Date(),
}: {
loggedInUser?: { id: number; roles: Role[] };
profileUserId: number;
season: number;
seasonsParticipatedIn: number[];
hasCalculatedSkill: boolean;
date?: Date;
}): boolean {
if (!loggedInUser || loggedInUser.id !== profileUserId) return false;
if (!seasonsParticipatedIn.includes(season)) return false;
if (!hasCalculatedSkill) return false;
if (!isSeasonFinished(season, date)) return false;
if (loggedInUser.roles.includes("SUPPORTER")) return true;
return isSeasonExportableByAll(season, date);
}
function fieldStrengthScore(topEightAvgSp: number | null) {
if (topEightAvgSp === null) return 0;
return Math.max(
0,
(topEightAvgSp - FIELD_STRENGTH_BASELINE_SP) / FIELD_STRENGTH_SP_PER_POINT,
);
}

View File

@ -66,6 +66,11 @@ const PERKS = [
name: "previewQ",
extraInfo: false,
},
{
tier: 2,
name: "seasonSummaryImage",
extraInfo: true,
},
{
tier: 2,
name: "userShortLink",

View File

@ -163,34 +163,42 @@ export async function findSeasonProgressionByUserId({
userId: number;
season: number;
}) {
return db
.selectFrom("Skill")
.leftJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
.leftJoin("Tournament", "Tournament.id", "Skill.tournamentId")
.leftJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.leftJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(({ fn }) => [
fn.max("Skill.ordinal").as("ordinal"),
sql<string>`date(coalesce("Skill"."createdAt", "GroupMatch"."createdAt", "CalendarEventDate"."startsAt"), 'unixepoch')`.as(
"date",
return seasonSkillsByDayQuery({ userId, season })
.select(({ fn }) => fn.max("Skill.ordinal").as("ordinal"))
.where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
.execute();
}
/**
* Days of the season the user played at least one set on, with whether they
* played SendouQ, tournaments or both that day. Dates in `yyyy-MM-dd` format.
*/
export async function findSeasonActiveDaysByUserId({
userId,
season,
}: {
userId: number;
season: number;
}): Promise<Array<{ date: string; activity: "sq" | "tournament" | "both" }>> {
const rows = await seasonSkillsByDayQuery({ userId, season })
.select([
// raw max over a null check: did any of the day's Skill rows come from this source?
sql<number>`max("Skill"."groupMatchId" is not null)`.as("playedSq"),
sql<number>`max("Skill"."tournamentId" is not null)`.as(
"playedTournament",
),
])
.where("Skill.userId", "=", userId)
.where("Skill.season", "=", season)
.where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
.where(({ or, eb }) =>
or([
eb("GroupMatch.id", "is not", null),
eb("Tournament.id", "is not", null),
]),
)
.groupBy("date")
.orderBy("date", "asc")
.execute();
return rows.map((row) => ({
date: row.date,
activity:
row.playedSq && row.playedTournament
? ("both" as const)
: row.playedSq
? ("sq" as const)
: ("tournament" as const),
}));
}
/**
@ -216,3 +224,42 @@ function latestSkillsOfSeason(
])
.where("season", "=", season);
}
/**
* User's Skill rows of a season that came from a set played, grouped by the day
* it was played on (`yyyy-MM-dd`) in ascending order. Callers select what they
* want aggregated per day.
*/
function seasonSkillsByDayQuery({
userId,
season,
}: {
userId: number;
season: number;
}) {
return db
.selectFrom("Skill")
.leftJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
.leftJoin("Tournament", "Tournament.id", "Skill.tournamentId")
.leftJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.leftJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(
sql<string>`date(coalesce("Skill"."createdAt", "GroupMatch"."createdAt", "CalendarEventDate"."startsAt"), 'unixepoch')`.as(
"date",
),
)
.where("Skill.userId", "=", userId)
.where("Skill.season", "=", season)
.where(({ or, eb }) =>
or([
eb("GroupMatch.id", "is not", null),
eb("Tournament.id", "is not", null),
]),
)
.groupBy("date")
.orderBy("date", "asc");
}

View File

@ -1,8 +1,23 @@
import { sql, type Transaction } from "kysely";
import {
type ExpressionBuilder,
type NotNull,
sql,
type Transaction,
} from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { commonUserJsonObject } from "~/utils/kysely.server";
import {
commonUserJsonObject,
tournamentLogoWithDefault,
} from "~/utils/kysely.server";
const BEST_SET_OPPONENTS_WITH_SKILL_NEEDED = 2;
/** How many extra SendouQ sets to consider so that the roster deduplication still has enough left to return. */
const BEST_SET_CANDIDATES_PER_RESULT = 10;
const TOURNAMENT_FIELD_STRENGTH_PLACEMENT = 8;
export function upsertMapResults(
results: Pick<
@ -134,6 +149,271 @@ export async function findSeasonMatesEnemiesByUserId({
.execute();
}
/**
* Chronological (oldest first) scores of every set the user played in a given
* season, both SendouQ matches and ranked tournaments.
*/
export async function findSeasonSetScoresByUserId(args: {
userId: number;
season: number;
}): Promise<Array<{ ownScore: number; opponentScore: number }>> {
const [sqSets, tournamentSets] = await Promise.all([
sqSetScoresQuery(args)
.select("GroupMatch.createdAt as playedAt")
.groupBy("GroupMatch.id")
.execute(),
db
.selectFrom(tournamentSetsQuery(args).as("TournamentSet"))
.select((eb) => [
"TournamentSet.playedAt",
tournamentSetGamesWonCount(eb, "own").as("ownScore"),
tournamentSetGamesWonCount(eb, "opponent").as("opponentScore"),
])
.execute(),
]);
return [...sqSets, ...tournamentSets]
.sort((a, b) => a.playedAt - b.playedAt)
.map((set) => ({
ownScore: set.ownScore,
opponentScore: set.opponentScore,
}));
}
/**
* The user's won sets of a season ranked by the average opponent skill
* (ordinal) at the time the set was played, best first. Covers both SendouQ
* matches and ranked tournaments; tournament sets are skipped unless at least
* two opponents have a calculated skill for that tournament. Only the best set
* against any given opponent roster is included.
*/
export async function findSeasonBestSetsByUserId({
userId,
season,
limit,
}: {
userId: number;
season: number;
limit: number;
}) {
const [sqSets, tournamentSets] = await Promise.all([
db
.selectFrom(
sqSetScoresQuery({ userId, season })
.select([
"GroupMatch.id as groupMatchId",
// raw iif: the opposing group is whichever side of the match the user's group is not
sql<number>`iif("OwnMember"."groupId" = "GroupMatch"."alphaGroupId", "GroupMatch"."bravoGroupId", "GroupMatch"."alphaGroupId")`.as(
"opponentGroupId",
),
])
.groupBy("GroupMatch.id")
.as("SqSet"),
)
.select((eb) => [
"SqSet.ownScore",
"SqSet.opponentScore",
eb
.selectFrom("Skill as OpponentSkill")
.select(({ fn }) =>
fn.avg<number>("OpponentSkill.ordinal").as("average"),
)
.whereRef("OpponentSkill.groupMatchId", "=", "SqSet.groupMatchId")
.where((ieb) =>
ieb(
"OpponentSkill.userId",
"in",
ieb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.whereRef("GroupMember.groupId", "=", "SqSet.opponentGroupId"),
),
)
.as("avgOpponentOrdinal"),
jsonArrayFrom(
eb
.selectFrom("GroupMember")
.innerJoin("User", "User.id", "GroupMember.userId")
.select(["User.id", "User.username", "User.country"])
.whereRef("GroupMember.groupId", "=", "SqSet.opponentGroupId")
.orderBy("User.username", "asc"),
).as("opponentPlayers"),
])
.whereRef("SqSet.ownScore", ">", "SqSet.opponentScore")
.orderBy("avgOpponentOrdinal", "desc")
.limit(limit * BEST_SET_CANDIDATES_PER_RESULT)
.execute(),
db
.selectFrom(tournamentSetsQuery({ userId, season }).as("TournamentSet"))
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentSet.tournamentId",
)
.select((eb) => {
const opponentUserIds = eb
.selectFrom(
"TournamentMatchGameResultParticipant as OpponentParticipant",
)
.innerJoin(
"TournamentMatchGameResult as OpponentGameResult",
"OpponentGameResult.id",
"OpponentParticipant.matchGameResultId",
)
.select("OpponentParticipant.userId")
.distinct()
.whereRef(
"OpponentGameResult.matchId",
"=",
"TournamentSet.tournamentMatchId",
)
.whereRef(
"OpponentParticipant.tournamentTeamId",
"!=",
"TournamentSet.ownTeamId",
);
return [
tournamentSetGamesWonCount(eb, "own").as("ownScore"),
tournamentSetGamesWonCount(eb, "opponent").as("opponentScore"),
"CalendarEvent.name as tournamentName",
eb
.selectFrom("Skill as OpponentSkill")
.select(({ fn }) =>
fn.avg<number>("OpponentSkill.ordinal").as("average"),
)
.whereRef(
"OpponentSkill.tournamentId",
"=",
"TournamentSet.tournamentId",
)
.where("OpponentSkill.userId", "in", opponentUserIds)
.as("avgOpponentOrdinal"),
eb
.selectFrom("Skill as OpponentSkill")
.select(({ fn }) => fn.countAll<number>().as("count"))
.whereRef(
"OpponentSkill.tournamentId",
"=",
"TournamentSet.tournamentId",
)
.where("OpponentSkill.userId", "in", opponentUserIds)
.$asScalar()
.$notNull()
.as("opponentsWithSkillCount"),
jsonArrayFrom(
eb
.selectFrom("User")
.select(["User.id", "User.username", "User.country"])
.where("User.id", "in", opponentUserIds)
.orderBy("User.username", "asc"),
).as("opponentPlayers"),
];
})
.execute(),
]);
const sets = [
...sqSets.flatMap((set) =>
set.avgOpponentOrdinal === null
? []
: [
{
ownScore: set.ownScore,
opponentScore: set.opponentScore,
avgOpponentOrdinal: set.avgOpponentOrdinal,
opponentPlayers: set.opponentPlayers,
tournamentName: null,
},
],
),
...tournamentSets.flatMap((set) =>
set.avgOpponentOrdinal === null ||
set.ownScore <= set.opponentScore ||
set.opponentsWithSkillCount < BEST_SET_OPPONENTS_WITH_SKILL_NEEDED
? []
: [
{
ownScore: set.ownScore,
opponentScore: set.opponentScore,
avgOpponentOrdinal: set.avgOpponentOrdinal,
opponentPlayers: set.opponentPlayers,
tournamentName: set.tournamentName,
},
],
),
].sort((a, b) => b.avgOpponentOrdinal - a.avgOpponentOrdinal);
// keeps the best set played against each distinct set of opponents
return R.uniqueBy(sets, (set) =>
set.opponentPlayers
.map((player) => player.id)
.toSorted((a, b) => a - b)
.join("-"),
).slice(0, limit);
}
/**
* The user's ranked tournament results of a season with the tournament's tier,
* field size and the average end of season skill (ordinal) of its top 8 placing
* players, for picking their best tournament run.
*/
export async function findSeasonTournamentRunsByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
return db
.selectFrom("Skill")
.innerJoin("TournamentResult", (join) =>
join
.onRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
.on("TournamentResult.userId", "=", userId),
)
.innerJoin("Tournament", "Tournament.id", "Skill.tournamentId")
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.select((eb) => [
"TournamentResult.placement",
"TournamentResult.participantCount as teamsCount",
"Tournament.tier",
"CalendarEvent.name",
tournamentLogoWithDefault(eb).as("logoUrl"),
eb
.selectFrom(
eb
.selectFrom("Skill as TopEightSkill")
.select((seb) => [
"TopEightSkill.ordinal",
// bare column with max(): the ordinal comes from the season's last skill row of that user
seb.fn.max("TopEightSkill.id").as("latestId"),
])
.where("TopEightSkill.season", "=", season)
.where("TopEightSkill.userId", "in", (ieb) =>
ieb
.selectFrom("TournamentResult as TopEightResult")
.select("TopEightResult.userId")
.whereRef("TopEightResult.tournamentId", "=", "Tournament.id")
.where(
"TopEightResult.placement",
"<=",
TOURNAMENT_FIELD_STRENGTH_PLACEMENT,
),
)
.groupBy("TopEightSkill.userId")
.as("TopEightLatestSkill"),
)
.select(({ fn }) =>
fn.avg<number>("TopEightLatestSkill.ordinal").as("average"),
)
.as("topEightAvgOrdinal"),
])
.where("Skill.userId", "=", userId)
.where("Skill.season", "=", season)
.execute();
}
export function upsertPlayerResults(
results: Tables["PlayerResult"][],
trx?: Transaction<DB>,
@ -165,3 +445,120 @@ export function upsertPlayerResults(
)
.execute();
}
/**
* SendouQ sets of the user's season with the map score summed per match.
* Callers add their extra selects and finish with a `groupBy("GroupMatch.id")`.
*/
function sqSetScoresQuery({
userId,
season,
}: {
userId: number;
season: number;
}) {
return db
.selectFrom("Skill")
.innerJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
.innerJoin("GroupMember as OwnMember", (join) =>
join
.onRef("OwnMember.userId", "=", "Skill.userId")
.on((eb) =>
eb("OwnMember.groupId", "in", [
eb.ref("GroupMatch.alphaGroupId"),
eb.ref("GroupMatch.bravoGroupId"),
]),
),
)
.innerJoin("GroupMatchMap", "GroupMatchMap.matchId", "GroupMatch.id")
.select([
// raw sums of comparisons: counts the maps won by each side of the match
sql<number>`sum("GroupMatchMap"."winnerGroupId" = "OwnMember"."groupId")`.as(
"ownScore",
),
sql<number>`sum("GroupMatchMap"."winnerGroupId" is not null and "GroupMatchMap"."winnerGroupId" != "OwnMember"."groupId")`.as(
"opponentScore",
),
])
.where("Skill.userId", "=", userId)
.where("Skill.season", "=", season);
}
interface SeasonTournamentSet {
tournamentMatchId: number;
tournamentId: number;
ownTeamId: number;
playedAt: number;
}
/**
* One row per set the user played in the season's ranked tournaments (the
* tournaments they have a `Skill` row for).
*/
function tournamentSetsQuery({
userId,
season,
}: {
userId: number;
season: number;
}) {
return db
.selectFrom("TournamentMatchGameResultParticipant as Participant")
.innerJoin(
"TournamentMatchGameResult as GameResult",
"GameResult.id",
"Participant.matchGameResultId",
)
.innerJoin("TournamentMatch", "TournamentMatch.id", "GameResult.matchId")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select(({ fn }) => [
"TournamentMatch.id as tournamentMatchId",
"TournamentStage.tournamentId",
"Participant.tournamentTeamId as ownTeamId",
fn.max("GameResult.createdAt").as("playedAt"),
])
.where("Participant.userId", "=", userId)
.where((eb) =>
eb(
"TournamentStage.tournamentId",
"in",
eb
.selectFrom("Skill")
.select("Skill.tournamentId")
.where("Skill.userId", "=", userId)
.where("Skill.season", "=", season)
.where("Skill.tournamentId", "is not", null)
.$narrowType<{ tournamentId: NotNull }>(),
),
)
.groupBy("TournamentMatch.id");
}
/**
* Games of a tournament set won by either side, counted over every game of the
* set since the user may have sat out some of them. Correlates on a
* `tournamentSetsQuery` aliased as `"TournamentSet"`.
*/
function tournamentSetGamesWonCount(
eb: ExpressionBuilder<
DB & { TournamentSet: SeasonTournamentSet },
"TournamentSet"
>,
side: "own" | "opponent",
) {
return eb
.selectFrom("TournamentMatchGameResult as SetGame")
.select(({ fn }) => fn.countAll<number>().as("count"))
.whereRef("SetGame.matchId", "=", "TournamentSet.tournamentMatchId")
.whereRef(
"SetGame.winnerTeamId",
side === "own" ? "=" : "!=",
"TournamentSet.ownTeamId",
)
.$asScalar()
.$notNull();
}

View File

@ -0,0 +1,80 @@
.title {
overflow: hidden;
font-size: var(--font-lg);
font-weight: var(--weight-extra);
line-height: 1.15;
text-align: center;
text-overflow: ellipsis;
}
.author {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
}
/* the author credit belongs visually to the title above it, counteract the container gap */
.title + .author {
margin-top: calc(-1 * var(--s-3));
}
/* without a title the lone author credit would leave the graphic looking top heavy */
.author:first-child {
margin-top: calc(-1 * var(--s-2));
}
.authorBy {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: lowercase;
color: var(--graphic-text-dim);
}
.authorName {
font-size: var(--font-xs);
font-weight: var(--weight-extra);
}
.tiers {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.tierRow {
display: flex;
align-items: stretch;
gap: var(--s-2);
min-height: 64px;
padding: var(--s-1-5);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.tierLabel {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
min-width: 68px;
max-width: 110px;
padding: var(--s-2);
font-weight: var(--weight-bold);
line-height: 1.2;
text-align: center;
/* tier label backgrounds are bright preset colors in both themes */
color: oklch(17% 0.03 268);
white-space: normal;
overflow-wrap: break-word;
border-radius: var(--radius-field);
}
.tierItems {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
}

View File

@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { GraphicContainer } from "~/features/img-export/components/Graphic";
import type {
TierListItem,
TierListMakerTier,
} from "../tier-list-maker-schemas";
import { tierListItemId, tierNameFontSize } from "../tier-list-maker-utils";
import styles from "./TierListGraphic.module.css";
import { TierListItemImage } from "./TierListItemImage";
export interface TierListGraphicAuthor {
username: string;
discordId: string;
discordAvatar: string | null;
}
export function TierListGraphic({
title,
author,
tiers,
showTierHeaders,
}: {
title: string;
author?: TierListGraphicAuthor;
tiers: Array<TierListMakerTier & { items: TierListItem[] }>;
showTierHeaders: boolean;
}) {
const { t } = useTranslation(["tier-list-maker"]);
return (
<GraphicContainer>
{title ? <div className={styles.title}>{title}</div> : null}
{author ? (
<div className={styles.author}>
<span className={styles.authorBy}>{t("tier-list-maker:by")}</span>
<Avatar user={author} size="xxxs" alt="" />
<span className={styles.authorName}>{author.username}</span>
</div>
) : null}
<div className={styles.tiers}>
{tiers.map((tier) => (
<div key={tier.id} className={styles.tierRow}>
{showTierHeaders ? (
<div
className={styles.tierLabel}
style={{ backgroundColor: tier.color }}
>
<span style={{ fontSize: tierNameFontSize(tier.name) }}>
{tier.name}
</span>
</div>
) : null}
<div className={styles.tierItems}>
{tier.items.map((item) => (
<TierListItemImage key={tierListItemId(item)} item={item} />
))}
</div>
</div>
))}
</div>
</GraphicContainer>
);
}

View File

@ -14,12 +14,10 @@ import { SendouPopover } from "~/components/elements/Popover";
import { useTierListState } from "../contexts/TierListContext";
import {
PRESET_COLORS,
TIER_NAME_FONT_SIZE_BREAKPOINTS,
TIER_NAME_FONT_SIZE_MIN,
TIER_NAME_MAX_LENGTH,
} from "../tier-list-maker-constants";
import type { TierListMakerTier } from "../tier-list-maker-schemas";
import { tierListItemId } from "../tier-list-maker-utils";
import { tierListItemId, tierNameFontSize } from "../tier-list-maker-utils";
import { DraggableItem } from "./DraggableItem";
import styles from "./TierRow.module.css";
@ -38,7 +36,6 @@ export function TierRow({ tier }: TierRowProps) {
handleMoveTierUp,
handleMoveTierDown,
showTierHeaders,
screenshotMode,
placementMode,
selectedTierId,
setSelectedTierId,
@ -60,8 +57,7 @@ export function TierRow({ tier }: TierRowProps) {
const isLastTier = tierIndex === state.tiers.length - 1;
const isClickMode = placementMode === "click";
const isSelected =
isClickMode && !screenshotMode && selectedTierId === tier.id;
const isSelected = isClickMode && selectedTierId === tier.id;
const selectTierProps = isClickMode
? {
@ -151,18 +147,14 @@ export function TierRow({ tier }: TierRowProps) {
<div
ref={combinedRef}
style={{
borderRadius: screenshotMode ? "var(--radius-field)" : undefined,
transition: screenshotMode ? "none" : undefined,
}}
className={clsx(styles.targetZone, {
[styles.targetZoneOver]: isOver,
[styles.targetZoneSelectable]: isClickMode && !screenshotMode,
[styles.targetZoneSelectable]: isClickMode,
[styles.targetZoneSelected]: isSelected,
})}
{...selectTierProps}
>
{items.length === 0 && !screenshotMode ? (
{items.length === 0 ? (
<div className={styles.emptyMessage}>
{isClickMode
? t("tier-list-maker:clickToAdd")
@ -180,28 +172,26 @@ export function TierRow({ tier }: TierRowProps) {
) : null}
</div>
{!screenshotMode ? (
<div className={styles.arrowControls}>
<button
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
onClick={() => handleMoveTierUp(tier.id)}
disabled={isFirstTier}
type="button"
aria-label="Move tier up"
>
<ChevronUp className={styles.arrowIcon} />
</button>
<button
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
onClick={() => handleMoveTierDown(tier.id)}
disabled={isLastTier}
type="button"
aria-label="Move tier down"
>
<ChevronDown className={styles.arrowIcon} />
</button>
</div>
) : null}
<div className={styles.arrowControls}>
<button
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
onClick={() => handleMoveTierUp(tier.id)}
disabled={isFirstTier}
type="button"
aria-label="Move tier up"
>
<ChevronUp className={styles.arrowIcon} />
</button>
<button
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
onClick={() => handleMoveTierDown(tier.id)}
disabled={isLastTier}
type="button"
aria-label="Move tier down"
>
<ChevronDown className={styles.arrowIcon} />
</button>
</div>
</div>
);
}
@ -248,13 +238,3 @@ function useLockedHeightWhileDragging({
return combinedRef;
}
function tierNameFontSize(name: string) {
const length = name.length;
for (const breakpoint of TIER_NAME_FONT_SIZE_BREAKPOINTS) {
if (length <= breakpoint.maxLength) {
return breakpoint.fontSize;
}
}
return TIER_NAME_FONT_SIZE_MIN;
}

View File

@ -1,26 +1,16 @@
import type { ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { createContext, useContext } from "react";
import { useTierList } from "../hooks/useTierList";
type TierListContextType = ReturnType<typeof useTierList> & {
screenshotMode: boolean;
setScreenshotMode: (value: boolean) => void;
};
type TierListContextType = ReturnType<typeof useTierList>;
const TierListContext = createContext<TierListContextType | null>(null);
export function TierListProvider({ children }: { children: ReactNode }) {
const state = useTierList();
const [screenshotMode, setScreenshotMode] = useState(false);
return (
<TierListContext.Provider
value={{
...state,
screenshotMode,
setScreenshotMode,
}}
>
<TierListContext.Provider value={state}>
{children}
</TierListContext.Provider>
);

View File

@ -22,7 +22,10 @@ import {
} from "~/modules/in-game-lists/weapon-ids";
import { assertUnreachable } from "~/utils/types";
import { modeShort, safeJSONParse } from "~/utils/zod";
import { DEFAULT_TIERS } from "../tier-list-maker-constants";
import {
DEFAULT_TIERS,
TIER_LIST_SEARCH_PARAM_NAMES,
} from "../tier-list-maker-constants";
import {
type TierListItem,
type TierListMakerTier,
@ -32,9 +35,9 @@ import {
} from "../tier-list-maker-schemas";
import {
addItemToTier,
compress,
decompress,
getNextNthForItem,
serializeTierListState,
} from "../tier-list-maker-utils";
export type TierListPlacementMode = "track" | "click";
@ -83,13 +86,13 @@ export function useTierList() {
});
const [showTierHeaders, setShowTierHeaders] = useSearchParamState({
name: "showTierHeaders",
name: TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
defaultValue: true,
revive: (value) => value === "true",
});
const [title, setTitle] = useSearchParamState({
name: "title",
name: TIER_LIST_SEARCH_PARAM_NAMES.TITLE,
defaultValue: "",
revive: (value) => value,
});
@ -512,12 +515,10 @@ export function useTierList() {
};
}
const TIER_SEARCH_PARAM_NAME = "state";
function useSearchParamTiersState() {
const [initialSearchParams] = useSearchParams();
const [tiers, setTiers] = React.useState<TierListState>(() => {
const param = initialSearchParams.get(TIER_SEARCH_PARAM_NAME);
const param = initialSearchParams.get(TIER_LIST_SEARCH_PARAM_NAMES.STATE);
try {
if (param) {
@ -543,11 +544,8 @@ function useSearchParamTiersState() {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set(
TIER_SEARCH_PARAM_NAME,
compress({
tiers: state.tiers,
tierItems: Array.from(state.tierItems.entries()),
}),
TIER_LIST_SEARCH_PARAM_NAMES.STATE,
serializeTierListState(state),
);
window.history.replaceState(

View File

@ -15,12 +15,6 @@
margin-bottom: var(--s-6);
}
.tierListScreenshotMode {
padding: var(--s-4);
max-width: 720px;
min-width: 720px;
}
.filters {
display: flex;
gap: var(--s-4);
@ -31,56 +25,3 @@
row-gap: var(--s-2);
flex-wrap: wrap;
}
.titleInput {
grid-column: 1 / -1;
width: 100%;
padding: 0 var(--s-2) var(--s-2) var(--s-2);
font-size: var(--font-lg);
font-weight: var(--weight-bold);
text-align: center;
background: transparent;
border: none;
color: var(--color-text);
outline: none;
&::placeholder {
color: var(--color-text-high);
opacity: 0.3;
}
&:focus::placeholder {
opacity: 0.5;
}
}
.authorSection {
grid-column: 1 / -1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: var(--s-0-5);
margin-bottom: var(--s-3);
margin-top: -15px;
}
.authorBy {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-high);
text-transform: lowercase;
}
.authorInfo {
display: flex;
align-items: center;
gap: var(--s-2);
}
.authorUsername {
font-weight: var(--weight-bold);
font-size: var(--font-xs);
color: var(--color-text);
white-space: nowrap;
}

View File

@ -11,11 +11,9 @@ import {
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import clsx from "clsx";
import { HardDriveDownload, Plus, RefreshCcw } from "lucide-react";
import { useRef } from "react";
import { flushSync } from "react-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
@ -33,6 +31,7 @@ import { ModeImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useUser } from "~/features/auth/core/user";
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
import { useHydrated } from "~/hooks/useHydrated";
import { modesShort } from "~/modules/in-game-lists/modes";
import { metaTags } from "~/utils/remix";
@ -40,6 +39,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, TIER_LIST_MAKER_URL } from "~/utils/urls";
import { ItemDragPreview } from "../components/ItemDragPreview";
import { ItemPool } from "../components/ItemPool";
import { TierListGraphic } from "../components/TierListGraphic";
import { TierRow } from "../components/TierRow";
import {
TierListProvider,
@ -47,6 +47,7 @@ import {
} from "../contexts/TierListContext";
import type { TierListPlacementMode } from "../hooks/useTierList";
import type { TierListItem } from "../tier-list-maker-schemas";
import { tierListMakerPathWithState } from "../tier-list-maker-utils";
import styles from "./tier-list-maker.module.css";
const PLACEMENT_MODES: TierListPlacementMode[] = ["click", "track"];
@ -89,7 +90,6 @@ export default function TierListMakerPage() {
function TierListMakerContent() {
const { t } = useTranslation(["tier-list-maker"]);
const user = useUser();
const {
itemType,
@ -109,10 +109,6 @@ function TierListMakerContent() {
setCanAddDuplicates,
showTierHeaders,
setShowTierHeaders,
title,
setTitle,
screenshotMode,
setScreenshotMode,
selectedModes,
setSelectedModes,
placementMode,
@ -132,27 +128,6 @@ function TierListMakerContent() {
}),
);
const tierListRef = useRef<HTMLDivElement>(null);
const handleDownload = async () => {
if (!tierListRef.current) return;
const { snapdom } = await import("@zumer/snapdom");
flushSync(() => setScreenshotMode(true));
await snapdom.download(tierListRef.current, {
type: "png",
filename: "tier-list",
quality: 1,
scale: 1.75,
embedFonts: true,
backgroundColor: getComputedStyle(document.body).backgroundColor,
});
setScreenshotMode(false);
};
return (
<Main bigger className={clsx(styles.container, "stack lg")}>
<div className={styles.header}>
@ -160,13 +135,7 @@ function TierListMakerContent() {
<SendouButton onPress={handleAddTier} size="small" icon={<Plus />}>
{t("tier-list-maker:addTier")}
</SendouButton>
<SendouButton
onPress={handleDownload}
size="small"
icon={<HardDriveDownload />}
>
{t("tier-list-maker:download")}
</SendouButton>
<TierListExportDialog />
</div>
<ResetPopover key={state.tierItems.size} handleReset={handleReset} />
</div>
@ -180,30 +149,7 @@ function TierListMakerContent() {
onDragEnd={handleDragEnd}
>
<div className="stack">
<div
className={clsx(styles.tierList, {
[styles.tierListScreenshotMode]: screenshotMode,
})}
ref={tierListRef}
>
{title || !screenshotMode ? (
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("tier-list-maker:titlePlaceholder")}
className={clsx(styles.titleInput, "plain")}
/>
) : null}
{screenshotMode && title && user ? (
<div className={styles.authorSection}>
<div className={styles.authorBy}>{t("tier-list-maker:by")}</div>
<div className={styles.authorInfo}>
<Avatar user={user} size="xxxs" alt={user.username} />
<span className={styles.authorUsername}>{user.username}</span>
</div>
</div>
) : null}
<div className={styles.tierList}>
{state.tiers.map((tier) => (
<TierRow key={tier.id} tier={tier} />
))}
@ -341,6 +287,57 @@ function TierListMakerContent() {
);
}
function TierListExportDialog() {
const { t } = useTranslation(["tier-list-maker", "common"]);
const user = useUser();
const { state, getItemsInTier, showTierHeaders, title, setTitle } =
useTierListState();
const [showUsername, setShowUsername] = useState(true);
return (
<ImageExportDialog
trigger={
<SendouButton size="small" icon={<HardDriveDownload />}>
{t("common:imageExport.export")}
</SendouButton>
}
heading={t("common:imageExport.export")}
filename="tier-list"
qrCodePath={tierListMakerPathWithState({
state,
title,
showTierHeaders,
})}
settings={
<>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("tier-list-maker:title")}
aria-label={t("tier-list-maker:title")}
/>
{user ? (
<SendouSwitch isSelected={showUsername} onChange={setShowUsername}>
{t("tier-list-maker:showUsername")}
</SendouSwitch>
) : null}
</>
}
>
<TierListGraphic
title={title}
author={showUsername && user ? user : undefined}
tiers={state.tiers.map((tier) => ({
...tier,
items: getItemsInTier(tier.id),
}))}
showTierHeaders={showTierHeaders}
/>
</ImageExportDialog>
);
}
function ResetPopover({ handleReset }: { handleReset: () => void }) {
const { t } = useTranslation(["tier-list-maker", "common"]);

View File

@ -2,6 +2,13 @@ import type { TierListMakerTier } from "./tier-list-maker-schemas";
export const TIER_NAME_MAX_LENGTH = 50;
/** Search params that hold the tier list itself, shared by the maker page and the links pointing back to it */
export const TIER_LIST_SEARCH_PARAM_NAMES = {
STATE: "state",
TITLE: "title",
SHOW_TIER_HEADERS: "showTierHeaders",
} as const;
export const TIER_NAME_FONT_SIZE_BREAKPOINTS = [
{ maxLength: 3, fontSize: "var(--font-xl)" },
{ maxLength: 8, fontSize: "var(--font-lg)" },

View File

@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { TIER_LIST_SEARCH_PARAM_NAMES } from "./tier-list-maker-constants";
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
import { tierListStateSerializedSchema } from "./tier-list-maker-schemas";
import {
addItemToTier,
decompress,
getNextNthForItem,
tierListItemId,
tierListMakerPathWithState,
} from "./tier-list-maker-utils";
function makeState(
@ -80,6 +84,68 @@ describe("getNextNthForItem", () => {
});
});
describe("tierListMakerPathWithState", () => {
function parseStateFromPath(path: string): TierListState {
const searchParams = new URLSearchParams(path.split("?")[1]);
const param = searchParams.get(TIER_LIST_SEARCH_PARAM_NAMES.STATE);
const parsed = tierListStateSerializedSchema.parse(decompress(param!));
return { tiers: parsed.tiers, tierItems: new Map(parsed.tierItems) };
}
it("round trips the tier list state", () => {
const state = makeState({
"tier-a": [splattershot, { ...splatRoller, nth: 2 }],
"tier-b": [splatRoller],
});
const path = tierListMakerPathWithState({
state,
title: "",
showTierHeaders: true,
});
expect(parseStateFromPath(path)).toEqual(state);
});
it("includes the title", () => {
const path = tierListMakerPathWithState({
state: makeState(),
title: "Weapons ranked & sorted",
showTierHeaders: true,
});
expect(
new URLSearchParams(path.split("?")[1]).get(
TIER_LIST_SEARCH_PARAM_NAMES.TITLE,
),
).toBe("Weapons ranked & sorted");
});
it("only includes tier headers param when they are hidden", () => {
const withHeaders = tierListMakerPathWithState({
state: makeState(),
title: "",
showTierHeaders: true,
});
const withoutHeaders = tierListMakerPathWithState({
state: makeState(),
title: "",
showTierHeaders: false,
});
expect(withHeaders).not.toContain(
TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
);
expect(
new URLSearchParams(withoutHeaders.split("?")[1]).get(
TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
),
).toBe("false");
});
});
describe("tierListItemId", () => {
it("omits nth when it is not set", () => {
expect(tierListItemId(splattershot)).toBe("main-weapon:40");

View File

@ -1,10 +1,54 @@
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
import {
TIER_LIST_SEARCH_PARAM_NAMES,
TIER_NAME_FONT_SIZE_BREAKPOINTS,
TIER_NAME_FONT_SIZE_MIN,
} from "./tier-list-maker-constants";
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
export function tierListItemId(item: TierListItem) {
return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`;
}
/** Compressed representation of the tier list, as stored in the page's search params. */
export function serializeTierListState(state: TierListState) {
return compress({
tiers: state.tiers,
tierItems: Array.from(state.tierItems.entries()),
});
}
/**
* Path to the tier list maker page that opens the given tier list as it was made,
* used by the exported image's QR code.
*/
export function tierListMakerPathWithState({
state,
title,
showTierHeaders,
}: {
state: TierListState;
title: string;
showTierHeaders: boolean;
}) {
const searchParams = new URLSearchParams({
[TIER_LIST_SEARCH_PARAM_NAMES.STATE]: serializeTierListState(state),
});
if (title) {
searchParams.set(TIER_LIST_SEARCH_PARAM_NAMES.TITLE, title);
}
if (!showTierHeaders) {
searchParams.set(
TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
String(showTierHeaders),
);
}
return `${TIER_LIST_MAKER_URL}?${searchParams}`;
}
/**
* Returns a new tier list state with the given item appended to the end of the
* specified tier. Used by the "click" placement mode. If the tier does not
@ -52,10 +96,21 @@ export function getNextNthForItem(
);
}
export function compress<T>(obj: T) {
return compressToBase64(JSON.stringify(obj), { urlSafe: true });
/**
* Resolves the font size for a tier label so that longer tier names
* shrink to fit inside the fixed-width label.
*/
export function tierNameFontSize(name: string) {
const length = name.length;
for (const breakpoint of TIER_NAME_FONT_SIZE_BREAKPOINTS) {
if (length <= breakpoint.maxLength) {
return breakpoint.fontSize;
}
}
return TIER_NAME_FONT_SIZE_MIN;
}
/** Reverses {@link serializeTierListState}, returning null if the input is not valid. */
export function decompress<T>(compressed: string) {
const json = decompressFromBase64(compressed);
if (json === null) return null;
@ -66,3 +121,7 @@ export function decompress<T>(compressed: string) {
return null;
}
}
function compress<T>(obj: T) {
return compressToBase64(JSON.stringify(obj), { urlSafe: true });
}

View File

@ -389,7 +389,8 @@ function ModelField({
{(["light", "dark"] as const).map((theme) => (
<div
key={theme}
className={clsx(styles.previewTheme, `${theme}-preview`)}
className={styles.previewTheme}
data-theme={theme}
>
<span className={styles.previewThemeLabel}>
{t(`trophies:new.form.preview.${theme}`)}

View File

@ -33,6 +33,21 @@ export function findIdByIdentifier(identifier: string) {
return userByIdentifierQuery(identifier).executeTakeFirst();
}
/** Country codes of the given users keyed by user id, users without a country set absent. */
export async function findCountriesByUserIds(userIds: number[]) {
if (userIds.length === 0) return new Map<number, string>();
const rows = await db
.selectFrom("User")
.select(["User.id", "User.country"])
.where("User.id", "in", userIds)
.where("User.country", "is not", null)
.$narrowType<{ country: NotNull }>()
.execute();
return new Map(rows.map((row) => [row.id, row.country]));
}
export async function findBuildFieldsByIdentifier(identifier: string) {
const row = await userByIdentifierQuery(identifier)
.select(({ eb }) => [

View File

@ -0,0 +1,228 @@
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as SeasonSummary from "~/features/img-export/core/SeasonSummary";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
import * as SkillRepository from "~/features/mmr/SkillRepository.server";
import { userSkills } from "~/features/mmr/tiered.server";
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import { forbidden, notFoundIfNullish } from "~/utils/remix.server";
import { resolveAvatarUrl } from "~/utils/urls";
import {
seasonSummaryGraphicSearchParamsSchema,
userParamsSchema,
} from "../user-page-schemas";
const BEST_SETS_COUNT = 3;
/** The graphic shows fewer than this when it also has weapons to show */
const TOP_MATES_COUNT = 6;
export type UserSeasonSummaryGraphicLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
const loggedInUser = requireUser();
const { identifier } = userParamsSchema.parse(params);
const parsedSearchParams = seasonSummaryGraphicSearchParamsSchema.safeParse(
Object.fromEntries(url.searchParams),
);
if (!parsedSearchParams.success) {
throw new Response(null, { status: 400 });
}
const { season } = parsedSearchParams.data;
const user = notFoundIfNullish(
await UserRepository.findIdByIdentifier(identifier),
);
const seasonsParticipatedIn =
await LeaderboardRepository.findSeasonsParticipatedInByUserId(user.id);
const skill = (await userSkills(season)).userSkills[user.id];
if (
!skill ||
skill.approximate ||
!SeasonSummary.canExportSeasonSummary({
loggedInUser,
profileUserId: user.id,
season,
seasonsParticipatedIn,
hasCalculatedSkill: true,
})
) {
throw forbidden();
}
const setScores = await PlayerStatRepository.findSeasonSetScoresByUserId({
userId: user.id,
season,
});
const setWinrate = await PlayerStatRepository.findSeasonSetWinrateByUserId({
userId: user.id,
season,
});
const mapWinrate = await PlayerStatRepository.findSeasonMapWinrateByUserId({
userId: user.id,
season,
});
const soloRank = (
await LeaderboardRepository.findUserSPLeaderboard(season)
).find((entry) => entry.id === user.id)?.placementRank;
const teamEntry = await findTeamEntry({ season, userId: user.id });
const mates = await PlayerStatRepository.findSeasonMatesEnemiesByUserId({
userId: user.id,
season,
type: "MATE",
});
const topMates = mates
.toSorted((a, b) => b.setWins + b.setLosses - (a.setWins + a.setLosses))
.slice(0, TOP_MATES_COUNT);
const countries = await UserRepository.findCountriesByUserIds([
...(teamEntry?.entry.members.map((member) => member.id) ?? []),
...topMates.map((mate) => mate.user.id),
]);
const bestSets = await PlayerStatRepository.findSeasonBestSetsByUserId({
userId: user.id,
season,
limit: BEST_SETS_COUNT,
});
const bestRun = SeasonSummary.bestTournamentRun(
(
await PlayerStatRepository.findSeasonTournamentRunsByUserId({
userId: user.id,
season,
})
).map((run) => ({
...run,
topEightAvgSp:
typeof run.topEightAvgOrdinal === "number"
? ordinalToSp(run.topEightAvgOrdinal)
: null,
})),
);
return {
season,
tier: skill.tier,
sp: ordinalToSp(skill.ordinal),
setsWon: setWinrate.wins,
setsLost: setWinrate.losses,
mapsWon: mapWinrate.wins,
mapsLost: mapWinrate.losses,
longestWinStreak: SeasonSummary.longestWinStreak(setScores),
clutch: SeasonSummary.clutchRecord(setScores),
soloRank,
teamRank: teamEntry
? {
rank: teamEntry.rank,
sp: teamEntry.entry.power,
mates: teamEntry.entry.members
.filter((member) => member.id !== user.id)
.map((member) => ({
name: member.username,
countryCode: countries.get(member.id),
})),
team: teamEntry.entry.team
? {
name: teamEntry.entry.team.name,
logoUrl: teamEntry.entry.team.avatarUrl ?? undefined,
}
: undefined,
}
: undefined,
topMates: topMates.map((mate) => ({
player: {
name: mate.user.username,
countryCode: countries.get(mate.user.id),
},
discordId: mate.user.discordId,
avatarUrl: resolveAvatarUrl({
customAvatarUrl: mate.user.customAvatarUrl,
discordId: mate.user.discordId,
discordAvatar: mate.user.discordAvatar,
size: "sm",
}),
setsCount: mate.setWins + mate.setLosses,
})),
bestStage: SeasonSummary.bestStage(
await PlayerStatRepository.findSeasonStagesByUserId({
userId: user.id,
season,
}),
),
spProgression: (
await SkillRepository.findSeasonProgressionByUserId({
userId: user.id,
season,
})
).map((point) => ({ date: point.date, sp: ordinalToSp(point.ordinal) })),
activeDays: await SkillRepository.findSeasonActiveDaysByUserId({
userId: user.id,
season,
}),
bestSets: bestSets.map((set) => ({
opponentPlayers: set.opponentPlayers.map((player) => ({
name: player.username,
countryCode: player.country ?? undefined,
})),
ownScore: set.ownScore,
opponentScore: set.opponentScore,
opponentSp: ordinalToSp(set.avgOpponentOrdinal),
context: set.tournamentName ?? "SendouQ",
})),
bestTournament: bestRun
? {
name: bestRun.name,
logoUrl: bestRun.logoUrl,
tier: bestRun.tier ?? undefined,
placement: bestRun.placement,
teamsCount: bestRun.teamsCount,
}
: undefined,
topWeapons: SeasonSummary.topWeaponUsages(
await ReportedWeaponRepository.findSeasonReportedWeaponsByUserId({
userId: user.id,
season,
}),
),
};
};
async function findTeamEntry({
season,
userId,
}: {
season: number;
userId: number;
}) {
const hasUser = (entry: { members: Array<{ id: number }> }) =>
entry.members.some((member) => member.id === userId);
const rankedEntry = (
await LeaderboardRepository.findTeamLeaderboardBySeason({
season,
onlyOneEntryPerUser: true,
})
).find(hasUser);
if (rankedEntry)
return { entry: rankedEntry, rank: rankedEntry.placementRank };
// rosters that only show up on the "all entries" leaderboard have no
// placement comparable to the one shown on the main team leaderboard
const unrankedEntry = (
await LeaderboardRepository.findTeamLeaderboardBySeason({
season,
onlyOneEntryPerUser: false,
})
).find(hasUser);
if (!unrankedEntry) return undefined;
return { entry: unrankedEntry, rank: undefined };
}

View File

@ -34,7 +34,7 @@ import userStyles from "../user-page.module.css";
import styles from "./u.$identifier.builds.module.css";
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
i18n: ["weapons", "builds", "gear", "analyzer"],
};
type BuildFilter = "ALL" | "PUBLIC" | "PRIVATE" | MainWeaponId;
@ -103,7 +103,13 @@ export default function UserBuildsPage() {
{builds.length > 0 ? (
<div className={styles.buildsContainer}>
{builds.map((build) => (
<BuildCard key={build.id} build={build} canEdit={isOwnPage} />
<BuildCard
key={build.id}
build={build}
owner={layoutData.user}
showOwner={false}
canEdit={isOwnPage}
/>
))}
</div>
) : (

View File

@ -57,6 +57,7 @@ export const handle: SendouRouteHandle = {
"weapons",
"gear",
"game-badges",
"analyzer",
"trophies",
],
};

View File

@ -0,0 +1 @@
export { loader } from "../loaders/u.$identifier.seasons.summary-graphic.server";

View File

@ -1,10 +1,12 @@
import clsx from "clsx";
import { HardDriveDownload } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Link,
Outlet,
type ShouldRevalidateFunction,
useFetcher,
useLoaderData,
useLocation,
useMatches,
@ -29,6 +31,10 @@ import { TierImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { mainStyles } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
import { SeasonSummaryGraphic } from "~/features/img-export/components/SeasonSummaryGraphic";
import * as SeasonSummary from "~/features/img-export/core/SeasonSummary";
import { TopTenPlayer } from "~/features/leaderboards/components/TopTenPlayer";
import { playerTopTenPlacement } from "~/features/leaderboards/leaderboards-utils";
import * as Seasons from "~/features/mmr/core/Seasons";
@ -37,9 +43,11 @@ import invariant from "~/utils/invariant";
import { isRevalidation } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
resolveAvatarUrl,
sendouQMatchPage,
TIERS_PAGE,
userPage,
userSeasonSummaryGraphicPage,
userSeasonsPage,
userSeasonsStatsPage,
} from "~/utils/urls";
@ -48,13 +56,14 @@ import {
loader,
type UserSeasonsPageLoaderData,
} from "../loaders/u.$identifier.seasons.server";
import type { UserSeasonSummaryGraphicLoaderData } from "../loaders/u.$identifier.seasons.summary-graphic.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "../user-page.module.css";
export { loader };
export const handle: SendouRouteHandle = {
i18n: ["user"],
i18n: ["user", "calendar"],
};
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
@ -103,10 +112,18 @@ export default function UserSeasonsLayout() {
user={layoutData.user}
backTo={userPage(layoutData.user)}
/>
<SeasonHeader
seasonViewed={data.season}
seasonsParticipatedIn={data.seasonsParticipatedIn}
/>
<div className="stack horizontal justify-between items-start">
<SeasonHeader
seasonViewed={data.season}
seasonsParticipatedIn={data.seasonsParticipatedIn}
/>
<SeasonSummaryExport
profileUser={layoutData.user}
season={data.season}
seasonsParticipatedIn={data.seasonsParticipatedIn}
hasCalculatedSkill={Boolean(data.currentOrdinal)}
/>
</div>
{data.currentOrdinal ? (
<div className="stack md">
<Rank
@ -176,6 +193,121 @@ function SeasonNav({
);
}
function SeasonSummaryExport({
profileUser,
season,
seasonsParticipatedIn,
hasCalculatedSkill,
}: {
profileUser: UserPageLoaderData["user"];
season: number;
seasonsParticipatedIn: number[];
hasCalculatedSkill: boolean;
}) {
const { t } = useTranslation(["user"]);
const loggedInUser = useUser();
if (
!loggedInUser ||
loggedInUser.id !== profileUser.id ||
!hasCalculatedSkill ||
!SeasonSummary.isSeasonFinished(season)
) {
return null;
}
const canExport = SeasonSummary.canExportSeasonSummary({
loggedInUser,
profileUserId: profileUser.id,
season,
seasonsParticipatedIn,
hasCalculatedSkill,
});
if (!canExport) {
return (
<SendouPopover
trigger={
<SendouButton
size="small"
variant="outlined"
icon={<HardDriveDownload />}
>
{t("user:seasons.summary.export")}
</SendouButton>
}
>
{t("user:seasons.summary.export.supporterPerk")}
</SendouPopover>
);
}
return (
<SeasonSummaryExportDialog
key={season}
profileUser={profileUser}
season={season}
/>
);
}
function SeasonSummaryExportDialog({
profileUser,
season,
}: {
profileUser: UserPageLoaderData["user"];
season: number;
}) {
const { t } = useTranslation(["user"]);
const fetcher = useFetcher<UserSeasonSummaryGraphicLoaderData>();
const handleOpen = () => {
if (fetcher.state === "idle" && !fetcher.data) {
fetcher.load(userSeasonSummaryGraphicPage({ user: profileUser, season }));
}
};
const data = fetcher.data;
return (
<ImageExportDialog
trigger={
<SendouButton
size="small"
variant="outlined"
icon={<HardDriveDownload />}
onPress={handleOpen}
>
{t("user:seasons.summary.export")}
</SendouButton>
}
heading={t("user:seasons.summary.export")}
filename={`season-${season}-summary`}
qrCodePath={userSeasonsPage({ user: profileUser, season })}
>
{data ? (
<SeasonSummaryGraphic
user={{
name: profileUser.username,
discordId: profileUser.discordId,
customUrl: profileUser.customUrl ?? undefined,
countryCode: profileUser.country ?? undefined,
avatarUrl: resolveAvatarUrl({
customAvatarUrl: profileUser.customAvatarUrl,
discordId: profileUser.discordId,
discordAvatar: profileUser.discordAvatar,
size: "lg",
}),
}}
season={data.season}
seasonDateRange={Seasons.nthToDateRange(data.season)}
stats={data}
/>
) : null}
</ImageExportDialog>
);
}
function SeasonHeader({
seasonViewed,
seasonsParticipatedIn,

View File

@ -58,6 +58,12 @@ export const seasonsSearchParamsSchema = z.object({
.refine((nth) => !nth || Seasons.allStarted(new Date()).includes(nth)),
});
export const seasonSummaryGraphicSearchParamsSchema = z.object({
season: z.coerce
.number()
.refine((nth) => Seasons.allStarted(new Date()).includes(nth)),
});
const SENS_ITEMS = [
-50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
40, 45, 50,

View File

@ -83,6 +83,10 @@ export default [
"edit-widgets",
"features/user-page/routes/u.$identifier.edit-widgets.tsx",
),
route(
"seasons/summary-graphic",
"features/user-page/routes/u.$identifier.seasons.summary-graphic.ts",
),
route("seasons", "features/user-page/routes/u.$identifier.seasons.tsx", [
index("features/user-page/routes/u.$identifier.seasons.index.tsx"),
route(

View File

@ -15,8 +15,13 @@ These should not be consumed directly, only used as a base for other vars!
The hue and chroma values should not be edited manually, instead
use oklch-gamut.ts to generate valid color palettes!
A subtree can force a color scheme regardless of the html class with
[data-theme="dark"|"light"] and opt out of an active custom theme with
[data-default-theme] (e.g. image export previews)
*/
html {
html,
[data-default-theme] {
--_base-h: 268;
--_base-c-0: 0;
--_base-c-1: 0.02357547860966049;
@ -81,7 +86,7 @@ Any changes here NEED to be reflected in oklch-gamut.ts as well
*/
html.dark,
html.dark [data-custom-theme],
.dark-preview {
[data-theme="dark"] {
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
@ -133,7 +138,7 @@ html.dark [data-custom-theme],
html.light,
html.light [data-custom-theme],
.light-preview {
[data-theme="light"] {
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
@ -187,10 +192,9 @@ html.light [data-custom-theme],
These are the vars you mainly want to use
*/
html,
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] and [data-theme] re-declare theme vars for subtrees; different properties than the html.dark/light blocks so no real override */
[data-custom-theme],
.dark-preview,
.light-preview {
[data-theme] {
--color-text: var(--color-base-0);
--color-text-high: var(--color-base-3);
--color-text-inverse: var(--color-base-7);

View File

@ -39,6 +39,29 @@ export const discordAvatarUrl = ({
discordAvatar
}.webp${size === "lg" ? "?size=240" : "?size=80"}`;
/**
* Resolves the avatar image url of an user, preferring their custom avatar over
* the Discord one. Returns undefined if the user has neither.
*/
export const resolveAvatarUrl = ({
customAvatarUrl,
discordId,
discordAvatar,
size,
}: {
customAvatarUrl?: string | null;
discordId: string;
discordAvatar?: string | null;
size: "lg" | "sm";
}) => {
if (customAvatarUrl) return customAvatarUrl;
if (discordAvatar) {
return discordAvatarUrl({ discordId, discordAvatar, size });
}
return undefined;
};
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
export const BADGES_DOC_LINK =
@ -187,6 +210,13 @@ export const userSeasonsPage = ({
`${userPage(user)}/seasons${
typeof season === "number" ? `?season=${season}` : ""
}`;
export const userSeasonSummaryGraphicPage = ({
user,
season,
}: {
user: UserLinkArgs;
season: number;
}) => `${userPage(user)}/seasons/summary-graphic?season=${season}`;
export const userSeasonsStatsPage = ({
user,
season,

View File

@ -31,6 +31,7 @@ export async function loadFactories(parallelIndex: number) {
return {
backdate: (await import("~/db/seed/core/backdate")).backdate,
reseason: (await import("~/db/seed/core/reseason")).reseason,
ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"),
ArtFactory: await import("~/db/seed/factories/ArtFactory"),
AssociationFactory: await import("~/db/seed/factories/AssociationFactory"),

View File

@ -0,0 +1,43 @@
import type { Page } from "@playwright/test";
import { userSeasonsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** A user profile's `/seasons` page, including the season summary image export. */
export class UserSeasonsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
exportImageButton: page.getByRole("button", { name: "Export image" }),
exportDialog: page.getByRole("dialog"),
downloadButton: page.getByRole("button", { name: "Download" }),
supporterPerkExplanation: page.getByText(/supporter perk/),
};
}
async goto(discordId: string, season?: number) {
await navigate({
page: this.page,
url: userSeasonsPage({ user: { discordId }, season }),
});
}
async openExportDialog() {
await this.locators.exportImageButton.click();
}
exportDialogText(content: string) {
return this.locators.exportDialog.getByText(content);
}
async downloadExportedImage() {
const downloadPromise = this.page.waitForEvent("download");
await this.locators.exportDialog
.getByRole("button", { name: "Download" })
.click();
return downloadPromise;
}
}

View File

@ -1,9 +1,18 @@
import { addDays } from "date-fns";
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { SettingsPage } from "./pages/settings/settings-page";
import { UserEditProfilePage } from "./pages/user/user-edit-profile-page";
import { UserPage } from "./pages/user/user-page";
import { UserSeasonsPage } from "./pages/user/user-seasons-page";
/** The only season the e2e seasons list has finished, i.e. the exportable one. */
const FINISHED_SEASON = 0;
test.describe("User page", () => {
test("uses badge pagination", async ({ page, factories }) => {
@ -147,6 +156,43 @@ test.describe("User page", () => {
await expect(settings.hasCustomTheme()).resolves.toBe(false);
});
test("exports season summary image as a supporter", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
await playFinishedSeason(factories, ADMIN_ID);
await impersonate(page);
const seasonsPage = new UserSeasonsPage(page);
await seasonsPage.goto(ADMIN_DISCORD_ID);
await seasonsPage.openExportDialog();
await expect(seasonsPage.exportDialogText("Best win streak")).toBeVisible();
const download = await seasonsPage.downloadExportedImage();
expect(download.suggestedFilename()).toBe(
`season-${FINISHED_SEASON}-summary.png`,
);
});
test("shows supporter perk explanation instead of exporting for non-supporter mid-season", async ({
page,
factories,
}) => {
await playFinishedSeason(factories, NZAP_TEST_ID);
await impersonate(page, NZAP_TEST_ID);
const seasonsPage = new UserSeasonsPage(page);
await seasonsPage.goto(NZAP_TEST_DISCORD_ID);
await seasonsPage.openExportDialog();
await expect(seasonsPage.locators.supporterPerkExplanation).toBeVisible();
await isNotVisible(seasonsPage.locators.downloadButton);
});
test("edits weapon pool", async ({ page, factories }) => {
await factories.UserFactory.grant(ADMIN_ID, {
weapons: ([200, 1100, 2000, 4000] as const).map((weaponSplId) => ({
@ -175,3 +221,32 @@ test.describe("User page", () => {
}
});
});
/**
* Plays the user through a whole season of SendouQ, ending it with a calculated
* (i.e. non-approximate) skill. The matches are spread over the days of the only
* finished season so that the season summary has something to show.
*/
async function playFinishedSeason(factories: Factories, userId: number) {
const mates = await factories.UserFactory.createMany(FULL_GROUP_SIZE - 1);
const enemies = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
const ownGroup = [userId, ...mates.map((mate) => mate.id)];
const opposingGroup = enemies.map((enemy) => enemy.id);
const { starts } = Seasons.nthToDateRange(FINISHED_SEASON);
for (let index = 0; index < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; index++) {
// alpha wins every map, so which side the user is on decides the set
const userWon = index % 3 !== 0;
await factories.SQMatchFactory.create(
{
alphaUserIds: userWon ? ownGroup : opposingGroup,
bravoUserIds: userWon ? opposingGroup : ownGroup,
},
{ isConcluded: true, createdAt: addDays(starts, index) },
);
}
await factories.reseason(FINISHED_SEASON);
}

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Bliv medlem",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Tilpas farver",
"custom.colors.bg": "Baggrund",
"custom.colors.bg-darker": "Mørkere baggrund",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -140,6 +140,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Der p.t. er ingen direkte udsendelser af denne turnering",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Vundne sæt",
"team.mapWins": "Sejr på baner",
"team.seed": "Seed",

View File

@ -186,6 +186,27 @@
"seasons.noReportedWeapons": "Der er endnu ikke blevet indrapporteret nogle våben",
"seasons.clickARow": "Tryk på en række for at se våbenbrugsstatistikker.",
"seasons.loading": "indlæser...",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "Ændr sortering",
"builds.sorting.header": "Ændr sæt-sortering",
"builds.sorting.backToDefaults": "Nulstil visning",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Beitreten",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Eigene Farben",
"custom.colors.bg": "Hintergrund",
"custom.colors.bg-darker": "Hintergrund dunkler",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -140,6 +140,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Aktuell sind keine Livestreams von diesem Turnier verfügbar",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Set-Siege",
"team.mapWins": "Arenen-Siege",
"team.seed": "Seed",

View File

@ -186,6 +186,27 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -149,6 +149,16 @@
"actions.outlined": "Outlined",
"actions.noOutline": "No outline",
"actions.join": "Join",
"imageExport.export": "Export image",
"imageExport.download": "Download",
"imageExport.theme.light": "Light",
"imageExport.theme.dark": "Dark",
"imageExport.theme.lightCustom": "Light (custom)",
"imageExport.theme.darkCustom": "Dark (custom)",
"imageExport.qrCode": "QR code",
"imageExport.buildTitle": "Build title",
"imageExport.abilityPoints": "Ability points",
"imageExport.abilityChunks": "Ability chunks",
"host": "Host",
"seed": "Seed {{number}}",
"actions.nevermind": "Nevermind",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "Log-in via Discord bot",
"support.perk.useBotToLogIn.extra": "Request a log-in link from the Lohi bot as an alternative to the normal website log-in",
"support.perk.earlyAccess": "Occasional early access to features",
"support.perk.seasonSummaryImage": "Export any season's summary image",
"support.perk.seasonSummaryImage.extra": "Export a summary image of any SendouQ season you played. Everyone can export the latest finished season's image during the off-season.",
"custom.colors.title": "Custom colors",
"custom.colors.bg": "Background",
"custom.colors.bg-darker": "Background darker",

View File

@ -1,6 +1,5 @@
{
"addTier": "Add tier",
"download": "Download (.png)",
"mainWeapons": "Main Weapons",
"subWeapons": "Sub Weapons",
"specialWeapons": "Special Weapons",
@ -18,7 +17,8 @@
"resetConfirmation": "Are you sure you want to reset the tier list? This will remove all items and restore default tiers.",
"noDuplicates": "No duplicates",
"showTierHeaders": "Show tier headers",
"titlePlaceholder": "Click to add title...",
"title": "Title",
"showUsername": "Show username",
"by": "Made by",
"custom": "Custom"
}

View File

@ -140,6 +140,12 @@
"finalize.receivingTeam.label": "Receiving team",
"finalize.receivingTeam.placeholder": "Select team…",
"streams.none": "No live streams of this tournament available currently",
"run.sets": "Sets",
"run.maps": "Maps",
"run.qualifiedFor": "Advanced to {{bracket}}",
"run.firstTitle": "First title",
"run.latestTitle": "Latest title",
"run.seriesTitles": "Series titles",
"team.setWins": "Set wins",
"team.mapWins": "Map wins",
"team.seed": "Seed",

View File

@ -186,6 +186,27 @@
"seasons.noReportedWeapons": "No reported weapons yet",
"seasons.clickARow": "Click a row to see weapon usage stats",
"seasons.loading": "Loading...",
"seasons.summary.sets": "Sets",
"seasons.summary.maps": "Maps",
"seasons.summary.winStreak": "Best win streak",
"seasons.summary.soloRank": "Solo rank",
"seasons.summary.teamRank": "Team rank",
"seasons.summary.activity": "Activity",
"seasons.summary.activity.tournament": "Tournament",
"seasons.summary.activity.both": "Both",
"seasons.summary.bestStage": "Best stage",
"seasons.summary.topWeapons": "Top weapons",
"seasons.summary.topMates": "Top teammates",
"seasons.summary.clutch": "Clutch",
"seasons.summary.bestWins": "Best wins",
"seasons.summary.bestTournament": "Best tournament",
"seasons.summary.opponentSp": "Opponent SP",
"seasons.summary.count.sets_one": "{{count}} set",
"seasons.summary.count.sets_other": "{{count}} sets",
"seasons.summary.count.maps_one": "{{count}} map",
"seasons.summary.count.maps_other": "{{count}} maps",
"seasons.summary.export": "Export image",
"seasons.summary.export.supporterPerk": "Exporting this season's summary image is a supporter perk. Everyone can export the latest finished season's image during the off-season.",
"builds.sorting.changeButton": "Change sorting",
"builds.sorting.header": "Change build sorting",
"builds.sorting.backToDefaults": "Back to defaults",

View File

@ -149,6 +149,16 @@
"actions.outlined": "Con borde",
"actions.noOutline": "Sin borde",
"actions.join": "Unirse",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "Olvídalo",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "Iniciar sesión con el bot de Discord",
"support.perk.useBotToLogIn.extra": "Solicita un enlace de inicio de sesión al bot Lohi como alternativa a iniciar sesión desde la web.",
"support.perk.earlyAccess": "Acceso anticipado a nuevas funciones",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Colores personalizados",
"custom.colors.bg": "Fondo",
"custom.colors.bg-darker": "Fondo más oscuro",

View File

@ -1,6 +1,5 @@
{
"addTier": "Añadir tier",
"download": "Descargar (.png)",
"mainWeapons": "Armas principales",
"subWeapons": "Armas secundarias",
"specialWeapons": "Armas especiales",
@ -18,7 +17,8 @@
"resetConfirmation": "¿Seguro que quieres reiniciar la tier list? Esto eliminará todos los elementos y restaurará las tiers por defecto.",
"noDuplicates": "",
"showTierHeaders": "Mostrar encabezados de las tiers",
"titlePlaceholder": "Haz clic para añadir un título...",
"title": "",
"showUsername": "",
"by": "Creado por",
"custom": "Personalizado"
}

View File

@ -142,6 +142,12 @@
"finalize.receivingTeam.label": "Equipo receptor",
"finalize.receivingTeam.placeholder": "Seleccionar equipo...",
"streams.none": "No hay streams disponibles para este torneo al momento",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Victorias de sets",
"team.mapWins": "Victorias de mapas",
"team.seed": "Colocado",

View File

@ -186,6 +186,29 @@
"seasons.noReportedWeapons": "No se han informado armas",
"seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas",
"seasons.loading": "Cargando...",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "Cambiar orden",
"builds.sorting.header": "Cambiar orden de las builds",
"builds.sorting.backToDefaults": "Volver por defecto",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Unirse",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "Cancelar",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Colores personalizados",
"custom.colors.bg": "Fondo",
"custom.colors.bg-darker": "Fondo más oscuro",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -142,6 +142,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "No hay streams disponibles para este torneo al momento",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Victorias de sets",
"team.mapWins": "Victorias de juegos",
"team.seed": "Colocado",

View File

@ -186,6 +186,29 @@
"seasons.noReportedWeapons": "No se han informado armas",
"seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas",
"seasons.loading": "Cargando...",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "Cambiar ordenación",
"builds.sorting.header": "Cambiar ordenación de builds",
"builds.sorting.backToDefaults": "Volver a los valores predeterminados",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Joindre",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Couleurs personalisées",
"custom.colors.bg": "Arrière-plan",
"custom.colors.bg-darker": "Arrière-plan sombre",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -142,6 +142,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Set wins",
"team.mapWins": "Map wins",
"team.seed": "Seed",

View File

@ -186,6 +186,29 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -149,6 +149,16 @@
"actions.outlined": "Outlined",
"actions.noOutline": "No outline",
"actions.join": "Joindre",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "Laisser tomber",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "Se connecter via le bot Discord",
"support.perk.useBotToLogIn.extra": "Demander un lien de connexion au bot Lohi comme alternative à la connexion normale au site Web",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Couleurs personalisées",
"custom.colors.bg": "Arrière-plan",
"custom.colors.bg-darker": "Arrière-plan sombre",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -142,6 +142,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Set gagné",
"team.mapWins": "Map gagnée",
"team.seed": "Seed",

View File

@ -186,6 +186,29 @@
"seasons.noReportedWeapons": "Aucune arme a été reporté",
"seasons.clickARow": "Cliquez sur une ligne pour voir les statistiques d'utilisation des armes",
"seasons.loading": "Chargement...",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "Changer le tri",
"builds.sorting.header": "Modifier le tri des builds",
"builds.sorting.backToDefaults": "Revenir par défaut",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "הצטרפות",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "צבעים מותאמים אישית",
"custom.colors.bg": "רקע",
"custom.colors.bg-darker": "רקע חשוך",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -142,6 +142,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "אין שידורים חיים לטורניר זה כרגע",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "נצחונות סטים",
"team.mapWins": "נצחונות במפות",
"team.seed": "דירוג",

View File

@ -186,6 +186,29 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_two": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_two": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -149,6 +149,16 @@
"actions.outlined": "Contornato",
"actions.noOutline": "Nessun contorno",
"actions.join": "Entra",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "Non importa",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Colori personalizzati",
"custom.colors.bg": "Sfondo",
"custom.colors.bg-darker": "Sfondo più scuro",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -142,6 +142,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Nessuna livestream di questo torneo disponibile al momento",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "Set vinti",
"team.mapWins": "Mappe vinte",
"team.seed": "Seed",

View File

@ -186,6 +186,29 @@
"seasons.noReportedWeapons": "Nessun'arma riportata",
"seasons.clickARow": "Clicca una riga per visualizzare le statistiche d'uso delle armi",
"seasons.loading": "Caricamento...",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "Cambia ordinamento",
"builds.sorting.header": "Cambia ordinamento build",
"builds.sorting.backToDefaults": "Torna al default",

View File

@ -149,6 +149,16 @@
"actions.outlined": "アウトラインあり",
"actions.noOutline": "アウトラインなし",
"actions.join": "参加する",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "部屋を建てる",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "Discord ボットを使用してログイン",
"support.perk.useBotToLogIn.extra": "Lohi ボットから直接ログインできる URL を貰える",
"support.perk.earlyAccess": "たまに新機能の事前お試し",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "カスタムカラー",
"custom.colors.bg": "背景",
"custom.colors.bg-darker": "背景 暗め",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -136,6 +136,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "現在このトーナメントのストリーミングはありません",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "勝利セット",
"team.mapWins": "勝利マップ",
"team.seed": "シード",

View File

@ -186,6 +186,23 @@
"seasons.noReportedWeapons": "報告された武器がありません",
"seasons.clickARow": "武器の使用統計を見るには行を選択してください",
"seasons.loading": "読み込み中...",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "並べ替え変更",
"builds.sorting.header": "ギアの並べ替えを変更",
"builds.sorting.backToDefaults": "デフォルトに戻す",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "참여하기",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "",
"custom.colors.bg": "",
"custom.colors.bg-darker": "",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -136,6 +136,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "",
"team.mapWins": "",
"team.seed": "",

View File

@ -186,6 +186,23 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "",
"custom.colors.bg": "",
"custom.colors.bg-darker": "",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -140,6 +140,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "",
"team.mapWins": "",
"team.seed": "",

View File

@ -186,6 +186,27 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -149,6 +149,16 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Dołącz",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",
"imageExport.theme.dark": "",
"imageExport.theme.lightCustom": "",
"imageExport.theme.darkCustom": "",
"imageExport.qrCode": "",
"imageExport.buildTitle": "",
"imageExport.abilityPoints": "",
"imageExport.abilityChunks": "",
"host": "",
"seed": "",
"actions.nevermind": "",
@ -287,6 +297,8 @@
"support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"support.perk.seasonSummaryImage": "",
"support.perk.seasonSummaryImage.extra": "",
"custom.colors.title": "Niestandardowe kolory",
"custom.colors.bg": "Tło",
"custom.colors.bg-darker": "Ciemniejsze tło",

View File

@ -1,6 +1,5 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -18,7 +17,8 @@
"resetConfirmation": "",
"noDuplicates": "",
"showTierHeaders": "",
"titlePlaceholder": "",
"title": "",
"showUsername": "",
"by": "",
"custom": ""
}

View File

@ -144,6 +144,12 @@
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "",
"run.sets": "",
"run.maps": "",
"run.qualifiedFor": "",
"run.firstTitle": "",
"run.latestTitle": "",
"run.seriesTitles": "",
"team.setWins": "",
"team.mapWins": "",
"team.seed": "",

View File

@ -186,6 +186,31 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.summary.sets": "",
"seasons.summary.maps": "",
"seasons.summary.winStreak": "",
"seasons.summary.soloRank": "",
"seasons.summary.teamRank": "",
"seasons.summary.activity": "",
"seasons.summary.activity.tournament": "",
"seasons.summary.activity.both": "",
"seasons.summary.bestStage": "",
"seasons.summary.topWeapons": "",
"seasons.summary.topMates": "",
"seasons.summary.clutch": "",
"seasons.summary.bestWins": "",
"seasons.summary.bestTournament": "",
"seasons.summary.opponentSp": "",
"seasons.summary.count.sets_one": "",
"seasons.summary.count.sets_few": "",
"seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "",
"seasons.summary.count.maps_one": "",
"seasons.summary.count.maps_few": "",
"seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "",
"seasons.summary.export": "",
"seasons.summary.export.supporterPerk": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

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