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
114 changed files with 5780 additions and 292 deletions

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,